diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 48dd99bc2..59ab3a397 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -23,4 +23,4 @@ - [ ] Ran `make fmt` — clean - [ ] Ran `make lint` (clippy with `-D warnings`) — clean -- [ ] Ran `make test` (`cargo test --workspace --profile release-fast`) — all passing \ No newline at end of file +- [ ] Ran `make test` (`test-consensus` plus `test-node`, at `release-fast`) — all passing \ No newline at end of file diff --git a/.github/actions/free-disk/action.yml b/.github/actions/free-disk/action.yml new file mode 100644 index 000000000..d4f0e5f56 --- /dev/null +++ b/.github/actions/free-disk/action.yml @@ -0,0 +1,19 @@ +name: "Free disk space" +description: "Remove pre-installed software to free disk space for Rust builds" + +runs: + using: "composite" + steps: + - name: Free up disk space + shell: bash + run: | + sudo rm -rf /usr/share/dotnet || true + sudo rm -rf /opt/ghc || true + sudo rm -rf "/usr/local/share/boost" || true + sudo rm -rf "$AGENT_TOOLSDIRECTORY" || true + sudo rm -rf /usr/local/lib/android || true + sudo rm -rf /usr/local/share/powershell || true + sudo rm -rf /usr/share/swift || true + sudo rm -rf /opt/hostedtoolcache/CodeQL || true + sudo rm -rf /usr/local/.ghcup || true + df -h / diff --git a/.github/actions/run-fixture-tests/action.yml b/.github/actions/run-fixture-tests/action.yml index 5d8c81179..9947f6b8e 100644 --- a/.github/actions/run-fixture-tests/action.yml +++ b/.github/actions/run-fixture-tests/action.yml @@ -1,9 +1,17 @@ name: "Run fixture-based tests" description: >- Fetches the latest leanSpec test fixtures (cached by release SHA), sets up the - Rust toolchain, and runs the workspace test suite via `make test`. Assumes the + Rust toolchain, and runs a fixture-backed `make` target. Assumes the repository has already been checked out by the caller. +inputs: + make-target: + description: >- + Which target to run. CI passes one half of the suite per job, because the + two together no longer fit a single runner's root filesystem. + required: false + default: "test" + runs: using: "composite" steps: @@ -106,9 +114,21 @@ runs: with: toolchain: "1.97.1" + # Keyed by target so the two halves do not evict each other: the default + # key covers the job id, which is one value for every leg of a matrix. - name: Setup cache uses: Swatinem/rust-cache@v2 + with: + key: ${{ inputs.make-target }} - name: Run tests shell: bash - run: make test + run: make ${{ inputs.make-target }} + + # How close this half is to the ceiling, which is why the suite is split in + # halves at all. `always()` because the reading matters most on the failure + # it instruments, where the build is the thing that ran out. + - name: Report disk usage after the build + if: always() + shell: bash + run: df -h / diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a42ebce2..b99cbf78e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,14 +16,30 @@ concurrency: env: CARGO_NET_GIT_FETCH_WITH_CLI: "true" CARGO_NET_RETRY: "10" + # Off in CI, on for local rebuilds, which is what the `release-fast` profile's + # `incremental = true` is for. An environment variable overrides the profile + # setting, so this turns it off here without touching the manifest. + # + # Incremental state is pure cost on a runner: `Swatinem/rust-cache` deletes + # `target/*/incremental` before saving, so it is never restored, and every job + # here starts from whatever the cache holds rather than from its own previous + # build. Nothing reads it, and it is comparable in size to the build output + # itself, on a filesystem that a cold workspace build has already outgrown. + CARGO_INCREMENTAL: "0" jobs: + # `lint`, `presets` and `tooling` were one job. Split because serially they + # took 34 minutes and, between them, more disk than a runner has: a full disk + # reaches `rust-lld` through `mmap`, so it is reported as + # `ld terminated with signal 7 [Bus error]` rather than as ENOSPC. lint: name: Lint - runs-on: ubuntu-latest + runs-on: ${{ vars.ETHLAMBDA_RUNNER || 'ubuntu-latest' }} steps: - uses: actions/checkout@v6 + - uses: ./.github/actions/free-disk + - name: Setup Rust uses: dtolnay/rust-toolchain@master with: @@ -31,14 +47,7 @@ jobs: components: rustfmt, clippy - name: Setup cache - # Tools under tooling/ are separate Cargo workspaces with their own - # target dir and Cargo.lock, so they need listing explicitly or their - # builds are neither cached nor reflected in the cache key. uses: Swatinem/rust-cache@v2 - with: - workspaces: | - . - tooling/event-monitor - name: Check formatting run: cargo fmt --all -- --check @@ -46,6 +55,7 @@ jobs: - name: Cargo check run: cargo check --locked --workspace --all-targets + # Seconds, not minutes: same fingerprints as `check` above. - name: Clippy run: cargo clippy --locked --workspace --all-targets -- -D warnings @@ -55,14 +65,81 @@ jobs: - name: Clippy (skipped spectests) run: cargo clippy --locked --workspace --test forkchoice_spectests --test signature_spectests --test stf_spectests --test ssz_spectests -- -D warnings - # tooling/event-monitor declares its own [workspace] table, so every step - # above stops at the root workspace members and never reaches it. Its - # tests run in this job rather than in `test` because clippy has already - # compiled the test targets, and because they need none of that job's - # leanSpec fixtures. - # `--locked` so the committed Cargo.lock is actually enforced: without it - # cargo silently resolves and rewrites the lockfile in CI, and a stale or - # missing entry never fails the build. + # Two features gate code that no step in `lint` reaches, so both would rot + # silently between merges. + # + # `beacon-spec-tests` guards the beacon spec-test target, which declares it as + # a required feature so that `cargo test` skips a suite whose fixtures are a + # multi-gigabyte download. `--all-targets` in `lint` therefore never builds + # it. The `beacon-spec-tests` job below does download them and run the suite + # for real, so this job is not what proves the runners work; it is here + # because that job is by far the slowest in the workflow, and because this is + # the only place the runners are linted at `-D warnings`. + # + # `preset-minimal` is a whole second compilation, because the beacon + # containers' SSZ bounds are const-generic arguments and everything in `lint` + # builds the default (mainnet) preset only. The fixture-reading unit tests + # stay `ignore`d here, since `beacon-spec-tests` is off. + # + # `--lib`: this job downloads no fixtures, and the integration targets + # (`ssz_spectests`, `stf_spectests`) walk the leanSpec tree, which only `test` + # has. Both presets, not just the default: the spec suite's `genesis` runner + # is `#![cfg(feature = "preset-minimal")]`, so a mainnet build type-checks + # every runner except the sole coverage `beacon::genesis` has. + presets: + name: Test minimal preset + runs-on: ${{ vars.ETHLAMBDA_RUNNER || 'ubuntu-latest' }} + steps: + - uses: actions/checkout@v6 + + - uses: ./.github/actions/free-disk + + - name: Setup Rust + uses: dtolnay/rust-toolchain@master + with: + toolchain: "1.97.1" + components: clippy + + # Its own entry, keyed on the job id by the action's default. These + # builds share almost no artifacts with `lint`'s, so on one key each run + # would evict the other's. + - name: Setup cache + uses: Swatinem/rust-cache@v2 + + - name: Check the beacon spec-test suite compiles + run: | + cargo clippy -p ethlambda-state-transition --all-targets --features beacon-spec-tests -- -D warnings + cargo clippy -p ethlambda-state-transition --all-targets --features beacon-spec-tests,preset-minimal -- -D warnings + + - name: Check the minimal preset + run: | + cargo test -p ethlambda-types --lib --features preset-minimal + cargo test -p ethlambda-state-transition --lib --features preset-minimal + + # tooling/event-monitor declares its own [workspace] table, so both jobs above + # stop at the root members and never reach it. Hence its own job, and the + # `workspaces` input below pointing the cache at its target dir. + # + # `--locked` so the committed Cargo.lock is actually enforced: without it + # cargo silently resolves and rewrites the lockfile in CI, and a stale or + # missing entry never fails the build. + tooling: + name: Tooling + runs-on: ${{ vars.ETHLAMBDA_RUNNER || 'ubuntu-latest' }} + steps: + - uses: actions/checkout@v6 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@master + with: + toolchain: "1.97.1" + components: rustfmt, clippy + + - name: Setup cache + uses: Swatinem/rust-cache@v2 + with: + workspaces: tooling/event-monitor + - name: Lint tooling working-directory: tooling/event-monitor run: | @@ -73,17 +150,53 @@ jobs: working-directory: tooling/event-monitor run: cargo test --locked + # The workspace suite in two halves; `CONSENSUS_CRATES` in the Makefile + # defines them and says why. Both need the leanSpec fixtures and share one + # cache entry for them, so only the first leg to arrive downloads anything. test: - name: Test - runs-on: ubuntu-latest + name: Test (${{ matrix.half }}) + runs-on: ${{ vars.ETHLAMBDA_RUNNER || 'ubuntu-latest' }} + strategy: + # Which half broke is most of the triage, so report both. + fail-fast: false + matrix: + half: [consensus, node] steps: - uses: actions/checkout@v6 + - uses: ./.github/actions/free-disk + - name: Run fixture-based tests uses: ./.github/actions/run-fixture-tests + with: + make-target: test-${{ matrix.half }} + + # Validates the benchmark harness and its JSON contract. This used to be a + # step in `Test (node)`, on the theory that the binary was already built there. + # It was not: `cargo test` never links the plain binary, and it builds with the + # dev-dependencies' features unified in (`bin/ethlambda` enables tokio's + # `test-util`), which `cargo run` leaves out. So the step rebuilt tokio and + # everything above it (libp2p, hyper, axum, the ethrex crates) into a target + # dir the test build had already brought to the disk ceiling, and the job then + # failed at random in the cache save step with ENOSPC. Here it builds only the + # binary's own graph. No fixtures: with `--mock-crypto` the synthetic benchmark + # generates its chain and reads no files. + benchmark-smoke: + name: Benchmark smoke + runs-on: ${{ vars.ETHLAMBDA_RUNNER || 'ubuntu-latest' }} + steps: + - uses: actions/checkout@v6 + + - uses: ./.github/actions/free-disk + + - name: Setup Rust + uses: dtolnay/rust-toolchain@master + with: + toolchain: "1.97.1" + + - name: Setup cache + uses: Swatinem/rust-cache@v2 - # Reuses the release build from the test step; validates the benchmark - # harness end-to-end and its JSON output contract in a few seconds. - name: Benchmark smoke (mock crypto) run: | cargo run --locked --profile release-fast --bin ethlambda -- benchmark synthetic --mock-crypto \ @@ -127,3 +240,64 @@ jobs: fi done exit $status + beacon-spec-tests: + name: Beacon spec tests (${{ matrix.preset }}) + runs-on: ${{ vars.ETHLAMBDA_RUNNER || 'ubuntu-latest' }} + # Far and away the longest job here: two full fixture trees to fetch and a + # release-grade build before the first case runs. Bounded well above what a + # healthy run takes, purely so a hang is reaped in tens of minutes rather + # than sitting on a runner for GitHub's six-hour default. + timeout-minutes: 120 + strategy: + # Report both presets rather than cancelling the second the moment the + # first fails. They walk different fixture trees, and whether a failure is + # preset-specific or common to both is most of the triage. + fail-fast: false + matrix: + preset: [mainnet, minimal] + env: + # Read by the Makefile, which declares this `?=` and so defers to the + # environment. A run reads its own preset's tree plus `general` and nothing + # else, so fetching the other preset's would be the largest single download + # in the workflow spent on files no case opens. + CONSENSUS_SPEC_TESTS_CONFIGS: general ${{ matrix.preset }} + steps: + - uses: actions/checkout@v6 + + - uses: ./.github/actions/free-disk + + - name: Setup Rust + uses: dtolnay/rust-toolchain@master + with: + toolchain: "1.97.1" + + # Keyed per preset. The preset fixes the beacon containers' SSZ bounds, + # which are const-generic arguments, so the two legs share no artifacts at + # all; on one key they would evict each other on every single run. + - name: Setup cache + uses: Swatinem/rust-cache@v2 + with: + key: beacon-${{ matrix.preset }} + + # Deliberately not cached. The trees expand to several GiB, which against a + # repository-wide 10 GiB cache budget would evict the Rust build caches + # that actually save time here. The assets are pinned to a release and + # served from the same CDN as the runner, so re-fetching is the cheap half + # of that trade. + # + # Its own step rather than leaning on the make prerequisite, so the log + # separates download time from build-and-test time and a fixture outage is + # not reported as a test failure. + - name: Download the consensus spec test fixtures + run: make consensus-spec-tests + + - name: Download the gossip validation test fixtures + run: make consensus-spec-gossip-tests + + - name: Run the Beacon Chain spec tests + run: make test-beacon-${{ matrix.preset }} + + # See the same step in the run-fixture-tests action. + - name: Report disk usage after the build + if: always() + run: df -h / diff --git a/.github/workflows/daily_ci.yml b/.github/workflows/daily_ci.yml index db1034ae0..ab349d353 100644 --- a/.github/workflows/daily_ci.yml +++ b/.github/workflows/daily_ci.yml @@ -26,19 +26,32 @@ permissions: env: CARGO_NET_GIT_FETCH_WITH_CLI: "true" CARGO_NET_RETRY: "10" + # See the same setting in ci.yml. This workflow runs the identical job, so it + # is under the identical disk pressure. + CARGO_INCREMENTAL: "0" jobs: + # The same two halves ci.yml runs, and for the same reason. test: - name: Test + name: Test (${{ matrix.half }}) runs-on: ubuntu-latest + strategy: + # A daily run exists to say what is broken, so report both halves. + fail-fast: false + matrix: + half: [consensus, node] steps: - uses: actions/checkout@v6 - name: Run fixture-based tests uses: ./.github/actions/run-fixture-tests + with: + make-target: test-${{ matrix.half }} notify: name: Post result to Slack + # `result` is already the worst outcome across the matrix legs, so a green + # report means every half was green. needs: test # Run even when the test job fails or is cancelled, so we always report. if: always() diff --git a/.gitignore b/.gitignore index d8ed6cf6f..987e71652 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,13 @@ target # Used for generating test vectors /leanSpec +# Beacon Chain spec test fixtures, downloaded by `make consensus-spec-tests` +/consensus-spec-tests + +# The gossip validation vectors, from a newer release, downloaded by +# `make consensus-spec-gossip-tests` +/consensus-spec-tests-gossip + # Personal Claude Code overrides (not shared with team) CLAUDE.local.md @@ -49,3 +56,7 @@ devnet.env # Filled-in copy of the multi-server-devnet server inventory (names hosts/ips) devnet.inventory + +# macOS Finder metadata. Added after one was committed by accident: this repo is +# developed on macOS, and a stray one is pure noise in a diff. +.DS_Store diff --git a/CLAUDE.md b/CLAUDE.md index f6737894c..747989a13 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,10 +9,19 @@ Not to be confused with Ethereum consensus clients AKA Beacon Chain clients AKA **Rust version:** 1.97.1 (edition 2024) **Test fixtures release:** Download latest production fixtures from leanSpec releases -## Codebase Structure (12 workspace crates) +## Codebase Structure (13 workspace crates) ``` bin/ethlambda/ # Entry point, CLI, orchestration + ├─ src/main.rs # run_node: one entry point for both chains (see below) + ├─ src/cli.rs # Options { common, network: Lean | Mainnet } + ├─ src/command.rs # Sub-command dispatch + default-subcommand injection + ├─ src/beacon.rs # Beacon wire params derived from a resolved network: epoch, fork digest + ├─ src/checkpoint_sync.rs # Checkpoint sync for both chains (lean's `/lean/v0/...`, beacon's Beacon API) + ├─ src/network/ # --network resolution: built-in name vs. directory of published files + │ └─ built_in.rs # Built-in chains (mainnet, sepolia, hoodi) and their genesis constants + ├─ assets/{mainnet,sepolia,hoodi}/ # config.yaml + bootstrap_nodes.yaml (eth-clients/'s files) + ├─ tests/fixtures/networks/mainnet/genesis.ssz # Mainnet genesis state, test-only └─ src/version.rs # Build-time version info (vergen-git2) crates/ blockchain/ # State machine actor (GenServer pattern) @@ -31,6 +40,7 @@ crates/ common/ ├─ types/ # Core types (State, Block, Attestation, Checkpoint) ├─ crypto/ # XMSS sign/verify + aggregation (leanVM wrapper) + ├─ ssz-tree/ # Persistent Merkle tree (List/Vector) behind the beacon registry and balances ├─ metrics/ # Prometheus re-exports, TimingGuard, gather utilities └─ test-fixtures/ # Spec-fixture loading (prod dep of rpc's Hive test driver) net/ @@ -87,6 +97,11 @@ make lint # Clippy with -D warnings make test # All tests + forkchoice spec tests ``` +`make test` is `test-consensus` plus `test-node`, two halves CI runs as separate +jobs because together they no longer fit a runner's disk. `CONSENSUS_CRATES` in +the Makefile names the first; `test-node` is the workspace minus it. Run one half +directly when iterating on it. + ### Common Operations ```bash rm -rf leanSpec && make leanSpec/fixtures # Download latest released test fixtures @@ -310,16 +325,35 @@ actual_slot = finalized_slot + 1 + relative_index ## Networking (libp2p) ### Protocols -- **Transport**: QUIC over UDP (TLS 1.3), plus TCP (noise + yamux) on the same port number as a fallback: a peer whose advertised `quic` doesn't answer can still be reached over TCP, and libp2p races both addresses within one dial (list order confers no preference; the default `dial_concurrency_factor` starts both handshakes) - - Binding TCP puts `--gossipsub-port` in the HTTP servers' namespace, so it must now differ from `--api-port`/`--metrics-port` too. `NodeOptions::validate_ports` rejects every clash before anything binds +- **Transport**: QUIC over UDP (TLS 1.3), plus TCP (noise, then yamux or mplex) on the same port number as a fallback: a peer whose advertised `quic` doesn't answer can still be reached over TCP. Both addresses go into one dial, `quic` first, and `DIAL_ADDRESS_CONCURRENCY` pins `dial_concurrency_factor` to one, so the order is a real preference and TCP is tried only after the QUIC attempt fails. Mainnet beacon peers answer `na` to a yamux-only proposal, so TCP connections negotiate mplex (see the `muxers` module doc), which is expensive: preferring QUIC is how that cost is avoided where the peer allows it + - Binding TCP puts `--gossipsub-port` in the HTTP servers' namespace, so it must now differ from `--api-port`/`--metrics-port` too. `Options::validate_ports` rejects every clash before anything binds - **Gossipsub**: Blocks + Attestations (snappy raw compression) - Topic: `/leanconsensus/{fork_digest}/{block|aggregation|attestation_N}/ssz_snappy` - `fork_digest` is a 4-byte hex string (no `0x` prefix); currently the dummy `12345678` agreed across clients - Mesh size: 8 (6-12 bounds), heartbeat: 700ms + - Beacon wire: `validate_messages()` is on, so every beacon message waits for a verdict (~4.2s before gossipsub's cache evicts it). Rules in `state_transition::beacon::gossip` (cheap half inline, stateful half on a bounded `spawn_blocking` task); plumbing in `p2p/src/beacon/verdict.rs`. Lean gossip still auto-forwards + - Data columns: every check runs in p2p. A column gossip did not accept (`Queue`/`Overloaded`), every fetched column, and parked columns replayed after their parent imports go through `column::chain_checks` in `p2p/src/beacon/column_checks.rs`. The chain actor stores what it gets unchecked; only debug builds re-run `chain_checks` there + - Beacon subscribes seven global topics plus two node-id-derived subnet families: custody + columns and backbone attestation subnets. `beacon_aggregate_and_proof` and + `beacon_attestation_{subnet_id}` validate in p2p like blocks/columns, with their own + permit pool. The actor applies only accepted aggregates, attesting indices already + gossip-verified, held one slot per `validate_on_attestation`'s `current_slot >= data.slot + + 1`; subnet attestations are verified and relayed but never applied. See + [`docs/beacon_wire.md`](docs/beacon_wire.md) - **Req/Resp**: Status, BlocksByRoot, BlocksByRange (snappy frame compression + varint length) - -### Peer Discovery (discv5, opt-in) -- Off by default; `--discovery.enable` plus `--discovery.port` (own UDP socket, must differ from `--gossipsub-port`) + - Beacon adds `beacon_blocks_by_{range,root}/2` alongside its Status/Ping/MetaData/Goodbye set. + Both serve from the checkpoint-anchored store, and `build_status` advertises it + (head, finalized checkpoint, and the anchor's slot as `earliest_available_slot`), so peers + have a reason to ask. Asking runs too: a peer's Status starts a range session paced by + `P2PServer::beacon_fetched_through`, the highest slot handed to the chain actor, rather + than the store's head, which trails a delivered batch by the whole actor mailbox. + Fetched blocks reach the actor as `BlockSource::Sync`. + Their chunks carry four `` (the block's own epoch's fork digest), which is why + `BeaconWire` and the codec carry `genesis_validators_root`. See [`docs/beacon_wire.md`](docs/beacon_wire.md) + +### Peer Discovery (discv5) +- Always on for `beacon` (mainnet bootnode ENRs are not statically dialable, so a crawl is its only way to find a peer); opt-in for `node` behind the lean-only `--discovery.enable`, off by default, so a lean node peers from `--bootnodes` alone and binds no discovery socket. `Network::discovery_enabled` is the one answer; `P2P::spawn` takes `Option` and `P2PServer.discovery` is an `Option`, `None` leaving the dial loop unscheduled +- Where it runs: `DEFAULT_DISCOVERY_PORT` (9000) unless `--discovery.port` says otherwise (own UDP socket, must differ from `--gossipsub-port`; checked once by `Options::validate_ports`, which skips the discovery rules when it is off). Co-located nodes that run discovery must each pass `--discovery.port` - Reuses ethrex's `DiscoveryServer` + `PeerTable` with discv4 disabled; `spawn` takes the prepared lean ENR, so the record ethrex serves is the one we report - ENR follows the beacon phase0 spec: `ip`/`udp`/`quic`/`tcp`/`secp256k1`/`eth2`/`attnets` - Admission mirrors lighthouse: `eth2.fork_digest` must match, `next_fork_*` may differ, a `quic` or `tcp` entry required. Handed to the peer table as `LeanFilter: PeerFilter`, so records are judged on arrival, not at dial time; a reject is re-judged on a higher-`seq` ENR @@ -332,12 +366,342 @@ actual_slot = finalized_slot + 1 + relative_index ### Message IDs - 20-byte truncated SHA256 of: domain (valid/invalid snappy) + topic + data +## One startup path for both chains (`bin/ethlambda/src/main.rs`) + +`ethlambda node` and `ethlambda beacon` are the same entry point. Each parses +into one `cli::Options { common, network }`, where `network` is +`Network::Lean(LeanOptions)` or `Network::Mainnet { mainnet, execution }`. Each +variant carries that chain's own flags, so a flag one chain does not take is +unreachable on the other's path by construction rather than by an `Option` +nobody unwraps. `MainnetOptions` holds the three flags `beacon` has of its own: +`--custody-group-count`, `--safe-slots-to-import-optimistically`, and +`--network` (default `mainnet`). The last is carried unresolved: `run_node` is +where a bad value is reported. + +`run_node` owns everything that is not chain-specific, in order: the discv5 +port check, metrics registration, the banner and version log, the +`RLIMIT_NOFILE` raise, the `HIVE_LEAN_TEST_DRIVER` early return (lean-only, and +it must still precede key loading), `--node-key` resolution, resolving +`--network` into a `NetworkSource` (mainnet only; must precede the next step, +since a loaded network's own bootnode list is part of its fallback), reading +`--bootnodes`, and building the aggregator, sync-status and event handles. + +It then branches **once**, on `network`, both arms inline, each evaluating to a +`ChainSetup`: the wire configuration, the ENR entries that describe it, the +`Store` the req/resp handlers answer from, the node-name roster, and a +`ChainActor` saying what to spawn this chain's actor with (lean's validator +keys and `BlockChainConfig`, or beacon's `Beacon` marker). The rest of the +discv5 configuration is the node key, the ports, the bootnodes and the peer +target, which are operator input and identical either way, so `run_node` fills +those in once below the match rather than having each arm repeat them. +Everything after that match is shared again: one `build_swarm`, one +`P2P::spawn`, one `start_rpc_server`, and one chain actor, since `ChainActor` +selects between `BlockChain::spawn` and `BlockChain::spawn_beacon` rather than +between spawning one and not. The `InitP2P`/`InitBlockChain` wiring and +`wait_for_shutdown` are then shared too. + +`ChainSetup` is a data bag, not an abstraction. The match that fills it stays +inline, because moving it into a method would relocate the branch rather than +remove it. + +Two consequences of "one call site" worth knowing. `P2P::spawn` now runs +*before* `BlockChain::spawn`, so gossip arriving in between hits a `P2PServer` +whose `blockchain` is still `None` and is dropped; every access is an `if let +Some`, and the window is a handful of statements. And `beacon` now binds +`--api-port` off a real, DB-backed anchored `Store`, not an empty in-memory +placeholder: checkpoint sync or resume gives it one, the same way `node` gets +its own, and it serves the **Beacon API** off it rather than the lean-shaped +`/lean/v0/...` routes, which read metadata keys and state variants a beacon +directory never carries. Which surface a node serves follows from +`Store::chain()`, not from the sub-command, so the two cannot disagree; a +`/lean/v0` path on a `beacon` run is a 404 rather than the panicked request it +used to be. + +`RunningNode.blockchain` is a plain `BlockChain`, not an `Option`: the beacon +follower runs a chain actor too, so there is always exactly one to stop and +join, and `run_node` spawns and wires it before building the struct. Mainnet +therefore gets the same graceful shutdown, metrics bootstrap and fd-limit raise +lean does (it used to park on `std::future::pending()`). + +`docs/cli.md` has the step-by-step table. + +### Built-in networks + +`beacon` takes a `--network` flag (built-in name `mainnet` (default), +`sepolia` or `hoodi`, or a path to a directory of published network files) +and resolves it into a `NetworkSource` before doing anything else; see +`bin/ethlambda/src/network/`. `genesis_time` and `genesis_validators_root`, +which the fork digest keying every gossip topic, the ENR `eth2` entry and +discv5 admission are computed from, come from that resolved network. + +Every built-in chain is a `network::built_in::EmbeddedChain`: its +`eth-clients` repo's `config.yaml` and `bootstrap_nodes.yaml` byte for byte +(`bin/ethlambda/assets//`), parsed through the same +`ConfigFile::parse`/bootnode reader a directory goes through, plus the two +genesis values as constants. None carries a **genesis state**: a built-in +network never anchors at genesis, and the states are 5 MB (mainnet) to 150 MB +(Hoodi). The constants are checked offline (mainnet's against +`tests/fixtures/networks/mainnet/genesis.ssz`, eth-clients' file, whose SHA-256 +`beacon::tests::the_fixture_state_is_eth_clients_file` pins; Sepolia's and +Hoodi's against fork digests published in their own bootnode ENRs), and at +runtime by checkpoint sync and resume, which both check the anchor state +against them. `beacon::tests::the_built_in_network_derives_what_it_always_did` +pins the parsed mainnet config to `Config::mainnet()`, so the file and the +Rust constant cannot drift apart. Sepolia's config schedules gloas, which this +build cannot process, so a Sepolia follower stops tracking the chain at +`GLOAS_FORK_EPOCH`; the ignored-keys warning at startup names it. + +A loaded network decodes its own `genesis.ssz` instead, at whatever fork its +own schedule names for epoch 0. Every `config.yaml`, built-in or loaded, goes +through two checks as soon as it parses, and a failure is a hard startup +error. For a directory they run before its `genesis.ssz` is decoded, since +the compiled preset sets that state's container bounds and a mismatch would +otherwise surface as an SSZ error: + +- `check_preset`: `PRESET_BASE` must name the compiled preset. +- `check_constants`: every key the node runs on a compile-time constant for + instead of reading `Config` (the custody counts, subnet counts, + `MAX_REQUEST_*`, `MAX_PAYLOAD_SIZE`, the snappy message domains, + `MAXIMUM_GOSSIP_CLOCK_DISPARITY`) must equal that constant. + `/eth/v1/config/spec` reports these keys off the stored `Config`, so this is + what keeps it from reporting a value the node does not use. A new such + constant needs a line in `check_constants`. + +Two consequences, on the built-in arm. `beacon` takes **no genesis** +configuration of its own the way `node` does: `genesis_time` and +`genesis_validators_root` need nothing from the command line beyond +`--network`. And a build with `ethlambda-types/preset-minimal` on refuses every +built-in network, since each declares `PRESET_BASE: mainnet`; nothing enables +that feature for this binary, and failing is the right answer if anything does. + +`--checkpoint-sync-url` is no longer merely accepted-but-unused on `beacon`: +the anchor work has landed, and this flag is now how `beacon` fetches its +finalized `BeaconState` and anchor block from a standard Beacon API server. +The full anchor precedence is a resumable data directory, then this URL, then, +for a loaded network only, the resolved network's own `genesis.ssz`, then +abort. The URL is therefore required on a fresh data directory only for the +built-in networks, since they alone have no genesis-sync path (see +`docs/checkpoint_sync.md`). + +These genesis values used to come from a Beacon API's `/eth/v1/beacon/genesis`, +which made a `beacon` run depend on a checkpoint provider being reachable. They +are properties of the chain, not of a provider. Checkpoint sync itself is a +separate concern, and now runs on both chains: lean still fetches a +*finalized* anchor, which genuinely has no local source, and `beacon` fetches +one too. The URL cleaning, base-URL trim and first-success fan-out that the +old genesis-fetching path and lean's checkpoint sync once shared through a +`checkpoint_common` module live in `checkpoint_sync.rs`, which today serves +both chains' anchor fetches rather than lean's alone. + ## HTTP Servers (API + Metrics) The RPC crate serves the API router (`--api-port`, default 5052) and the metrics/debug routers (`--metrics-port`, default 5054). When the two ports differ it binds two independent Axum servers; when they are equal it merges all three routers onto a single listener, so pointing both flags at -one port is supported and not a misconfiguration. See [`docs/rpc.md`](docs/rpc.md) for the full reference: CLI flags and defaults, the API endpoints (health, finalized state/block, justified checkpoint, blocks by root/slot, fork-choice tree + D3.js UI, runtime aggregator toggle), the metrics/debug endpoints (Prometheus `/metrics`, jemalloc heap profiling), the Hive test-driver endpoints, plus request/response shapes, status codes, and content types. +one port is supported and not a misconfiguration. + +Both sub-commands bind from one site in `run_node`, which picks the API router by +`Store::chain()` rather than by sub-command, so the surface and the data behind it cannot +disagree. `node` calls `start_rpc_server` and gets `/lean/v0`; `beacon` calls +`start_beacon_rpc_server` and gets the **Beacon API** under `/eth/v1` and `/eth/v2` +(`crates/net/rpc/src/beacon/`, one file per endpoint group). Both reach it with real +handles: the DB-backed anchored `Store` the `P2PServer` already holds, plus a clone of the +same `SyncStatusController` the chain actor writes to. The beacon arm takes no +`AggregatorController` and no `EventBus`, because a follower has no aggregator duty to +toggle and the chain-events stream is part of the lean surface. It does take the P2P +actor's `RpcToP2PRef` (`crates/net/api`), the one path by which this node gossips on the +beacon wire: `POST /eth/v2/beacon/pool/attestations` validates a validator client's +attestations and publishes them through it. + +The two API routers are alternatives, never merged. A `/lean/v0` path on a `beacon` run is +a 404: those handlers read lean state variants and metadata keys a beacon directory never +carries, so serving both off one store would answer lean questions with beacon data, or +panic trying. `crates/net/rpc/tests/http_servers.rs` pins both halves of that. +`start_http_servers(config, api_router, shutdown)` still takes `api_router` as an `Option` +and that test still covers the `None` arm, which is now the test-driver-less shape rather +than the beacon one. + +Encoding differs between the two surfaces, deliberately. The Beacon API serves **JSON by +default** and SSZ on `Accept: application/octet-stream`, quotes every integer, and tags +fork-versioned responses with `Eth-Consensus-Version`. The lean surface keeps **SSZ as the +default** on its two SSZ endpoints and bare integers in JSON, because +`checkpoint_sync.rs` and other clients' lean sync read those bytes and may send no +`Accept` at all; `/lean/v0/blocks/finalized` now answers JSON when asked for it by name. + +See [`docs/rpc.md`](docs/rpc.md) for the full reference: CLI flags and defaults, the lean API endpoints (health, finalized state/block, justified checkpoint, blocks by root/slot, fork-choice tree + D3.js UI, runtime aggregator toggle), the Beacon API endpoints and the three ids they refuse, the metrics/debug endpoints (Prometheus `/metrics`, jemalloc heap profiling), the Hive test-driver endpoints, plus request/response shapes, status codes, and content types. + +## Beacon Chain types (`crates/common/types/src/beacon/`) + +`ethlambda-types` carries the **Ethereum Beacon Chain** containers (phase0 +through fulu) alongside lean's own types. The Beacon Chain is a different +protocol from the Lean consensus this repo implements; the types share a crate +so that one `BlockChainServer` can dispatch on a single state type instead of +existing once per chain. + +- `BeaconState` has a **`Lean` variant holding lean's `State`**, and `ForkName` + a matching `Lean`. Only `fork_name()` and `from_ssz()` handle it; every other + beacon accessor answers `unreachable!()` naming itself. The guarantee is the + single `match` at the top of each handler, not the type system, so a lean + state reaching a beacon accessor should fail as a named panic rather than a + silent wrong answer. +- **`ForkName::Lean` is deliberately absent from `ForkName::ALL`.** `ALL` is + what `parse`, `previous` and `next` search, so its absence keeps + `parse("lean")` at `None` and `Fulu.next()` at `None`, meaning a fork upgrade + cannot walk off the end into lean. Lean is not a point on the Beacon Chain's + fork timeline. `Lean` is declared *last* so the derived `Ord` puts it after + every beacon fork, which is what `fork >= ForkName::X` gating reads. +- Preset is a **compile-time** choice (`preset-minimal` feature on this crate) + because SSZ container bounds are const-generic arguments; fork scheduling is + runtime (`beacon::config`) instead. Every lean crate depends on + `ethlambda-types`, so enabling the feature rebuilds it for the whole graph; + lean code reads none of the beacon preset constants, so it cannot change lean + behavior. +- Per-fork containers are plain structs behind an enum, so SSZ stays derived: + two of phase0's fields are *replaced* in altair, one field changes type in + five separate forks, and the state's merkle tree gains a level at electra. +- **`beacon::primitives::Root` *is* `primitives::H256`**, not a second 32-byte + hash converted at the boundary, and `beacon::primitives::HashTreeRoot` + re-exports lean's convenience trait rather than declaring its own. The + primitive family a beacon container needs beyond that is two newtypes, + `H160` and `U256`, so the crate declares them instead of depending on + `ethereum-types`. Two consequences worth knowing: + - `U256` holds the **32 little-endian bytes SSZ encodes it as**, so its + stored byte order is the reverse of its numeric one and `Ord` is written + out by hand. Do not derive it: a derived `Ord` compares the least + significant byte first, which would silently invert + `terminal_total_difficulty` comparisons. + - `H160` writes out `libssz_merkle::HashTreeRoot` because the derive drops + `is_basic_type`, and at 20 bytes wide that answer changes the merkle tree + of any list or vector of addresses. Everything else here is 32 bytes, + where packing and per-element padding coincide, so the derive is fine. +- **Requires mutable element access on `SszList`**, which no published libssz + release has, so all four libssz crates are **git dependencies** on + `lambdaclass/libssz` (for lambdaclass/libssz#33), pinned by `rev` rather than + tracking `main`: this is the SSZ encoder and merkleizer behind every + `hash_tree_root`, so a routine `cargo update` must not be able to move it. + Return them to a crates.io version once a release carries #33. A git dependency rather than a + `[patch.crates-io]` override because nothing outside this workspace depends on + libssz, so there is no second copy to unify; that also keeps the manifest free + of a `[patch]` table, which `shadow/cargo-patch.toml` would collide with. +- The state transition consuming these containers is **not** in this repo yet; + it lives on `feat/beacon-chain-stf`, where these types are verified against + consensus-specs v1.6.1 (5705 mainnet / 40009 minimal cases). What runs here + is the containers' own round-trip and shape tests. +- **`Validators` and `Balances` are `ethlambda_ssz_tree::List`s**, persistent + Merkle trees that cache node hashes and share unchanged subtrees between + states through `Arc`; they have no slices and no `iter_mut`. A leaf holds a + page-sized run of elements rather than one chunk, and an inner node a page of + child pointers spanning several binary levels, so a lookup crosses a handful + of nodes and a rebuilt leaf or node copies one page. Writes are + buffered until `BeaconState::apply_pending_mutations`, which the state + transition calls before every state-root computation. A state decoded from + storage is rebased onto a cached one (`Store::get_state`). + - **`state.validator(i)` and `balances()[i]` are tree descents, not array + indexing.** A loop over the registry should walk `validators().iter()` + (zipped with `balances().iter()` where it needs both), not index per + validator: helpers that build the active-index `Vec` and then read each + index back were the largest cost left in the import profile + (`docs/beacon_stf.md`, "Registry and balances"). + +## Beacon Chain STF (`crates/blockchain/state_transition/src/beacon/`) + +The **Ethereum Beacon Chain** consensus specs (phase0 through fulu), a different +protocol from the Lean consensus the rest of this repo implements, live in the +`beacon` module of `ethlambda-state-transition` beside lean's own state +transition. The module holds the *behavior* (state transition, fork choice, +helpers, BLS and KZG); the containers, presets, configuration and primitives it +transitions are in `ethlambda-types`, per the section above. Nothing above +`beacon` reads anything inside it, and nothing inside it reads lean's modules. + +- **`blst` and `c-kzg` are now on the lean binary's dependency path**, since + `ethlambda-blockchain`, `ethlambda-rpc` and `ethlambda-test-fixtures` all + depend on this crate. That is the cost of one crate holding both chains' + rules; the module is not feature-gated. +- The `beacon_aggregate_and_proof`/`beacon_attestation_{subnet_id}` gossip rules + (committees, `is_aggregator`, all the signatures) live in + `gossip::{aggregate,attestation}`, validated in `ethlambda-p2p` off the vote + block's own cached post-state, not the chain actor. `aggregate.rs` keeps only + what the actor's applied-bits gate still needs (`is_non_strict_superset`, + `MAX_AGGREGATES_PER_SLOT`); `fork_choice::apply_verified_aggregate` is + apply-only, no committee lookup or signature check left in it. `das.rs` is + the one module still holding `p2p-interface.md` rules here rather than in + p2p: it needs no state, but the `networking` fixture suite has handlers for + `get_custody_groups` and `compute_columns_for_custody_group`, and that runner + lives in this crate's `tests/beacon_spec/`. The attestation-subnet backbone's + own subnet-selection math is neither, so it lives with the wire code it + serves, in `ethlambda-p2p`'s + `beacon::subnets`. +- Tests: `make test-beacon` (builds once per preset), or `test-beacon-mainnet` / + `test-beacon-minimal` for one. CI runs the two as a job each, so they build and + run concurrently. `make test` covers the whole + workspace, in two halves, and still needs no fixture download: the + `beacon_spec_tests` target declares `required-features = ["beacon-spec-tests"]` + so `cargo test` skips it, and the BLS and KZG fixture vectors, which are unit + tests inside the module, are `#[cfg_attr(not(feature = ...), ignore)]` so they + report as ignored rather than silently absent. Turning the feature on is what + `make test-beacon` does, and it runs `--lib` too so those 15 are not missed. +- Every fixture case is its own test, named `////`, + so a failure names the case and not the suite around it. The spec binary + therefore supplies its own harness (`harness = false`), since a case is only + known once the fixture tree is walked. A substring filter selects a whole + suite or one case: `cargo test -p ethlambda-state-transition --test + beacon_spec_tests --features beacon-spec-tests -- electra/attester_slashing`. +- Fixtures: `make consensus-spec-tests`, pinned to a `consensus-specs` release. + The tree is stamped with the version *and* the configs it holds, so changing + either wipes and re-downloads rather than leaving the old cases in place and + silently green, or marking a partial tree complete. + `CONSENSUS_SPEC_TESTS_CONFIGS` narrows the download: a run reads its own + preset's tree plus `general` and nothing else, which is what each CI job sets. +- Preset is a **compile-time** choice (`preset-minimal` feature) because SSZ + container bounds are const-generic arguments; fork scheduling is runtime + because the `transition` suite moves fork epochs per case. +- Per-fork containers are plain structs behind an enum, so SSZ stays derived. See + [`docs/beacon_stf.md`](docs/beacon_stf.md) for why, including the fork-by-fork + field counts and the merkle depth change at electra. +- The types are re-exported at their old paths (`crate::beacon::containers`, + `crate::beacon::preset`, `crate::beacon::config`, ...), so a use site inside + the module reads as if they were local, and + `ethlambda_state_transition::beacon::containers::X` and + `ethlambda_types::beacon::containers::X` name one type. Two things do move the + other way: `fork_choice` re-exports `LatestMessage` and `PowBlock` from types + (`ethlambda-storage` persists them), and `helpers::misc` re-exports + `compute_fork_data_root` (the networking crate needs the fork digest built on + it). +- **`BeaconState` and `ForkName` carry a `Lean` variant**, so every match on + either needs an arm for it. Nothing here can transition a lean value, so those + arms panic through `lean_state_unreachable`/`lean_fork_unreachable` + (`src/beacon/lean_boundary.rs`, same names and wording as `ethlambda-types`' + own `pub(crate)` pair) rather than widening a signature to a `Result` no + correct caller would see. Functions, not a macro, and `#[cold]` + + `#[track_caller]` so the panic still reports the arm that was reached rather + than `lean_boundary.rs`. In the spec tests the same arms call + `lean_is_not_a_fixture_fork`, which is `#[track_caller]` for the same reason, + since a case's fork is parsed from a directory name and `ForkName::ALL` has no + lean entry. Both are named arms rather than a + catch-all `_`, so a real new fork still breaks every match that must grow one. +- **Needs mutable element access on `SszList`/`SszVector`**, which no published + libssz release has yet. Nothing extra is required here: the workspace already + tracks all four libssz crates from git at `36802dd` for the beacon containers + in `ethlambda-types` (see the section above), and that rev is the `0.3.0` + release plus the single commit adding `DerefMut`/`IndexMut` + (lambdaclass/libssz#33). This module needs that commit for the same reason. +- **Status:** all seven forks (phase0 through fulu) have containers, fork + upgrades, state transitions, and epoch processing. Every fixture case passes + on both presets: mainnet is 5705 cases and minimal 40009. The crate's lib + target holds 200 tests with `beacon-spec-tests` on, 185 plus 15 ignored + without; both figures cover lean's own unit tests as well, since the two + chains now share one lib target. Fork choice is fixture-verified too: 150 mainnet + `fork_choice` cases pass, covering bellatrix's `on_merge_block`/terminal-PoW + validation, `should_override_forkchoice_update`, deneb's blob data + availability, and fulu's column data availability. +- Nothing is ignored for being unimplemented. Ignored cases are the + `LightClient*` containers (a different layer, out of scope) and the `gloas` + and `eip7805` fixture trees. Those two do not parse as a `ForkName`, so + `collect` would skip them silently; `UNMODELED_FORKS` names them and + `fixture_forks/every_directory_is_accounted_for` fails on any fork directory + that is neither parseable nor listed, so a new fork forces a decision. +- A fixture case with no `post` state asserts the input must be **rejected**. That + rule lives in `check_transition`; do not add a runner that ignores it. ## Configuration Files @@ -410,7 +774,7 @@ snapshot (`States`) + diff (`StateDiffs`) pairs; `BlockRoots` and `LiveChain` index by slot for range serving and fork choice. Attestations and gossip signatures are not persisted; they live in in-memory `Store` buffers consumed during the tick pipeline. See [`docs/data_storage.md`](docs/data_storage.md) -for the full reference: what each of the eight tables holds and how it's +for the full reference: what each of the ten tables holds and how it's keyed, the snapshot/diff reconstruction algorithm, the block-import write sequence, pruning rules, what never changes at runtime, and startup/restore behavior. @@ -420,7 +784,22 @@ behavior. - A `StateDiff` omits `config` and `validators`, trusting they never mutate; breaking that invariant would silently corrupt every reconstructed state. - `Metadata["config"]` is written once at bootstrap and never rewritten; it - doubles as the DB's genesis-time fingerprint on resume. + doubles as the DB's genesis-time fingerprint on resume. A beacon resume also + refuses a config file that changes a chain value in it + (`first_config_difference`: fork schedule, slot time, `PRESET_BASE`, ...), + but only warns about a changed `CONFIG_NAME`, a label with no consensus + effect. The stored name is the one the node keeps reporting. +- `PendingDataColumns` holds *unverified* sidecars parked until their block's + parent has a post-state. `data_column_indices_for` reads `DataColumns` only, + which is what keeps a parked column from satisfying the availability gate. + Its only index is the chain actor's in-memory `sidecars_awaiting_parent`, so + `start_actor` clears the whole table at startup. +- `DB_VERSION` is 4: `Config` gained `PRESET_BASE` and `CONFIG_NAME` (as + `ConfigName`, a bounded string) at the front of its encoding, and it is + SSZ-encoded under `KEY_CONFIG`, so a data directory written by an earlier + version decodes into the wrong fields. (3 was the runtime keys a + `config.yaml` supplies.) `Store::from_db_state` refuses any other version + outright; there is no migration. ### State Root Computation - Always computed via `hash_tree_root()` after full state transition diff --git a/Cargo.lock b/Cargo.lock index 6a65521ba..fcf2acfcd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -720,7 +720,9 @@ dependencies = [ "bitflags 2.11.1", "cexpr", "clang-sys", - "itertools 0.12.1", + "itertools 0.13.0", + "log", + "prettyplease", "proc-macro2", "quote", "regex", @@ -1939,22 +1941,27 @@ dependencies = [ "clap", "ethlambda-blockchain", "ethlambda-crypto", + "ethlambda-engine", "ethlambda-metrics", "ethlambda-network-api", "ethlambda-p2p", "ethlambda-rpc", + "ethlambda-state-transition", "ethlambda-storage", "ethlambda-types", + "ethlambda-validator", "eyre", "hex", "libc", - "libssz", - "libssz-types", + "libssz 0.3.0 (git+https://github.com/lambdaclass/libssz?rev=36802dd1d3e3a83d95d2ac552647539cbe3f7fd7)", + "libssz-types 0.3.0 (git+https://github.com/lambdaclass/libssz?rev=36802dd1d3e3a83d95d2ac552647539cbe3f7fd7)", "rayon", "reqwest", + "secp256k1 0.30.0", "serde", "serde_json", "serde_yaml_ng", + "sha2", "tempfile", "thiserror 2.0.18", "tikv-jemallocator", @@ -1971,6 +1978,7 @@ version = "0.1.0" dependencies = [ "datatest-stable", "ethlambda-crypto", + "ethlambda-engine", "ethlambda-fork-choice", "ethlambda-metrics", "ethlambda-network-api", @@ -1979,8 +1987,8 @@ dependencies = [ "ethlambda-test-fixtures", "ethlambda-types", "hex", - "libssz", - "libssz-types", + "libssz 0.3.0 (git+https://github.com/lambdaclass/libssz?rev=36802dd1d3e3a83d95d2ac552647539cbe3f7fd7)", + "libssz-types 0.3.0 (git+https://github.com/lambdaclass/libssz?rev=36802dd1d3e3a83d95d2ac552647539cbe3f7fd7)", "serde", "spawned-concurrency", "thiserror 2.0.18", @@ -2002,6 +2010,23 @@ dependencies = [ "zk_alloc", ] +[[package]] +name = "ethlambda-engine" +version = "0.1.0" +dependencies = [ + "base64", + "ethlambda-types", + "hex", + "hmac", + "reqwest", + "serde", + "serde_json", + "sha2", + "thiserror 2.0.18", + "tokio", + "tracing", +] + [[package]] name = "ethlambda-fork-choice" version = "0.1.0" @@ -2029,8 +2054,10 @@ dependencies = [ name = "ethlambda-p2p" version = "0.1.0" dependencies = [ + "either", "ethlambda-metrics", "ethlambda-network-api", + "ethlambda-state-transition", "ethlambda-storage", "ethlambda-types", "ethrex-common", @@ -2039,10 +2066,11 @@ dependencies = [ "futures", "hex", "libp2p", - "libssz", - "libssz-derive", - "libssz-merkle", - "libssz-types", + "libp2p-mplex", + "libssz 0.3.0 (git+https://github.com/lambdaclass/libssz?rev=36802dd1d3e3a83d95d2ac552647539cbe3f7fd7)", + "libssz-derive 0.3.0 (git+https://github.com/lambdaclass/libssz?rev=36802dd1d3e3a83d95d2ac552647539cbe3f7fd7)", + "libssz-merkle 0.3.0 (git+https://github.com/lambdaclass/libssz?rev=36802dd1d3e3a83d95d2ac552647539cbe3f7fd7)", + "libssz-types 0.3.0 (git+https://github.com/lambdaclass/libssz?rev=36802dd1d3e3a83d95d2ac552647539cbe3f7fd7)", "rand 0.8.6", "secp256k1 0.30.0", "sha2", @@ -2060,37 +2088,68 @@ version = "0.1.0" dependencies = [ "axum", "ethlambda-blockchain", + "ethlambda-engine", "ethlambda-fork-choice", "ethlambda-metrics", + "ethlambda-network-api", "ethlambda-state-transition", "ethlambda-storage", "ethlambda-test-fixtures", "ethlambda-types", + "ethlambda-validator", "futures-util", "hex", "http-body-util", "jemalloc_pprof", - "libssz", + "libssz 0.3.0 (git+https://github.com/lambdaclass/libssz?rev=36802dd1d3e3a83d95d2ac552647539cbe3f7fd7)", + "libssz-derive 0.3.0 (git+https://github.com/lambdaclass/libssz?rev=36802dd1d3e3a83d95d2ac552647539cbe3f7fd7)", + "libssz-types 0.3.0 (git+https://github.com/lambdaclass/libssz?rev=36802dd1d3e3a83d95d2ac552647539cbe3f7fd7)", "serde", "serde_json", + "spawned-concurrency", "tokio", "tokio-util", "tower", "tracing", ] +[[package]] +name = "ethlambda-ssz-tree" +version = "0.1.0" +dependencies = [ + "libssz 0.3.0 (git+https://github.com/lambdaclass/libssz?rev=36802dd1d3e3a83d95d2ac552647539cbe3f7fd7)", + "libssz-derive 0.3.0 (git+https://github.com/lambdaclass/libssz?rev=36802dd1d3e3a83d95d2ac552647539cbe3f7fd7)", + "libssz-merkle 0.3.0 (git+https://github.com/lambdaclass/libssz?rev=36802dd1d3e3a83d95d2ac552647539cbe3f7fd7)", + "libssz-types 0.3.0 (git+https://github.com/lambdaclass/libssz?rev=36802dd1d3e3a83d95d2ac552647539cbe3f7fd7)", + "proptest", + "rayon", +] + [[package]] name = "ethlambda-state-transition" version = "0.1.0" dependencies = [ + "blst", + "c-kzg", "datatest-stable", "ethlambda-metrics", + "ethlambda-storage", "ethlambda-test-fixtures", "ethlambda-types", "hex", - "libssz-types", + "libssz 0.3.0 (git+https://github.com/lambdaclass/libssz?rev=36802dd1d3e3a83d95d2ac552647539cbe3f7fd7)", + "libssz-derive 0.3.0 (git+https://github.com/lambdaclass/libssz?rev=36802dd1d3e3a83d95d2ac552647539cbe3f7fd7)", + "libssz-merkle 0.3.0 (git+https://github.com/lambdaclass/libssz?rev=36802dd1d3e3a83d95d2ac552647539cbe3f7fd7)", + "libssz-types 0.3.0 (git+https://github.com/lambdaclass/libssz?rev=36802dd1d3e3a83d95d2ac552647539cbe3f7fd7)", + "libtest-mimic", + "lru", + "num-bigint", + "rayon", "serde", "serde_json", + "serde_yaml_ng", + "sha2", + "snap", "thiserror 2.0.18", "tracing", ] @@ -2100,15 +2159,18 @@ name = "ethlambda-storage" version = "0.1.0" dependencies = [ "ethlambda-crypto", + "ethlambda-metrics", "ethlambda-types", - "libssz", - "libssz-derive", - "libssz-types", + "libssz 0.3.0 (git+https://github.com/lambdaclass/libssz?rev=36802dd1d3e3a83d95d2ac552647539cbe3f7fd7)", + "libssz-derive 0.3.0 (git+https://github.com/lambdaclass/libssz?rev=36802dd1d3e3a83d95d2ac552647539cbe3f7fd7)", + "libssz-types 0.3.0 (git+https://github.com/lambdaclass/libssz?rev=36802dd1d3e3a83d95d2ac552647539cbe3f7fd7)", "lru", + "proptest", "rocksdb", "tempfile", "thiserror 2.0.18", "tracing", + "xdelta3", ] [[package]] @@ -2118,8 +2180,8 @@ dependencies = [ "ethlambda-state-transition", "ethlambda-types", "hex", - "libssz", - "libssz-types", + "libssz 0.3.0 (git+https://github.com/lambdaclass/libssz?rev=36802dd1d3e3a83d95d2ac552647539cbe3f7fd7)", + "libssz-types 0.3.0 (git+https://github.com/lambdaclass/libssz?rev=36802dd1d3e3a83d95d2ac552647539cbe3f7fd7)", "serde", "serde_json", ] @@ -2129,17 +2191,55 @@ name = "ethlambda-types" version = "0.1.0" dependencies = [ "datatest-stable", + "ethlambda-ssz-tree", "ethlambda-test-fixtures", "hex", - "libssz", - "libssz-derive", - "libssz-merkle", - "libssz-types", + "libssz 0.3.0 (git+https://github.com/lambdaclass/libssz?rev=36802dd1d3e3a83d95d2ac552647539cbe3f7fd7)", + "libssz-derive 0.3.0 (git+https://github.com/lambdaclass/libssz?rev=36802dd1d3e3a83d95d2ac552647539cbe3f7fd7)", + "libssz-merkle 0.3.0 (git+https://github.com/lambdaclass/libssz?rev=36802dd1d3e3a83d95d2ac552647539cbe3f7fd7)", + "libssz-types 0.3.0 (git+https://github.com/lambdaclass/libssz?rev=36802dd1d3e3a83d95d2ac552647539cbe3f7fd7)", "rand 0.10.1", "serde", "serde_json", "serde_yaml_ng", + "sha2", + "thiserror 2.0.18", +] + +[[package]] +name = "ethlambda-validator" +version = "0.1.0" +dependencies = [ + "aes", + "async-trait", + "axum", + "blst", + "ctr", + "ethlambda-metrics", + "ethlambda-types", + "hex", + "hmac", + "http-body-util", + "libc", + "libssz 0.3.0 (git+https://github.com/lambdaclass/libssz?rev=36802dd1d3e3a83d95d2ac552647539cbe3f7fd7)", + "libssz-derive 0.3.0 (git+https://github.com/lambdaclass/libssz?rev=36802dd1d3e3a83d95d2ac552647539cbe3f7fd7)", + "libssz-types 0.3.0 (git+https://github.com/lambdaclass/libssz?rev=36802dd1d3e3a83d95d2ac552647539cbe3f7fd7)", + "pbkdf2", + "reqwest", + "scrypt", + "serde", + "serde_json", + "serde_yaml_ng", + "sha2", + "subtle", + "tempfile", "thiserror 2.0.18", + "tokio", + "tower", + "tracing", + "unicode-normalization", + "uuid", + "zeroize", ] [[package]] @@ -2157,8 +2257,8 @@ dependencies = [ "ethrex-storage", "ethrex-trie", "ethrex-vm", - "libssz", - "libssz-merkle", + "libssz 0.3.0 (registry+https://github.com/rust-lang/crates.io-index)", + "libssz-merkle 0.3.0 (registry+https://github.com/rust-lang/crates.io-index)", "rayon", "rustc-hash", "thiserror 2.0.18", @@ -2184,10 +2284,10 @@ dependencies = [ "indexmap 2.14.0", "lazy_static", "libc", - "libssz", - "libssz-derive", - "libssz-merkle", - "libssz-types", + "libssz 0.3.0 (registry+https://github.com/rust-lang/crates.io-index)", + "libssz-derive 0.3.0 (registry+https://github.com/rust-lang/crates.io-index)", + "libssz-merkle 0.3.0 (registry+https://github.com/rust-lang/crates.io-index)", + "libssz-types 0.3.0 (registry+https://github.com/rust-lang/crates.io-index)", "lru", "once_cell", "rayon", @@ -2236,10 +2336,10 @@ dependencies = [ "ethrex-rlp", "ethrex-vm", "hex", - "libssz", - "libssz-derive", - "libssz-merkle", - "libssz-types", + "libssz 0.3.0 (registry+https://github.com/rust-lang/crates.io-index)", + "libssz-derive 0.3.0 (registry+https://github.com/rust-lang/crates.io-index)", + "libssz-merkle 0.3.0 (registry+https://github.com/rust-lang/crates.io-index)", + "libssz-types 0.3.0 (registry+https://github.com/rust-lang/crates.io-index)", "rkyv", "serde", "serde_with", @@ -2274,7 +2374,7 @@ dependencies = [ "ethrex-common", "ethrex-crypto", "ethrex-rlp", - "libssz", + "libssz 0.3.0 (registry+https://github.com/rust-lang/crates.io-index)", "malachite", "rustc-hash", "serde", @@ -3495,15 +3595,6 @@ dependencies = [ "either", ] -[[package]] -name = "itertools" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" -dependencies = [ - "either", -] - [[package]] name = "itertools" version = "0.13.0" @@ -4128,6 +4219,24 @@ dependencies = [ "web-time", ] +[[package]] +name = "libp2p-mplex" +version = "0.44.0" +source = "git+https://github.com/lambdaclass/rust-libp2p.git?rev=2f14d0ec9665a01cfb6a02326c90628c4bba521c#2f14d0ec9665a01cfb6a02326c90628c4bba521c" +dependencies = [ + "asynchronous-codec", + "bytes", + "futures", + "libp2p-core", + "libp2p-identity", + "nohash-hasher", + "parking_lot", + "rand 0.8.6", + "smallvec", + "tracing", + "unsigned-varint", +] + [[package]] name = "libp2p-noise" version = "0.47.0" @@ -4503,6 +4612,14 @@ dependencies = [ "smallvec", ] +[[package]] +name = "libssz" +version = "0.3.0" +source = "git+https://github.com/lambdaclass/libssz?rev=36802dd1d3e3a83d95d2ac552647539cbe3f7fd7#36802dd1d3e3a83d95d2ac552647539cbe3f7fd7" +dependencies = [ + "smallvec", +] + [[package]] name = "libssz-derive" version = "0.3.0" @@ -4514,13 +4631,32 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "libssz-derive" +version = "0.3.0" +source = "git+https://github.com/lambdaclass/libssz?rev=36802dd1d3e3a83d95d2ac552647539cbe3f7fd7#36802dd1d3e3a83d95d2ac552647539cbe3f7fd7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "libssz-merkle" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "863eca32d1a43e5ec41106a515552efa8307768d37c68d26b7f21ff13cfee1a7" dependencies = [ - "libssz", + "libssz 0.3.0 (registry+https://github.com/rust-lang/crates.io-index)", + "sha2", +] + +[[package]] +name = "libssz-merkle" +version = "0.3.0" +source = "git+https://github.com/lambdaclass/libssz?rev=36802dd1d3e3a83d95d2ac552647539cbe3f7fd7#36802dd1d3e3a83d95d2ac552647539cbe3f7fd7" +dependencies = [ + "libssz 0.3.0 (git+https://github.com/lambdaclass/libssz?rev=36802dd1d3e3a83d95d2ac552647539cbe3f7fd7)", "sha2", ] @@ -4530,8 +4666,18 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4231ac301726840a3fe111f11bd4619d3c97ed155cb94c88dbf92b70e04e017" dependencies = [ - "libssz", - "libssz-merkle", + "libssz 0.3.0 (registry+https://github.com/rust-lang/crates.io-index)", + "libssz-merkle 0.3.0 (registry+https://github.com/rust-lang/crates.io-index)", + "smallvec", +] + +[[package]] +name = "libssz-types" +version = "0.3.0" +source = "git+https://github.com/lambdaclass/libssz?rev=36802dd1d3e3a83d95d2ac552647539cbe3f7fd7#36802dd1d3e3a83d95d2ac552647539cbe3f7fd7" +dependencies = [ + "libssz 0.3.0 (git+https://github.com/lambdaclass/libssz?rev=36802dd1d3e3a83d95d2ac552647539cbe3f7fd7)", + "libssz-merkle 0.3.0 (git+https://github.com/lambdaclass/libssz?rev=36802dd1d3e3a83d95d2ac552647539cbe3f7fd7)", "smallvec", ] @@ -5195,6 +5341,16 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", + "hmac", +] + [[package]] name = "pcs" version = "0.1.0" @@ -5537,7 +5693,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" dependencies = [ "anyhow", - "itertools 0.12.1", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.117", @@ -6350,6 +6506,17 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "pbkdf2", + "salsa20", + "sha2", +] + [[package]] name = "sec1" version = "0.7.3" @@ -6569,6 +6736,16 @@ dependencies = [ "cfg-if", "cpufeatures 0.2.17", "digest 0.10.7", + "sha2-asm", +] + +[[package]] +name = "sha2-asm" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b845214d6175804686b2bd482bcffe96651bb2d1200742b712003504a2dac1ab" +dependencies = [ + "cc", ] [[package]] @@ -7392,6 +7569,15 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-segmentation" version = "1.13.2" @@ -7425,6 +7611,10 @@ name = "unsigned-varint" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb066959b24b5196ae73cb057f45598450d2c5f71460e98c49b738086eff9c06" +dependencies = [ + "asynchronous-codec", + "bytes", +] [[package]] name = "untrusted" @@ -8239,6 +8429,18 @@ dependencies = [ "time", ] +[[package]] +name = "xdelta3" +version = "0.1.5" +source = "git+https://github.com/sigp/xdelta3-rs?rev=fe3906605c87#fe3906605c87b6c0515bd7c8fc671f47875e3ccc" +dependencies = [ + "bindgen", + "cc", + "libc", + "log", + "rand 0.9.4", +] + [[package]] name = "xml-rs" version = "0.8.28" @@ -8376,6 +8578,7 @@ version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" dependencies = [ + "serde", "zeroize_derive", ] diff --git a/Cargo.toml b/Cargo.toml index a0cfcfd05..c641d6696 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,13 +7,16 @@ members = [ "crates/blockchain/fork_choice", "crates/blockchain/state_transition", "crates/common/crypto", + "crates/common/ssz-tree", "crates/common/metrics", "crates/common/test-fixtures", "crates/common/types", "crates/net/api", + "crates/net/engine", "crates/net/p2p", "crates/net/rpc", "crates/storage", + "crates/validator", ] [workspace.package] @@ -51,19 +54,32 @@ lto = false codegen-units = 16 debug = "line-tables-only" incremental = true +# Inheriting `release` would leave these off, so a `debug_assert!` would run +# nowhere: the suite runs under this profile, and a plain `dev` build is not a +# fallback because signature verification and aggregation stack-overflow +# without release-grade opt-level. Turning them on here is what makes +# `debug_assertions` mean "checked in tests, absent from shipped binaries". +debug-assertions = true +# Set explicitly rather than inherited, so arithmetic behaviour stays identical +# to `release` regardless of what Cargo defaults this to alongside +# `debug-assertions`. +overflow-checks = false [workspace.dependencies] ethlambda-blockchain = { path = "crates/blockchain" } ethlambda-fork-choice = { path = "crates/blockchain/fork_choice" } ethlambda-state-transition = { path = "crates/blockchain/state_transition" } ethlambda-crypto = { path = "crates/common/crypto" } +ethlambda-ssz-tree = { path = "crates/common/ssz-tree" } ethlambda-metrics = { path = "crates/common/metrics" } ethlambda-test-fixtures = { path = "crates/common/test-fixtures" } ethlambda-types = { path = "crates/common/types" } ethlambda-network-api = { path = "crates/net/api" } +ethlambda-engine = { path = "crates/net/engine" } ethlambda-p2p = { path = "crates/net/p2p" } ethlambda-rpc = { path = "crates/net/rpc" } ethlambda-storage = { path = "crates/storage" } +ethlambda-validator = { path = "crates/validator" } tracing = "0.1" thiserror = "2.0.9" @@ -71,6 +87,8 @@ serde = { version = "1", features = ["derive"] } serde_json = "1.0.117" serde_yaml_ng = "0.10" hex = "0.4" +hmac = "0.12" +base64 = "0.22" spawned-concurrency = "0.5.0" spawned-rt = "0.5.0" @@ -81,6 +99,21 @@ prometheus = "0.14" clap = { version = "4.3", features = ["derive", "env"] } +# Version pinned to ethrex's workspace: `SecretKey` crosses that API boundary. +# `rand` is re-exported as `secp256k1::rand`, so nothing needs a direct dep on it +# to seed a key. +secp256k1 = { version = "0.30.0", default-features = false, features = ["global-context", "rand"] } + +# Merkleizing a beacon state is almost entirely SHA-256 compressions, so which +# implementation `sha2` picks matters. What `asm` actually buys is aarch64: on +# x86/x86_64, sha2 0.10.9 compiles its `x86` backend and dispatches to the SHA-NI +# intrinsics through `cpufeatures` whether or not this feature is set, and `asm` +# only swaps the *fallback* for that dispatch from pure Rust to `sha2-asm`. On +# aarch64 the feature is the whole hardware path: without it the crate falls +# through to the pure-Rust backend. Selection stays at run time either way, so the +# build remains portable to hardware lacking the extensions. +sha2 = { version = "0.10.9", features = ["asm"] } + # XMSS signatures + recursive aggregation, through leanVM's own facade crate: # it re-exports the aggregation API, the `xmss` module (keys, signing, and the # SSZ codec for the two wire types) and the `rand` that signing draws from, so @@ -95,11 +128,36 @@ zk_alloc = { git = "https://github.com/leanEthereum/leanVM.git", rev = "48a90420 # Secret-key (de)serialization for the leanVM xmss key format. postcard = { version = "1.1.3", features = ["alloc"] } -# SSZ implementation -libssz = "0.3.0" -libssz-derive = "0.3.0" -libssz-merkle = "0.3.0" -libssz-types = "0.3.0" +# SSZ implementation. +# +# Tracked from git rather than crates.io because the Beacon Chain containers +# hand out `&mut` into an `SszList` (the validator registry, through +# `altair_validator_lists_mut`), which published libssz-types 0.3.0 cannot do: +# it exposes only `Deref` and `Index`. `DerefMut` and `IndexMut` landed after +# that release, in lambdaclass/libssz#33. +# +# Pinned by `rev` rather than tracking `main`: the requirement is a commit that +# carries #33, not whatever `main` holds today, and `main` here is the SSZ encoder +# and merkleizer that decides every `hash_tree_root`, so a routine `cargo update` +# must not be able to move it. Return these to a crates.io version once a release +# carrying #33 exists. +libssz = { git = "https://github.com/lambdaclass/libssz", rev = "36802dd1d3e3a83d95d2ac552647539cbe3f7fd7" } +libssz-derive = { git = "https://github.com/lambdaclass/libssz", rev = "36802dd1d3e3a83d95d2ac552647539cbe3f7fd7" } +libssz-merkle = { git = "https://github.com/lambdaclass/libssz", rev = "36802dd1d3e3a83d95d2ac552647539cbe3f7fd7" } +libssz-types = { git = "https://github.com/lambdaclass/libssz", rev = "36802dd1d3e3a83d95d2ac552647539cbe3f7fd7" } + +# VCDIFF delta compression for beacon state diffs. +# +# Sigma Prime's maintained fork of the unmaintained liushuyu/xdelta3-rs on +# crates.io, kept alive for lighthouse's own state-diff layer. Pinned by rev +# rather than tracking a branch, for the same reason libssz is: this decides +# whether a reconstructed state is correct, so a routine `cargo update` must +# not be able to move it. +# +# `default-features = false` is required, not tidiness: the default `stream` +# feature pulls futures-io/futures-util for an async API whose module does not +# compile at this rev. The one-shot in-memory API used here is outside it. +xdelta3 = { git = "https://github.com/sigp/xdelta3-rs", rev = "fe3906605c87", default-features = false } # Build-time version info vergen-git2 = { version = "9", features = ["rustc"] } diff --git a/Dockerfile b/Dockerfile index 16e62239c..4d94d86a4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -75,7 +75,7 @@ COPY --from=builder /app/ethlambda /usr/local/bin # Copy licenses COPY LICENSE ./ -# 9000/tcp, 9000/udp - P2P networking (discv5 when --discovery.enable) +# 9000/tcp, 9000/udp - P2P networking (discv5: always on for beacon, --discovery.enable for node; --discovery.port) # 9001/udp - libp2p QUIC connections # 9001/tcp - libp2p TCP (noise + yamux) connections, the fallback transport # 5052 - API RPC diff --git a/Makefile b/Makefile index 5f3e703bb..dcce4680a 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help fmt lint bench update cooldown-check docker-build shadow-build shadow-docker-build run-devnet test docs docs-deps docs-serve +.PHONY: help fmt lint bench update cooldown-check docker-build shadow-build shadow-docker-build run-devnet test test-consensus test-node test-beacon test-beacon-mainnet test-beacon-minimal consensus-spec-tests consensus-spec-gossip-tests docs docs-deps docs-serve help: ## 📚 Show help for each of the Makefile recipes @grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' @@ -12,10 +12,51 @@ lint: ## 🔍 Run clippy on all workspace crates # `--all-targets` skips: lint them by name so they keep compiling cargo clippy --locked --workspace --test forkchoice_spectests --test signature_spectests --test stf_spectests --test ssz_spectests -- -D warnings -test: leanSpec/fixtures ## 🧪 Run all tests - # release-fast: release-grade opt-level to avoid stack overflows during - # signature verification/aggregation, without paying for LTO on every rebuild - cargo test --locked --workspace --profile release-fast +# release-fast: release-grade opt-level to avoid stack overflows during +# signature verification/aggregation, without paying for LTO on every rebuild +# +# The Beacon Chain spec tests have their own target and are not run here: their +# fixtures are a separate multi-gigabyte download, and the suite has to be built +# once per preset. The `--exclude` flags below only divide the halves: the +# beacon target requires the `beacon-spec-tests` feature, so these commands skip +# it without excluding anything. +TEST=cargo test --locked --profile release-fast + +# Two halves, one CI job each: undivided, a release-grade build of every test +# target measured 14 GiB against the 13-14 GiB a stock runner has free. Both +# halves build the shared dependency graph, so what the split halves is the +# linked test binaries. +# +# Named once, and `test-node` is the workspace minus this list, so the two are +# exhaustive by construction and a crate added later cannot silently go +# untested. Keep them roughly even by build weight; the boundary means nothing +# else. +CONSENSUS_CRATES=ethlambda-types ethlambda-fork-choice ethlambda-state-transition ethlambda-blockchain ethlambda-crypto ethlambda-ssz-tree + +test: test-consensus test-node ## 🧪 Run all tests + +test-consensus: leanSpec/fixtures ## 🧪 Run the consensus half of the workspace suite + $(TEST) $(addprefix -p ,$(CONSENSUS_CRATES)) + +test-node: leanSpec/fixtures ## 🧪 Run the node half of the workspace suite + $(TEST) --workspace $(addprefix --exclude ,$(CONSENSUS_CRATES)) + +# --lib as well as the spec target: the BLS and KZG modules keep their fixture +# vectors as unit tests, which are `ignore`d unless this feature is on. +BEACON_TEST=cargo test -p ethlambda-state-transition --lib --test beacon_spec_tests --profile release-fast + +# The preset fixes SSZ container bounds at compile time, so each preset needs its +# own build, and a run walks its own fixture tree plus `general` and nothing else. +# Hence a target per preset rather than one recipe running both: CI gives each its +# own job, so the two build and run concurrently and each downloads only the trees +# its preset reads. +test-beacon: test-beacon-mainnet test-beacon-minimal ## 🧪 Run the Beacon Chain spec tests, both presets + +test-beacon-mainnet: consensus-spec-tests consensus-spec-gossip-tests ## 🧪 Run the Beacon Chain spec tests, mainnet preset + $(BEACON_TEST) --features beacon-spec-tests + +test-beacon-minimal: consensus-spec-tests consensus-spec-gossip-tests ## 🧪 Run the Beacon Chain spec tests, minimal preset + $(BEACON_TEST) --features beacon-spec-tests,preset-minimal # Used ONLY to resolve dependency updates: min-publish-age (.cargo/config.toml) # is nightly-only, everything else runs on the stable toolchain pinned in @@ -95,6 +136,98 @@ leanSpec/fixtures: mkdir -p leanSpec/fixtures; \ tar -xzf "$$tmpdir/fixtures-prod-scheme.tar.gz" -C leanSpec/fixtures --strip-components=1 +# Beacon Chain spec test fixtures, for the `beacon` module of +# crates/blockchain/state_transition. +# +# Pinned rather than tracking the latest release: this fixture tree *is* the +# definition of correctness for that module, so it should move only when we choose +# to move it. The release publishes no checksums for these assets, so unlike the +# leanSpec bundle below there is nothing to verify against. +CONSENSUS_SPEC_TESTS_VERSION ?= v1.6.1 +CONSENSUS_SPEC_TESTS_BASE_URL ?= https://github.com/ethereum/consensus-specs/releases/download/$(CONSENSUS_SPEC_TESTS_VERSION) + +# Which fixture trees to fetch. A run reads its own preset's tree plus `general`, +# the preset-independent BLS and KZG vectors, and nothing else, so a CI job pinned +# to one preset narrows this and skips the other preset's tree. That is worth +# doing: the three together are ~1.25 GiB compressed and several times that on +# disk, against a runner that has neither the space nor the time to spare. +CONSENSUS_SPEC_TESTS_CONFIGS ?= general minimal mainnet + +# The stamp is named after the version AND the configs, so changing either names +# a file that does not exist and forces a fresh download. Depending on the +# extracted directories instead would make a bump a silent no-op: they already +# exist, make would consider them up to date, and the suite would go green +# against the old tree while the docs claimed the new version. Nothing in the +# fixtures themselves records which release they came from, so the stamp is the +# only thing that can carry it. +# +# The configs belong in the name for the same reason: the recipe wipes the tree +# before extracting, so a narrowed run leaves the other preset's tree gone, and a +# stamp naming only the version would then mark a partial tree as complete. +# `sort` normalises order and duplicates, so the same set always names one stamp. +empty:= +space:=$(empty) $(empty) +CONSENSUS_SPEC_TESTS_STAMP=consensus-spec-tests/.version-$(CONSENSUS_SPEC_TESTS_VERSION)-$(subst $(space),-,$(sort $(CONSENSUS_SPEC_TESTS_CONFIGS))) + +consensus-spec-tests: $(CONSENSUS_SPEC_TESTS_STAMP) ## ⬇️ Download the Beacon Chain spec test fixtures + +# The old tree goes first, rather than being extracted over: every tarball +# unpacks to `tests//...`, so all three land side by side in one directory, +# and unpacking a new version on top of an old one would merge the two, leaving +# cases a release deleted still present and still passing. +$(CONSENSUS_SPEC_TESTS_STAMP): + @rm -rf consensus-spec-tests + @mkdir -p consensus-spec-tests + @for config in $(CONSENSUS_SPEC_TESTS_CONFIGS); do \ + echo "Downloading $$config spec test fixtures ($(CONSENSUS_SPEC_TESTS_VERSION))"; \ + tmpdir=$$(mktemp -d); \ + trap 'rm -rf "$$tmpdir"' EXIT; \ + curl -L -f -o "$$tmpdir/$$config.tar.gz" "$(CONSENSUS_SPEC_TESTS_BASE_URL)/$$config.tar.gz" || exit 1; \ + tar -xzf "$$tmpdir/$$config.tar.gz" -C consensus-spec-tests || exit 1; \ + rm -rf "$$tmpdir"; \ + done + @touch $@ + +# The gossip validation vectors (`networking/gossip_*`) first ship in a +# pre-release, so they come from a tree of their own rather than moving the pin +# above. Only fulu's block, column, aggregate and attestation handlers are +# extracted, since those are the topics this node validates; the rest of each +# tarball is never unpacked. Fold this back into the main tree once that +# release is final. +CONSENSUS_SPEC_GOSSIP_TESTS_VERSION ?= v1.7.0-beta.1 +CONSENSUS_SPEC_GOSSIP_TESTS_BASE_URL ?= https://github.com/ethereum/consensus-specs/releases/download/$(CONSENSUS_SPEC_GOSSIP_TESTS_VERSION) +# Follows CONSENSUS_SPEC_TESTS_CONFIGS, so narrowing that one narrows this too. +# `general` is dropped because it has no `networking` runner: naming its +# members would fail the extraction. +CONSENSUS_SPEC_GOSSIP_TESTS_CONFIGS = $(filter-out general,$(CONSENSUS_SPEC_TESTS_CONFIGS)) +CONSENSUS_SPEC_GOSSIP_TESTS_HANDLERS = gossip_beacon_block gossip_data_column_sidecar gossip_beacon_aggregate_and_proof gossip_beacon_attestation +# Includes the handler list, not just the version and configs: growing the +# list must re-extract an existing tree, which a stamp keyed on version and +# configs alone would not notice, since neither of those changed. +CONSENSUS_SPEC_GOSSIP_TESTS_STAMP=consensus-spec-tests-gossip/.version-$(CONSENSUS_SPEC_GOSSIP_TESTS_VERSION)-$(subst $(space),-,$(sort $(CONSENSUS_SPEC_GOSSIP_TESTS_CONFIGS)))-$(subst $(space),-,$(sort $(CONSENSUS_SPEC_GOSSIP_TESTS_HANDLERS))) + +consensus-spec-gossip-tests: $(CONSENSUS_SPEC_GOSSIP_TESTS_STAMP) ## ⬇️ Download the gossip validation spec test fixtures + +# Member directories are named exactly rather than globbed: GNU tar needs +# `--wildcards` for a pattern and BSD tar rejects that flag, while a plain +# directory name extracts its whole subtree on both. +$(CONSENSUS_SPEC_GOSSIP_TESTS_STAMP): + @rm -rf consensus-spec-tests-gossip + @mkdir -p consensus-spec-tests-gossip + @for config in $(CONSENSUS_SPEC_GOSSIP_TESTS_CONFIGS); do \ + echo "Downloading $$config gossip test fixtures ($(CONSENSUS_SPEC_GOSSIP_TESTS_VERSION))"; \ + tmpdir=$$(mktemp -d); \ + trap 'rm -rf "$$tmpdir"' EXIT; \ + curl -L -f -o "$$tmpdir/$$config.tar.gz" "$(CONSENSUS_SPEC_GOSSIP_TESTS_BASE_URL)/$$config.tar.gz" || exit 1; \ + members=""; \ + for handler in $(CONSENSUS_SPEC_GOSSIP_TESTS_HANDLERS); do \ + members="$$members tests/$$config/fulu/networking/$$handler"; \ + done; \ + tar -xzf "$$tmpdir/$$config.tar.gz" -C consensus-spec-tests-gossip $$members || exit 1; \ + rm -rf "$$tmpdir"; \ + done + @touch $@ + # lambdaclass fork of lean-quickstart: genesis keys come from `ethlambda keygen`, and the # partner clients run their devnet-5 images. An existing lean-quickstart/ is never # re-cloned, so delete it to pick up a new pin. diff --git a/README.md b/README.md index 4750517a1..ee14645bc 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,136 @@ Minimalist, fast and modular implementation of the Lean Ethereum client written 🌐 Visit our website at [**ethlambda.xyz**](https://ethlambda.xyz) to learn more about the project. -## Getting started +## Quickstart + +### Beacon Chain follower + +Follows Ethereum mainnet from a checkpoint-synced anchor and serves the +standard Beacon API on port 5052, paired with an +[ethrex](https://github.com/lambdaclass/ethrex) execution client that validates +each block's execution payload. Both run from pre-built images +(`ghcr.io/lambdaclass/ethlambda:beacon` and `ghcr.io/lambdaclass/ethrex`), so +only [Docker](https://www.docker.com/get-started) is needed. + +> **Warning:** this uses a lot of disk. ethrex needs at least 500 GB for +> mainnet (1 TB recommended; see its +> [hardware requirements](https://docs.ethrex.xyz/getting-started/hardware_requirements.html)). + +```sh +docker pull ghcr.io/lambdaclass/ethlambda:beacon +docker pull ghcr.io/lambdaclass/ethrex + +mkdir -p beacon-data ethrex-data +# A persisted key keeps the node's identity, and so its custody set, stable across restarts +openssl rand -hex 32 > beacon-data/node-key +# The secret both clients authenticate the Engine API with +openssl rand -hex 32 > jwt.hex +# This host's public address, published in the node's ENR (see below) +PUBLIC_IP=$(curl -s https://ifconfig.me) + +# A private network, so the beacon node reaches ethrex's Engine API by name +# without publishing it on the host +docker network create ethereum + +docker run -d --name ethrex --network ethereum \ + -p 30303:30303 -p 30303:30303/udp \ + -v "$PWD/ethrex-data:/data" \ + -v "$PWD/jwt.hex:/jwt.hex:ro" \ + ghcr.io/lambdaclass/ethrex \ + --network mainnet \ + --datadir /data \ + --authrpc.addr 0.0.0.0 \ + --authrpc.jwtsecret /jwt.hex + +# The node shuts down gracefully on SIGINT only, so this makes `docker stop` flush its state +docker run -d --name ethlambda-beacon --network ethereum --stop-signal SIGINT \ + -p 9000:9000/udp -p 9001:9001/udp -p 9001:9001/tcp \ + -p 127.0.0.1:5052:5052 \ + -v "$PWD/beacon-data:/data" \ + -v "$PWD/jwt.hex:/jwt.hex:ro" \ + ghcr.io/lambdaclass/ethlambda:beacon beacon \ + --network mainnet \ + --checkpoint-sync-url https://beaconstate.ethstaker.cc \ + --node-key /data/node-key \ + --data-dir /data/db \ + --http-address 0.0.0.0 \ + --discovery.advertise-ip "$PUBLIC_IP" \ + --execution-endpoint http://ethrex:8551 \ + --execution-jwt-secret /jwt.hex +``` + +
+Running without an execution client + +To run the consensus layer only, drop ethrex, the shared network, the JWT secret +and the two `--execution-*` flags. The node then follows the chain without +validating execution payloads: + +```sh +docker pull ghcr.io/lambdaclass/ethlambda:beacon + +mkdir -p beacon-data +# A persisted key keeps the node's identity, and so its custody set, stable across restarts +openssl rand -hex 32 > beacon-data/node-key +# This host's public address, published in the node's ENR (see below) +PUBLIC_IP=$(curl -s https://ifconfig.me) + +# The node shuts down gracefully on SIGINT only, so this makes `docker stop` flush its state +docker run -d --name ethlambda-beacon --stop-signal SIGINT \ + -p 9000:9000/udp -p 9001:9001/udp -p 9001:9001/tcp \ + -p 127.0.0.1:5052:5052 \ + -v "$PWD/beacon-data:/data" \ + ghcr.io/lambdaclass/ethlambda:beacon beacon \ + --network mainnet \ + --checkpoint-sync-url https://beaconstate.ethstaker.cc \ + --node-key /data/node-key \ + --data-dir /data/db \ + --http-address 0.0.0.0 \ + --discovery.advertise-ip "$PUBLIC_IP" +``` + +
+ +`--discovery.advertise-ip` is the address published in the node's ENR. Behind +Docker's port mapping the node cannot see its own public address, so without +the flag it advertises `0.0.0.0` and logs a warning. It still dials out and +follows the chain, but peers cannot reach it until discovery learns the address +from their replies. + +Follow the logs with `docker logs -f ethlambda-beacon`. The first start +downloads the finalized state (several hundred MB) and logs +`Beacon checkpoint sync complete`, then a few minutes of +`Block parent missing, storing as pending` before `Block imported successfully` +lines start. Later starts resume from `beacon-data/db`. + +ethrex logs `No messages from the consensus layer` until the beacon node's +first fork choice update, then starts snap sync (`docker logs -f ethrex`). +Until that finishes, which takes hours on mainnet, ethrex answers each payload +`SYNCING` and the beacon node imports blocks optimistically. + +Check progress from another terminal: + +```sh +curl -s localhost:5052/eth/v1/node/syncing +``` + +The node has caught up once `sync_distance` (the chain's current slot minus +`head_slot`) is near 0. Don't rely on `is_syncing` yet: it currently reads +`false` during catch-up as well. + +For Sepolia or Hoodi, change `--network mainnet` to `--network sepolia` or +`--network hoodi` in both the ethrex and the beacon node arguments (not Docker's +own `--network ethereum`), and point `--checkpoint-sync-url` at that network's +provider. See +[`ethlambda beacon`](#ethlambda-beacon--the-ethereum-beacon-chain) below for +the remaining flags. + +### Lean consensus devnet + +To run a local lean devnet with ethlambda, follow the instructions on the +[`devnet5-ethlambda-keygen` branch of lambdaclass/lean-quickstart](https://github.com/lambdaclass/lean-quickstart/tree/devnet5-ethlambda-keygen). + +## Building from source ### Prerequisites @@ -34,6 +163,95 @@ make docker-build DOCKER_TAG=local Run `make help` or take a look at our [`Makefile`](./Makefile) for other useful commands. +### Running the node + +The binary follows one of two chains, chosen by a sub-command: + +```sh +cargo build --release +./target/release/ethlambda [flags] +``` + +[`docs/cli.md`](./docs/cli.md) is the full flag reference for both. + +#### `ethlambda node` — the Lean consensus chain + +This is what the rest of this README is about, and it is the default: a bare +flag list with no sub-command still runs the node, so existing scripts and +Docker entrypoints keep working unchanged. + +It needs a genesis config, a validator registry, a bootnode list and this +node's own keys, all of which a devnet generates for you: + +```sh +./target/release/ethlambda node \ + --genesis config/config.yaml \ + --validators config/annotated_validators.yaml \ + --bootnodes config/nodes.yaml \ + --validator-config config/validator-config.yaml \ + --hash-sig-keys-dir config/hash-sig-keys \ + --node-key config/ethlambda_0.key \ + --node-id ethlambda_0 \ + --data-dir ./data \ + --is-aggregator +``` + +`--node-id` picks this node's entry out of `annotated_validators.yaml`, so it +is what decides which validators this process runs. + +The easiest way to get those files is `make run-devnet`, which generates them +under `lean-quickstart/local-devnet/genesis/`. See +[Running in a devnet](#running-in-a-devnet) below. + +> **Important:** at least one node on the network must run with +> `--is-aggregator`, or attestations are never aggregated into blocks and the +> chain produces blocks but never finalizes. + +#### `ethlambda beacon` — the Ethereum Beacon Chain + +A beacon chain follower. It anchors at a finalized checkpoint fetched from a +Beacon API, then follows the chain to its tip: blocks arriving over gossip and +range sync are imported through fork choice, and it custodies its slice of the +fulu data column matrix. What it stores it serves back, to peers over +`beacon_blocks_by_{range,root}` and to anyone over the standard Beacon API on +`--api-port`. It has no validator duties: it publishes nothing and subscribes to +no attestation or sync committee subnet. + +```sh +./target/release/ethlambda beacon \ + --network mainnet \ + --checkpoint-sync-url \ + --node-key ./node-key \ + --data-dir ./data +``` + +| Flag | Meaning | +|---|---| +| `--network` | `mainnet` (default), `sepolia`, `hoodi`, or a path to a directory of network files (`config.yaml`, `genesis.ssz`, optionally `bootstrap_nodes.yaml`), the layout `eth-clients` publishes and kurtosis mounts | +| `--checkpoint-sync-url` | Where the anchor comes from. Required on a fresh data directory for a built-in network, since those never start from genesis. A resumable data directory is used before it; a network loaded from a directory anchors at its own `genesis.ssz` when no URL is given | +| `--node-key` | Optional, but worth persisting: the columns this node custodies are a function of its node id, so without a key file it changes identity, and custody set, on every restart | +| `--execution-endpoint`, `--execution-jwt-secret` | Optional Engine API pairing, given together. Without them, blocks import without payload validation | + +A built-in network needs nothing else on disk. Its `eth-clients` `config.yaml` +and bootnode list, and the two genesis values the fork digest is computed from, +are compiled into the binary, so deriving the wire parameters touches no +network. + +A healthy run logs its anchor, then imports each new block within a couple of +seconds of its slot starting (from a Sepolia run): + +``` +Beacon checkpoint sync complete slot=11197376 fork=fulu validators=1997 finalized_epoch=349916 anchor_block_slot=11197376 +Beacon block decoded slot=11197516 proposer=834 fork="fulu" block_root=c44ad3d8 bytes=92333 +Block imported successfully slot=11197516 proposer=834 block_root=c44ad3d8 parent_root=4397e224 +Beacon aggregate attestation decoded slot=11197516 aggregator=574 attesters=53 target_epoch=349922 … +``` + +See [`docs/beacon_wire.md`](./docs/beacon_wire.md) for what goes on the wire, +[`docs/checkpoint_sync.md`](./docs/checkpoint_sync.md) for how the anchor is +chosen and verified, and [`docs/rpc.md`](./docs/rpc.md) for the Beacon API +endpoints. + ### Running in a devnet To run a local devnet with multiple clients using [lean-quickstart](https://github.com/blockblaz/lean-quickstart): @@ -53,8 +271,6 @@ Press `Ctrl+C` to stop all nodes. > ``` > To persist across reboots, add to `/etc/sysctl.conf`. For Docker, pass `--sysctl net.core.rmem_max=7340032 --sysctl net.core.wmem_max=7340032`. -> **Important:** When running nodes manually (outside `make run-devnet`), at least one node must be started with `--is-aggregator` for attestations to be aggregated and included in blocks. Without this flag, the network will produce blocks but never finalize. - For custom devnet configurations, go to `lean-quickstart/local-devnet/genesis/validator-config.yaml` and edit the file before running the command above. See `lean-quickstart`'s documentation for more details on how to configure the devnet. ## Philosophy diff --git a/bin/ethlambda/Cargo.toml b/bin/ethlambda/Cargo.toml index 8b565c02d..1e239cae3 100644 --- a/bin/ethlambda/Cargo.toml +++ b/bin/ethlambda/Cargo.toml @@ -24,9 +24,11 @@ ethlambda-crypto.workspace = true ethlambda-metrics.workspace = true ethlambda-network-api.workspace = true ethlambda-p2p.workspace = true +ethlambda-state-transition.workspace = true ethlambda-types.workspace = true ethlambda-rpc.workspace = true ethlambda-storage.workspace = true +ethlambda-validator.workspace = true # Parallel XMSS keygen for the real-crypto benchmark corpus. rayon.workspace = true @@ -36,7 +38,6 @@ libssz-types.workspace = true tokio.workspace = true tokio-util.workspace = true - tracing.workspace = true tracing-subscriber = "0.3" @@ -47,9 +48,14 @@ hex.workspace = true clap.workspace = true reqwest.workspace = true +ethlambda-engine.workspace = true thiserror.workspace = true eyre.workspace = true +# For `resolve_node_key`'s ephemeral-key generation when `--node-key` is omitted. +secp256k1.workspace = true + + tikv-jemallocator = { workspace = true, optional = true } libc.workspace = true @@ -63,5 +69,9 @@ tokio = { workspace = true, features = ["test-util"] } # key set, so their tests need a throwaway directory. tempfile = "3" +# Pins the SHA-256 of the built-in mainnet genesis state, so swapping that +# asset has to be deliberate. Tests only: nothing in the binary hashes it. +sha2.workspace = true + [build-dependencies] vergen-git2.workspace = true diff --git a/bin/ethlambda/assets/hoodi/bootstrap_nodes.yaml b/bin/ethlambda/assets/hoodi/bootstrap_nodes.yaml new file mode 100644 index 000000000..f764fc05b --- /dev/null +++ b/bin/ethlambda/assets/hoodi/bootstrap_nodes.yaml @@ -0,0 +1,17 @@ +# hoodi consensus layer bootnodes +# --------------------------------------- +# 1. Tag nodes with maintainer +# 2. Keep nodes updated +# 3. Review PRs: check ENR duplicates, fork-digest, connection. + +# EF +- enr:-KG4QEfvG40PslpTF5F0SAnDMHYwQu7u9dMxVmglDyR0iKEsTUr0MilWHWKPh_Cyo0cHt0muy2SsrWpiC2sC_TRPiMcBgmlkgnY0gmlwhNRj2kKDaXA2kCoAHKALAA0CAAAAAAAAAF6Jc2VjcDI1NmsxoQIM-dQNDiL8ldy7S8t_bkW9awktKz1HHSF2Qups_K5S64N1ZHCCTryEdWRwNoJOvA # 212.99.218.66 | colo-dcl1 +- enr:-KG4QDNae3UVXdwSvWbZYotO9IpGRiBDzXr4owQ1_ONk_sOtIuZoI55Ja8EGtD-kzY5I_0bTaYpVefgRK2q2hD1T8sABgmlkgnY0gmlwhIHUpj2DaXA2kCYEqIAABAHQAAAAA2GdUACJc2VjcDI1NmsxoQMu3GRf_l288UJNQcXiLp4NbOQmigxSx14ddTal4tBp9IN1ZHCCI_CEdWRwNoIj8A # 129.212.166.61 | digitalocean-sfo3 +- enr:-KG4QOOHORt2Kmo3lgoRTcqJnxH07aELtuidFEuBzN8Xdbzkfb4MblrUOXJnDEJ8RzXpTXWBEqM3q0DRphMm8xOIVbIBgmlkgnY0gmlwhJB-_BiDaXA2kCQAYYABAADQAAAAAYEgYAGJc2VjcDI1NmsxoQOf6T6A1lri5bTBzvb3sAb42Ki9L1pSqQsNzqvBUr7BjoN1ZHCCI_CEdWRwNoIj8A # 144.126.252.24 | digitalocean-blr1 +- enr:-KG4QM0TIrjoocAJvIY2XYOa1UzeSM1c2d3rBf1QzyxchGmzJ3OPdLKUFrjBRCPDYHhq69pEB5YKmFtKOBuF63k1pB4BgmlkgnY0gmlwhLKc14yDaXA2kCoBBP8A9DxKAAAAAAAAAAGJc2VjcDI1NmsxoQNFCY3Kl3VQfYl3lqOTN8YG0598xcIrlg1mmqKzdpLm5IN1ZHCCI_CEdWRwNoIj8A # 178.156.215.140 | hetzner-ash +- enr:-KG4QLBt5eeWOp11A7l2WfR-sC5j3SYybU0PeEepotPzpt4kZE0nDFFZCy8NPjun3dcM8D4_xmYxZCB0WTnitKj7dAMBgmlkgnY0gmlwhAXfXlGDaXA2kCoBBP8C8BytAAAAAAAAAAGJc2VjcDI1NmsxoQLhrnwm2X7ZcxLideAlCmQvGkyHXMl7KXL0K-WDOdAoLIN1ZHCCI_CEdWRwNoIj8A # 5.223.94.81 | hetzner-sin +# Teku +- enr:-LK4QDwhXMitMbC8xRiNL-XGMhRyMSOnxej-zGifjv9Nm5G8EF285phTU-CAsMHRRefZimNI7eNpAluijMQP7NDC8kEMh2F0dG5ldHOIAAAAAAAABgCEZXRoMpDS8Zl_YAAJEAAIAAAAAAAAgmlkgnY0gmlwhAOIT_SJc2VjcDI1NmsxoQMoHWNL4MAvh6YpQeM2SUjhUrLIPsAVPB8nyxbmckC6KIN0Y3CCIyiDdWRwgiMo +- enr:-LK4QPYl2HnMPQ7b1es6Nf_tFYkyya5bj9IqAKOEj2cmoqVkN8ANbJJJK40MX4kciL7pZszPHw6vLNyeC-O3HUrLQv8Mh2F0dG5ldHOIAAAAAAAAAMCEZXRoMpDS8Zl_YAAJEAAIAAAAAAAAgmlkgnY0gmlwhAMYRG-Jc2VjcDI1NmsxoQPQ35tjr6q1qUqwAnegQmYQyfqxC_6437CObkZneI9n34N0Y3CCIyiDdWRwgiMo +# Lodestar +- enr:-KG4QKRSUi4IOAIK_xt5ERrwW_J47wmNCLWFh7Jo0hFE69drZsiZ5Pb5CEcM_njFTTLlIR6SCf67HTcSV1g6hCXdhWkCgmlkgnY0gmlwhLkvrBODaXA2kCoGxcAWAAAYAAAAAAAAABCJc2VjcDI1NmsxoQPU7g2jQGTz8BYbB2vLTb39S_PrcZAehwMM0b3bWsM5rIN1ZHCCIyiEdWRwNoIjKA diff --git a/bin/ethlambda/assets/hoodi/config.yaml b/bin/ethlambda/assets/hoodi/config.yaml new file mode 100644 index 000000000..884ff8091 --- /dev/null +++ b/bin/ethlambda/assets/hoodi/config.yaml @@ -0,0 +1,185 @@ +# Extends the mainnet preset +PRESET_BASE: mainnet +CONFIG_NAME: hoodi + +# Genesis +# --------------------------------------------------------------- +# `2**14` (= 16,384) +MIN_GENESIS_ACTIVE_VALIDATOR_COUNT: 16384 +# 2025-Mar-17 12:00:00 PM UTC +MIN_GENESIS_TIME: 1742212800 +GENESIS_FORK_VERSION: 0x10000910 +GENESIS_DELAY: 600 + + +# Forking +# --------------------------------------------------------------- +# Some forks are disabled for now: +# - These may be re-assigned to another fork-version later +# - Temporarily set to max uint64 value: 2**64 - 1 + +# Altair +ALTAIR_FORK_VERSION: 0x20000910 +ALTAIR_FORK_EPOCH: 0 +# Merge +BELLATRIX_FORK_VERSION: 0x30000910 +BELLATRIX_FORK_EPOCH: 0 +TERMINAL_TOTAL_DIFFICULTY: 0 +TERMINAL_BLOCK_HASH: 0x0000000000000000000000000000000000000000000000000000000000000000 +TERMINAL_BLOCK_HASH_ACTIVATION_EPOCH: 18446744073709551615 + +# Capella +CAPELLA_FORK_VERSION: 0x40000910 +CAPELLA_FORK_EPOCH: 0 + +# DENEB +DENEB_FORK_VERSION: 0x50000910 +DENEB_FORK_EPOCH: 0 + +# Electra +ELECTRA_FORK_VERSION: 0x60000910 +ELECTRA_FORK_EPOCH: 2048 + +# Fulu +FULU_FORK_VERSION: 0x70000910 +FULU_FORK_EPOCH: 50688 + +# Time parameters +# --------------------------------------------------------------- +# 12 seconds (*deprecated*) +SECONDS_PER_SLOT: 12 +# 12000 milliseconds +SLOT_DURATION_MS: 12000 +# 14 (estimate from Eth1 mainnet) +SECONDS_PER_ETH1_BLOCK: 12 +# 2**8 (= 256) epochs +MIN_VALIDATOR_WITHDRAWABILITY_DELAY: 256 +# 2**8 (= 256) epochs +SHARD_COMMITTEE_PERIOD: 256 +# 2**11 (= 2,048) Eth1 blocks +ETH1_FOLLOW_DISTANCE: 2048 +# 1667 basis points, ~17% of SLOT_DURATION_MS +PROPOSER_REORG_CUTOFF_BPS: 1667 +# 3333 basis points, ~33% of SLOT_DURATION_MS +ATTESTATION_DUE_BPS: 3333 +# 6667 basis points, ~67% of SLOT_DURATION_MS +AGGREGATE_DUE_BPS: 6667 + +# Altair +# 3333 basis points, ~33% of SLOT_DURATION_MS +SYNC_MESSAGE_DUE_BPS: 3333 +# 6667 basis points, ~67% of SLOT_DURATION_MS +CONTRIBUTION_DUE_BPS: 6667 + +# Validator cycle +# --------------------------------------------------------------- +# 2**2 (= 4) +INACTIVITY_SCORE_BIAS: 4 +# 2**4 (= 16) +INACTIVITY_SCORE_RECOVERY_RATE: 16 +# 2**4 * 10**9 (= 16,000,000,000) Gwei +EJECTION_BALANCE: 16000000000 +# 2**2 (= 4) validators +MIN_PER_EPOCH_CHURN_LIMIT: 4 +# 2**16 (= 65,536) +CHURN_LIMIT_QUOTIENT: 65536 + +# Deneb +# 2**3 (= 8) (*deprecated*) +MAX_PER_EPOCH_ACTIVATION_CHURN_LIMIT: 8 + +# Electra +# 2**7 * 10**9 (= 128,000,000,000) Gwei +MIN_PER_EPOCH_CHURN_LIMIT_ELECTRA: 128000000000 +# 2**8 * 10**9 (= 256,000,000,000) Gwei +MAX_PER_EPOCH_ACTIVATION_EXIT_CHURN_LIMIT: 256000000000 + +# Fork choice +# --------------------------------------------------------------- +# 40% +PROPOSER_SCORE_BOOST: 40 +# 20% +REORG_HEAD_WEIGHT_THRESHOLD: 20 +# 160% +REORG_PARENT_WEIGHT_THRESHOLD: 160 +# 2 epochs +REORG_MAX_EPOCHS_SINCE_FINALIZATION: 2 + +# Deposit contract +# --------------------------------------------------------------- +DEPOSIT_CHAIN_ID: 560048 +DEPOSIT_NETWORK_ID: 560048 +DEPOSIT_CONTRACT_ADDRESS: 0x00000000219ab540356cBB839Cbe05303d7705Fa + +# Networking +# --------------------------------------------------------------- +# 10 * 2**20 (= 10,485,760) bytes, 10 MiB +MAX_PAYLOAD_SIZE: 10485760 +# 2**10 (= 1,024) blocks +MAX_REQUEST_BLOCKS: 1024 +# 2**8 (= 256) epochs +EPOCHS_PER_SUBNET_SUBSCRIPTION: 256 +# MIN_VALIDATOR_WITHDRAWABILITY_DELAY + CHURN_LIMIT_QUOTIENT // 2 (= 33,024) epochs +MIN_EPOCHS_FOR_BLOCK_REQUESTS: 33024 +# 2**5 (= 32) slots +ATTESTATION_PROPAGATION_SLOT_RANGE: 32 +# 500ms +MAXIMUM_GOSSIP_CLOCK_DISPARITY: 500 +MESSAGE_DOMAIN_INVALID_SNAPPY: 0x00000000 +MESSAGE_DOMAIN_VALID_SNAPPY: 0x01000000 +# 2 subnets per node +SUBNETS_PER_NODE: 2 +# 2**6 (= 64) subnets +ATTESTATION_SUBNET_COUNT: 64 +# 0 bits +ATTESTATION_SUBNET_EXTRA_BITS: 0 +# ceillog2(ATTESTATION_SUBNET_COUNT) + ATTESTATION_SUBNET_EXTRA_BITS (= 6 + 0) bits +ATTESTATION_SUBNET_PREFIX_BITS: 6 + +# Deneb +# 2**7 (= 128) blocks +MAX_REQUEST_BLOCKS_DENEB: 128 +# 2**12 (= 4,096) epochs +MIN_EPOCHS_FOR_BLOB_SIDECARS_REQUESTS: 4096 +# 6 subnets +BLOB_SIDECAR_SUBNET_COUNT: 6 +# 6 blobs +MAX_BLOBS_PER_BLOCK: 6 +# MAX_REQUEST_BLOCKS_DENEB * MAX_BLOBS_PER_BLOCK (= 128 * 6) sidecars +MAX_REQUEST_BLOB_SIDECARS: 768 + +# Electra +# 9 subnets +BLOB_SIDECAR_SUBNET_COUNT_ELECTRA: 9 +# 9 blobs +MAX_BLOBS_PER_BLOCK_ELECTRA: 9 +# MAX_REQUEST_BLOCKS_DENEB * MAX_BLOBS_PER_BLOCK_ELECTRA (= 128 * 9) sidecars +MAX_REQUEST_BLOB_SIDECARS_ELECTRA: 1152 + +# Fulu +# 2**7 (= 128) groups +NUMBER_OF_CUSTODY_GROUPS: 128 +# 2**7 (= 128) subnets +DATA_COLUMN_SIDECAR_SUBNET_COUNT: 128 +# MAX_REQUEST_BLOCKS_DENEB * NUMBER_OF_COLUMNS (= 128 * 128) sidecars +MAX_REQUEST_DATA_COLUMN_SIDECARS: 16384 +# 2**3 (= 8) samples +SAMPLES_PER_SLOT: 8 +# 2**2 (= 4) sidecars +CUSTODY_REQUIREMENT: 4 +# 2**3 (= 8) sidecars +VALIDATOR_CUSTODY_REQUIREMENT: 8 +# 2**5 * 10**9 (= 32,000,000,000) Gwei +BALANCE_PER_ADDITIONAL_CUSTODY_GROUP: 32000000000 +# 2**12 (= 4,096) epochs +MIN_EPOCHS_FOR_DATA_COLUMN_SIDECARS_REQUESTS: 4096 + + +# Blob Scheduling +# --------------------------------------------------------------- + +BLOB_SCHEDULE: + - EPOCH: 52480 + MAX_BLOBS_PER_BLOCK: 15 + - EPOCH: 54016 + MAX_BLOBS_PER_BLOCK: 21 diff --git a/bin/ethlambda/assets/mainnet/bootstrap_nodes.yaml b/bin/ethlambda/assets/mainnet/bootstrap_nodes.yaml new file mode 100644 index 000000000..22f8dc81d --- /dev/null +++ b/bin/ethlambda/assets/mainnet/bootstrap_nodes.yaml @@ -0,0 +1,35 @@ +# Eth mainnet consensus layer bootnodes +# --------------------------------------- +# 1. Tag nodes with maintainer +# 2. Keep nodes updated +# 3. Review PRs: check ENR duplicates, fork-digest, connection. + +# Teku team's bootnodes +- enr:-Iu4QLm7bZGdAt9NSeJG0cEnJohWcQTQaI9wFLu3Q7eHIDfrI4cwtzvEW3F3VbG9XdFXlrHyFGeXPn9snTCQJ9bnMRABgmlkgnY0gmlwhAOTJQCJc2VjcDI1NmsxoQIZdZD6tDYpkpEfVo5bgiU8MGRjhcOmHGD2nErK0UKRrIN0Y3CCIyiDdWRwgiMo # 3.147.37.0 | aws-us-east-2-ohio +- enr:-Iu4QEDJ4Wa_UQNbK8Ay1hFEkXvd8psolVK6OhfTL9irqz3nbXxxWyKwEplPfkju4zduVQj6mMhUCm9R2Lc4YM5jPcIBgmlkgnY0gmlwhANrfESJc2VjcDI1NmsxoQJCYz2-nsqFpeEj6eov9HSi9QssIVIVNr0I89J1vXM9foN0Y3CCIyiDdWRwgiMo # 3.107.124.68 | aws-ap-southeast-2-sydney + +# Prylab team's bootnodes +- enr:-Ku4QImhMc1z8yCiNJ1TyUxdcfNucje3BGwEHzodEZUan8PherEo4sF7pPHPSIB1NNuSg5fZy7qFsjmUKs2ea1Whi0EBh2F0dG5ldHOIAAAAAAAAAACEZXRoMpD1pf1CAAAAAP__________gmlkgnY0gmlwhBLf22SJc2VjcDI1NmsxoQOVphkDqal4QzPMksc5wnpuC3gvSC8AfbFOnZY_On34wIN1ZHCCIyg # 18.223.219.100 | aws-us-east-2-ohio +- enr:-Ku4QP2xDnEtUXIjzJ_DhlCRN9SN99RYQPJL92TMlSv7U5C1YnYLjwOQHgZIUXw6c-BvRg2Yc2QsZxxoS_pPRVe0yK8Bh2F0dG5ldHOIAAAAAAAAAACEZXRoMpD1pf1CAAAAAP__________gmlkgnY0gmlwhBLf22SJc2VjcDI1NmsxoQMeFF5GrS7UZpAH2Ly84aLK-TyvH-dRo0JM1i8yygH50YN1ZHCCJxA # 18.223.219.100 | aws-us-east-2-ohio +- enr:-Ku4QPp9z1W4tAO8Ber_NQierYaOStqhDqQdOPY3bB3jDgkjcbk6YrEnVYIiCBbTxuar3CzS528d2iE7TdJsrL-dEKoBh2F0dG5ldHOIAAAAAAAAAACEZXRoMpD1pf1CAAAAAP__________gmlkgnY0gmlwhBLf22SJc2VjcDI1NmsxoQMw5fqqkw2hHC4F5HZZDPsNmPdB1Gi8JPQK7pRc9XHh-oN1ZHCCKvg # 18.223.219.100 | aws-us-east-2-ohio + +# Lighthouse team's bootnodes +- enr:-Le4QPUXJS2BTORXxyx2Ia-9ae4YqA_JWX3ssj4E_J-3z1A-HmFGrU8BpvpqhNabayXeOZ2Nq_sbeDgtzMJpLLnXFgAChGV0aDKQtTA_KgEAAAAAIgEAAAAAAIJpZIJ2NIJpcISsaa0Zg2lwNpAkAIkHAAAAAPA8kv_-awoTiXNlY3AyNTZrMaEDHAD2JKYevx89W0CcFJFiskdcEzkH_Wdv9iW42qLK79ODdWRwgiMohHVkcDaCI4I # 172.105.173.25 | linode-au-sydney +- enr:-Le4QLHZDSvkLfqgEo8IWGG96h6mxwe_PsggC20CL3neLBjfXLGAQFOPSltZ7oP6ol54OvaNqO02Rnvb8YmDR274uq8ChGV0aDKQtTA_KgEAAAAAIgEAAAAAAIJpZIJ2NIJpcISLosQxg2lwNpAqAX4AAAAAAPA8kv_-ax65iXNlY3AyNTZrMaEDBJj7_dLFACaxBfaI8KZTh_SSJUjhyAyfshimvSqo22WDdWRwgiMohHVkcDaCI4I # 139.162.196.49 | linode-uk-london +- enr:-Le4QH6LQrusDbAHPjU_HcKOuMeXfdEB5NJyXgHWFadfHgiySqeDyusQMvfphdYWOzuSZO9Uq2AMRJR5O4ip7OvVma8BhGV0aDKQtTA_KgEAAAAAIgEAAAAAAIJpZIJ2NIJpcISLY9ncg2lwNpAkAh8AgQIBAAAAAAAAAAmXiXNlY3AyNTZrMaECDYCZTZEksF-kmgPholqgVt8IXr-8L7Nu7YrZ7HUpgxmDdWRwgiMohHVkcDaCI4I # 139.99.217.220 | ovh-au-sydney +- enr:-Le4QIqLuWybHNONr933Lk0dcMmAB5WgvGKRyDihy1wHDIVlNuuztX62W51voT4I8qD34GcTEOTmag1bcdZ_8aaT4NUBhGV0aDKQtTA_KgEAAAAAIgEAAAAAAIJpZIJ2NIJpcISLY04ng2lwNpAkAh8AgAIBAAAAAAAAAA-fiXNlY3AyNTZrMaEDscnRV6n1m-D9ID5UsURk0jsoKNXt1TIrj8uKOGW6iluDdWRwgiMohHVkcDaCI4I # 139.99.78.39 | ovh-singapore + +# EF bootnodes +- enr:-KG4QIH7EyRfHFmXLZaG6j0bMvow18k63nKWPfppuKh6iBHMPGM93HX3W3hl7jZdv_Hz8yd_jXVHX2loStJKOZqNRu0BgmlkgnY0gmlwhNRj2kKDaXA2kCoAHKALAA0CAAAAAAAAAF6Jc2VjcDI1NmsxoQMngQgKvKJR49-jugrZ_05LhAHbUlSCwZ_HyqVCb3SXYoN1ZHCCTrqEdWRwNoJOug # 212.99.218.66 | colo-dcl1 +- enr:-KG4QJIyNiCpvXrnK8dugxmFckcIduvuQraNlX0GlKwF-XyPeZ-ZG7_yHhsr08K85X1utedECuRXhXiPYJoMogs8ai4BgmlkgnY0gmlwhIHUpj2DaXA2kCYEqIAABAHQAAAAA2GdUACJc2VjcDI1NmsxoQNQbzy36fddhPGH1I6D5rQyj8zUDGWQAkkWS37qBLB_yIN1ZHCCIyiEdWRwNoIjKA # 129.212.166.61 | digitalocean-sfo3 +- enr:-KG4QDU7s2q7Cl_qGr2BucsrhN1bKywwstBqMLUtR6f_pOejVAjXLAQsFBOCSgALH_Oy7eshQ2ic7CbFwRZIxZGIMMsBgmlkgnY0gmlwhJB-_BiDaXA2kCQAYYABAADQAAAAAYEgYAGJc2VjcDI1NmsxoQLACT5Njs8OjnCiL4_11wgqunT0BPxQ5PndoKoF6ICNWYN1ZHCCIyiEdWRwNoIjKA # 144.126.252.24 | digitalocean-blr1 +- enr:-KG4QD_qJswcSJKmI_kjUZ-QbUuLUzniIakQbJKgh4YFzluGEKikFzaVoIbS7jpbw2K9hjmWTn7Ha3zyNIm0Ysu-dSgBgmlkgnY0gmlwhLKc14yDaXA2kCoBBP8A9DxKAAAAAAAAAAGJc2VjcDI1NmsxoQOVp1YSg2ZkGenRZi4iGebFira2xZrER7F_WW55-Rd3boN1ZHCCIyiEdWRwNoIjKA # 178.156.215.140 | hetzner-ash +- enr:-KG4QIFbm7kLOmOeiDwjSxLXEN0Ms4advV742CYLpGUCndm2XvAnM9uKVdWpydb8Gpstk44eFDlZlPDyV2gX7uH7KHgBgmlkgnY0gmlwhAXfXlGDaXA2kCoBBP8C8BytAAAAAAAAAAGJc2VjcDI1NmsxoQIAizAK-MKs-s08GzGzoPVIaBmfdKqosqxSkwxUuV2UnIN1ZHCCIyiEdWRwNoIjKA # 5.223.94.81 | hetzner-sin + +# Nimbus team's bootnodes +- enr:-LK4QA8FfhaAjlb_BXsXxSfiysR7R52Nhi9JBt4F8SPssu8hdE1BXQQEtVDC3qStCW60LSO7hEsVHv5zm8_6Vnjhcn0Bh2F0dG5ldHOIAAAAAAAAAACEZXRoMpC1MD8qAAAAAP__________gmlkgnY0gmlwhAN4aBKJc2VjcDI1NmsxoQJerDhsJ-KxZ8sHySMOCmTO6sHM3iCFQ6VMvLTe948MyYN0Y3CCI4yDdWRwgiOM # 3.120.104.18 | aws-eu-central-1-frankfurt +- enr:-LK4QKWrXTpV9T78hNG6s8AM6IO4XH9kFT91uZtFg1GcsJ6dKovDOr1jtAAFPnS2lvNltkOGA9k29BUN7lFh_sjuc9QBh2F0dG5ldHOIAAAAAAAAAACEZXRoMpC1MD8qAAAAAP__________gmlkgnY0gmlwhANAdd-Jc2VjcDI1NmsxoQLQa6ai7y9PMN5hpLe5HmiJSlYzMuzP7ZhwRiwHvqNXdoN0Y3CCI4yDdWRwgiOM # 3.64.117.223 | aws-eu-central-1-frankfurt + +# Lodestar team's bootnodes +- enr:-IS4QPi-onjNsT5xAIAenhCGTDl4z-4UOR25Uq-3TmG4V3kwB9ljLTb_Kp1wdjHNj-H8VVLRBSSWVZo3GUe3z6k0E-IBgmlkgnY0gmlwhKB3_qGJc2VjcDI1NmsxoQMvAfgB4cJXvvXeM6WbCG86CstbSxbQBSGx31FAwVtOTYN1ZHCCIyg # 160.119.254.161 | hostafrica-southafrica +- enr:-KG4QPUf8-g_jU-KrwzG42AGt0wWM1BTnQxgZXlvCEIfTQ5hSmptkmgmMbRkpOqv6kzb33SlhPHJp7x4rLWWiVq5lSECgmlkgnY0gmlwhFPlR9KDaXA2kCoGxcAJAAAVAAAAAAAAABCJc2VjcDI1NmsxoQLdUv9Eo9sxCt0tc_CheLOWnX59yHJtkBSOL7kpxdJ6GYN1ZHCCIyiEdWRwNoIjKA # 83.229.71.210 | kamatera-telaviv-israel diff --git a/bin/ethlambda/assets/mainnet/config.yaml b/bin/ethlambda/assets/mainnet/config.yaml new file mode 100644 index 000000000..370266e62 --- /dev/null +++ b/bin/ethlambda/assets/mainnet/config.yaml @@ -0,0 +1,204 @@ +# Mainnet config + +# Extends the mainnet preset +PRESET_BASE: 'mainnet' + +# Free-form short name of the network that this configuration applies to - known +# canonical network names include: +# * 'mainnet' - there can be only one +# * 'sepolia' - testnet +# * 'holesky' - testnet +# * 'hoodi' - testnet +# Must match the regex: [a-z0-9\-] +CONFIG_NAME: 'mainnet' + +# Transition +# --------------------------------------------------------------- +# Estimated on Sept 15, 2022 +TERMINAL_TOTAL_DIFFICULTY: 58750000000000000000000 +# By default, don't use these params +TERMINAL_BLOCK_HASH: 0x0000000000000000000000000000000000000000000000000000000000000000 +TERMINAL_BLOCK_HASH_ACTIVATION_EPOCH: 18446744073709551615 + + +# Genesis +# --------------------------------------------------------------- +# `2**14` (= 16,384) +MIN_GENESIS_ACTIVE_VALIDATOR_COUNT: 16384 +# Dec 1, 2020, 12pm UTC +MIN_GENESIS_TIME: 1606824000 +# Mainnet initial fork version, recommend altering for testnets +GENESIS_FORK_VERSION: 0x00000000 +# 604800 seconds (7 days) +GENESIS_DELAY: 604800 + + +# Forking +# --------------------------------------------------------------- +# Some forks are disabled for now: +# - These may be re-assigned to another fork-version later +# - Temporarily set to max uint64 value: 2**64 - 1 + +# Altair +ALTAIR_FORK_VERSION: 0x01000000 +ALTAIR_FORK_EPOCH: 74240 # Oct 27, 2021, 10:56:23am UTC +# Bellatrix +BELLATRIX_FORK_VERSION: 0x02000000 +BELLATRIX_FORK_EPOCH: 144896 # Sept 6, 2022, 11:34:47am UTC +# Capella +CAPELLA_FORK_VERSION: 0x03000000 +CAPELLA_FORK_EPOCH: 194048 # April 12, 2023, 10:27:35pm UTC +# Deneb +DENEB_FORK_VERSION: 0x04000000 +DENEB_FORK_EPOCH: 269568 # March 13, 2024, 01:55:35pm UTC +# Electra +ELECTRA_FORK_VERSION: 0x05000000 +ELECTRA_FORK_EPOCH: 364032 # May 7, 2025, 10:05:11am UTC +# Fulu +FULU_FORK_VERSION: 0x06000000 +FULU_FORK_EPOCH: 411392 # December 3, 2025, 09:49:11pm UTC + + +# Time parameters +# --------------------------------------------------------------- +# 12 seconds (*deprecated*) +SECONDS_PER_SLOT: 12 +# 12000 milliseconds +SLOT_DURATION_MS: 12000 +# 14 (estimate from Eth1 mainnet) +SECONDS_PER_ETH1_BLOCK: 14 +# 2**8 (= 256) epochs +MIN_VALIDATOR_WITHDRAWABILITY_DELAY: 256 +# 2**8 (= 256) epochs +SHARD_COMMITTEE_PERIOD: 256 +# 2**11 (= 2,048) Eth1 blocks +ETH1_FOLLOW_DISTANCE: 2048 +# 1667 basis points, ~17% of SLOT_DURATION_MS +PROPOSER_REORG_CUTOFF_BPS: 1667 +# 3333 basis points, ~33% of SLOT_DURATION_MS +ATTESTATION_DUE_BPS: 3333 +# 6667 basis points, ~67% of SLOT_DURATION_MS +AGGREGATE_DUE_BPS: 6667 + +# Altair +# 3333 basis points, ~33% of SLOT_DURATION_MS +SYNC_MESSAGE_DUE_BPS: 3333 +# 6667 basis points, ~67% of SLOT_DURATION_MS +CONTRIBUTION_DUE_BPS: 6667 + + +# Validator cycle +# --------------------------------------------------------------- +# 2**2 (= 4) +INACTIVITY_SCORE_BIAS: 4 +# 2**4 (= 16) +INACTIVITY_SCORE_RECOVERY_RATE: 16 +# 2**4 * 10**9 (= 16,000,000,000) Gwei +EJECTION_BALANCE: 16000000000 +# 2**2 (= 4) validators +MIN_PER_EPOCH_CHURN_LIMIT: 4 +# 2**16 (= 65,536) +CHURN_LIMIT_QUOTIENT: 65536 + +# Deneb +# 2**3 (= 8) (*deprecated*) +MAX_PER_EPOCH_ACTIVATION_CHURN_LIMIT: 8 + +# Electra +# 2**7 * 10**9 (= 128,000,000,000) Gwei +MIN_PER_EPOCH_CHURN_LIMIT_ELECTRA: 128000000000 +# 2**8 * 10**9 (= 256,000,000,000) Gwei +MAX_PER_EPOCH_ACTIVATION_EXIT_CHURN_LIMIT: 256000000000 + +# Fork choice +# --------------------------------------------------------------- +# 40% +PROPOSER_SCORE_BOOST: 40 +# 20% +REORG_HEAD_WEIGHT_THRESHOLD: 20 +# 160% +REORG_PARENT_WEIGHT_THRESHOLD: 160 +# 2 epochs +REORG_MAX_EPOCHS_SINCE_FINALIZATION: 2 + + +# Deposit contract +# --------------------------------------------------------------- +# Ethereum PoW Mainnet +DEPOSIT_CHAIN_ID: 1 +DEPOSIT_NETWORK_ID: 1 +DEPOSIT_CONTRACT_ADDRESS: 0x00000000219ab540356cBB839Cbe05303d7705Fa + + +# Networking +# --------------------------------------------------------------- +# 10 * 2**20 (= 10,485,760) bytes, 10 MiB +MAX_PAYLOAD_SIZE: 10485760 +# 2**10 (= 1,024) blocks +MAX_REQUEST_BLOCKS: 1024 +# 2**8 (= 256) epochs +EPOCHS_PER_SUBNET_SUBSCRIPTION: 256 +# MIN_VALIDATOR_WITHDRAWABILITY_DELAY + CHURN_LIMIT_QUOTIENT // 2 (= 33,024) epochs +MIN_EPOCHS_FOR_BLOCK_REQUESTS: 33024 +# 2**5 (= 32) slots +ATTESTATION_PROPAGATION_SLOT_RANGE: 32 +# 500ms +MAXIMUM_GOSSIP_CLOCK_DISPARITY: 500 +MESSAGE_DOMAIN_INVALID_SNAPPY: 0x00000000 +MESSAGE_DOMAIN_VALID_SNAPPY: 0x01000000 +# 2 subnets per node +SUBNETS_PER_NODE: 2 +# 2**6 (= 64) subnets +ATTESTATION_SUBNET_COUNT: 64 +# 0 bits +ATTESTATION_SUBNET_EXTRA_BITS: 0 +# ceillog2(ATTESTATION_SUBNET_COUNT) + ATTESTATION_SUBNET_EXTRA_BITS (= 6 + 0) bits +ATTESTATION_SUBNET_PREFIX_BITS: 6 + +# Deneb +# 2**7 (= 128) blocks +MAX_REQUEST_BLOCKS_DENEB: 128 +# 2**12 (= 4,096) epochs +MIN_EPOCHS_FOR_BLOB_SIDECARS_REQUESTS: 4096 +# 6 subnets +BLOB_SIDECAR_SUBNET_COUNT: 6 +# 6 blobs +MAX_BLOBS_PER_BLOCK: 6 +# MAX_REQUEST_BLOCKS_DENEB * MAX_BLOBS_PER_BLOCK (= 128 * 6) sidecars +MAX_REQUEST_BLOB_SIDECARS: 768 + +# Electra +# 9 subnets +BLOB_SIDECAR_SUBNET_COUNT_ELECTRA: 9 +# 9 blobs +MAX_BLOBS_PER_BLOCK_ELECTRA: 9 +# MAX_REQUEST_BLOCKS_DENEB * MAX_BLOBS_PER_BLOCK_ELECTRA (= 128 * 9) sidecars +MAX_REQUEST_BLOB_SIDECARS_ELECTRA: 1152 + +# Fulu +# 2**7 (= 128) groups +NUMBER_OF_CUSTODY_GROUPS: 128 +# 2**7 (= 128) subnets +DATA_COLUMN_SIDECAR_SUBNET_COUNT: 128 +# MAX_REQUEST_BLOCKS_DENEB * NUMBER_OF_COLUMNS (= 128 * 128) sidecars +MAX_REQUEST_DATA_COLUMN_SIDECARS: 16384 +# 2**3 (= 8) samples +SAMPLES_PER_SLOT: 8 +# 2**2 (= 4) sidecars +CUSTODY_REQUIREMENT: 4 +# 2**3 (= 8) sidecars +VALIDATOR_CUSTODY_REQUIREMENT: 8 +# 2**5 * 10**9 (= 32,000,000,000) Gwei +BALANCE_PER_ADDITIONAL_CUSTODY_GROUP: 32000000000 +# 2**12 (= 4,096) epochs +MIN_EPOCHS_FOR_DATA_COLUMN_SIDECARS_REQUESTS: 4096 + + +# Blob Scheduling +# --------------------------------------------------------------- + +BLOB_SCHEDULE: + - EPOCH: 412672 # December 9, 2025, 02:21:11pm UTC + MAX_BLOBS_PER_BLOCK: 15 + - EPOCH: 419072 # January 7, 2026, 01:01:11am UTC + MAX_BLOBS_PER_BLOCK: 21 diff --git a/bin/ethlambda/assets/sepolia/bootstrap_nodes.yaml b/bin/ethlambda/assets/sepolia/bootstrap_nodes.yaml new file mode 100644 index 000000000..bada631d2 --- /dev/null +++ b/bin/ethlambda/assets/sepolia/bootstrap_nodes.yaml @@ -0,0 +1,22 @@ +# sepolia consensus layer bootnodes +# --------------------------------------- +# 1. Tag nodes with maintainer +# 2. Keep nodes updated +# 3. Review PRs: check ENR duplicates, fork-digest, connection. + +# EF +- enr:-KG4QCK5YeEoL55e2hoS6nCregwx0Zd6NQ3rhVDfeg5Q8ozUNmUYTskpmuqo2WYFo3z24-cWC9qrU3yYKDSJ299lh8sBgmlkgnY0gmlwhNRj2kKDaXA2kCoAHKALAA0CAAAAAAAAAF6Jc2VjcDI1NmsxoQLzqnTxu_nlM8V_semraAjfbH9HZpcbVUCXH2qanVPsroN1ZHCCTruEdWRwNoJOuw # 212.99.218.66 | colo-dcl1 +- enr:-KG4QF0FvRL7Eqc4oURFhOkS0V6guntLnw54dYgTruM7z9TAMWhRpCrxZ7Pd536-q4qlwdW13czht8_UEWwGyJesu1gBgmlkgnY0gmlwhIHUpj2DaXA2kCYEqIAABAHQAAAAA2GdUACJc2VjcDI1NmsxoQL5iA7gNCs4SDmnXz8Isacq0EJbJfvV_uJlccoHxHU5ZYN1ZHCCI4yEdWRwNoIjjA # 129.212.166.61 | digitalocean-sfo3 +- enr:-KG4QI4reJ1D_BwCwg6EKAuo2HEWoIVVNjphtOTJP2gzPVLSTYM3NFwp39TAKw-7QiQ2NVts7DK4rjJR2BEcAwh3BckBgmlkgnY0gmlwhJB-_BiDaXA2kCQAYYABAADQAAAAAYEgYAGJc2VjcDI1NmsxoQLXzHa5K0M3F4pqErIhleMByA8votAUhUXylRT6SWX2HoN1ZHCCI4yEdWRwNoIjjA # 144.126.252.24 | digitalocean-blr1 +- enr:-KG4QI1KOrogxK8u3Oc0QLdgkNTbAPuAMtixa6Vx05N-Bl7IOCVURUvqZ2N6JA97ts7YG1B4D3hQvZ9uQlCPYVjy1DABgmlkgnY0gmlwhLKc14yDaXA2kCoBBP8A9DxKAAAAAAAAAAGJc2VjcDI1NmsxoQLB0ZhHGRmVwXja_4o-GRN1VVJYRI11F45CTAlu1s00Q4N1ZHCCI4yEdWRwNoIjjA # 178.156.215.140 | hetzner-ash +- enr:-KG4QKU4YfXfB3_BVI7u0VvXnSJI6cqo-tCRm-Ggh3XxBImcYvrUoKUDbIjJjG9-QphuH6gzScdf69t597M0nHut4kABgmlkgnY0gmlwhAXfXlGDaXA2kCoBBP8C8BytAAAAAAAAAAGJc2VjcDI1NmsxoQL0y83XKpPgvY7XReWg9S8bdI2UUIe5dE0N7rjOIIj4xYN1ZHCCI4yEdWRwNoIjjA # 5.223.94.81 | hetzner-sin + +# Teku bootnode +- enr:-Iu4QKvMF7Ne_RSQoZGvavTuZ1QA5_Pgeb0nq_hrjhU8s0UDV3KhcMXJkGwOWhsDGZL3ISjL0CTP-hfoTjZtEtCEwR4BgmlkgnY0gmlwhAOAaySJc2VjcDI1NmsxoQNta5b_bexSSwwrGW2Re24MjfMntzFd0f2SAxQtMj3ueYN0Y3CCIyiDdWRwgiMo + +# Lodestar +- enr:-KG4QJejf8KVtMeAPWFhN_P0c4efuwu1pZHELTveiXUeim6nKYcYcMIQpGxxdgT2Xp9h-M5pr9gn2NbbwEAtxzu50Y8BgmlkgnY0gmlwhEEVkQCDaXA2kCoBBPnAEJg4AAAAAAAAAAGJc2VjcDI1NmsxoQLEh_eVvk07AQABvLkTGBQTrrIOQkzouMgSBtNHIRUxOIN1ZHCCIyiEdWRwNoIjKA + +# Unknown +- enr:-Iq4QMCTfIMXnow27baRUb35Q8iiFHSIDBJh6hQM5Axohhf4b6Kr_cOCu0htQ5WvVqKvFgY28893DHAg8gnBAXsAVqmGAX53x8JggmlkgnY0gmlwhLKAlv6Jc2VjcDI1NmsxoQK6S-Cii_KmfFdUJL2TANL3ksaKUnNXvTCv1tLwXs0QgIN1ZHCCIyk +- enr:-L64QC9Hhov4DhQ7mRukTOz4_jHm4DHlGL726NWH4ojH1wFgEwSin_6H95Gs6nW2fktTWbPachHJ6rUFu0iJNgA0SB2CARqHYXR0bmV0c4j__________4RldGgykDb6UBOQAABx__________-CaWSCdjSCaXCEA-2vzolzZWNwMjU2azGhA17lsUg60R776rauYMdrAz383UUgESoaHEzMkvm4K6k6iHN5bmNuZXRzD4N0Y3CCIyiDdWRwgiMo diff --git a/bin/ethlambda/assets/sepolia/config.yaml b/bin/ethlambda/assets/sepolia/config.yaml new file mode 100644 index 000000000..e6c17a836 --- /dev/null +++ b/bin/ethlambda/assets/sepolia/config.yaml @@ -0,0 +1,218 @@ +# Extends the mainnet preset +PRESET_BASE: 'mainnet' +CONFIG_NAME: 'sepolia' + +# Genesis +# --------------------------------------------------------------- +MIN_GENESIS_ACTIVE_VALIDATOR_COUNT: 1300 +# Sunday, June 19, 2022 2:00:00 PM +UTC +MIN_GENESIS_TIME: 1655647200 +GENESIS_FORK_VERSION: 0x90000069 +GENESIS_DELAY: 86400 + + +# Forking +# --------------------------------------------------------------- +# Some forks are disabled for now: +# - These may be re-assigned to another fork-version later +# - Temporarily set to max uint64 value: 2**64 - 1 + +# Altair +ALTAIR_FORK_VERSION: 0x90000070 +ALTAIR_FORK_EPOCH: 50 + +# Merge +BELLATRIX_FORK_VERSION: 0x90000071 +BELLATRIX_FORK_EPOCH: 100 +TERMINAL_TOTAL_DIFFICULTY: 17000000000000000 +TERMINAL_BLOCK_HASH: 0x0000000000000000000000000000000000000000000000000000000000000000 +TERMINAL_BLOCK_HASH_ACTIVATION_EPOCH: 18446744073709551615 + +# Capella +CAPELLA_FORK_VERSION: 0x90000072 +CAPELLA_FORK_EPOCH: 56832 + +# Deneb +DENEB_FORK_VERSION: 0x90000073 +DENEB_FORK_EPOCH: 132608 + +# Electra +ELECTRA_FORK_VERSION: 0x90000074 +ELECTRA_FORK_EPOCH: 222464 + +# Fulu +FULU_FORK_VERSION: 0x90000075 +FULU_FORK_EPOCH: 272640 + +# Gloas +GLOAS_FORK_VERSION: 0x90000076 +GLOAS_FORK_EPOCH: 353024 + +# Time parameters +# --------------------------------------------------------------- +# 12 seconds (*deprecated*) +SECONDS_PER_SLOT: 12 +# 12000 milliseconds +SLOT_DURATION_MS: 12000 +# 14 (estimate from Eth1 mainnet) +SECONDS_PER_ETH1_BLOCK: 14 +# 2**8 (= 256) epochs +MIN_VALIDATOR_WITHDRAWABILITY_DELAY: 256 +# 2**8 (= 256) epochs +SHARD_COMMITTEE_PERIOD: 256 +# 2**11 (= 2,048) Eth1 blocks +ETH1_FOLLOW_DISTANCE: 2048 +# 1667 basis points, ~17% of SLOT_DURATION_MS +PROPOSER_REORG_CUTOFF_BPS: 1667 +# 3333 basis points, ~33% of SLOT_DURATION_MS +ATTESTATION_DUE_BPS: 3333 +# 6667 basis points, ~67% of SLOT_DURATION_MS +AGGREGATE_DUE_BPS: 6667 + +# Altair +# 3333 basis points, ~33% of SLOT_DURATION_MS +SYNC_MESSAGE_DUE_BPS: 3333 +# 6667 basis points, ~67% of SLOT_DURATION_MS +CONTRIBUTION_DUE_BPS: 6667 + +# Gloas +# 2**6 (= 64) epochs +MIN_BUILDER_WITHDRAWABILITY_DELAY: 64 +# 2500 basis points, 25% of SLOT_DURATION_MS +ATTESTATION_DUE_BPS_GLOAS: 2500 +# 5000 basis points, 50% of SLOT_DURATION_MS +AGGREGATE_DUE_BPS_GLOAS: 5000 +# 2500 basis points, 25% of SLOT_DURATION_MS +SYNC_MESSAGE_DUE_BPS_GLOAS: 2500 +# 5000 basis points, 50% of SLOT_DURATION_MS +CONTRIBUTION_DUE_BPS_GLOAS: 5000 +# 5000 basis points, 50% of SLOT_DURATION_MS +PAYLOAD_DUE_BPS: 5000 +# 7500 basis points, 75% of SLOT_DURATION_MS +PAYLOAD_ATTESTATION_DUE_BPS: 7500 + + +# Validator cycle +# --------------------------------------------------------------- +# 2**2 (= 4) +INACTIVITY_SCORE_BIAS: 4 +# 2**4 (= 16) +INACTIVITY_SCORE_RECOVERY_RATE: 16 +# 2**4 * 10**9 (= 16,000,000,000) Gwei +EJECTION_BALANCE: 16000000000 +# 2**2 (= 4) validators +MIN_PER_EPOCH_CHURN_LIMIT: 4 +# 2**16 (= 65,536) +CHURN_LIMIT_QUOTIENT: 65536 + +# Deneb +# 2**3 (= 8) (*deprecated*) +MAX_PER_EPOCH_ACTIVATION_CHURN_LIMIT: 8 + +# Electra +# 2**7 * 10**9 (= 128,000,000,000) Gwei +MIN_PER_EPOCH_CHURN_LIMIT_ELECTRA: 128000000000 +# 2**8 * 10**9 (= 256,000,000,000) Gwei +MAX_PER_EPOCH_ACTIVATION_EXIT_CHURN_LIMIT: 256000000000 + +# Gloas +# 2**15 (= 32,768) +CHURN_LIMIT_QUOTIENT_GLOAS: 32768 +# 2**16 (= 65,536) +CONSOLIDATION_CHURN_LIMIT_QUOTIENT: 65536 +# 2**8 * 10**9 (= 256,000,000,000) Gwei +MAX_PER_EPOCH_ACTIVATION_CHURN_LIMIT_GLOAS: 256000000000 + +# Fork choice +# --------------------------------------------------------------- +# 40% +PROPOSER_SCORE_BOOST: 40 +# 20% +REORG_HEAD_WEIGHT_THRESHOLD: 20 +# 160% +REORG_PARENT_WEIGHT_THRESHOLD: 160 +# 2 epochs +REORG_MAX_EPOCHS_SINCE_FINALIZATION: 2 + +# Deposit contract +# --------------------------------------------------------------- +DEPOSIT_CHAIN_ID: 11155111 +DEPOSIT_NETWORK_ID: 11155111 +DEPOSIT_CONTRACT_ADDRESS: 0x7f02C3E3c98b133055B8B348B2Ac625669Ed295D + +# Networking +# --------------------------------------------------------------- +# 10 * 2**20 (= 10,485,760) bytes, 10 MiB +MAX_PAYLOAD_SIZE: 10485760 +# 2**10 (= 1,024) blocks +MAX_REQUEST_BLOCKS: 1024 +# 2**8 (= 256) epochs +EPOCHS_PER_SUBNET_SUBSCRIPTION: 256 +# MIN_VALIDATOR_WITHDRAWABILITY_DELAY + CHURN_LIMIT_QUOTIENT // 2 (= 33,024) epochs +MIN_EPOCHS_FOR_BLOCK_REQUESTS: 33024 +# 2**5 (= 32) slots +ATTESTATION_PROPAGATION_SLOT_RANGE: 32 +# 500ms +MAXIMUM_GOSSIP_CLOCK_DISPARITY: 500 +MESSAGE_DOMAIN_INVALID_SNAPPY: 0x00000000 +MESSAGE_DOMAIN_VALID_SNAPPY: 0x01000000 +# 2 subnets per node +SUBNETS_PER_NODE: 2 +# 2**6 (= 64) subnets +ATTESTATION_SUBNET_COUNT: 64 +# 0 bits +ATTESTATION_SUBNET_EXTRA_BITS: 0 +# ceillog2(ATTESTATION_SUBNET_COUNT) + ATTESTATION_SUBNET_EXTRA_BITS (= 6 + 0) bits +ATTESTATION_SUBNET_PREFIX_BITS: 6 + +# Deneb +# 2**7 (= 128) blocks +MAX_REQUEST_BLOCKS_DENEB: 128 +# 2**12 (= 4,096) epochs +MIN_EPOCHS_FOR_BLOB_SIDECARS_REQUESTS: 4096 +# 6 subnets +BLOB_SIDECAR_SUBNET_COUNT: 6 +# 6 blobs +MAX_BLOBS_PER_BLOCK: 6 +# MAX_REQUEST_BLOCKS_DENEB * MAX_BLOBS_PER_BLOCK (= 128 * 6) sidecars +MAX_REQUEST_BLOB_SIDECARS: 768 + +# Electra +# 9 subnets +BLOB_SIDECAR_SUBNET_COUNT_ELECTRA: 9 +# 9 blobs +MAX_BLOBS_PER_BLOCK_ELECTRA: 9 +# MAX_REQUEST_BLOCKS_DENEB * MAX_BLOBS_PER_BLOCK_ELECTRA (= 128 * 9) sidecars +MAX_REQUEST_BLOB_SIDECARS_ELECTRA: 1152 + +# Fulu +# 2**7 (= 128) groups +NUMBER_OF_CUSTODY_GROUPS: 128 +# 2**7 (= 128) subnets +DATA_COLUMN_SIDECAR_SUBNET_COUNT: 128 +# MAX_REQUEST_BLOCKS_DENEB * NUMBER_OF_COLUMNS (= 128 * 128) sidecars +MAX_REQUEST_DATA_COLUMN_SIDECARS: 16384 +# 2**3 (= 8) samples +SAMPLES_PER_SLOT: 8 +# 2**2 (= 4) sidecars +CUSTODY_REQUIREMENT: 4 +# 2**3 (= 8) sidecars +VALIDATOR_CUSTODY_REQUIREMENT: 8 +# 2**5 * 10**9 (= 32,000,000,000) Gwei +BALANCE_PER_ADDITIONAL_CUSTODY_GROUP: 32000000000 +# 2**12 (= 4,096) epochs +MIN_EPOCHS_FOR_DATA_COLUMN_SIDECARS_REQUESTS: 4096 + +# Gloas +# 2**7 (= 128) payloads +MAX_REQUEST_PAYLOADS: 128 + + +# Blob Scheduling +# --------------------------------------------------------------- + +BLOB_SCHEDULE: + - EPOCH: 274176 + MAX_BLOBS_PER_BLOCK: 15 + - EPOCH: 275712 + MAX_BLOBS_PER_BLOCK: 21 diff --git a/bin/ethlambda/src/beacon.rs b/bin/ethlambda/src/beacon.rs new file mode 100644 index 000000000..26a670bb3 --- /dev/null +++ b/bin/ethlambda/src/beacon.rs @@ -0,0 +1,495 @@ +//! `ethlambda beacon`: the wire parameters derived from a resolved network. +//! +//! What a network *is* (its config, genesis values and bootnodes) lives in +//! `crate::network`: a built-in chain's embedded files, or a directory on +//! disk. This module derives what a node needs before it can put itself on the +//! wire, the same way for every network. The order matters, because the fork +//! digest depends on the epoch, which depends on genesis time: +//! +//! ```text +//! genesis_validators_root, genesis_time (crate::network::NetworkSource::genesis) +//! └─► epoch = (now - genesis_time) / (seconds_per_slot * SLOTS_PER_EPOCH) +//! └─► fork_digest = compute_fork_digest(config, gvr, epoch) +//! └─► gossip topics, ENR eth2 entry, discv5 admission +//! ``` +//! +//! `crate::run_node` builds the swarm, for both chains, from what +//! [`wire_params`] returns. +//! +//! The two genesis values used to come from a Beacon API's +//! `/eth/v1/beacon/genesis`, which made `--checkpoint-sync-url` mandatory on +//! `beacon` and made startup fail whenever every configured provider was down. +//! They are properties of the chain, not of a provider, so they are now carried +//! by the network itself and `beacon` boots with no network configuration at +//! all. `node`'s checkpoint sync is untouched: a lean node fetches a +//! *finalized* anchor, which genuinely has no local source. + +use ethlambda_p2p::beacon::swarm::BeaconWireConfig; +use ethlambda_types::beacon::config::Config; +use ethlambda_types::beacon::constants::FAR_FUTURE_EPOCH; +use ethlambda_types::beacon::containers::BeaconState; +use ethlambda_types::beacon::fork_digest::{compute_fork_digest, next_fork_boundary}; +use ethlambda_types::beacon::preset; +use ethlambda_types::beacon::primitives::{Epoch, Root}; +use ethlambda_types::enr::EnrForkId; +use tracing::{info, warn}; + +/// The epoch containing wall-clock second `now`. +/// +/// Before genesis this is 0 rather than an error: a node started early should +/// pick the genesis fork's topics and wait, not refuse to boot. +pub fn epoch_at(config: &Config, genesis_time: u64, now: u64) -> Epoch { + now.saturating_sub(genesis_time) / (config.seconds_per_slot * preset::SLOTS_PER_EPOCH) +} + +/// The wall-clock second `epoch` begins at. +pub fn time_at_epoch(config: &Config, genesis_time: u64, epoch: Epoch) -> u64 { + genesis_time + epoch * config.seconds_per_slot * preset::SLOTS_PER_EPOCH +} + +/// The `eth2` ENR entry for this chain at this epoch. +/// +/// `next_fork_*` point at the next boundary that moves the digest, which +/// includes blob-parameter-only forks. Peers tolerate a difference here by +/// design: only `fork_digest` has to match. +pub fn enr_fork_id(config: &Config, genesis_validators_root: Root, epoch: Epoch) -> EnrForkId { + let fork_digest = compute_fork_digest(config, genesis_validators_root, epoch); + // With no boundary ahead, the spec says to repeat the current fork's own + // version and name `FAR_FUTURE_EPOCH` as the epoch it activates at. + let boundary = next_fork_boundary(config, epoch); + let named_epoch = boundary.unwrap_or(epoch); + EnrForkId { + fork_digest, + next_fork_version: config.fork_version(config.fork_at_epoch(named_epoch)), + next_fork_epoch: boundary.unwrap_or(FAR_FUTURE_EPOCH), + } +} + +/// Ethereum mainnet's genesis `BeaconState`, SSZ-encoded: a test fixture. +/// +/// This is `metadata/genesis.ssz` from `eth-clients/mainnet` byte for byte; +/// [`the_fixture_state_is_eth_clients_file`] pins its SHA-256. The binary does +/// not carry it: a built-in network never anchors at genesis, so the node +/// needs only the two values `crate::network::built_in` holds as constants. +/// Tests keep it because it is a real phase0 state, which is what every test +/// that builds a network directory, upgrades a state through the forks or +/// checks those constants needs. +#[cfg(test)] +static MAINNET_GENESIS_SSZ: &[u8] = + include_bytes!("../tests/fixtures/networks/mainnet/genesis.ssz"); + +/// The two genesis fields the fork digest is derived from. +#[derive(Debug, Clone, Copy)] +pub struct Genesis { + pub genesis_time: u64, + pub genesis_validators_root: Root, +} + +impl Genesis { + /// Read the pair off a genesis state. + pub fn of(state: &BeaconState) -> Self { + Self { + genesis_time: state.genesis_time(), + genesis_validators_root: state.genesis_validators_root(), + } + } +} + +/// Decode the mainnet genesis fixture. +/// +/// The fork is `Phase0` because this is *genesis*, not the current head: the +/// state predates altair by definition, whatever fork the chain is on now. +/// A build with `ethlambda-types/preset-minimal` on fails here: the minimal +/// preset shortens the state's fixed-size vectors, so mainnet's encoding no +/// longer fits the container. +#[cfg(test)] +pub fn mainnet_genesis_state() -> eyre::Result { + use ethlambda_types::beacon::fork::ForkName; + use eyre::WrapErr as _; + + BeaconState::from_ssz(ForkName::Phase0, MAINNET_GENESIS_SSZ) + .wrap_err("the mainnet genesis fixture did not decode as a phase0 BeaconState") +} + +/// The genesis pair read off the mainnet fixture, for the checkpoint-sync +/// tests that only want these two fields rather than a whole `NetworkSource`. +#[cfg(test)] +pub fn mainnet_genesis() -> eyre::Result { + Ok(Genesis::of(&mainnet_genesis_state()?)) +} + +/// The mainnet wire parameters [`wire_params`] derives. +/// +/// The node key, the ports, the HTTP server and the bootnode list are not here: +/// they are the same on either chain, so `crate::run_node` owns them. +pub struct BeaconWireParams { + /// The beacon half of the swarm configuration: the fork digest every topic + /// name is keyed on, plus the schedule and genesis time gossip decode needs + /// once the node is running. + pub wire: BeaconWireConfig, + /// The `eth2` ENR entry: published in this node's record, and compared + /// against every record discv5 turns up. + pub fork_id: EnrForkId, +} + +/// The discv5 node id for this process's `--node-key` bytes. +/// +/// A thin pass-through, kept as its own function so `run_node`'s exact +/// composition — resolve the key, derive the id, feed it to [`wire_params`] — +/// is unit-testable here; `run_node` itself needs a live swarm to drive and a +/// test cannot call it directly. +pub fn beacon_node_id(node_key: &[u8]) -> eyre::Result<[u8; 32]> { + Ok(ethlambda_p2p::discovery::enr::node_id_from_secret_key( + node_key, + )?) +} + +/// Derive a resolved network's wire parameters. +/// +/// This is the whole of what startup needs before it can build a swarm, and it +/// touches no network: every value here is a function of `source` (a built-in +/// network or a loaded directory), the wall clock, and `node_id`, which the +/// caller must have derived via [`beacon_node_id`], since a peer computes our +/// custody set off the identity we publish. The anchor state itself belongs to +/// the anchor-and-follow work, and must be checked against the genesis values +/// used here when it lands. +/// +/// `node_key_supplied` carries no key material, only whether `node_id` came +/// from a persisted `--node-key` or one generated fresh for this run: this +/// function has no other way to tell the two apart, and that is exactly what +/// the startup warning below needs to know. +pub fn wire_params( + source: &crate::network::NetworkSource, + node_id: [u8; 32], + node_key_supplied: bool, + custody_group_count: u64, +) -> eyre::Result { + let chain = source.config().clone(); + let genesis = source.genesis(); + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock is after the unix epoch") + .as_secs(); + let epoch = epoch_at(&chain, genesis.genesis_time, now); + let fork = chain.fork_at_epoch(epoch); + let fork_id = enr_fork_id(&chain, genesis.genesis_validators_root, epoch); + let digest_hex = hex::encode(fork_id.fork_digest); + + info!( + network = %source.name(), + genesis_time = genesis.genesis_time, + genesis_validators_root = %format!("0x{}", hex::encode(genesis.genesis_validators_root.0)), + epoch, + fork = fork.as_str(), + fork_digest = %digest_hex, + "Derived the wire parameters for this network" + ); + ethlambda_p2p::metrics::set_beacon_fork_digest(&digest_hex); + + // The digest is computed once. Crossing a boundary while running strands + // this node on topic names nobody publishes to, so say when that is. + match next_fork_boundary(&chain, epoch) { + Some(boundary) => info!( + boundary_epoch = boundary, + boundary_unix_time = time_at_epoch(&chain, genesis.genesis_time, boundary), + "The fork digest changes at this boundary; restart the node to cross it" + ), + None => info!("No fork or blob-schedule boundary is scheduled"), + } + + // The node id is the discv5 one, so what this node custodies here is what + // any peer computes for it from its ENR. A node without a persistent + // --node-key gets a new identity and therefore a new custody set on every + // restart, which is why startup warns about it right below. + // + // `sampling_size` is the larger of the advertised count and + // `SAMPLES_PER_SLOT`, so the default advertisement still custodies + // `SAMPLES_PER_SLOT` columns and the two only converge once + // `--custody-group-count` is raised past that floor. + let sampling = ethlambda_state_transition::beacon::das::sampling_size(custody_group_count); + let custody_columns = + ethlambda_state_transition::beacon::das::custody_columns(node_id, sampling) + .expect("the sampling size is within NUMBER_OF_CUSTODY_GROUPS"); + info!( + custody_group_count, + sampling, + columns = ?custody_columns, + "Custodying data columns" + ); + + // The columns above are stored under this identity and served to peers + // who compute the same set from it. An ephemeral identity makes both + // sides of that agreement stale on the next restart: the sidecars already + // on disk belong to a node id nobody, including this node, will select + // again, and the fresh id this run advertises has nothing custodied for + // it yet. Placed next to the custody log line above so an operator reads + // the two together rather than finding this warning buried in startup + // noise. + if !node_key_supplied { + warn!( + "No --node-key supplied: this node's custody columns are a function of its \ + discv5 node id, so a fresh identity on every restart means the columns already \ + stored on disk belong to a different custody set than the one this run \ + advertises and serves. Pass --node-key with a persisted key file to keep one \ + identity, and therefore one custody set, across restarts." + ); + } + + // The same node id again, and the same consequence of an ephemeral one: + // `p2p-interface.md` makes this a public function of the node id so that a + // peer can compute what this node should be listening to from its ENR + // alone. Computed at the current epoch and then kept for the process's + // lifetime; see `subnets`'s module documentation for why this does not + // rotate. + let attestation_subnets = + ethlambda_p2p::beacon::subnets::compute_subscribed_subnets(node_id, epoch, &chain) + .map_err(|err| eyre::eyre!("computing the attestation subnet backbone: {err:?}"))?; + info!( + subnets_per_node = chain.subnets_per_node, + subnets = ?attestation_subnets, + "Backboning attestation subnets" + ); + + // Say plainly what is still advertised without being backed by behavior, + // so a running node never implies more than it does. Storing and serving + // the custodied columns logged above is no longer in that gap, and neither + // is the attestation subnet backbone; sync committee subnet subscription, + // and publishing, still are. + warn!( + "Advertising cgc={custody_group_count} while subscribing to no sync committee \ + subnet, and publishing nothing" + ); + + Ok(BeaconWireParams { + wire: BeaconWireConfig { + fork_digest: fork_id.fork_digest, + fork, + config: chain, + genesis_time: genesis.genesis_time, + genesis_validators_root: genesis.genesis_validators_root, + custody_columns, + attestation_subnets, + }, + fork_id, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use ethlambda_types::beacon::constants::CUSTODY_REQUIREMENT; + use ethlambda_types::beacon::fork::ForkName; + + /// Mainnet's genesis, 2020-12-01 12:00:23 UTC. + const MAINNET_GENESIS_TIME: u64 = 1_606_824_023; + + fn mainnet_gvr() -> Root { + Root::from_slice( + &hex::decode("4b363db94e286120d76eb905340fdd4e54bfe9f06bf33ff6cf5ad27f511bfe95") + .expect("valid hex"), + ) + } + + /// The fixture is eth-clients' state, unmodified. + /// + /// Every mainnet value below is read out of this file, and so is the check + /// on the built-in mainnet constants, so replacing it silently would make + /// those tests agree with the wrong chain. Re-derive with: + /// + /// ```text + /// shasum -a 256 bin/ethlambda/tests/fixtures/networks/mainnet/genesis.ssz + /// ``` + #[test] + fn the_fixture_state_is_eth_clients_file() { + use sha2::Digest as _; + let digest = sha2::Sha256::digest(MAINNET_GENESIS_SSZ); + assert_eq!( + hex::encode(digest), + "bbdf6fa5ffd6ead8ca6714c60a17d14d48ccaabbb18622b8485f88b58633d620" + ); + } + + /// The fixture decodes, and is the state mainnet started from. + #[test] + fn the_fixture_is_mainnets_genesis() { + let state = mainnet_genesis_state().expect("the fixture decodes"); + assert_eq!(state.fork_name(), ForkName::Phase0); + // Genesis, not a later anchor: slot 0, and `genesis_validators_root` + // already populated, which is what makes the field readable here. + assert_eq!(state.slot(), 0); + assert_eq!(state.genesis_time(), MAINNET_GENESIS_TIME); + assert_eq!(state.genesis_validators_root(), mainnet_gvr()); + + let genesis = mainnet_genesis().expect("the pair is read off that state"); + assert_eq!(genesis.genesis_time, MAINNET_GENESIS_TIME); + assert_eq!(genesis.genesis_validators_root, mainnet_gvr()); + } + + /// The fast path in [`BeaconState::slot_from_ssz`] reads a byte offset + /// rather than the container, so pin it against a genuine encoded mainnet + /// state. The slot is moved off zero first: at zero an offset landing in + /// the `fork` field that follows would read zero too and pass. + #[test] + fn the_state_slot_offset_matches_a_real_encoded_state() { + let mut state = mainnet_genesis_state().expect("the fixture decodes"); + *state.slot_mut() = 12_345; + + let bytes = state.to_ssz(); + + assert_eq!(BeaconState::slot_from_ssz(&bytes).unwrap(), 12_345); + } + + /// Startup derives every wire parameter without touching the network. + /// + /// The point of the change: this used to require a reachable Beacon API, so + /// a test could not call it at all. + #[test] + fn the_wire_parameters_are_derived_offline() { + let source = crate::network::NetworkSource::built_in_mainnet().unwrap(); + let params = wire_params(&source, [0x11; 32], true, CUSTODY_REQUIREMENT) + .expect("no network is needed"); + assert_eq!(params.wire.genesis_time, MAINNET_GENESIS_TIME); + // The digest is whatever fork the wall clock lands in, so it is not + // pinned here; that it agrees with the ENR entry is the invariant. + assert_eq!(params.wire.fork_digest, params.fork_id.fork_digest); + // Mainnet's wall clock is past fulu and no later fork is defined, so + // fulu is the only fork the wire can name. + assert_eq!(params.wire.fork, ForkName::Fulu); + } + + /// Two node ids sampling the same size select different columns: this is + /// what makes the network's total custody wide rather than every node + /// serving the same slice. + #[test] + fn the_custody_columns_are_a_function_of_the_node_id() { + let sampling = ethlambda_state_transition::beacon::das::sampling_size( + ethlambda_types::beacon::constants::CUSTODY_REQUIREMENT, + ); + + let source = crate::network::NetworkSource::built_in_mainnet().unwrap(); + + let a = wire_params(&source, [0x11; 32], true, CUSTODY_REQUIREMENT) + .expect("no network is needed"); + assert_eq!(a.wire.custody_columns.len(), sampling as usize); + + let b = wire_params(&source, [0x22; 32], true, CUSTODY_REQUIREMENT) + .expect("no network is needed"); + assert_ne!(a.wire.custody_columns, b.wire.custody_columns); + } + + /// `run_node` cannot be driven from a test, but its two-line composition + /// (resolve the key, derive the id, feed it to `wire_params`) can be, via + /// `beacon_node_id`. + #[test] + fn beacon_node_id_feeds_wire_params_a_valid_identity() { + let node_id = beacon_node_id(&[0x33; 32]).expect("a well-formed key"); + let source = crate::network::NetworkSource::built_in_mainnet().unwrap(); + let params = + wire_params(&source, node_id, true, CUSTODY_REQUIREMENT).expect("no network is needed"); + let sampling = ethlambda_state_transition::beacon::das::sampling_size( + ethlambda_types::beacon::constants::CUSTODY_REQUIREMENT, + ); + assert_eq!(params.wire.custody_columns.len(), sampling as usize); + } + + #[test] + fn the_epoch_is_read_off_the_wall_clock() { + let config = Config::mainnet(); + assert_eq!( + epoch_at(&config, MAINNET_GENESIS_TIME, MAINNET_GENESIS_TIME), + 0 + ); + // One epoch is SLOTS_PER_EPOCH slots of seconds_per_slot each. + let one_epoch = config.seconds_per_slot * preset::SLOTS_PER_EPOCH; + assert_eq!( + epoch_at( + &config, + MAINNET_GENESIS_TIME, + MAINNET_GENESIS_TIME + one_epoch + ), + 1 + ); + assert_eq!( + epoch_at( + &config, + MAINNET_GENESIS_TIME, + MAINNET_GENESIS_TIME + one_epoch - 1 + ), + 0 + ); + } + + #[test] + fn a_clock_before_genesis_reports_epoch_zero_rather_than_underflowing() { + let config = Config::mainnet(); + assert_eq!(epoch_at(&config, MAINNET_GENESIS_TIME, 0), 0); + } + + #[test] + fn epoch_and_time_are_inverses() { + let config = Config::mainnet(); + for epoch in [0u64, 1, 411_392, 419_072] { + let at = time_at_epoch(&config, MAINNET_GENESIS_TIME, epoch); + assert_eq!(epoch_at(&config, MAINNET_GENESIS_TIME, at), epoch); + } + } + + #[test] + fn the_enr_fork_id_carries_the_computed_digest() { + let config = Config::mainnet(); + let fork_id = enr_fork_id(&config, mainnet_gvr(), 419_072); + assert_eq!(fork_id.fork_digest, [0x8c, 0x9f, 0x62, 0xfe]); + // Nothing is scheduled past the last blob-schedule entry. + assert_eq!(fork_id.next_fork_epoch, FAR_FUTURE_EPOCH); + assert_eq!(fork_id.next_fork_version, config.fulu_fork_version); + } + + #[test] + fn a_pending_boundary_is_advertised() { + let config = Config::mainnet(); + let fork_id = enr_fork_id(&config, mainnet_gvr(), 411_392); + assert_eq!(fork_id.next_fork_epoch, 412_672); + // A blob-parameter-only fork keeps fulu's version: it moves the digest + // without introducing a new fork version, which is EIP-7892's point. + assert_eq!(fork_id.next_fork_version, config.fulu_fork_version); + } + + /// The regression that matters: mainnet's wire parameters must not move + /// now that its config comes from the embedded `config.yaml` and its + /// genesis values from constants, instead of from `Config::mainnet()` and + /// a decoded genesis state. + #[test] + fn the_built_in_network_derives_what_it_always_did() { + let source = crate::network::NetworkSource::built_in_mainnet().unwrap(); + assert_eq!(source.config(), &Config::mainnet()); + + let genesis = source.genesis(); + let fixture = mainnet_genesis().unwrap(); + assert_eq!(genesis.genesis_time, fixture.genesis_time); + assert_eq!( + genesis.genesis_validators_root, + fixture.genesis_validators_root + ); + } + + #[test] + fn a_loaded_directory_supplies_its_own_config() { + let dir = tempfile::tempdir().unwrap(); + std::fs::copy( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/networks/devnet/config.yaml"), + dir.path().join("config.yaml"), + ) + .unwrap(); + let state = mainnet_genesis_state().unwrap(); + std::fs::write(dir.path().join("genesis.ssz"), state.to_ssz()).unwrap(); + + let loaded = crate::network::dir::NetworkDir::load(dir.path()).unwrap(); + let source = crate::network::NetworkSource::Loaded(Box::new(loaded)); + assert_eq!(source.config().seconds_per_slot, 6); + assert_eq!(source.config().deposit_chain_id, 3_151_908); + // The genesis state written above is mainnet's, so this is its time. + assert_eq!(source.genesis().genesis_time, 1_606_824_023); + } +} diff --git a/bin/ethlambda/src/benchmark/corpus.rs b/bin/ethlambda/src/benchmark/corpus.rs index 8dc3e3f17..c82133b1b 100644 --- a/bin/ethlambda/src/benchmark/corpus.rs +++ b/bin/ethlambda/src/benchmark/corpus.rs @@ -207,9 +207,11 @@ impl SyntheticCorpus { /// Import the sealed block. Real mode verifies the merged proof, so a bad /// seal fails the run instead of producing a report about invalid blocks. pub(crate) fn import(&self, store: &mut Store, block: SignedBlock) -> Result<(), StoreError> { + // The import's own timing timings are dropped: this harness times the + // build, and reports the import through its own phase timers. match self.crypto { - CryptoMode::Mock => on_block_without_verification(store, block), - CryptoMode::Real { .. } => on_block(store, block), + CryptoMode::Mock => on_block_without_verification(store, block).map(|_| ()), + CryptoMode::Real { .. } => on_block(store, block).map(|_| ()), } } diff --git a/bin/ethlambda/src/benchmark/import/corpus.rs b/bin/ethlambda/src/benchmark/import/corpus.rs new file mode 100644 index 000000000..0d3c245c5 --- /dev/null +++ b/bin/ethlambda/src/benchmark/import/corpus.rs @@ -0,0 +1,141 @@ +//! The on-disk corpus: a manifest, an anchor pair, and one SSZ file per +//! non-empty slot. +//! +//! Blocks rather than a prepared RocksDB directory, so a corpus stays +//! readable, diffable and portable across revisions of the store format. +//! Blocks are never all in memory at once: a reader takes one file per turn. + +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +pub(crate) const MANIFEST_FILE: &str = "manifest.json"; +pub(crate) const ANCHOR_STATE_FILE: &str = "anchor.state.ssz"; +pub(crate) const ANCHOR_BLOCK_FILE: &str = "anchor.block.ssz"; +pub(crate) const BLOCKS_DIR: &str = "blocks"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct Manifest { + /// `"beacon"`. Recorded so a future lean arm cannot be replayed by the + /// beacon one by accident. + pub chain: String, + /// The `--network` value the corpus was fetched against. + pub network: String, + pub genesis_validators_root: String, + pub anchor_block_root: String, + /// The first slot of an epoch; see `source::resolve_anchor` for why. + pub anchor_slot: u64, + /// Slots between the anchor and `range_start` that hold a block, + /// ascending. Replay imports them before the range, since the range's + /// first block descends from them, but takes no sample of them. + /// + /// Defaulted so a corpus written before this field existed still reads; + /// whether its anchor is usable is `replay`'s to check. + #[serde(default)] + pub warmup_slots: Vec, + pub range_start: u64, + pub range_end: u64, + /// Slots in the range that hold a block, ascending. Slots in the range but + /// absent here were empty on the source chain. + pub slots: Vec, +} + +impl Manifest { + pub(crate) fn write(&self, dir: &Path) -> eyre::Result<()> { + let json = serde_json::to_string_pretty(self)?; + std::fs::write(dir.join(MANIFEST_FILE), json)?; + Ok(()) + } + + pub(crate) fn read(dir: &Path) -> eyre::Result { + let json = std::fs::read_to_string(dir.join(MANIFEST_FILE))?; + Ok(serde_json::from_str(&json)?) + } + + /// Slots in the range that hold no block. + pub(crate) fn missing_slots(&self) -> Vec { + (self.range_start..=self.range_end) + .filter(|slot| self.slots.binary_search(slot).is_err()) + .collect() + } +} + +/// A root as a manifest spells it: `0x` plus lowercase hex. +/// +/// Shared with `fetch`, which fills the manifest, and `replay`, which checks +/// the recorded root against the network it was asked to replay against. +pub(crate) fn hex_root(root: ethlambda_types::primitives::H256) -> String { + format!("0x{}", hex::encode(root.0)) +} + +/// Where a block at `slot` lives inside a corpus. +/// +/// Zero-padded to ten digits so a directory listing sorts, which matters when +/// a human is reading a corpus rather than its manifest. +pub(crate) fn block_path(dir: &Path, slot: u64) -> PathBuf { + dir.join(BLOCKS_DIR).join(format!("{slot:010}.ssz")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn manifest() -> Manifest { + Manifest { + chain: "beacon".to_string(), + network: "mainnet".to_string(), + genesis_validators_root: "0x4b363db9".to_string(), + anchor_block_root: "0xabcdef01".to_string(), + anchor_slot: 9_123_424, + warmup_slots: vec![9_123_425, 9_123_455], + range_start: 9_123_456, + range_end: 9_123_460, + slots: vec![9_123_456, 9_123_458, 9_123_460], + } + } + + #[test] + fn a_manifest_round_trips_through_a_corpus_directory() { + let dir = tempfile::tempdir().expect("tempdir"); + let expected = manifest(); + + expected.write(dir.path()).expect("write"); + let loaded = Manifest::read(dir.path()).expect("read"); + + assert_eq!(loaded, expected); + } + + #[test] + fn a_manifest_written_before_warm_up_slots_existed_still_reads() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut json = serde_json::to_value(manifest()).expect("to json"); + json.as_object_mut() + .expect("an object") + .remove("warmup_slots"); + std::fs::write(dir.path().join(MANIFEST_FILE), json.to_string()).expect("write"); + + let loaded = Manifest::read(dir.path()).expect("read"); + + assert!(loaded.warmup_slots.is_empty()); + } + + #[test] + fn absent_slots_are_recorded_as_gaps_not_errors() { + // 9_123_457 and 9_123_459 held no block. A replay must skip them + // without treating the corpus as truncated. + let m = manifest(); + assert_eq!(m.missing_slots(), vec![9_123_457, 9_123_459]); + } + + #[test] + fn a_block_path_sorts_lexically() { + // Zero-padded so a directory listing reads in slot order, which + // matters when a human is inspecting a corpus rather than its + // manifest. + let dir = std::path::Path::new("/corpus"); + let early = block_path(dir, 9_123_456); + let late = block_path(dir, 10_000_000); + + assert!(early < late, "{early:?} must sort before {late:?}"); + } +} diff --git a/bin/ethlambda/src/benchmark/import/fetch.rs b/bin/ethlambda/src/benchmark/import/fetch.rs new file mode 100644 index 000000000..422befca7 --- /dev/null +++ b/bin/ethlambda/src/benchmark/import/fetch.rs @@ -0,0 +1,322 @@ +//! Pulling a range into a corpus. +//! +//! # Memory +//! +//! Nothing here scales with the length of the range. The anchor state is the +//! only state this phase ever holds: it is fetched alone, written straight to +//! disk and dropped before the block loop starts. Blocks are written as each +//! response completes, never accumulated. That is why there is no cap on the +//! range: the ceiling comes from streaming, not from refusing long runs. +//! +//! # What a 404 means +//! +//! The Beacon API answers `404` for an empty slot, for a slot past its head +//! and for one before its own history, and those look identical from here. +//! `fetch` records a 404 as an empty slot only where it can prove that is what +//! it was: `--to` may not pass the source's head, and every block must name +//! the previous one as its parent, so a block missing from the middle of the +//! range (or a reorg between two requests) stops the fetch instead of +//! surfacing later as a replay that imports onto the wrong parent. + +use std::path::Path; + +use ethlambda_p2p::beacon::decode; +use ethlambda_types::beacon::config::Config; +use ethlambda_types::beacon::containers::BeaconState; +use ethlambda_types::beacon::preset; +use ethlambda_types::genesis::verify_state_genesis; + +use super::corpus::{ + ANCHOR_BLOCK_FILE, ANCHOR_STATE_FILE, BLOCKS_DIR, MANIFEST_FILE, Manifest, block_path, hex_root, +}; +use super::source::{CorpusSource, resolve_anchor}; + +/// Print a progress line every this many slots, so a long range does not run +/// silent: each slot is one request, which is a round trip to a remote source. +const PROGRESS_INTERVAL: u64 = 100; + +/// Removes a corpus directory this fetch created, unless the fetch finished. +/// +/// A failed fetch otherwise leaves a manifest-less directory behind (an empty +/// one at best, a partial set of blocks at worst) that looks like a corpus +/// and is not one. A directory that already existed is left alone: it is the +/// caller's, and might hold anything. +struct CreatedDir<'a> { + path: Option<&'a Path>, +} + +impl<'a> CreatedDir<'a> { + fn create(path: &'a Path) -> std::io::Result { + let created = !path.exists(); + std::fs::create_dir_all(path.join(BLOCKS_DIR))?; + Ok(Self { + path: created.then_some(path), + }) + } + + /// The fetch finished: keep the directory. + fn keep(mut self) { + self.path = None; + } +} + +impl Drop for CreatedDir<'_> { + fn drop(&mut self) { + if let Some(path) = self.path { + let _ = std::fs::remove_dir_all(path); + } + } +} + +/// Fetch `from..=to` into a fresh corpus at `dir`. +/// +/// Refuses to run if `dir` already holds a manifest, so a caller retrying a +/// failed run does not silently blend a half-written corpus with a new one; +/// deleting an existing corpus before calling this (the `--force` flag) is +/// the caller's job, not this function's. +/// +/// The corpus also holds the blocks between the anchor and `from` (see +/// [`resolve_anchor`] for why the anchor is usually before `from - 1`), as +/// warm-up blocks the replay imports without sampling. +pub(crate) async fn fetch_corpus( + source: &impl CorpusSource, + dir: &Path, + from: u64, + to: u64, + network: &str, + config: &Config, + genesis: &crate::beacon::Genesis, +) -> eyre::Result { + eyre::ensure!(from <= to, "--from {from} is after --to {to}"); + eyre::ensure!( + !dir.join(MANIFEST_FILE).exists(), + "{} already holds a corpus; pass --force to replace it", + dir.display() + ); + let head = source.head_slot().await?; + eyre::ensure!( + to <= head, + "--to {to} is past the source's head at slot {head}; the slots after it \ + would be recorded as empty" + ); + + let anchor = resolve_anchor(source, from, preset::SLOTS_PER_EPOCH).await?; + let created = CreatedDir::create(dir)?; + + // The one state this phase holds. Decoded to read the network + // fingerprint, written, then dropped before any block is fetched, so the + // peak is one state and not one state plus a range. + let genesis_validators_root = { + let bytes = source.state_bytes_at_slot(anchor.slot).await?; + let slot = BeaconState::slot_from_ssz(&bytes) + .map_err(|err| eyre::eyre!("anchor state slot does not decode: {err:?}"))?; + let fork = decode::fork_at_slot(config, slot); + let state = BeaconState::from_ssz(fork, &bytes) + .map_err(|err| eyre::eyre!("anchor state does not decode as {fork:?}: {err:?}"))?; + // Fail here rather than write a corpus that replay will refuse. + verify_state_genesis( + &state, + genesis.genesis_time, + genesis.genesis_validators_root, + )?; + std::fs::write(dir.join(ANCHOR_STATE_FILE), &bytes)?; + hex_root(state.genesis_validators_root()) + }; + + let anchor_block = source.block_bytes_by_root(&anchor.root).await?; + std::fs::write(dir.join(ANCHOR_BLOCK_FILE), &anchor_block)?; + + eprintln!( + "anchor resolved at slot {}; fetching slots {}..={to} ({} of them warm-up)", + anchor.slot, + anchor.slot + 1, + from - anchor.slot - 1 + ); + + let mut warmup_slots = Vec::new(); + let mut slots = Vec::new(); + let mut previous_root = anchor.root.clone(); + for slot in (anchor.slot + 1)..=to { + if let Some((block, bytes)) = source.block_with_bytes_at_slot(slot).await? { + eyre::ensure!( + block.slot == slot, + "asked for the block at slot {slot}, got one at slot {}", + block.slot + ); + eyre::ensure!( + block.parent_root == previous_root, + "the block at slot {slot} ({}) names parent {}, but the corpus's previous \ + block is {previous_root}: the source's chain changed during the fetch, or \ + it is missing a block between them", + block.root, + block.parent_root + ); + std::fs::write(block_path(dir, slot), &bytes)?; + previous_root = block.root; + if slot < from { + warmup_slots.push(slot); + } else { + slots.push(slot); + } + } + + if slot % PROGRESS_INTERVAL == 0 || slot == to { + eprintln!( + "fetched through slot {slot} of {to}: {} blocks", + warmup_slots.len() + slots.len() + ); + } + } + + let manifest = Manifest { + chain: "beacon".to_string(), + network: network.to_string(), + genesis_validators_root, + anchor_block_root: anchor.root, + anchor_slot: anchor.slot, + warmup_slots, + range_start: from, + range_end: to, + slots, + }; + manifest.write(dir)?; + created.keep(); + Ok(manifest) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::benchmark::import::source::tests::FakeSource; + + fn config() -> Config { + Config::mainnet() + } + + fn genesis() -> crate::beacon::Genesis { + crate::beacon::mainnet_genesis().expect("mainnet genesis fixture decodes") + } + + async fn fetch(source: &FakeSource, dir: &Path, from: u64, to: u64) -> eyre::Result { + fetch_corpus(source, dir, from, to, "mainnet", &config(), &genesis()).await + } + + #[tokio::test] + async fn a_gap_is_recorded_rather_than_failing_the_fetch() { + let dir = tempfile::tempdir().expect("tempdir"); + let source = FakeSource::chain(&[64, 65, 67, 68]); + + let manifest = fetch(&source, dir.path(), 65, 68) + .await + .expect("a gap is not a failure"); + + assert_eq!(manifest.slots, vec![65, 67, 68]); + assert_eq!(manifest.missing_slots(), vec![66]); + assert!(dir.path().join("blocks/0000000067.ssz").exists()); + } + + #[tokio::test] + async fn blocks_between_the_anchor_and_from_are_warm_up_rather_than_samples() { + let dir = tempfile::tempdir().expect("tempdir"); + let source = FakeSource::chain(&[64, 65, 66, 68, 69, 70]); + + let manifest = fetch(&source, dir.path(), 69, 70).await.expect("fetch"); + + assert_eq!(manifest.anchor_slot, 64); + assert_eq!(manifest.warmup_slots, vec![65, 66, 68]); + assert_eq!(manifest.slots, vec![69, 70]); + assert!(dir.path().join("blocks/0000000065.ssz").exists()); + } + + #[tokio::test] + async fn an_existing_corpus_is_not_silently_overwritten() { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write(dir.path().join("manifest.json"), "{}").expect("seed"); + let source = FakeSource::chain(&[64, 65, 66]); + + let err = fetch(&source, dir.path(), 65, 66) + .await + .expect_err("a half-written corpus must not be mistaken for a complete one"); + + assert!( + err.to_string().contains("--force"), + "the error says how: {err}" + ); + } + + #[tokio::test] + async fn a_range_whose_first_slot_is_empty_is_rejected_and_leaves_nothing_behind() { + let parent = tempfile::tempdir().expect("tempdir"); + let dir = parent.path().join("corpus"); + let source = FakeSource::chain(&[64, 65, 67]); + + let err = fetch(&source, &dir, 66, 67) + .await + .expect_err("66 holds no block"); + + assert!( + err.to_string().contains("66"), + "the error names the slot: {err}" + ); + assert!(!dir.exists(), "a refused fetch must not leave a directory"); + } + + #[tokio::test] + async fn a_range_past_the_source_head_is_rejected() { + // Every slot past the head answers 404, which would otherwise be + // recorded as a run of empty slots. + let dir = tempfile::tempdir().expect("tempdir"); + let source = FakeSource::chain(&[64, 65, 66]); + + let err = fetch(&source, dir.path(), 65, 70) + .await + .expect_err("70 is past the head"); + + assert!( + err.to_string().contains("head at slot 66"), + "the error names the head: {err}" + ); + } + + #[tokio::test] + async fn a_block_that_does_not_extend_the_corpus_aborts_the_fetch_and_cleans_up() { + let parent = tempfile::tempdir().expect("tempdir"); + let dir = parent.path().join("corpus"); + let source = FakeSource::chain(&[64, 65, 66, 67]).with_foreign_parent_at(66); + + let err = fetch(&source, &dir, 65, 67) + .await + .expect_err("66 does not descend from 65"); + + assert!( + err.to_string().contains("slot 66"), + "the error names the slot: {err}" + ); + assert!( + !dir.exists(), + "a fetch that created the directory removes it on failure" + ); + } + + #[tokio::test] + async fn a_failed_fetch_leaves_a_directory_it_did_not_create() { + let dir = tempfile::tempdir().expect("tempdir"); + let source = FakeSource::chain(&[64, 65, 66, 67]).with_foreign_parent_at(66); + + let result = fetch(&source, dir.path(), 65, 67).await; + + assert!(result.is_err(), "66 does not descend from 65"); + assert!( + dir.path().exists(), + "the caller's directory is not ours to delete" + ); + } + + #[tokio::test] + async fn a_backwards_range_is_rejected() { + let dir = tempfile::tempdir().expect("tempdir"); + let source = FakeSource::chain(&[64, 65, 66]); + + assert!(fetch(&source, dir.path(), 66, 65).await.is_err()); + } +} diff --git a/bin/ethlambda/src/benchmark/import/mod.rs b/bin/ethlambda/src/benchmark/import/mod.rs new file mode 100644 index 000000000..79dde1682 --- /dev/null +++ b/bin/ethlambda/src/benchmark/import/mod.rs @@ -0,0 +1,171 @@ +//! The import workload: fetch a real block range into an SSZ corpus, then +//! replay it offline through the client's own import path. +//! +//! See docs/benchmarking.md for what is and is not measured. + +use std::path::PathBuf; + +use eyre::WrapErr as _; + +pub(crate) mod corpus; +pub(crate) mod fetch; +pub(crate) mod replay; +pub(crate) mod source; + +use super::OutputFormat; +use corpus::Manifest; +use source::HttpSource; + +/// Flags for `ethlambda benchmark import`. +#[derive(Debug, clap::Args)] +pub(crate) struct ImportOptions { + #[command(subcommand)] + phase: Phase, +} + +#[derive(Debug, clap::Subcommand)] +enum Phase { + /// Pull a block range from a running beacon node into a corpus. + Fetch(FetchOptions), + /// Replay a corpus through this client's import path. + Replay(ReplayOptions), +} + +#[derive(Debug, clap::Args)] +struct FetchOptions { + /// Base URL of the source beacon node, e.g. `http://127.0.0.1:5052`. + #[arg(long)] + url: String, + /// First slot of the range, and its first sample, so it must hold a block. + /// The anchor is the block at the first slot of the epoch before it (fork + /// choice can only anchor there); the blocks in between are fetched too, + /// and replayed as unsampled warm-up. + #[arg(long)] + from: u64, + /// Last slot of the range, inclusive. May not pass the source's head. + /// Otherwise not capped: `fetch` streams, so a long range costs disk and + /// time but not memory. + #[arg(long)] + to: u64, + /// Corpus directory to create. + #[arg(long)] + corpus: PathBuf, + /// Replace an existing corpus in that directory. + #[arg(long)] + force: bool, + /// Which network the source follows. + #[arg(long, default_value = crate::network::DEFAULT_NETWORK)] + network: String, +} + +/// Flags for `ethlambda benchmark import replay`. +/// +/// Defined here rather than in `replay.rs` so the flags and the code reading +/// them live together. +#[derive(Debug, clap::Args)] +pub(crate) struct ReplayOptions { + /// Corpus directory written by `fetch`. + #[arg(long)] + pub corpus: PathBuf, + /// Which network the corpus belongs to. Checked against the manifest's + /// recorded genesis validators root before anything is built. + #[arg(long, default_value = crate::network::DEFAULT_NETWORK)] + pub network: String, + /// Where to build the replay's RocksDB store. A real backend rather than + /// the in-memory one, because state persistence is 9% of an import and an + /// in-memory backend would delete that cost from the measurement. + #[arg(long)] + pub data_dir: PathBuf, + /// Replace an existing store in that directory. + #[arg(long)] + pub force: bool, + /// How far behind the wall clock a block must be before it may be + /// imported optimistically on age alone. A corpus replay never sees a + /// merge transition block, the only thing this gates, so the default is + /// almost always right; it is exposed for parity with `beacon`'s own flag + /// of the same name. + #[arg(long, default_value_t = ethlambda_types::beacon::constants::SAFE_SLOTS_TO_IMPORT_OPTIMISTICALLY)] + pub safe_slots_to_import_optimistically: u64, + /// Report format printed to stdout. Logs go to stderr, so JSON output can + /// be piped directly (e.g. into jq). + #[arg(long, value_enum, default_value_t = OutputFormat::Human)] + pub format: OutputFormat, + /// Also write the JSON report to this file. + #[arg(long)] + pub output: Option, +} + +/// The import workload's entry. +/// +/// `#[tokio::main]` here rather than on `benchmark::run`: the synthetic +/// workload is synchronous CPU-bound work and deliberately never starts a +/// runtime, while this one does HTTP and awaits the import path. Mirrors how +/// `run_node` carries its own attribute. +#[tokio::main] +pub(crate) async fn run(options: ImportOptions) -> eyre::Result<()> { + match options.phase { + Phase::Fetch(fetch) => run_fetch(fetch).await, + Phase::Replay(replay) => run_replay(replay).await, + } +} + +async fn run_fetch(options: FetchOptions) -> eyre::Result<()> { + let spec = crate::network::NetworkSpec::parse(&options.network)?; + let network = crate::network::NetworkSource::resolve(&spec)?; + let source = HttpSource::new(options.url.clone(), network.config().clone()) + .wrap_err("building the beacon API client")?; + + // `fetch_corpus` only refuses a non-empty directory; deleting it first is + // this flag's job, exactly as `--force` does for `replay`'s data dir. + if options.force && options.corpus.exists() { + std::fs::remove_dir_all(&options.corpus).wrap_err_with(|| { + format!( + "removing the existing corpus at {}", + options.corpus.display() + ) + })?; + } + + let manifest: Manifest = fetch::fetch_corpus( + &source, + &options.corpus, + options.from, + options.to, + &options.network, + network.config(), + &network.genesis(), + ) + .await?; + + let gaps = manifest.missing_slots(); + println!( + "fetched {} blocks for slots [{}, {}] ({} gaps, plus {} warm-up blocks) into {}; \ + anchor slot={} root={}", + manifest.slots.len(), + manifest.range_start, + manifest.range_end, + gaps.len(), + manifest.warmup_slots.len(), + options.corpus.display(), + manifest.anchor_slot, + manifest.anchor_block_root, + ); + + Ok(()) +} + +async fn run_replay(options: ReplayOptions) -> eyre::Result<()> { + let report = replay::replay_corpus(&options.corpus, &options).await?; + + match options.format { + OutputFormat::Human => println!("{}", report.human_table()), + OutputFormat::Json => println!("{}", report.to_json()?), + } + if let Some(path) = &options.output { + std::fs::write(path, report.to_json()?) + .wrap_err_with(|| format!("failed to write report to {}", path.display()))?; + eprintln!("report written to {}", path.display()); + } + + Ok(()) +} diff --git a/bin/ethlambda/src/benchmark/import/replay.rs b/bin/ethlambda/src/benchmark/import/replay.rs new file mode 100644 index 000000000..104e8dea7 --- /dev/null +++ b/bin/ethlambda/src/benchmark/import/replay.rs @@ -0,0 +1,270 @@ +//! Replaying a corpus through the real import path. +//! +//! Drives [`BlockChainServer::import_block`] directly, one block per turn: no +//! mailbox, no tick loop, no p2p. The only production entry points this +//! workload measures are the ones a live node's cascade already runs +//! ([`fork_choice::get_forkchoice_store`] to bootstrap, then `on_block` per +//! import), so a phase regression here is a regression a live node would +//! also see. +//! +//! # Memory +//! +//! Exactly one block is decoded, imported and dropped per turn; the manifest +//! is held, never the corpus's blocks. Nothing here keeps an +//! `Arc` of its own: the state cache +//! ([`ethlambda_storage::Store`]'s `STATE_CACHE_CAPACITY`-bounded LRU) is the +//! only thing allowed to hold post-states, and a stray clone here would pin +//! an entry the LRU believes it evicted, raising the real ceiling silently. + +use std::path::Path; +use std::sync::Arc; +use std::time::Instant; + +use ethlambda_blockchain::metrics::{BLOCK_ARRIVAL_PHASES, BLOCK_IMPORT_PHASES}; +use ethlambda_blockchain::{BlockChainServer, ImportOutcome}; +use ethlambda_p2p::beacon::decode; +use ethlambda_state_transition::beacon::fork_choice; +use ethlambda_storage::Store; +use ethlambda_storage::backend::RocksDBBackend; +use ethlambda_types::ShortRoot; +use ethlambda_types::beacon::config::Config; +use ethlambda_types::beacon::containers::{BeaconState, SignedBeaconBlock}; +use ethlambda_types::beacon::preset; +use ethlambda_types::primitives::H256; + +use super::ReplayOptions; +use super::corpus::{ANCHOR_BLOCK_FILE, ANCHOR_STATE_FILE, Manifest, block_path, hex_root}; +use crate::benchmark::PhaseTimer; +use crate::benchmark::report::common::{Environment, format_ms}; +use crate::benchmark::report::import::{Params, Report, Sample}; + +/// The histogram `ImportTimings` writes to, read back by [`PhaseTimer`] the +/// same way the synthetic workload reads +/// `lean_block_proposal_attestation_build_phase_seconds`. +const IMPORT_PHASE_HISTOGRAM: &str = "lean_block_import_phase_seconds"; + +/// The phases a sample records: every per-block section, plus the +/// per-arrival sections that do not enclose the others. +/// +/// `import_block` is one arrival per block, so those run at most once per +/// sample too, and `get_head` is where the head recomputation `on_block` runs +/// after every import is charged. `arrival` and `cascade` are left out +/// because they are spans around everything else, which as columns beside +/// their own parts would count the same time twice. +fn sampled_phases() -> impl Iterator { + let arrival_leaves = BLOCK_ARRIVAL_PHASES + .iter() + .copied() + .filter(|phase| !matches!(*phase, "arrival" | "cascade")); + BLOCK_IMPORT_PHASES.iter().copied().chain(arrival_leaves) +} + +/// Replay every block a corpus at `dir` names, in manifest order, through a +/// freshly bootstrapped store: the warm-up blocks first, unsampled, then the +/// range. +/// +/// Aborts on the first block that fails to decode or does not import, rather +/// than recording a partial result and continuing: every later block in a +/// corpus descends from the one before it, so a skip does not leave a +/// shorter valid run behind, it leaves a distribution over blocks applied to +/// the wrong parent. +pub(crate) async fn replay_corpus(dir: &Path, options: &ReplayOptions) -> eyre::Result { + let manifest = Manifest::read(dir)?; + eyre::ensure!( + manifest.chain == "beacon", + "corpus holds a {} chain, not a beacon one", + manifest.chain + ); + + let spec = crate::network::NetworkSpec::parse(&options.network)?; + let source = crate::network::NetworkSource::resolve(&spec)?; + let config = source.config().clone(); + let genesis = source.genesis(); + eyre::ensure!( + manifest.genesis_validators_root == hex_root(genesis.genesis_validators_root), + "corpus was fetched against a different network: manifest says {}, \ + --network {} has {}", + manifest.genesis_validators_root, + options.network, + hex_root(genesis.genesis_validators_root) + ); + + prepare_data_dir(&options.data_dir, options.force)?; + + let (anchor_state, anchor_block) = read_anchor(dir, &config)?; + // A corpus fetched before `resolve_anchor` knew this rule can carry an + // anchor mid-epoch, and would otherwise fail on its first block with an + // assertion that names neither the anchor nor the fix. + eyre::ensure!( + anchor_state.slot() % preset::SLOTS_PER_EPOCH == 0, + "the corpus's anchor state is at slot {}, which is not the first slot of an epoch, \ + so fork choice cannot anchor on it; re-fetch the corpus with this build", + anchor_state.slot() + ); + let backend = Arc::new(RocksDBBackend::open(&options.data_dir).map_err(|err| { + eyre::eyre!( + "opening the rocksdb store at {}: {err}", + options.data_dir.display() + ) + })?); + let store = fork_choice::get_forkchoice_store(backend, anchor_state, anchor_block, &config)?; + // A clone, not a borrow: `Store::set_time_ms` writes to the shared + // backend's metadata, so this clone's write is the write every other + // handle (including `server`'s own `store` field) reads back. See the + // module doc for why the clock has to be placed from this store's own + // `config()` rather than the network-resolved `config` above: the two + // disagree on `genesis_time`, and only the store's own copy is the one + // `on_block`'s future-block check actually reads. + let mut clock = store.clone(); + let mut server = + BlockChainServer::for_replay(store, None, options.safe_slots_to_import_optimistically); + + let mut samples = Vec::with_capacity(manifest.slots.len()); + + let warmup_total = manifest.warmup_slots.len(); + for (position, &slot) in manifest.warmup_slots.iter().enumerate() { + let (block, block_root) = load_block(dir, &config, &mut clock, slot)?; + let wall_seconds = import(&mut server, block, slot, block_root).await?; + eprintln!( + "warm-up {}/{warmup_total}: slot {slot} imported in {}", + position + 1, + format_ms(wall_seconds) + ); + } + + let total = manifest.slots.len(); + for (iteration, &slot) in manifest.slots.iter().enumerate() { + let (block, block_root) = load_block(dir, &config, &mut clock, slot)?; + + let timer = PhaseTimer::start(IMPORT_PHASE_HISTOGRAM); + let wall_seconds = import(&mut server, block, slot, block_root).await?; + let phases = timer.finish_at_most_once(sampled_phases())?; + + eprintln!( + "block {}/{total}: slot {slot} imported in {}", + iteration + 1, + format_ms(wall_seconds) + ); + samples.push(Sample { + iteration: iteration as u64, + slot, + block_root: hex_root(block_root), + wall_seconds, + phases, + outcome: "imported", + }); + } + + Ok(Report::new( + Environment::collect(), + params_from(&manifest, dir), + samples, + )) +} + +/// Read and decode the corpus block at `slot`, and place the store clock at +/// the start of that slot, so the block is neither from the future nor +/// arriving late. +fn load_block( + dir: &Path, + config: &Config, + clock: &mut Store, + slot: u64, +) -> eyre::Result<(SignedBeaconBlock, H256)> { + let bytes = std::fs::read(block_path(dir, slot))?; + let block = decode::decode_block(config, &bytes) + .map_err(|err| eyre::eyre!("corpus block at slot {slot} does not decode: {err:?}"))?; + let block_root = block.message_hash_tree_root(); + + // `get_forkchoice_store` seeds the store's own genesis_time from the + // *anchor state's* field, overriding whatever the network-resolved + // `config` carried (`Store::init_beacon`, `crates/storage/src/store.rs`). + // Reading it back from `clock.config()` here, rather than from the + // `config` parameter, is what keeps this arithmetic on the same clock + // `on_block`'s future-block check reads; computing it from the network's + // config instead would silently disable that check whenever the two + // disagree. + let store_config = clock.config(); + let slot_start_ms = (store_config.genesis_time + slot * store_config.seconds_per_slot) * 1_000; + clock.set_time_ms(slot_start_ms)?; + + Ok((block, block_root)) +} + +/// Import one block, returning the wall time of the `import_block` call +/// alone, and fail unless it imported. +async fn import( + server: &mut BlockChainServer, + block: SignedBeaconBlock, + slot: u64, + block_root: H256, +) -> eyre::Result { + let started = Instant::now(); + let outcome = server.import_block(block).await; + let wall_seconds = started.elapsed().as_secs_f64(); + eyre::ensure!( + outcome == Some(ImportOutcome::Imported), + "block at slot {slot} ({}) did not import: {outcome:?}", + ShortRoot(&block_root.0) + ); + Ok(wall_seconds) +} + +/// Reads and decodes a corpus's anchor pair. +/// +/// Mirrors `checkpoint_sync`'s own beacon path: the slot is peeked off the +/// state's own bytes to pick a fork (SSZ carries no type tag), and the +/// anchor block is decoded as a standard signed block, the shape a real +/// fetch (`/eth/v2/beacon/blocks/{id}`) always returns. +fn read_anchor(dir: &Path, config: &Config) -> eyre::Result<(BeaconState, SignedBeaconBlock)> { + let state_bytes = std::fs::read(dir.join(ANCHOR_STATE_FILE))?; + let slot = BeaconState::slot_from_ssz(&state_bytes) + .map_err(|err| eyre::eyre!("anchor state slot does not decode: {err:?}"))?; + let fork = decode::fork_at_slot(config, slot); + let anchor_state = BeaconState::from_ssz(fork, &state_bytes) + .map_err(|err| eyre::eyre!("anchor state does not decode as {fork:?}: {err:?}"))?; + + let block_bytes = std::fs::read(dir.join(ANCHOR_BLOCK_FILE))?; + let anchor_block = decode::decode_block(config, &block_bytes) + .map_err(|err| eyre::eyre!("anchor block does not decode: {err}"))?; + + Ok((anchor_state, anchor_block)) +} + +/// The report parameters a manifest already carries, so the loop above never +/// has to reconstruct them from samples. +fn params_from(manifest: &Manifest, dir: &Path) -> Params { + Params { + mode: "import", + corpus: dir.display().to_string(), + network: manifest.network.clone(), + anchor_block_root: manifest.anchor_block_root.clone(), + anchor_slot: manifest.anchor_slot, + warmup_blocks: manifest.warmup_slots.len(), + range_start: manifest.range_start, + range_end: manifest.range_end, + blocks: manifest.slots.len(), + } +} + +/// Refuse a non-empty `data_dir` unless `--force`, which replaces it. +/// +/// A stale RocksDB directory left over from a previous run would otherwise +/// resume that run's store, whose anchor may not agree with this corpus's, +/// rather than start the fresh one this replay's report claims to measure. +fn prepare_data_dir(data_dir: &Path, force: bool) -> eyre::Result<()> { + let occupied = data_dir + .read_dir() + .map(|mut entries| entries.next().is_some()) + .unwrap_or(false); + if occupied { + eyre::ensure!( + force, + "{} already holds data; pass --force to replace it", + data_dir.display() + ); + std::fs::remove_dir_all(data_dir)?; + } + std::fs::create_dir_all(data_dir)?; + Ok(()) +} diff --git a/bin/ethlambda/src/benchmark/import/source.rs b/bin/ethlambda/src/benchmark/import/source.rs new file mode 100644 index 000000000..3475a50ef --- /dev/null +++ b/bin/ethlambda/src/benchmark/import/source.rs @@ -0,0 +1,407 @@ +//! Where a corpus's bytes come from. +//! +//! A trait rather than a bare reqwest client so the anchor rule below is +//! testable without a live server or a new dev-dependency. `HttpSource` is +//! the only production implementation. +//! +//! `CorpusSource`'s methods are native `async fn`s in the trait (stable since +//! edition 2024): nothing here needs `dyn CorpusSource` (`resolve_anchor` +//! takes `&impl CorpusSource`, and `fetch` does the same), so there is no +//! reason to pay for `async-trait`'s boxing. + +use ethlambda_p2p::beacon::decode; +use ethlambda_types::beacon::config::Config; + +use super::corpus::hex_root; + +/// How many epochs [`resolve_anchor`] steps back past an empty first slot +/// before giving up. +/// +/// An empty first slot is rare and a run of them rarer still, so a source +/// with none holding a block across this many epochs most likely does not +/// hold the history at all. Its 404s are indistinguishable from empty slots, +/// and without a bound the walk would continue back to genesis. +const MAX_ANCHOR_EPOCHS_BACK: u64 = 8; + +/// One block's identity, as much of it as a fetch needs. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct BlockRef { + pub root: String, + pub slot: u64, + pub parent_root: String, +} + +/// The anchor a replay starts from: a block and its own post-state. +#[derive(Debug, Clone)] +pub(crate) struct AnchorRef { + pub root: String, + pub slot: u64, +} + +pub(crate) trait CorpusSource { + /// The slot of the source's current head block. + async fn head_slot(&self) -> eyre::Result; + /// The block at `slot`, or `None` if that slot is empty. + async fn block_at_slot(&self, slot: u64) -> eyre::Result>; + /// The block at `slot` together with its raw SSZ, or `None` if that slot + /// is empty. The identity rides along so `fetch` can check each block's + /// parent link without decoding the bytes a second time. + async fn block_with_bytes_at_slot( + &self, + slot: u64, + ) -> eyre::Result)>>; + /// The raw SSZ of the block with `root`. + async fn block_bytes_by_root(&self, root: &str) -> eyre::Result>; + /// The raw SSZ of the state at `slot`. + async fn state_bytes_at_slot(&self, slot: u64) -> eyre::Result>; +} + +/// Resolve the anchor a range starting at `from` must be replayed from. +/// +/// The anchor has to sit on the first slot of an epoch. `get_forkchoice_store` +/// makes it the store's justified and finalized checkpoint at the anchor +/// state's own epoch, and every import then asks `get_checkpoint_block` for +/// the finalized checkpoint's block, which walks back from the new block to +/// that epoch's first slot. From an anchor past that slot the walk steps +/// below the anchor, onto a block the store never held, and the very first +/// import fails its `root in store.blocks` assertion. The specification has +/// the same requirement, since its anchor is a checkpoint, which is also why a +/// checkpoint-synced node anchors on the finalized checkpoint's state. +/// +/// So the anchor is the block at the first slot of the epoch holding +/// `from - 1`, and every block between it and `from` becomes a warm-up block +/// the replay imports but does not sample. An empty first slot would be +/// anchorable too, through the slot-advanced state `get_forkchoice_store` +/// accepts, but only from a source that serves states at empty slots; stepping +/// back one more epoch needs nothing beyond what the block loop already asks +/// for. +/// +/// `from` itself must hold a block: it is the first sample, and a range that +/// opens on nothing is more likely a typo than an intent. +pub(crate) async fn resolve_anchor( + source: &impl CorpusSource, + from: u64, + slots_per_epoch: u64, +) -> eyre::Result { + eyre::ensure!(from > 0, "--from 0 is genesis, which nothing precedes"); + source + .block_at_slot(from) + .await? + .ok_or_else(|| eyre::eyre!("slot {from} holds no block; a range must start at one"))?; + + let mut epoch_start = (from - 1) / slots_per_epoch * slots_per_epoch; + for _ in 0..MAX_ANCHOR_EPOCHS_BACK { + if let Some(block) = source.block_at_slot(epoch_start).await? { + // A source that answered an empty slot with the latest block + // before it would put the anchor mid-epoch again. + eyre::ensure!( + block.slot == epoch_start, + "asked for the block at slot {epoch_start}, got one at slot {}", + block.slot + ); + return Ok(AnchorRef { + root: block.root, + slot: block.slot, + }); + } + let Some(earlier) = epoch_start.checked_sub(slots_per_epoch) else { + break; + }; + epoch_start = earlier; + } + eyre::bail!( + "no block at the first slot of any of the {MAX_ANCHOR_EPOCHS_BACK} epochs up to slot \ + {from}; the anchor must sit on one, and a source missing that many in a row most \ + likely does not hold history that far back" + ) +} + +/// The Beacon API implementation. +/// +/// Reuses `checkpoint_sync`'s client: a `BeaconState` is hundreds of +/// megabytes, and that client is already built with a connect timeout plus an +/// inactivity read timeout, so a healthy slow transfer is not killed by a +/// total-time limit. +pub(crate) struct HttpSource { + client: reqwest::Client, + base_url: String, + /// Needed to pick the fork a decoded block's slot names; see + /// `decode::decode_block`. + config: Config, +} + +impl HttpSource { + pub(crate) fn new(base_url: String, config: Config) -> eyre::Result { + let client = crate::checkpoint_sync::build_client()?; + Ok(Self { + client, + base_url: base_url.trim_end_matches('/').to_string(), + config, + }) + } + + fn block_url(&self, id: &str) -> String { + format!("{}/eth/v2/beacon/blocks/{id}", self.base_url) + } + + fn state_url(&self, id: &str) -> String { + format!("{}/eth/v2/debug/beacon/states/{id}", self.base_url) + } + + /// GET `url` with an `application/octet-stream` accept header, mapping a + /// `404` to `None`. Any other non-2xx status is an error. + async fn fetch_bytes(&self, url: &str) -> eyre::Result>> { + let response = self + .client + .get(url) + .header("Accept", "application/octet-stream") + .send() + .await?; + if response.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(None); + } + let bytes = response.error_for_status()?.bytes().await?; + Ok(Some(bytes.to_vec())) + } + + /// Fetch and decode the block the Beacon API's block-id grammar names + /// (a slot number, a `0x`-prefixed root, or `head`), or `None` if absent. + /// + /// Roots are formatted with `hex_root`, the same helper the manifest + /// uses, so a `parent_root` this method hands back compares equal to the + /// `root` of the block it names. + async fn fetch_block(&self, id: &str) -> eyre::Result)>> { + let Some(bytes) = self.fetch_bytes(&self.block_url(id)).await? else { + return Ok(None); + }; + let block = decode::decode_block(&self.config, &bytes) + .map_err(|err| eyre::eyre!("block {id} did not decode: {err}"))?; + let block_ref = BlockRef { + root: hex_root(block.message_hash_tree_root()), + slot: block.slot(), + parent_root: hex_root(block.parent_root()), + }; + Ok(Some((block_ref, bytes))) + } +} + +impl CorpusSource for HttpSource { + async fn head_slot(&self) -> eyre::Result { + let url = self.block_url("head"); + let (head, _) = self + .fetch_block("head") + .await? + .ok_or_else(|| eyre::eyre!("the source has no head block at {url}"))?; + Ok(head.slot) + } + + async fn block_at_slot(&self, slot: u64) -> eyre::Result> { + let block = self.fetch_block(&slot.to_string()).await?; + Ok(block.map(|(block_ref, _)| block_ref)) + } + + async fn block_with_bytes_at_slot( + &self, + slot: u64, + ) -> eyre::Result)>> { + self.fetch_block(&slot.to_string()).await + } + + async fn block_bytes_by_root(&self, root: &str) -> eyre::Result> { + let url = self.block_url(root); + self.fetch_bytes(&url) + .await? + .ok_or_else(|| eyre::eyre!("block {root} not found at {url}")) + } + + async fn state_bytes_at_slot(&self, slot: u64) -> eyre::Result> { + let url = self.state_url(&slot.to_string()); + // A source having no historical state this old is the most likely + // failure in practice: most beacon nodes only serve recent states. + // Name the endpoint so the operator knows which one to check. + self.fetch_bytes(&url) + .await? + .ok_or_else(|| eyre::eyre!("state at slot {slot} not found at {url}")) + } +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use std::collections::HashMap; + + /// Mainnet's epoch length, so the slot numbers below read the way a real + /// range would. + pub(crate) const SLOTS_PER_EPOCH: u64 = 32; + + /// A linear chain with a block at each of a given set of slots. + pub(crate) struct FakeSource { + blocks_by_slot: HashMap, + blocks_by_root: HashMap, + } + + fn fake_root(slot: u64) -> String { + format!("0x{slot:064x}") + } + + impl FakeSource { + /// A block at each of `slots`, each naming the one before it as its + /// parent, so a slot absent from `slots` is an empty slot on this + /// chain rather than a missing block. + pub(crate) fn chain(slots: &[u64]) -> Self { + let mut blocks_by_slot = HashMap::new(); + let mut blocks_by_root = HashMap::new(); + let mut parent_root = fake_root(u64::MAX); + for &slot in slots { + let block = BlockRef { + root: fake_root(slot), + slot, + parent_root: parent_root.clone(), + }; + parent_root = block.root.clone(); + blocks_by_slot.insert(slot, block.clone()); + blocks_by_root.insert(block.root.clone(), block); + } + Self { + blocks_by_slot, + blocks_by_root, + } + } + + /// Point the block at `slot` at a parent the chain does not hold, as + /// a reorg landing between two requests would. + pub(crate) fn with_foreign_parent_at(mut self, slot: u64) -> Self { + let block = self + .blocks_by_slot + .get_mut(&slot) + .expect("the slot holds a block"); + block.parent_root = fake_root(u64::MAX - 1); + self.blocks_by_root + .insert(block.root.clone(), block.clone()); + self + } + } + + impl CorpusSource for FakeSource { + async fn head_slot(&self) -> eyre::Result { + self.blocks_by_slot + .keys() + .max() + .copied() + .ok_or_else(|| eyre::eyre!("empty chain")) + } + + async fn block_at_slot(&self, slot: u64) -> eyre::Result> { + Ok(self.blocks_by_slot.get(&slot).cloned()) + } + + async fn block_with_bytes_at_slot( + &self, + slot: u64, + ) -> eyre::Result)>> { + Ok(self + .blocks_by_slot + .get(&slot) + .map(|block| (block.clone(), block.root.as_bytes().to_vec()))) + } + + async fn block_bytes_by_root(&self, root: &str) -> eyre::Result> { + self.blocks_by_root + .get(root) + .map(|block| block.root.as_bytes().to_vec()) + .ok_or_else(|| eyre::eyre!("no block with root {root}")) + } + + /// Real, decodable bytes rather than a placeholder: `fetch_corpus` + /// decodes whatever this returns as a `BeaconState` to read the + /// network fingerprint before it ever touches a block, so a fake + /// source needs a fake state that survives that decode too. The + /// binary already embeds mainnet's genesis state for `ethlambda + /// beacon`'s own use, which is a real, spec-shaped state with a known + /// `genesis_time`/`genesis_validators_root` pair (see + /// `crate::beacon::mainnet_genesis`), so it is reused here rather + /// than hand-building a minimal one field by field. + async fn state_bytes_at_slot(&self, _slot: u64) -> eyre::Result> { + Ok(crate::beacon::mainnet_genesis_state()?.to_ssz()) + } + } + + #[tokio::test] + async fn the_anchor_is_the_block_at_the_first_slot_of_the_epoch_before_from() { + // 66 is empty. The first block's parent is 65, but an anchor there + // would sit mid-epoch; the anchor is epoch 2's first slot instead. + let source = FakeSource::chain(&[64, 65, 67, 68]); + + let anchor = resolve_anchor(&source, 67, SLOTS_PER_EPOCH) + .await + .expect("anchor"); + + assert_eq!(anchor.slot, 64); + assert_eq!(anchor.root, fake_root(64)); + } + + #[tokio::test] + async fn a_range_opening_one_slot_past_an_epoch_start_anchors_on_it() { + let source = FakeSource::chain(&[64, 65, 66]); + + let anchor = resolve_anchor(&source, 65, SLOTS_PER_EPOCH) + .await + .expect("anchor"); + + assert_eq!(anchor.slot, 64); + } + + #[tokio::test] + async fn a_range_opening_on_an_epoch_start_anchors_on_the_epoch_before() { + // `from - 1` is the anchor's upper bound: the block at `from` is the + // first sample, never the anchor. + let source = FakeSource::chain(&[32, 40, 64, 65]); + + let anchor = resolve_anchor(&source, 64, SLOTS_PER_EPOCH) + .await + .expect("anchor"); + + assert_eq!(anchor.slot, 32); + } + + #[tokio::test] + async fn an_empty_epoch_start_steps_back_to_the_one_before() { + let source = FakeSource::chain(&[32, 40, 63, 65, 66]); + + let anchor = resolve_anchor(&source, 66, SLOTS_PER_EPOCH) + .await + .expect("anchor"); + + assert_eq!(anchor.slot, 32, "64 is empty, so the anchor is epoch 1's"); + } + + #[tokio::test] + async fn a_source_missing_that_history_is_reported_rather_than_walked_to_genesis() { + // Nothing at any epoch start: exactly what a node that never held + // this history answers, since its 404s read as empty slots. + let source = FakeSource::chain(&[1001, 1002]); + + let err = resolve_anchor(&source, 1002, SLOTS_PER_EPOCH) + .await + .expect_err("no epoch start holds a block"); + + assert!( + err.to_string().contains("history"), + "the error names the likely cause: {err}" + ); + } + + #[tokio::test] + async fn a_range_starting_at_an_empty_slot_is_rejected() { + let source = FakeSource::chain(&[64, 65, 67]); + + let err = resolve_anchor(&source, 66, SLOTS_PER_EPOCH) + .await + .expect_err("66 is empty"); + + assert!( + err.to_string().contains("66"), + "the error names the slot: {err}" + ); + } +} diff --git a/bin/ethlambda/src/benchmark/mod.rs b/bin/ethlambda/src/benchmark/mod.rs index 490d30e88..4453bd562 100644 --- a/bin/ethlambda/src/benchmark/mod.rs +++ b/bin/ethlambda/src/benchmark/mod.rs @@ -12,6 +12,7 @@ //! report, and the current limitations. mod corpus; +mod import; mod keys; mod report; @@ -26,7 +27,9 @@ use ethlambda_types::primitives::HashTreeRoot as _; use eyre::WrapErr as _; use corpus::CryptoMode; -use report::{Environment, Params, Report, Sample}; +use import::ImportOptions; +use report::common::Environment; +use report::synthetic::{Params, Report, Sample}; #[derive(Debug, clap::Args)] pub(crate) struct BenchmarkOptions { @@ -38,6 +41,8 @@ pub(crate) struct BenchmarkOptions { enum Workload { /// Benchmark block building on a synthetic in-memory chain. Synthetic(SyntheticOptions), + /// Benchmark block import by replaying a real corpus offline. + Import(ImportOptions), } #[derive(Debug, clap::Args)] @@ -140,8 +145,10 @@ enum OutputFormat { } pub(crate) fn run(options: BenchmarkOptions) -> eyre::Result<()> { - let Workload::Synthetic(synthetic) = options.workload; - run_synthetic(synthetic) + match options.workload { + Workload::Synthetic(synthetic) => run_synthetic(synthetic), + Workload::Import(import) => import::run(import), + } } fn run_synthetic(options: SyntheticOptions) -> eyre::Result<()> { @@ -262,7 +269,7 @@ fn build_one_slot( // Round-robin proposer, matching `is_proposer`. let proposer = slot % num_validators; - let phases = PhaseTimer::start(); + let phases = PhaseTimer::start(PHASE_HISTOGRAM); let build_start = Instant::now(); let (block, aggregates, _checkpoints) = produce_block_with_signatures(store, slot, proposer, proposer_config) @@ -310,20 +317,26 @@ fn build_one_slot( const PHASE_HISTOGRAM: &str = "lean_block_proposal_attestation_build_phase_seconds"; -/// Exact per-phase durations for one block build and seal, taken from the -/// block-proposal phase histogram in the default prometheus registry. +/// Exact per-phase durations for one block build and seal (or one import), +/// taken from a phase histogram in the default prometheus registry. /// /// Histogram sums accumulate the raw f64 seconds of every observation, so the /// difference between two readings IS the build's phase time — bucket /// boundaries play no role, and the hot path needs no extra instrumentation. struct PhaseTimer { + /// The histogram this timer reads; the import workload uses a different + /// one (`lean_block_import_phase_seconds`) than the synthetic build does. + histogram: &'static str, /// Per-phase (sample_sum, sample_count) before the build. before: HashMap, } impl PhaseTimer { - fn start() -> Self { - Self { before: read() } + fn start(histogram: &'static str) -> Self { + Self { + histogram, + before: read(histogram), + } } /// Per-phase durations since [`PhaseTimer::start`] for `expected` phases. @@ -336,7 +349,7 @@ impl PhaseTimer { self, expected: impl Iterator, ) -> eyre::Result> { - let after = read(); + let after = read(self.histogram); let mut phases = BTreeMap::new(); for phase in expected { let (sum_before, count_before) = self.before.get(phase).copied().unwrap_or((0.0, 0)); @@ -351,13 +364,43 @@ impl PhaseTimer { } Ok(phases) } + + /// Per-phase durations for phases that ran at most once. + /// + /// [`Self::finish`]'s exactly-once assertion is right for a block build, + /// where every phase runs. It is wrong for an import: `decode`, `defer`, + /// `parent_wait` and `columns_wait` never run for a corpus block, and a + /// phase that did not run is omitted rather than recorded as zero, so it + /// stays distinguishable from one that ran instantly. + /// + fn finish_at_most_once( + self, + expected: impl Iterator, + ) -> eyre::Result> { + let after = read(self.histogram); + let mut phases = BTreeMap::new(); + for phase in expected { + let (sum_before, count_before) = self.before.get(phase).copied().unwrap_or((0.0, 0)); + let (sum_after, count_after) = after.get(phase).copied().unwrap_or((0.0, 0)); + let observations = count_after.saturating_sub(count_before); + eyre::ensure!( + observations <= 1, + "phase '{phase}' was observed {observations} times during one import \ + (expected at most 1); phase attribution would be wrong" + ); + if observations == 1 { + phases.insert(phase.to_string(), sum_after - sum_before); + } + } + Ok(phases) + } } -/// Current (sample_sum, sample_count) per phase label. -fn read() -> HashMap { +/// Current (sample_sum, sample_count) per phase label on `histogram`. +fn read(histogram: &str) -> HashMap { ethlambda_metrics::gather() .iter() - .filter(|family| family.name() == PHASE_HISTOGRAM) + .filter(|family| family.name() == histogram) .flat_map(|family| family.get_metric()) .filter_map(|metric| { let phase = metric @@ -374,3 +417,103 @@ fn read() -> HashMap { }) .collect() } + +#[cfg(test)] +mod phase_timer_tests { + use super::*; + + #[test] + fn an_unobserved_phase_is_omitted_rather_than_reported_as_zero() { + // An import runs a subset of BLOCK_IMPORT_PHASES: decode, defer, + // parent_wait and columns_wait legitimately never run for a corpus + // block. A zero would read as work that took no time, which is not the + // same claim as work that did not happen. + let histogram = ethlambda_metrics::register_histogram_vec!( + "test_phase_timer_at_most_once_seconds", + "Phase histogram used only by the PhaseTimer tests", + &["phase"] + ) + .expect("registers once"); + + let timer = PhaseTimer::start("test_phase_timer_at_most_once_seconds"); + histogram.with_label_values(&["stf"]).observe(0.5); + + let phases = timer + .finish_at_most_once(["stf", "columns_wait"].into_iter()) + .expect("finish"); + + assert!((phases["stf"] - 0.5).abs() < 1e-9); + assert!( + !phases.contains_key("columns_wait"), + "a phase that never ran is absent, not zero" + ); + } + + #[test] + fn finish_succeeds_when_every_expected_phase_ran_exactly_once() { + // Pins the pre-refactor success path of `finish` under the new + // `histogram` parameter. + let histogram = ethlambda_metrics::register_histogram_vec!( + "test_phase_timer_exactly_once_ok_seconds", + "Phase histogram used only by the PhaseTimer tests", + &["phase"] + ) + .expect("registers once"); + + let timer = PhaseTimer::start("test_phase_timer_exactly_once_ok_seconds"); + histogram + .with_label_values(&["select_payloads"]) + .observe(0.25); + histogram.with_label_values(&["compact"]).observe(0.1); + + let phases = timer + .finish(["select_payloads", "compact"].into_iter()) + .expect("every expected phase ran exactly once"); + assert!((phases["select_payloads"] - 0.25).abs() < 1e-9); + assert!((phases["compact"] - 0.1).abs() < 1e-9); + } + + #[test] + fn finish_rejects_a_phase_that_never_ran() { + // Unlike `finish_at_most_once`, a phase that never fired is a hard + // error for `finish`, since every phase in a block build is expected + // to run. + let histogram = ethlambda_metrics::register_histogram_vec!( + "test_phase_timer_exactly_once_missing_seconds", + "Phase histogram used only by the PhaseTimer tests", + &["phase"] + ) + .expect("registers once"); + + let timer = PhaseTimer::start("test_phase_timer_exactly_once_missing_seconds"); + histogram + .with_label_values(&["select_payloads"]) + .observe(0.25); + + let result = timer.finish(["select_payloads", "compact"].into_iter()); + assert!( + result.is_err(), + "a phase observed zero times must be a hard error for `finish`" + ); + } + + #[test] + fn finish_rejects_a_phase_observed_twice() { + let histogram = ethlambda_metrics::register_histogram_vec!( + "test_phase_timer_exactly_once_doubled_seconds", + "Phase histogram used only by the PhaseTimer tests", + &["phase"] + ) + .expect("registers once"); + + let timer = PhaseTimer::start("test_phase_timer_exactly_once_doubled_seconds"); + histogram.with_label_values(&["compact"]).observe(0.1); + histogram.with_label_values(&["compact"]).observe(0.2); + + let result = timer.finish(["compact"].into_iter()); + assert!( + result.is_err(), + "a phase observed twice must be a hard error for `finish`" + ); + } +} diff --git a/bin/ethlambda/src/benchmark/report/common.rs b/bin/ethlambda/src/benchmark/report/common.rs new file mode 100644 index 000000000..2d6bc34a0 --- /dev/null +++ b/bin/ethlambda/src/benchmark/report/common.rs @@ -0,0 +1,143 @@ +//! Statistics and environment reporting shared by every benchmark workload. +//! +//! Raw per-iteration samples are always included in a workload's JSON report: +//! outliers are never discarded (XMSS signing and OTS window advancement +//! produce legitimate heavy tails worth inspecting), and per-iteration block +//! roots let a baseline-vs-optimized diff prove an optimization changed only +//! speed, not which attestations get selected. + +use serde::Serialize; + +use crate::version; + +/// Coefficient-of-variation threshold above which wall-time results are +/// flagged as too noisy to compare, per the benchmarking workflow standard. +pub(crate) const CV_WARN_THRESHOLD: f64 = 0.10; + +#[derive(Debug, Serialize)] +pub(crate) struct Environment { + pub client_version: &'static str, + /// Resolved leanVM git revision from Cargo.lock. leanVM owns the whole + /// signature stack (XMSS and aggregation), so a rev bump moves the + /// measured crypto and results are not comparable across revisions. + pub leanvm_rev: &'static str, + pub os: &'static str, + pub arch: &'static str, + pub available_parallelism: usize, +} + +impl Environment { + pub(crate) fn collect() -> Self { + Self { + client_version: version::CLIENT_VERSION, + leanvm_rev: env!("ETHLAMBDA_LEANVM_REV"), + os: std::env::consts::OS, + arch: std::env::consts::ARCH, + available_parallelism: std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(0), + } + } +} + +#[derive(Debug, Serialize)] +pub(crate) struct Stats { + pub count: usize, + pub min_seconds: f64, + pub mean_seconds: f64, + pub p50_seconds: f64, + pub p90_seconds: f64, + pub max_seconds: f64, + /// Coefficient of variation (stddev / mean); NaN-free (0 when mean is 0). + pub cv: f64, +} + +pub(crate) fn format_ms(seconds: f64) -> String { + format!("{:.3}ms", seconds * 1e3) +} + +pub(crate) fn stats(values: &[f64]) -> Stats { + if values.is_empty() { + return Stats { + count: 0, + min_seconds: 0.0, + mean_seconds: 0.0, + p50_seconds: 0.0, + p90_seconds: 0.0, + max_seconds: 0.0, + cv: 0.0, + }; + } + let mut sorted = values.to_vec(); + sorted.sort_by(|a, b| a.total_cmp(b)); + let count = sorted.len(); + let mean = sorted.iter().sum::() / count as f64; + let variance = sorted + .iter() + .map(|value| (value - mean).powi(2)) + .sum::() + / count as f64; + let cv = if mean > 0.0 { + variance.sqrt() / mean + } else { + 0.0 + }; + Stats { + count, + min_seconds: sorted[0], + mean_seconds: mean, + p50_seconds: percentile(&sorted, 0.50), + p90_seconds: percentile(&sorted, 0.90), + max_seconds: sorted[count - 1], + cv, + } +} + +/// Nearest-rank percentile over a sorted slice (no interpolation; sample +/// counts are small so exact sample values are preferable to blends). +fn percentile(sorted: &[f64], q: f64) -> f64 { + let index = ((sorted.len() - 1) as f64 * q).round() as usize; + sorted[index] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn percentile_handles_single_sample() { + let sorted = [7.0]; + assert_eq!(percentile(&sorted, 0.0), 7.0); + assert_eq!(percentile(&sorted, 0.5), 7.0); + assert_eq!(percentile(&sorted, 1.0), 7.0); + } + + #[test] + fn percentile_odd_and_even_lengths() { + let odd = [1.0, 2.0, 3.0, 4.0, 5.0]; + assert_eq!(percentile(&odd, 0.5), 3.0); + assert_eq!(percentile(&odd, 1.0), 5.0); + let even = [1.0, 2.0, 3.0, 4.0]; + assert_eq!(percentile(&even, 0.5), 3.0); + assert_eq!(percentile(&even, 0.0), 1.0); + } + + #[test] + fn stats_on_known_values() { + let stats = stats(&[2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0]); + assert_eq!(stats.count, 8); + assert_eq!(stats.min_seconds, 2.0); + assert_eq!(stats.max_seconds, 9.0); + assert_eq!(stats.mean_seconds, 5.0); + // population stddev of this classic set is 2.0 => cv = 0.4 + assert!((stats.cv - 0.4).abs() < 1e-12); + } + + #[test] + fn stats_on_empty_input_is_zeroed() { + let stats = stats(&[]); + assert_eq!(stats.count, 0); + assert_eq!(stats.mean_seconds, 0.0); + assert_eq!(stats.cv, 0.0); + } +} diff --git a/bin/ethlambda/src/benchmark/report/import.rs b/bin/ethlambda/src/benchmark/report/import.rs new file mode 100644 index 000000000..86c976bab --- /dev/null +++ b/bin/ethlambda/src/benchmark/report/import.rs @@ -0,0 +1,253 @@ +//! The import workload's report. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt::Write as _; + +use serde::Serialize; + +use super::common::{Environment, Stats, format_ms, stats}; + +#[derive(Debug, Serialize)] +pub(crate) struct Params { + pub mode: &'static str, + pub corpus: String, + pub network: String, + pub anchor_block_root: String, + pub anchor_slot: u64, + /// Blocks imported between the anchor and `range_start` without being + /// sampled. + pub warmup_blocks: usize, + pub range_start: u64, + /// Inclusive, as `fetch --to` is. + pub range_end: u64, + pub blocks: usize, +} + +#[derive(Debug, Serialize)] +pub(crate) struct Sample { + pub iteration: u64, + pub slot: u64, + /// Determinism checksum: two runs over one corpus must produce the same + /// roots in the same order. + pub block_root: String, + pub wall_seconds: f64, + /// Per-phase seconds from `lean_block_import_phase_seconds` sum deltas. + /// A phase that did not run is absent, not zero. + pub phases: BTreeMap, + /// `"imported"`. A run that reaches a report has no other outcome: a + /// held or rejected block aborts it. + pub outcome: &'static str, +} + +#[derive(Debug, Serialize)] +pub(crate) struct Summary { + pub phases: BTreeMap, + pub wall: Stats, +} + +#[derive(Debug, Serialize)] +pub(crate) struct Report { + pub schema_version: u32, + pub environment: Environment, + pub params: Params, + pub samples: Vec, + pub summary: Summary, +} + +impl Report { + pub(crate) fn new(environment: Environment, params: Params, samples: Vec) -> Self { + // Union over every sample's phase keys, not just the first sample's: + // unlike a synthetic build (where every phase runs on every block), a + // corpus block legitimately skips phases (decode, defer, parent_wait, + // columns_wait), and which ones it skips can differ block to block. + let mut phase_names: BTreeSet<&str> = BTreeSet::new(); + for sample in &samples { + phase_names.extend(sample.phases.keys().map(String::as_str)); + } + let mut phases: BTreeMap = BTreeMap::new(); + for phase in phase_names { + let values: Vec = samples + .iter() + .filter_map(|sample| sample.phases.get(phase).copied()) + .collect(); + phases.insert(phase.to_string(), stats(&values)); + } + // No coefficient-of-variation warning, unlike the synthetic report: + // there every iteration is the same work, so spread is noise, while + // here every sample is a different block, and the spread is mostly + // the blocks themselves. Run-to-run noise shows up as the difference + // between two replays of one corpus, not within one. + let wall = stats( + &samples + .iter() + .map(|sample| sample.wall_seconds) + .collect::>(), + ); + + Self { + schema_version: 1, + environment, + params, + samples, + summary: Summary { phases, wall }, + } + } + + pub(crate) fn to_json(&self) -> eyre::Result { + serde_json::to_string_pretty(self).map_err(Into::into) + } + + pub(crate) fn human_table(&self) -> String { + let mut out = String::new(); + let params = &self.params; + let env = &self.environment; + let _ = writeln!(out, "Block-import benchmark — {} workload", params.mode); + let _ = writeln!( + out, + " corpus={} network={} anchor_slot={} warmup_blocks={} range=[{}, {}] blocks={}", + params.corpus, + params.network, + params.anchor_slot, + params.warmup_blocks, + params.range_start, + params.range_end, + params.blocks + ); + let _ = writeln!( + out, + " {} leanvm={} os={} arch={} threads={}", + env.client_version, env.leanvm_rev, env.os, env.arch, env.available_parallelism + ); + let _ = writeln!(out); + + if self.samples.is_empty() { + return out; + } + + // Phase columns are the union over every sample's keys, matching + // `Report::new`: the first sample alone is not representative, since a + // phase absent from it may still be present on a later block. + let mut phase_names: BTreeSet<&String> = BTreeSet::new(); + for sample in &self.samples { + phase_names.extend(sample.phases.keys()); + } + let phases: Vec<&String> = phase_names.into_iter().collect(); + + let _ = write!(out, " {:<5}", "iter"); + for phase in &phases { + let _ = write!(out, " {phase:>16}"); + } + let _ = writeln!(out, " {:>10} {:>12}", "wall", "root"); + + for sample in &self.samples { + let _ = write!(out, " {:<5}", sample.iteration); + for phase in &phases { + match sample.phases.get(*phase) { + Some(seconds) => { + let _ = write!(out, " {:>16}", format_ms(*seconds)); + } + None => { + let _ = write!(out, " {:>16}", "-"); + } + } + } + let _ = writeln!( + out, + " {:>10} {:>12}", + format_ms(sample.wall_seconds), + &sample.block_root[..10], + ); + } + + let _ = writeln!(out); + let _ = writeln!( + out, + " {:<18} {:>5} {:>10} {:>10} {:>10} {:>10} {:>10}", + "phase", "count", "min", "mean", "p50", "p90", "max" + ); + for (phase, stats) in &self.summary.phases { + let _ = writeln!(out, "{}", stats_row(phase, stats)); + } + let _ = writeln!(out, "{}", stats_row("wall", &self.summary.wall)); + out + } +} + +fn stats_row(name: &str, stats: &Stats) -> String { + format!( + " {:<18} {:>5} {:>10} {:>10} {:>10} {:>10} {:>10}", + name, + stats.count, + format_ms(stats.min_seconds), + format_ms(stats.mean_seconds), + format_ms(stats.p50_seconds), + format_ms(stats.p90_seconds), + format_ms(stats.max_seconds), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample(iteration: u64, phases: &[(&str, f64)]) -> Sample { + Sample { + iteration, + slot: iteration, + block_root: format!("0x{iteration:064x}"), + wall_seconds: 0.01, + phases: phases.iter().map(|(k, v)| ((*k).to_string(), *v)).collect(), + outcome: "imported", + } + } + + fn params() -> Params { + Params { + mode: "import", + corpus: "corpus.ssz".to_string(), + network: "devnet".to_string(), + anchor_block_root: "0xabc".to_string(), + anchor_slot: 0, + warmup_blocks: 0, + range_start: 1, + range_end: 3, + blocks: 2, + } + } + + #[test] + fn the_range_prints_inclusive_as_fetch_takes_it() { + let report = Report::new(Environment::collect(), params(), Vec::new()); + + assert!( + report.human_table().contains("range=[1, 3]"), + "`--to` is inclusive, so the range must not print half-open" + ); + } + + #[test] + fn a_phase_missing_from_one_sample_still_gets_a_column_with_stats_over_only_the_samples_that_have_it() + { + let samples = vec![ + sample(1, &[("stf", 0.1), ("decode", 0.2)]), + sample(2, &[("stf", 0.3)]), + ]; + let report = Report::new(Environment::collect(), params(), samples); + + // `decode` ran on one of two samples: its Stats reflect only that one + // observation, not a zero-filled second one. + assert_eq!(report.summary.phases["stf"].count, 2); + assert_eq!(report.summary.phases["decode"].count, 1); + assert!((report.summary.phases["decode"].mean_seconds - 0.2).abs() < 1e-9); + + let table = report.human_table(); + assert!(table.contains("decode"), "column missing from human_table"); + assert!(table.contains("stf"), "column missing from human_table"); + // The second sample's row has no `decode` entry: it must render as a + // placeholder, not a fabricated zero. + assert!( + table.contains(" - "), + "missing phase should render as a placeholder, not 0" + ); + } +} diff --git a/bin/ethlambda/src/benchmark/report/mod.rs b/bin/ethlambda/src/benchmark/report/mod.rs new file mode 100644 index 000000000..9224ae443 --- /dev/null +++ b/bin/ethlambda/src/benchmark/report/mod.rs @@ -0,0 +1,11 @@ +//! Benchmark reports. +//! +//! Split by workload rather than by a tagged enum: `Report::new` builds its +//! summary from closures over fields that exist on one workload only, and +//! `human_table` reads params the other does not have, so a shared type would +//! share a name and nothing else. What is genuinely shared lives in +//! [`common`]. + +pub(crate) mod common; +pub(crate) mod import; +pub(crate) mod synthetic; diff --git a/bin/ethlambda/src/benchmark/report.rs b/bin/ethlambda/src/benchmark/report/synthetic.rs similarity index 62% rename from bin/ethlambda/src/benchmark/report.rs rename to bin/ethlambda/src/benchmark/report/synthetic.rs index cd7d29ce6..0158db77a 100644 --- a/bin/ethlambda/src/benchmark/report.rs +++ b/bin/ethlambda/src/benchmark/report/synthetic.rs @@ -1,21 +1,11 @@ -//! Statistics and report emission for the block-building benchmark. -//! -//! Raw per-iteration samples are always included in the JSON report: outliers -//! are never discarded (XMSS signing and OTS window advancement produce -//! legitimate heavy tails worth inspecting), and per-iteration block roots let -//! a baseline-vs-optimized diff prove an optimization changed only speed, not -//! which attestations get selected. +//! Report emission for the synthetic block-building benchmark. use std::collections::BTreeMap; use std::fmt::Write as _; use serde::Serialize; -use crate::version; - -/// Coefficient-of-variation threshold above which wall-time results are -/// flagged as too noisy to compare, per the benchmarking workflow standard. -const CV_WARN_THRESHOLD: f64 = 0.10; +use super::common::{CV_WARN_THRESHOLD, Environment, Stats, format_ms, stats}; #[derive(Debug, Serialize)] pub(crate) struct Sample { @@ -46,32 +36,6 @@ pub(crate) struct Sample { pub import_seconds: f64, } -#[derive(Debug, Serialize)] -pub(crate) struct Environment { - pub client_version: &'static str, - /// Resolved leanVM git revision from Cargo.lock. leanVM owns the whole - /// signature stack (XMSS and aggregation), so a rev bump moves the - /// measured crypto and results are not comparable across revisions. - pub leanvm_rev: &'static str, - pub os: &'static str, - pub arch: &'static str, - pub available_parallelism: usize, -} - -impl Environment { - pub(crate) fn collect() -> Self { - Self { - client_version: version::CLIENT_VERSION, - leanvm_rev: env!("ETHLAMBDA_LEANVM_REV"), - os: std::env::consts::OS, - arch: std::env::consts::ARCH, - available_parallelism: std::thread::available_parallelism() - .map(|n| n.get()) - .unwrap_or(0), - } - } -} - #[derive(Debug, Serialize)] pub(crate) struct Params { pub mode: &'static str, @@ -85,18 +49,6 @@ pub(crate) struct Params { pub max_attestations_per_block: usize, } -#[derive(Debug, Serialize)] -pub(crate) struct Stats { - pub count: usize, - pub min_seconds: f64, - pub mean_seconds: f64, - pub p50_seconds: f64, - pub p90_seconds: f64, - pub max_seconds: f64, - /// Coefficient of variation (stddev / mean); NaN-free (0 when mean is 0). - pub cv: f64, -} - #[derive(Debug, Serialize)] pub(crate) struct Summary { pub phases: BTreeMap, @@ -257,93 +209,3 @@ fn stats_row(name: &str, stats: &Stats) -> String { format_ms(stats.max_seconds), ) } - -fn format_ms(seconds: f64) -> String { - format!("{:.3}ms", seconds * 1e3) -} - -fn stats(values: &[f64]) -> Stats { - if values.is_empty() { - return Stats { - count: 0, - min_seconds: 0.0, - mean_seconds: 0.0, - p50_seconds: 0.0, - p90_seconds: 0.0, - max_seconds: 0.0, - cv: 0.0, - }; - } - let mut sorted = values.to_vec(); - sorted.sort_by(|a, b| a.total_cmp(b)); - let count = sorted.len(); - let mean = sorted.iter().sum::() / count as f64; - let variance = sorted - .iter() - .map(|value| (value - mean).powi(2)) - .sum::() - / count as f64; - let cv = if mean > 0.0 { - variance.sqrt() / mean - } else { - 0.0 - }; - Stats { - count, - min_seconds: sorted[0], - mean_seconds: mean, - p50_seconds: percentile(&sorted, 0.50), - p90_seconds: percentile(&sorted, 0.90), - max_seconds: sorted[count - 1], - cv, - } -} - -/// Nearest-rank percentile over a sorted slice (no interpolation; sample -/// counts are small so exact sample values are preferable to blends). -fn percentile(sorted: &[f64], q: f64) -> f64 { - let index = ((sorted.len() - 1) as f64 * q).round() as usize; - sorted[index] -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn percentile_handles_single_sample() { - let sorted = [7.0]; - assert_eq!(percentile(&sorted, 0.0), 7.0); - assert_eq!(percentile(&sorted, 0.5), 7.0); - assert_eq!(percentile(&sorted, 1.0), 7.0); - } - - #[test] - fn percentile_odd_and_even_lengths() { - let odd = [1.0, 2.0, 3.0, 4.0, 5.0]; - assert_eq!(percentile(&odd, 0.5), 3.0); - assert_eq!(percentile(&odd, 1.0), 5.0); - let even = [1.0, 2.0, 3.0, 4.0]; - assert_eq!(percentile(&even, 0.5), 3.0); - assert_eq!(percentile(&even, 0.0), 1.0); - } - - #[test] - fn stats_on_known_values() { - let stats = stats(&[2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0]); - assert_eq!(stats.count, 8); - assert_eq!(stats.min_seconds, 2.0); - assert_eq!(stats.max_seconds, 9.0); - assert_eq!(stats.mean_seconds, 5.0); - // population stddev of this classic set is 2.0 => cv = 0.4 - assert!((stats.cv - 0.4).abs() < 1e-12); - } - - #[test] - fn stats_on_empty_input_is_zeroed() { - let stats = stats(&[]); - assert_eq!(stats.count, 0); - assert_eq!(stats.mean_seconds, 0.0); - assert_eq!(stats.cv, 0.0); - } -} diff --git a/bin/ethlambda/src/checkpoint_sync.rs b/bin/ethlambda/src/checkpoint_sync.rs index 535dc631c..9030e33f7 100644 --- a/bin/ethlambda/src/checkpoint_sync.rs +++ b/bin/ethlambda/src/checkpoint_sync.rs @@ -1,9 +1,36 @@ +//! Checkpoint sync for `ethlambda node`, against a lean peer's `/lean/v0/…` +//! API. +//! +//! The URL cleaning, the base-URL trim and the "try each URL, first success +//! wins" fan-out below were a `checkpoint_common` module while `crate::beacon` +//! read mainnet's genesis metadata as JSON off the same `--checkpoint-sync-url` +//! list. It reads that from the genesis state built into the binary now, so +//! this is the only caller left and they have folded back in here. +//! +//! The HTTP client is built here for a reason that outlived that split: a +//! finalized `State` is large enough to need a connect timeout plus an +//! inactivity read timeout, where a plain total timeout would kill a healthy +//! slow transfer. +//! +//! This path fetches the state and then the block, from endpoints that each +//! mean "whatever is finalized right now", so the peer can advance +//! finalization between the two requests. That is what +//! [`fetch_finalized_anchor`]'s retry loop (via [`try_checkpoint_url`]) is +//! for. Sequential rather than concurrent so both chains run one shape: the +//! beacon path cannot issue its block request until it has read the anchor +//! block's slot off the state. + +use std::future::Future; use std::time::Duration; +use ethlambda_p2p::beacon::decode; +use ethlambda_types::beacon::config::Config; +use ethlambda_types::beacon::containers::{BeaconState, SignedBeaconBlock}; +use ethlambda_types::beacon::preset; use ethlambda_types::block::SignedBlock; use ethlambda_types::genesis::{GenesisMismatch, verify_state_genesis}; -use ethlambda_types::primitives::HashTreeRoot as _; -use ethlambda_types::state::{State, Validator, anchor_pair_is_consistent}; +use ethlambda_types::primitives::{H256, HashTreeRoot as _}; +use ethlambda_types::state::{State, anchor_pair_is_consistent}; use libssz::{DecodeError, SszDecode}; use reqwest::Client; use tracing::{error, info, warn}; @@ -22,6 +49,18 @@ const FINALIZED_STATE_PATH: &str = "/lean/v0/states/finalized"; /// Path of the finalized-block endpoint (relative to the peer's API base URL). const FINALIZED_BLOCK_PATH: &str = "/lean/v0/blocks/finalized"; +/// Path of the finalized-state endpoint on a Beacon API server. +/// +/// `finalized` resolves to the state at the finalized checkpoint's epoch +/// boundary, which may be a slot with no block in it. See +/// [`fetch_beacon_anchor`]. +const BEACON_FINALIZED_STATE_PATH: &str = "/eth/v2/debug/beacon/states/finalized"; + +/// Path of the block-by-slot endpoint on a Beacon API server. +fn beacon_block_path(slot: u64) -> String { + format!("/eth/v2/beacon/blocks/{slot}") +} + /// Maximum attempts to refetch the anchor pair if the state and block roots don't match. const MAX_ANCHOR_FETCH_ATTEMPTS: u32 = 3; @@ -36,6 +75,58 @@ const CHECKPOINT_RETRY_BACKOFF: Duration = Duration::from_secs(5); /// client-startup budget when each one fails fast. const MAX_CHECKPOINT_ATTEMPTS: u32 = 5; +/// Strip one trailing slash, so `{base}{path}` never doubles one. +fn trim_trailing_slash(url: &str) -> &str { + url.trim_end_matches('/') +} + +/// Trim whitespace from each URL and drop any that become empty. +/// +/// A shell expanding an unset variable into `--checkpoint-sync-url ""` is an +/// easy way to end up with an empty string in the list; treating it as a URL +/// worth dialing is never useful. +pub(crate) fn clean_urls(urls: I) -> Vec +where + I: IntoIterator, + I::Item: AsRef, +{ + urls.into_iter() + .map(|url| url.as_ref().trim().to_string()) + .filter(|url| !url.is_empty()) + .collect() +} + +/// Try each URL in turn, stopping at the first success. +/// +/// `attempt` receives the URL (owned, not borrowed: a closure returning a +/// future can't hand back one that borrows its own argument, since `Fut` is a +/// single associated type rather than one per call; cloning a handful of +/// short strings is a cheap way around that) and whether another one remains +/// after it, so the caller can word a "trying next" vs. "no more URLs" log +/// without this function knowing what logging looks like on either side. +/// `on_exhausted` turns whatever `attempt` left behind into the error to +/// return: it is called with `None` only when `urls` was empty to begin with, +/// and with `Some(the last error)` once every URL has been tried and failed. +async fn try_urls_in_order( + urls: &[String], + mut attempt: impl FnMut(String, bool) -> Fut, + on_exhausted: impl FnOnce(Option) -> E, +) -> Result +where + Fut: Future>, +{ + let mut iter = urls.iter().peekable(); + let mut last_err = None; + while let Some(url) = iter.next() { + let has_more = iter.peek().is_some(); + match attempt(url.clone(), has_more).await { + Ok(value) => return Ok(value), + Err(err) => last_err = Some(err), + } + } + Err(on_exhausted(last_err)) +} + #[derive(Debug, thiserror::Error)] pub enum CheckpointSyncError { #[error("HTTP request failed: {0}")] @@ -48,11 +139,33 @@ pub enum CheckpointSyncError { NoValidators, #[error("checkpoint state does not match the configured genesis: {0}")] Genesis(#[from] GenesisMismatch), - /// Reading the persisted store failed, including the case where the data - /// directory holds another network's chain. Startup aborts: the operator - /// has to point at the right data directory or remove it. + /// Reading the persisted store failed. Startup aborts: the operator has + /// to point at the right data directory or remove it. #[error("failed to load persisted DB state: {0}")] DbState(#[from] ethlambda_storage::Error), + /// The data directory holds the other chain. Refusing to touch it is + /// deliberate: re-initializing on top would leave the foreign blocks in + /// place, and they are reachable through the slot-indexed reads that serve + /// `BlocksByRange`. + #[error( + "data directory holds a {found:?} chain, not a {expected:?} one; \ + wipe it or switch sub-command" + )] + WrongChain { + expected: ethlambda_storage::Chain, + found: ethlambda_storage::Chain, + }, + /// The directory is for the right chain, but the network config supplied + /// this run disagrees with the one it was initialized with: most likely a + /// fork epoch was edited in place. A changed fork epoch leaves genesis + /// time and the validators root untouched, so it survives the checks + /// above, while putting this node on a different chain from its peers + /// from that epoch on. + #[error( + "this data directory was initialized with a different configuration ({difference}); \ + delete it or point --data-dir elsewhere" + )] + ConfigChanged { difference: String }, #[error("finalized slot cannot exceed state slot")] FinalizedExceedsStateSlot, #[error("justified slot cannot precede finalized slot")] @@ -71,6 +184,56 @@ pub enum CheckpointSyncError { NoCheckpointUrls, #[error("failed to insert anchor signed block into store")] StoreInsertSignedBlock, + /// `get_forkchoice_store` checks the same pair of forks; reaching it here + /// first lets the error name the peer that served the mismatched pair, + /// and keeps a peer problem out of a function whose other errors mean the + /// specification was violated. + #[error("anchor state is at {state} but the anchor block is at {block}")] + AnchorForkMismatch { + state: ethlambda_types::beacon::fork::ForkName, + block: ethlambda_types::beacon::fork::ForkName, + }, + #[error("peer served no block at the anchor slot {slot}")] + AnchorBlockMissing { slot: u64 }, + // Only a built-in network reaches this: a `--network ` network + // anchors at the directory's own `genesis.ssz`. Every built-in network has + // been live for years and this follower imports nothing at startup, so + // anchoring there would park it at slot 0 while claiming to follow a live + // chain. + #[error( + "a built-in network has no genesis-sync path, so a fresh data directory needs \ + --checkpoint-sync-url; a network loaded with --network anchors at its own \ + genesis.ssz instead" + )] + BeaconGenesisSync, + /// The buffer served for the finalized beacon state is too short to hold + /// the slot at its fixed offset, so no fork could even be resolved before + /// decoding could be attempted. Carries `slot_from_ssz`'s own error, which + /// is always an `InvalidByteLength` naming the offset it expected against + /// the length it got. + #[error("beacon state buffer too short to read its slot: {0:?}")] + BeaconStateSlotDecode(ethlambda_types::beacon::error::Error), + /// The beacon state did not decode as the fork its own slot named. + /// Separate from [`CheckpointSyncError::BeaconStateSlotDecode`], whose + /// slot read already succeeded: the fork is known by this point, and + /// "decoded as the wrong fork" is the failure mode that actually matters + /// here, so it is kept rather than discarded along with the rest of + /// `ethlambda-types`' own decode error. + #[error("beacon state did not decode as {fork}: {source:?}")] + BeaconStateDecode { + fork: ethlambda_types::beacon::fork::ForkName, + source: ethlambda_types::beacon::error::Error, + }, + /// The beacon block at `slot` did not decode. Reuses the gossip path's + /// decoder, which resolves and discards its own fork internally and + /// collapses every libssz failure into one `Ssz` variant alongside + /// `Truncated`/`UnknownTopic`, so its own [`decode::DecodeError`] is the + /// most detail available at this call site. Named `err` rather than + /// `source`: that type does not implement `std::error::Error`, and + /// thiserror would otherwise require it to for the automatic + /// `Error::source()` a field literally named `source` gets. + #[error("beacon block at slot {slot} did not decode: {err}")] + BeaconBlockDecode { slot: u64, err: decode::DecodeError }, } /// Build the HTTP client used for checkpoint sync fetches. @@ -85,15 +248,24 @@ pub enum CheckpointSyncError { /// failing fast if the connection stalls. A plain total timeout would /// disconnect even for valid downloads if the state is simply too large to /// transfer within the time limit. -fn build_client() -> Result { +pub(crate) fn build_client() -> Result { Ok(Client::builder() .connect_timeout(CHECKPOINT_CONNECT_TIMEOUT) .read_timeout(CHECKPOINT_READ_TIMEOUT) .build()?) } -/// Fetch and SSZ-decode an `application/octet-stream` body from `url`. -async fn fetch_ssz(client: &Client, url: &str) -> Result { +/// Fetch an `application/octet-stream` body from `url` and decode it with +/// `decode`. +/// +/// Takes a closure rather than returning the bytes so the body is never +/// copied: a mainnet `BeaconState` is hundreds of megabytes, and handing it +/// back as a `Vec` would double the peak. +async fn fetch_decoded( + client: &Client, + url: &str, + decode: impl FnOnce(&[u8]) -> Result, +) -> Result { let bytes = client .get(url) .header("Accept", "application/octet-stream") @@ -103,7 +275,16 @@ async fn fetch_ssz(client: &Client, url: &str) -> Result(client: &Client, url: &str) -> Result { + fetch_decoded(client, url, |bytes| { + T::from_ssz_bytes(bytes).map_err(CheckpointSyncError::SszDecode) + }) + .await } /// Normalize a checkpoint-sync URL to a base URL. @@ -112,13 +293,14 @@ async fn fetch_ssz(client: &Client, url: &str) -> Result &str { // Trim trailing slashes FIRST so that the legacy-suffix strip succeeds on // inputs like `…/lean/v0/states/finalized/`; otherwise we'd leave the // state path embedded in the "base URL" and double-prefix every request. - let trimmed = url.trim_end_matches('/'); + let trimmed = trim_trailing_slash(url); trimmed .strip_suffix(FINALIZED_STATE_PATH) .unwrap_or(trimmed) @@ -130,11 +312,28 @@ async fn fetch_finalized_state( client: &Client, base_url: &str, expected_genesis_time: u64, - expected_validators: &[Validator], + expected_genesis_validators_root: H256, ) -> Result { let url = format!("{base_url}{FINALIZED_STATE_PATH}"); let state: State = fetch_ssz(client, &url).await?; - verify_checkpoint_state(&state, expected_genesis_time, expected_validators)?; + + verify_checkpoint_state(&state)?; + + // Then the genesis identity, through the check the resume path and the + // beacon path also run, so all three agree on what makes a state ours. + // It takes a `BeaconState`, so the lean state moves into that wrapper and + // straight back out of it; a move, not a copy. + let state = BeaconState::Lean(state); + let verdict = verify_state_genesis( + &state, + expected_genesis_time, + expected_genesis_validators_root, + ); + let BeaconState::Lean(state) = state else { + unreachable!("the variant constructed one line above") + }; + verdict?; + Ok(state) } @@ -147,28 +346,32 @@ async fn fetch_finalized_block( fetch_ssz(client, &url).await } -/// Fetch the finalized state and signed block in parallel and verify they pair. +/// Fetch the finalized state, then the finalized block, and verify they pair. /// /// If the peer advances finalization between the two requests the pairing will /// not hold; the caller is expected to retry. pub async fn fetch_finalized_anchor( url: &str, expected_genesis_time: u64, - expected_validators: &[Validator], + genesis_validators_root: H256, ) -> Result<(State, SignedBlock), CheckpointSyncError> { let base_url = normalize_base_url(url); let client = build_client()?; - // Issue both fetches concurrently; either failure cancels the pair. - let (mut state, signed_block) = tokio::try_join!( - fetch_finalized_state( - &client, - base_url, - expected_genesis_time, - expected_validators - ), - fetch_finalized_block(&client, base_url), - )?; + // State first, then the block, sequentially. It is the order the beacon + // path needs, since that one addresses the block by a slot read off the + // state, and running one shape on both chains is worth more than the + // window the concurrent fetch saved. Both endpoints answer "whatever is + // finalized right now", so the peer can still advance finalization + // between them; `try_checkpoint_url` retries that. + let mut state = fetch_finalized_state( + &client, + base_url, + expected_genesis_time, + genesis_validators_root, + ) + .await?; + let signed_block = fetch_finalized_block(&client, base_url).await?; // Strictly mirrors the invariants `Store::get_forkchoice_store` asserts — // header equality, state self-consistency, and `block.state_root` equal @@ -180,17 +383,11 @@ pub async fn fetch_finalized_anchor( Ok((state, signed_block)) } -/// Verify checkpoint state is structurally valid. +/// Verify a downloaded checkpoint state is structurally valid. /// -/// Arguments: -/// - state: The downloaded checkpoint state -/// - expected_genesis_time: Genesis time from local config -/// - expected_validators: Validator pubkeys from local genesis config -fn verify_checkpoint_state( - state: &State, - expected_genesis_time: u64, - expected_validators: &[Validator], -) -> Result<(), CheckpointSyncError> { +/// Says nothing about which network the state belongs to; that is +/// [`verify_state_genesis`]'s job, and the caller runs both. +fn verify_checkpoint_state(state: &State) -> Result<(), CheckpointSyncError> { // Slot sanity check. Checkpoint-specific: unlike a state loaded from our // own data directory, a downloaded anchor at genesis is never legitimate. if state.slot == 0 { @@ -202,11 +399,6 @@ fn verify_checkpoint_state( return Err(CheckpointSyncError::NoValidators); } - // Genesis time and the full validator registry match our config. Shared - // with the resume-from-disk path so both entry points agree on what makes a - // state ours. - verify_state_genesis(state, expected_genesis_time, expected_validators)?; - // Finalized slot sanity if state.latest_finalized.slot > state.slot { return Err(CheckpointSyncError::FinalizedExceedsStateSlot); @@ -253,11 +445,11 @@ fn verify_checkpoint_state( async fn try_checkpoint_url( url: &str, genesis_time: u64, - validators: &[Validator], + genesis_validators_root: H256, ) -> Result<(State, SignedBlock), CheckpointSyncError> { let mut attempt = 1; loop { - match fetch_finalized_anchor(url, genesis_time, validators).await { + match fetch_finalized_anchor(url, genesis_time, genesis_validators_root).await { Ok(pair) => return Ok(pair), Err(CheckpointSyncError::AnchorPairingMismatch) if attempt < MAX_ANCHOR_FETCH_ATTEMPTS => @@ -282,35 +474,35 @@ async fn try_checkpoint_url( pub async fn fetch_anchor_block_and_state( checkpoint_urls: &[String], genesis_time: u64, - validators: &[Validator], + genesis_validators_root: H256, ) -> Result<(State, SignedBlock), CheckpointSyncError> { - let mut iter = checkpoint_urls.iter().peekable(); - let mut last_err: Option = None; - loop { - let Some(url) = iter.next() else { - return Err(match last_err { - Some(err) => { - error!(%err, "All checkpoint sync attempts failed"); - err + try_urls_in_order( + checkpoint_urls, + |url, has_more| async move { + match try_checkpoint_url(&url, genesis_time, genesis_validators_root).await { + Ok(pair) => { + info!(%url, "Checkpoint sync successful with this peer"); + Ok(pair) } - None => CheckpointSyncError::NoCheckpointUrls, - }); - }; - match try_checkpoint_url(url, genesis_time, validators).await { - Ok(pair) => { - info!(%url, "Checkpoint sync successful with this peer"); - return Ok(pair); - } - Err(err) => { - if iter.peek().is_some() { - warn!(%url, %err, "Checkpoint sync failed for this peer; trying next URL"); - } else { - warn!(%url, %err, "Checkpoint sync failed for this peer; no more URLs to try"); + Err(err) => { + if has_more { + warn!(%url, %err, "Checkpoint sync failed for this peer; trying next URL"); + } else { + warn!(%url, %err, "Checkpoint sync failed for this peer; no more URLs to try"); + } + Err(err) } - last_err = Some(err); } - } - } + }, + |last_err| match last_err { + Some(err) => { + error!(%err, "All checkpoint sync attempts failed"); + err + } + None => CheckpointSyncError::NoCheckpointUrls, + }, + ) + .await } /// Fetch the finalized anchor, retrying any failure (e.g. the peer not yet @@ -319,11 +511,225 @@ pub async fn fetch_anchor_block_and_state( pub async fn fetch_anchor_with_retry( checkpoint_urls: &[String], genesis_time: u64, - validators: &[Validator], + genesis_validators_root: H256, ) -> Result<(State, SignedBlock), CheckpointSyncError> { let mut attempt: u32 = 1; loop { - match fetch_anchor_block_and_state(checkpoint_urls, genesis_time, validators).await { + match fetch_anchor_block_and_state(checkpoint_urls, genesis_time, genesis_validators_root) + .await + { + Ok(pair) => return Ok(pair), + Err(err) if attempt < MAX_CHECKPOINT_ATTEMPTS => { + warn!(attempt, %err, "Checkpoint sync attempt failed; retrying"); + tokio::time::sleep(CHECKPOINT_RETRY_BACKOFF).await; + attempt += 1; + } + Err(err) => return Err(err), + } + } +} + +// --------------------------------------------------------------------------- +// Beacon path: checkpoint sync against a standard Beacon API server. +// --------------------------------------------------------------------------- + +/// Fetch the finalized beacon state, decoding it at the fork its own slot +/// names. +/// +/// The fork comes from the slot rather than the `Eth-Consensus-Version` +/// header: SSZ carries no type tag, and lighthouse's checkpoint-sync client +/// resolves it the same way. Deriving it removes any dependence on the peer +/// setting a header correctly. +async fn fetch_beacon_finalized_state( + client: &Client, + base_url: &str, + config: &Config, +) -> Result { + let url = format!("{base_url}{BEACON_FINALIZED_STATE_PATH}"); + fetch_decoded(client, &url, |bytes| { + let slot = BeaconState::slot_from_ssz(bytes) + .map_err(CheckpointSyncError::BeaconStateSlotDecode)?; + let fork = decode::fork_at_slot(config, slot); + BeaconState::from_ssz(fork, bytes) + .map_err(|source| CheckpointSyncError::BeaconStateDecode { fork, source }) + }) + .await +} + +/// Fetch the signed beacon block at `slot`, decoding it at the fork its own +/// slot names. +/// +/// Reuses the gossip path's decoder: the problem is identical, and its slot +/// peek already handles the outer container's offset. +async fn fetch_beacon_block( + client: &Client, + base_url: &str, + config: &Config, + slot: u64, +) -> Result { + let url = format!("{base_url}{}", beacon_block_path(slot)); + let response = client + .get(&url) + .header("Accept", "application/octet-stream") + .send() + .await?; + if response.status() == reqwest::StatusCode::NOT_FOUND { + return Err(CheckpointSyncError::AnchorBlockMissing { slot }); + } + let bytes = response.error_for_status()?.bytes().await?; + + decode::decode_block(config, &bytes) + .map_err(|err| CheckpointSyncError::BeaconBlockDecode { slot, err }) +} + +/// Verify a downloaded beacon anchor state is structurally sound. +/// +/// The beacon counterpart to [`verify_checkpoint_state`], checking the same +/// classes of fault against beacon's own fields: a genesis anchor is never a +/// legitimate checkpoint, checkpoints cannot sit in the future or out of +/// order, and the header cannot lead the state. +/// +/// Says nothing about which network the state belongs to, exactly as the lean +/// one does not; [`verify_state_genesis`] answers that and the caller runs +/// both. +fn verify_beacon_checkpoint_state(state: &BeaconState) -> Result<(), CheckpointSyncError> { + if state.slot() == 0 { + return Err(CheckpointSyncError::SlotIsZero); + } + + if state.validators().is_empty() { + return Err(CheckpointSyncError::NoValidators); + } + + let current_epoch = state.slot() / preset::SLOTS_PER_EPOCH; + if state.finalized_checkpoint().epoch > current_epoch { + return Err(CheckpointSyncError::FinalizedExceedsStateSlot); + } + + if state.current_justified_checkpoint().epoch < state.finalized_checkpoint().epoch { + return Err(CheckpointSyncError::JustifiedPrecedesFinalized); + } + + if state.latest_block_header().slot > state.slot() { + return Err(CheckpointSyncError::BlockHeaderSlotExceedsState); + } + + Ok(()) +} + +/// Check that a beacon anchor's state and block belong together. +/// +/// Two checks, in order. First, that the two agree on which fork applies: a +/// mismatch here means the peer served a container shaped for the wrong +/// fork, which `get_forkchoice_store` checks too, so catching it here lets +/// the error name the peer rather than surfacing a spec violation deeper in. +/// +/// Second, that `block` is the one `state.latest_block_header` names, checked +/// on the header root rather than on `block.state_root == +/// hash_tree_root(state)`. `states/finalized` resolves to the state at the +/// finalized epoch's boundary slot, which may have been empty; when it was, +/// the state has advanced one slot past its own `latest_block_header` (the +/// header still names the last block that actually existed, not the empty +/// boundary slot), so the state's own root no longer matches what the header +/// committed to. The header is unaffected by that advance, since +/// `latest_block_header.state_root` is left zero only for the duration of +/// the block's own slot and is filled in with the real root the moment the +/// slot moves past it (`process_slot`). So the zero is substituted with the +/// state's current root only when the header still carries the placeholder; +/// once the slot has advanced, the header already carries the real value and +/// that value is trusted as-is. +fn verify_beacon_anchor_pairing( + state: &BeaconState, + block: &SignedBeaconBlock, +) -> Result<(), CheckpointSyncError> { + if block.fork_name() != state.fork_name() { + return Err(CheckpointSyncError::AnchorForkMismatch { + state: state.fork_name(), + block: block.fork_name(), + }); + } + + let mut header = state.latest_block_header().clone(); + if header.state_root == H256::ZERO { + header.state_root = state.hash_tree_root(); + } + if header.hash_tree_root() != block.message_hash_tree_root() { + return Err(CheckpointSyncError::AnchorPairingMismatch); + } + + Ok(()) +} + +/// Fetch a beacon anchor pair and verify it. +/// +/// State first, then the block at `state.latest_block_header.slot`, which is +/// lighthouse's order and the only one available: the block cannot be +/// addressed until the state names its slot. The pairing and fork checks +/// themselves are [`verify_beacon_anchor_pairing`]'s job, kept pure so they +/// are reachable from a test without an HTTP server. +pub async fn fetch_beacon_anchor( + url: &str, + config: &Config, + genesis_time: u64, + genesis_validators_root: H256, +) -> Result<(BeaconState, SignedBeaconBlock), CheckpointSyncError> { + let base_url = trim_trailing_slash(url); + let client = build_client()?; + + let state = fetch_beacon_finalized_state(&client, base_url, config).await?; + verify_beacon_checkpoint_state(&state)?; + verify_state_genesis(&state, genesis_time, genesis_validators_root)?; + + let block_slot = state.latest_block_header().slot; + let block = fetch_beacon_block(&client, base_url, config, block_slot).await?; + + verify_beacon_anchor_pairing(&state, &block)?; + + Ok((state, block)) +} + +/// Try each checkpoint URL in order, then retry the whole round, exactly as +/// the lean path does. The two loops are shared rather than duplicated: +/// `try_urls_in_order` and the fixed backoff mean the same thing on both +/// chains. +pub async fn fetch_beacon_anchor_with_retry( + checkpoint_urls: &[String], + config: &Config, + genesis_time: u64, + genesis_validators_root: H256, +) -> Result<(BeaconState, SignedBeaconBlock), CheckpointSyncError> { + let mut attempt: u32 = 1; + loop { + let round = try_urls_in_order( + checkpoint_urls, + |url, has_more| async move { + match fetch_beacon_anchor(&url, config, genesis_time, genesis_validators_root).await + { + Ok(pair) => { + info!(%url, "Checkpoint sync successful with this peer"); + Ok(pair) + } + Err(err) => { + if has_more { + warn!(%url, %err, "Checkpoint sync failed for this peer; trying next URL"); + } else { + warn!(%url, %err, "Checkpoint sync failed for this peer; no more URLs to try"); + } + Err(err) + } + } + }, + |last_err| match last_err { + Some(err) => { + error!(%err, "All checkpoint sync attempts failed"); + err + } + None => CheckpointSyncError::NoCheckpointUrls, + }, + ) + .await; + + match round { Ok(pair) => return Ok(pair), Err(err) if attempt < MAX_CHECKPOINT_ATTEMPTS => { warn!(attempt, %err, "Checkpoint sync attempt failed; retrying"); @@ -338,10 +744,98 @@ pub async fn fetch_anchor_with_retry( #[cfg(test)] mod tests { use super::*; + + // The URL helpers first, then the checkpoint-sync path that uses them. + + #[test] + fn trim_trailing_slash_strips_exactly_one() { + assert_eq!(trim_trailing_slash("http://peer:5052/"), "http://peer:5052"); + assert_eq!(trim_trailing_slash("http://peer:5052"), "http://peer:5052"); + } + + #[test] + fn clean_urls_trims_and_drops_empties() { + let urls = vec![ + " http://a ".to_string(), + String::new(), + " ".to_string(), + "http://b".to_string(), + ]; + assert_eq!(clean_urls(urls), vec!["http://a", "http://b"]); + } + + #[test] + fn clean_urls_accepts_a_borrowed_slice_too() { + // `run_node` cleans a borrowed URL list rather than consuming it; + // this pins that the generic bound covers that. + let urls = vec![" http://a ".to_string()]; + assert_eq!(clean_urls(&urls), vec!["http://a"]); + } + + #[tokio::test] + async fn try_urls_in_order_returns_the_first_success() { + let urls = vec!["a".to_string(), "b".to_string()]; + let result: Result<&str, &str> = try_urls_in_order( + &urls, + |url, _has_more| async move { if url == "a" { Err("nope") } else { Ok("yes") } }, + |_last_err| "exhausted", + ) + .await; + assert_eq!(result, Ok("yes")); + } + + #[tokio::test] + async fn an_empty_list_is_distinguished_from_an_exhausted_one() { + let empty: Vec = vec![]; + let empty_result: Result<&str, &str> = try_urls_in_order( + &empty, + |_url, _has_more| async { Err("unreachable") }, + |last_err| { + assert!(last_err.is_none(), "an empty list never attempts anything"); + "no urls configured" + }, + ) + .await; + assert_eq!(empty_result, Err("no urls configured")); + + let urls = vec!["a".to_string()]; + let exhausted_result: Result<&str, &str> = try_urls_in_order( + &urls, + |_url, _has_more| async { Err("boom") }, + |last_err| { + assert_eq!(last_err, Some("boom")); + "exhausted" + }, + ) + .await; + assert_eq!(exhausted_result, Err("exhausted")); + } + + #[tokio::test] + async fn has_more_is_false_only_on_the_last_url() { + let urls = vec!["a".to_string(), "b".to_string(), "c".to_string()]; + let mut seen = Vec::new(); + let _: Result<(), &str> = try_urls_in_order( + &urls, + |url, has_more| { + seen.push((url, has_more)); + async { Err("keep going") } + }, + |_| "exhausted", + ) + .await; + assert_eq!( + seen, + vec![ + ("a".to_string(), true), + ("b".to_string(), true), + ("c".to_string(), false), + ] + ); + } use ethlambda_types::block::BlockHeader; use ethlambda_types::checkpoint::Checkpoint; - use ethlambda_types::primitives::H256; - use ethlambda_types::state::{JustificationValidators, JustifiedSlots, StateConfig}; + use ethlambda_types::state::{JustificationValidators, JustifiedSlots, StateConfig, Validator}; use libssz_types::SszList; // Helper to create valid test state @@ -380,185 +874,118 @@ mod tests { } } - fn create_different_validator() -> Validator { - Validator { - attestation_pubkey: [2u8; 32], - proposal_pubkey: [22u8; 32], - index: 0, - } - } - - fn create_validators_with_indices(count: usize) -> Vec { - (0..count) - .map(|i| Validator { - attestation_pubkey: [i as u8 + 1; 32], - proposal_pubkey: [i as u8 + 101; 32], - index: i as u64, - }) - .collect() - } - #[test] fn verify_accepts_valid_state() { let validators = vec![create_test_validator()]; - let state = create_test_state(100, validators.clone(), 1000); - assert!(verify_checkpoint_state(&state, 1000, &validators).is_ok()); + let state = create_test_state(100, validators, 1000); + assert!(verify_checkpoint_state(&state).is_ok()); } #[test] fn verify_rejects_slot_zero() { let validators = vec![create_test_validator()]; - let state = create_test_state(0, validators.clone(), 1000); - assert!(verify_checkpoint_state(&state, 1000, &validators).is_err()); + let state = create_test_state(0, validators, 1000); + assert!(verify_checkpoint_state(&state).is_err()); } #[test] fn verify_rejects_empty_validators() { let state = create_test_state(100, vec![], 1000); - assert!(verify_checkpoint_state(&state, 1000, &[]).is_err()); - } - - #[test] - fn verify_rejects_genesis_time_mismatch() { - let validators = vec![create_test_validator()]; - let state = create_test_state(100, validators.clone(), 1000); - // State has genesis_time=1000, we pass expected=9999 - assert!(verify_checkpoint_state(&state, 9999, &validators).is_err()); - } - - #[test] - fn verify_rejects_validator_count_mismatch() { - let validators = vec![create_test_validator()]; - let state = create_test_state(100, validators.clone(), 1000); - let extra_validators = create_validators_with_indices(2); - assert!(verify_checkpoint_state(&state, 1000, &extra_validators).is_err()); - } - - #[test] - fn verify_accepts_multiple_validators_with_sequential_indices() { - let validators = create_validators_with_indices(3); - let state = create_test_state(100, validators.clone(), 1000); - assert!(verify_checkpoint_state(&state, 1000, &validators).is_ok()); - } - - #[test] - fn verify_rejects_non_sequential_validator_indices() { - let mut validators = create_validators_with_indices(3); - validators[1].index = 5; // Wrong index at position 1 - let state = create_test_state(100, validators.clone(), 1000); - let expected_validators = create_validators_with_indices(3); - assert!(verify_checkpoint_state(&state, 1000, &expected_validators).is_err()); - } - - #[test] - fn verify_rejects_duplicate_validator_indices() { - let mut validators = create_validators_with_indices(3); - validators[2].index = 0; // Duplicate index - let state = create_test_state(100, validators.clone(), 1000); - let expected_validators = create_validators_with_indices(3); - assert!(verify_checkpoint_state(&state, 1000, &expected_validators).is_err()); - } - - #[test] - fn verify_rejects_validator_pubkey_mismatch() { - let validators = vec![create_test_validator()]; - let state = create_test_state(100, validators.clone(), 1000); - let different_validators = vec![create_different_validator()]; - assert!(verify_checkpoint_state(&state, 1000, &different_validators).is_err()); + assert!(verify_checkpoint_state(&state).is_err()); } #[test] fn verify_rejects_finalized_after_state_slot() { let validators = vec![create_test_validator()]; - let mut state = create_test_state(100, validators.clone(), 1000); + let mut state = create_test_state(100, validators, 1000); state.latest_finalized.slot = 101; // Finalized after state slot - assert!(verify_checkpoint_state(&state, 1000, &validators).is_err()); + assert!(verify_checkpoint_state(&state).is_err()); } #[test] fn verify_rejects_justified_before_finalized() { let validators = vec![create_test_validator()]; - let mut state = create_test_state(100, validators.clone(), 1000); + let mut state = create_test_state(100, validators, 1000); state.latest_finalized.slot = 50; state.latest_justified.slot = 40; // Justified before finalized - assert!(verify_checkpoint_state(&state, 1000, &validators).is_err()); + assert!(verify_checkpoint_state(&state).is_err()); } #[test] fn verify_accepts_justified_equals_finalized_with_matching_roots() { use ethlambda_types::primitives::H256; let validators = vec![create_test_validator()]; - let mut state = create_test_state(100, validators.clone(), 1000); + let mut state = create_test_state(100, validators, 1000); let common_root = H256::from([42u8; 32]); state.latest_finalized.slot = 50; state.latest_finalized.root = common_root; state.latest_justified.slot = 50; // Same slot state.latest_justified.root = common_root; // Same root - assert!(verify_checkpoint_state(&state, 1000, &validators).is_ok()); + assert!(verify_checkpoint_state(&state).is_ok()); } #[test] fn verify_rejects_justified_equals_finalized_with_different_roots() { use ethlambda_types::primitives::H256; let validators = vec![create_test_validator()]; - let mut state = create_test_state(100, validators.clone(), 1000); + let mut state = create_test_state(100, validators, 1000); state.latest_finalized.slot = 50; state.latest_finalized.root = H256::from([1u8; 32]); state.latest_justified.slot = 50; // Same slot state.latest_justified.root = H256::from([2u8; 32]); // Different root - conflict! - assert!(verify_checkpoint_state(&state, 1000, &validators).is_err()); + assert!(verify_checkpoint_state(&state).is_err()); } #[test] fn verify_rejects_block_header_slot_exceeds_state() { let validators = vec![create_test_validator()]; - let mut state = create_test_state(100, validators.clone(), 1000); + let mut state = create_test_state(100, validators, 1000); state.latest_block_header.slot = 101; // Block header slot exceeds state slot - assert!(verify_checkpoint_state(&state, 1000, &validators).is_err()); + assert!(verify_checkpoint_state(&state).is_err()); } #[test] fn verify_accepts_block_header_matches_finalized_with_correct_root() { let validators = vec![create_test_validator()]; - let mut state = create_test_state(100, validators.clone(), 1000); + let mut state = create_test_state(100, validators, 1000); state.latest_block_header.slot = 50; let block_root = state.latest_block_header.hash_tree_root(); state.latest_finalized.slot = 50; state.latest_finalized.root = block_root; - assert!(verify_checkpoint_state(&state, 1000, &validators).is_ok()); + assert!(verify_checkpoint_state(&state).is_ok()); } #[test] fn verify_rejects_block_header_matches_finalized_with_wrong_root() { use ethlambda_types::primitives::H256; let validators = vec![create_test_validator()]; - let mut state = create_test_state(100, validators.clone(), 1000); + let mut state = create_test_state(100, validators, 1000); state.latest_block_header.slot = 50; state.latest_finalized.slot = 50; state.latest_finalized.root = H256::from([99u8; 32]); // Wrong root - assert!(verify_checkpoint_state(&state, 1000, &validators).is_err()); + assert!(verify_checkpoint_state(&state).is_err()); } #[test] fn verify_accepts_block_header_matches_justified_with_correct_root() { let validators = vec![create_test_validator()]; - let mut state = create_test_state(100, validators.clone(), 1000); + let mut state = create_test_state(100, validators, 1000); state.latest_block_header.slot = 90; let block_root = state.latest_block_header.hash_tree_root(); state.latest_justified.slot = 90; state.latest_justified.root = block_root; - assert!(verify_checkpoint_state(&state, 1000, &validators).is_ok()); + assert!(verify_checkpoint_state(&state).is_ok()); } #[test] fn verify_rejects_block_header_matches_justified_with_wrong_root() { use ethlambda_types::primitives::H256; let validators = vec![create_test_validator()]; - let mut state = create_test_state(100, validators.clone(), 1000); + let mut state = create_test_state(100, validators, 1000); state.latest_block_header.slot = 90; state.latest_justified.slot = 90; state.latest_justified.root = H256::from([99u8; 32]); // Wrong root - assert!(verify_checkpoint_state(&state, 1000, &validators).is_err()); + assert!(verify_checkpoint_state(&state).is_err()); } // --- normalize_base_url --- @@ -590,4 +1017,259 @@ mod tests { "http://peer:5052" ); } + + // --- beacon anchor verification --- + + /// A recent-looking anchor built from the mainnet genesis fixture. A real + /// mainnet state, and the one the identity check runs against, moved off + /// slot 0 so it is a legitimate checkpoint. + fn beacon_anchor_state() -> BeaconState { + let mut state = crate::beacon::mainnet_genesis_state().expect("the fixture decodes"); + *state.slot_mut() = 288; + state + } + + /// Slot 0 is never a legitimate checkpoint anchor, however well-formed the + /// state is otherwise. + #[test] + fn a_beacon_state_at_genesis_is_rejected_as_an_anchor() { + let state = crate::beacon::mainnet_genesis_state().unwrap(); + + assert!(matches!( + verify_beacon_checkpoint_state(&state), + Err(CheckpointSyncError::SlotIsZero) + )); + } + + #[test] + fn a_structurally_sound_beacon_anchor_passes() { + assert!(verify_beacon_checkpoint_state(&beacon_anchor_state()).is_ok()); + } + + /// An anchor with an empty validator registry is never legitimate, + /// however sound its checkpoints and header otherwise are. + #[test] + fn a_beacon_anchor_with_no_validators_is_rejected() { + let mut state = beacon_anchor_state(); + // `SszList`'s `DerefMut` target is a slice, which cannot shrink, so + // emptying the list means replacing it rather than mutating in place. + *state.validators_mut() = Default::default(); + + assert!(matches!( + verify_beacon_checkpoint_state(&state), + Err(CheckpointSyncError::NoValidators) + )); + } + + /// The finalized checkpoint can never name an epoch beyond the one the + /// state's own slot has reached. + #[test] + fn a_beacon_anchor_with_finalized_epoch_beyond_state_is_rejected() { + let mut state = beacon_anchor_state(); + let current_epoch = state.slot() / preset::SLOTS_PER_EPOCH; + state.finalized_checkpoint_mut().epoch = current_epoch + 1; + + assert!(matches!( + verify_beacon_checkpoint_state(&state), + Err(CheckpointSyncError::FinalizedExceedsStateSlot) + )); + } + + /// The justified checkpoint can never sit behind the finalized one. + /// `beacon_anchor_state`'s genesis-derived checkpoints both start at + /// epoch 0, so moving finalized ahead is the one mutation needed to put + /// justified behind it. + #[test] + fn a_beacon_anchor_with_justified_epoch_before_finalized_is_rejected() { + let mut state = beacon_anchor_state(); + state.finalized_checkpoint_mut().epoch = 1; + + assert!(matches!( + verify_beacon_checkpoint_state(&state), + Err(CheckpointSyncError::JustifiedPrecedesFinalized) + )); + } + + /// The header's own slot can never lead the state's slot. + #[test] + fn a_beacon_anchor_with_block_header_slot_beyond_state_is_rejected() { + let mut state = beacon_anchor_state(); + state.latest_block_header_mut().slot = state.slot() + 1; + + assert!(matches!( + verify_beacon_checkpoint_state(&state), + Err(CheckpointSyncError::BlockHeaderSlotExceedsState) + )); + } + + /// Structurally fine and still not ours: the identity check is what + /// separates the two. + #[test] + fn a_beacon_state_from_another_network_is_rejected() { + let genesis = crate::beacon::mainnet_genesis().unwrap(); + let state = beacon_anchor_state(); + + assert!(verify_beacon_checkpoint_state(&state).is_ok()); + assert!(matches!( + verify_state_genesis( + &state, + genesis.genesis_time + 1, + genesis.genesis_validators_root + ), + Err(GenesisMismatch::GenesisTime { .. }) + )); + } + + #[test] + fn a_beacon_state_of_this_network_is_accepted() { + let genesis = crate::beacon::mainnet_genesis().unwrap(); + let state = beacon_anchor_state(); + + assert_eq!( + verify_state_genesis( + &state, + genesis.genesis_time, + genesis.genesis_validators_root + ), + Ok(()) + ); + } + + // --- beacon anchor pairing --- + + use ethlambda_types::beacon::containers::{BeaconBlockHeader, altair, phase0}; + use ethlambda_types::beacon::fork::ForkName; + + /// A phase0 signed block with an empty body, for tests that only care + /// about `slot`/`parent_root`/`state_root`/`body_root`. Same shape as the + /// `block` helper in `ethlambda_state_transition`'s own `fork_choice` + /// test module. + fn phase0_block(slot: u64, parent_root: H256) -> SignedBeaconBlock { + SignedBeaconBlock::Phase0(phase0::SignedBeaconBlock { + message: phase0::BeaconBlock { + slot, + proposer_index: 0, + parent_root, + state_root: H256::ZERO, + body: phase0::BeaconBlockBody { + randao_reveal: Default::default(), + eth1_data: Default::default(), + graffiti: H256::ZERO, + proposer_slashings: Default::default(), + attester_slashings: Default::default(), + attestations: Default::default(), + deposits: Default::default(), + voluntary_exits: Default::default(), + }, + }, + signature: Default::default(), + }) + } + + /// An exact anchor pair, built from the mainnet genesis fixture: `state`'s + /// `latest_block_header` names `block` as a fixed point, the way the + /// state transition produces one. The header's `state_root` is left + /// zero, the way it sits inside the block's own slot; the state's root is + /// then computed against that and written back into the block, so the + /// header root and the block root agree once that zero is substituted. + fn beacon_anchor_pair() -> (BeaconState, SignedBeaconBlock) { + let mut state = beacon_anchor_state(); + let parent_root = state.latest_block_header().parent_root; + let mut signed = phase0_block(state.slot(), parent_root); + + let SignedBeaconBlock::Phase0(inner) = &signed else { + unreachable!("phase0_block builds a phase0 signed block"); + }; + *state.latest_block_header_mut() = BeaconBlockHeader { + slot: inner.message.slot, + proposer_index: inner.message.proposer_index, + parent_root, + state_root: H256::ZERO, + body_root: inner.message.body.hash_tree_root(), + }; + + let state_root = state.hash_tree_root(); + let SignedBeaconBlock::Phase0(inner) = &mut signed else { + unreachable!("phase0_block builds a phase0 signed block"); + }; + inner.message.state_root = state_root; + + (state, signed) + } + + /// Advance a state one empty slot by hand, the way `process_slot` does: + /// fill in the header's `state_root`, then move the slot on. The state is + /// then past its own anchor block, which is the shape a checkpoint-synced + /// anchor arrives in when the finalized epoch boundary was empty. + fn advance_one_empty_slot(state: &mut BeaconState) { + let root = state.hash_tree_root(); + state.latest_block_header_mut().state_root = root; + *state.slot_mut() += 1; + } + + #[test] + fn an_exact_anchor_pair_passes_pairing() { + let (state, block) = beacon_anchor_pair(); + assert!(verify_beacon_anchor_pairing(&state, &block).is_ok()); + } + + /// The whole point of the deviation from pairing on `block.state_root == + /// hash_tree_root(state)`: a `finalized` state that has advanced past its + /// own anchor block (the epoch boundary was empty) must still pass. + #[test] + fn a_state_advanced_past_its_block_still_passes_pairing() { + let (mut state, block) = beacon_anchor_pair(); + advance_one_empty_slot(&mut state); + + assert!(verify_beacon_anchor_pairing(&state, &block).is_ok()); + } + + #[test] + fn a_block_the_state_does_not_name_is_rejected() { + let (state, mut block) = beacon_anchor_pair(); + let SignedBeaconBlock::Phase0(inner) = &mut block else { + unreachable!("beacon_anchor_pair builds a phase0 signed block"); + }; + // Change the block without updating the header that is supposed to + // name it: the header still points at the original block. + inner.message.parent_root = H256::repeat_byte(0xAA); + + assert!(matches!( + verify_beacon_anchor_pairing(&state, &block), + Err(CheckpointSyncError::AnchorPairingMismatch) + )); + } + + #[test] + fn a_fork_mismatch_between_state_and_block_is_rejected() { + let (state, _) = beacon_anchor_pair(); + let altair_block = SignedBeaconBlock::Altair(altair::SignedBeaconBlock { + message: altair::BeaconBlock { + slot: state.slot(), + proposer_index: 0, + parent_root: H256::ZERO, + state_root: H256::ZERO, + body: altair::BeaconBlockBody { + randao_reveal: Default::default(), + eth1_data: Default::default(), + graffiti: H256::ZERO, + proposer_slashings: Default::default(), + attester_slashings: Default::default(), + attestations: Default::default(), + deposits: Default::default(), + voluntary_exits: Default::default(), + sync_aggregate: Default::default(), + }, + }, + signature: Default::default(), + }); + + assert!(matches!( + verify_beacon_anchor_pairing(&state, &altair_block), + Err(CheckpointSyncError::AnchorForkMismatch { + state: ForkName::Phase0, + block: ForkName::Altair, + }) + )); + } } diff --git a/bin/ethlambda/src/cli.rs b/bin/ethlambda/src/cli.rs index 7b799e515..45cbe0fa6 100644 --- a/bin/ethlambda/src/cli.rs +++ b/bin/ethlambda/src/cli.rs @@ -1,26 +1,28 @@ //! Command-line interface for the ethlambda binary. use ethlambda_p2p::discovery::DEFAULT_DISCOVERY_TARGET_PEERS; +use ethlambda_types::beacon::constants::SAFE_SLOTS_TO_IMPORT_OPTIMISTICALLY; use std::net::IpAddr; use std::path::PathBuf; +/// Flags every sub-command takes, with the same meaning on each. +/// +/// `--node-key`, `--bootnodes` and `--checkpoint-sync-url` used to be declared +/// once per sub-command because each was required on one and optional on the +/// other, and a flattened struct has one requiredness. They are optional on +/// both now, which is what lets one declaration serve both: each sub-command +/// decides what an absent value means, and says so on the field below. +/// +/// The `--discovery.*` tuning flags are here too, in one [`DiscoveryConfig`], +/// and mean the same thing on both whenever discv5 runs: on +/// [`DEFAULT_DISCOVERY_PORT`] unless `--discovery.port` says otherwise. Whether +/// it runs is not a common flag: always on `beacon`, and on `node` only with +/// `--discovery.enable` ([`LeanOptions::discovery_enable`]). See +/// [`Options::validate_ports`]. +/// +/// `--node-id` is still not here: it exists only on `node`. #[derive(Debug, clap::Args)] -pub(crate) struct NodeOptions { - /// Path to the chain genesis config (e.g., config.yaml). - #[arg(long)] - pub(crate) genesis: PathBuf, - /// Path to the validator registry (e.g., annotated_validators.yaml). - #[arg(long)] - pub(crate) validators: PathBuf, - /// Path to the bootnode list (e.g., nodes.yaml). - #[arg(long)] - pub(crate) bootnodes: PathBuf, - /// Path to validator-config.yaml (validator name registry for metrics labels). - #[arg(long)] - pub(crate) validator_config: PathBuf, - /// Directory containing per-validator XMSS keys (e.g., hash-sig-keys/). - #[arg(long)] - pub(crate) hash_sig_keys_dir: PathBuf, +pub(crate) struct CommonOptions { /// Port for the libp2p listeners: UDP for QUIC and TCP for the noise+yamux /// fallback, both on this same number. /// @@ -28,8 +30,8 @@ pub(crate) struct NodeOptions { /// still differ from every other port the node binds: `--discovery.port` /// (also UDP), and `--api-port`/`--metrics-port` (also TCP). /// - /// Defaults one above `--discovery.port` so that `--discovery.enable` works - /// on its own. + /// Defaults one above the discv5 port, since both are UDP sockets and + /// cannot share a port. #[arg(long, default_value = "9001")] pub(crate) gossipsub_port: u16, #[arg(long, default_value = "127.0.0.1")] @@ -38,31 +40,292 @@ pub(crate) struct NodeOptions { pub(crate) api_port: u16, #[arg(long, default_value = "5054")] pub(crate) metrics_port: u16, + /// Directory for RocksDB storage + #[arg(long, default_value = "./data")] + pub(crate) data_dir: PathBuf, + /// Hex file holding the secp256k1 key that is this node's libp2p and + /// discv5 identity. + /// + /// Optional on both sub-commands. When omitted, a fresh key is generated + /// in memory at startup (logged as a warning) and used for that run only, + /// so the PeerId and ENR differ on the next start. Pass a persisted key + /// file for a stable identity. #[arg(long)] - pub(crate) node_key: PathBuf, - /// The node ID to look up in annotated_validators.yaml (e.g., "ethlambda_0") + pub(crate) node_key: Option, + /// Path to a bootnode list: ENRs, one per YAML entry. + /// + /// Optional on both sub-commands, but an absent file means different + /// things. `beacon` falls back to whatever `--network` resolved to: a + /// built-in network's own ENR list, or a loaded directory's own + /// `bootstrap_nodes.yaml`/`.txt` (empty if the directory has neither). + /// `node` has no built-in list for a lean network, so it starts with no + /// bootnodes and reaches peers only through discv5, if that is enabled. #[arg(long)] - pub(crate) node_id: String, - /// Base URL(s) of checkpoint-sync peer API servers (e.g., http://peer:5052). - /// When set, fetches the finalized state and block from each peer's - /// `/lean/v0/states/finalized` and `/lean/v0/blocks/finalized` endpoints. - /// For backward compatibility, a URL ending in - /// `/lean/v0/states/finalized` is accepted and the trailing path is - /// stripped. - /// - /// This is a fallback, not a precedence: state already in the data - /// directory always wins, so these URLs are only used when there is no - /// resumable state on disk (or it has fallen too far behind the current - /// slot). With neither resumable state nor URLs, the node starts from - /// genesis. + pub(crate) bootnodes: Option, + /// Base URL(s) of the peer API servers to take a checkpoint from, e.g. + /// `http://peer:5052`. /// /// Multiple URLs may be supplied for redundancy, either comma-separated /// (`--checkpoint-sync-url u1,u2`) or by repeating the flag /// (`--checkpoint-sync-url u1 --checkpoint-sync-url u2`). URLs are tried /// in order; the first one that succeeds is used and any failures fall - /// over to the next URL. Startup only aborts if every URL fails. + /// over to the next. Startup only aborts if every URL fails. + /// + /// On `node` this reads each peer's `/lean/v0/states/finalized` and + /// `/lean/v0/blocks/finalized`, and is a fallback rather than a + /// precedence: state already in the data directory always wins, so these + /// URLs are only used when there is no resumable state on disk (or it has + /// fallen too far behind the current slot). With neither resumable state + /// nor URLs, the node starts from genesis. For backward compatibility a + /// URL ending in `/lean/v0/states/finalized` is accepted and the trailing + /// path is stripped. + /// + /// On `beacon` this reads each peer's standard Beacon API, + /// `/eth/v2/debug/beacon/states/finalized` and `/eth/v2/beacon/blocks/{slot}`, + /// with the same resumable-directory precedence `node` uses, plus one more + /// fallback below it. A network loaded with `--network` (a directory of + /// published files) anchors at its own `genesis.ssz` when there is + /// neither a resumable directory nor a URL, since a freshly started devnet + /// has no checkpoint provider at slot 0 and this is the only way to join + /// one. The built-in networks (`mainnet`, `sepolia`, `hoodi`) still refuse + /// that fallback: each has been live for years, and this follower imports + /// nothing at startup, so anchoring there would park it at slot 0 while + /// claiming to follow a live chain. With neither a resumable directory, a + /// URL, nor a loaded network's genesis, startup aborts. #[arg(long, value_delimiter = ',')] pub(crate) checkpoint_sync_url: Vec, + #[command(flatten)] + pub(crate) discovery: DiscoveryConfig, +} + +/// Which chain this process follows, and the flags only that chain takes. +/// +/// Lean's payload lives in the variant rather than beside it: `--genesis` and +/// the validator flags mean nothing to a beacon follower, so holding them in +/// one flat struct would make them `Option` and leave `run_node` unwrapping +/// what the tag promised was there. +#[derive(Debug)] +pub(crate) enum Network { + /// The lean consensus chain this repo implements: `ethlambda node`. + Lean(Box), + /// The Ethereum Beacon Chain: `ethlambda beacon`. + /// + /// Carries this chain's own flags and `execution`, the execution client + /// its payloads are validated against. Everything else it needs is a + /// common flag. + Mainnet { + mainnet: MainnetOptions, + execution: Option, + }, +} + +/// The execution client pairing, present only when both flags were supplied. +/// +/// A struct rather than two `Option`s on the variant, so "endpoint without +/// secret" cannot be represented past this point: `From` is +/// where the pair is checked, once. +#[derive(Debug)] +pub(crate) struct ExecutionOptions { + pub(crate) endpoint: String, + pub(crate) jwt_secret: PathBuf, +} + +/// Everything [`crate::run_node`] needs, for either chain. +/// +/// One entry point takes this, so the startup steps that are not +/// chain-specific happen once and in one order: metrics registration, the +/// version banner, the file-descriptor limit, the node key, the HTTP server +/// and the shutdown sequence. +#[derive(Debug)] +pub(crate) struct Options { + pub(crate) common: CommonOptions, + pub(crate) network: Network, +} + +impl Network { + /// Whether this chain runs discv5. + /// + /// Always on `beacon`, which never had the choice: published mainnet + /// bootnode ENRs carry no `quic` entry, so a crawl is its only way to reach + /// a peer. Opt-in on `node`, behind `--discovery.enable`: nothing else on + /// a lean network speaks discv5 yet, so a crawl there finds only other + /// ethlambda nodes, and every co-located devnet node would otherwise bind + /// the same default UDP port. + pub(crate) fn discovery_enabled(&self) -> bool { + match self { + Network::Lean(lean) => lean.discovery_enable, + Network::Mainnet { .. } => true, + } + } +} + +impl Options { + /// Reject port assignments that cannot all bind, before anything binds. + /// + /// Called once at the top of `run_node`, for either chain, so a port that + /// cannot work aborts before the metrics registry, the file-descriptor + /// limit and the data directory are touched. Here rather than on + /// [`CommonOptions`] because half the rules depend on whether discv5 runs, + /// which is a chain's own answer. + pub(crate) fn validate_ports(&self) -> eyre::Result<()> { + self.common.validate_ports(self.network.discovery_enabled()) + } +} + +impl From for Options { + fn from(options: NodeOptions) -> Self { + Options { + common: options.common, + network: Network::Lean(Box::new(options.lean)), + } + } +} + +impl From for Options { + fn from(options: BeaconOptions) -> Self { + let execution = match (options.execution_endpoint, options.execution_jwt_secret) { + (Some(endpoint), Some(jwt_secret)) => Some(ExecutionOptions { + endpoint, + jwt_secret, + }), + (None, None) => None, + // Unreachable: `requires` on each of the two flags is what rules a + // half-configured Engine API out, so the parser answers a mismatch + // with a usage error long before this conversion runs. + // `cli::tests::execution_flags_come_as_a_pair` pins that. + _ => unreachable!( + "clap's `requires` rejects --execution-endpoint without \ + --execution-jwt-secret, and the reverse" + ), + }; + + Options { + common: options.common, + network: Network::Mainnet { + mainnet: options.mainnet, + execution, + }, + } + } +} + +/// The `node` sub-command's argv: the common flags plus lean's own. +/// +/// The `node` sub-command owns the top-level `Parser` attributes, so this is a +/// plain `Args`: see `crate::command`. It exists to be parsed and then +/// converted into [`Options`]; nothing reads it directly. +#[derive(Debug, clap::Args)] +pub(crate) struct NodeOptions { + #[command(flatten)] + pub(crate) common: CommonOptions, + #[command(flatten)] + pub(crate) lean: LeanOptions, +} + +/// The `beacon` sub-command's argv: the common flags plus this chain's own. +/// +/// Mirrors [`NodeOptions`], which is the point: a flag that means nothing to +/// the other chain lives in that chain's struct, so neither `run_node` arm has +/// to unwrap an `Option` the tag already promised was there. Beyond +/// [`MainnetOptions`], it carries the execution client pairing +/// (`--execution-endpoint` and `--execution-jwt-secret`, which are given +/// together or not at all), whose checked form is what `Network::Mainnet` +/// holds. +#[derive(Debug, clap::Args)] +pub(crate) struct BeaconOptions { + #[command(flatten)] + pub(crate) common: CommonOptions, + #[command(flatten)] + pub(crate) mainnet: MainnetOptions, + + /// Base URL of the execution client's Engine API endpoint, e.g. + /// `http://127.0.0.1:8551`. + /// + /// Paired with `--execution-jwt-secret`: supplying one without the other is + /// a usage error, since an Engine API endpoint always requires + /// authentication. With neither, this node imports blocks without asking + /// any execution client about their payloads, which is what it did before + /// this flag existed. + #[arg(long, requires = "execution_jwt_secret")] + pub(crate) execution_endpoint: Option, + + /// File holding the 32-byte hex JWT secret shared with the execution + /// client. + #[arg(long, requires = "execution_endpoint")] + pub(crate) execution_jwt_secret: Option, +} + +/// Flags only the beacon chain takes. +#[derive(Debug, clap::Args)] +pub(crate) struct MainnetOptions { + /// How many custody groups this node custodies, advertised as the ENR's + /// `cgc` and used to size its own custody set. + /// + /// Raising it makes this node useful to more peers and costs it storage and + /// bandwidth in proportion: a node at `NUMBER_OF_CUSTODY_GROUPS` is a + /// supernode, custodying every column. Lowering it is not possible below + /// `CUSTODY_REQUIREMENT`, which the specification makes the floor every + /// node must meet. + /// + /// Note this is not the number of columns custodied. That is + /// `sampling_size`, the larger of this and `SAMPLES_PER_SLOT`, so the + /// default of `CUSTODY_REQUIREMENT` still custodies `SAMPLES_PER_SLOT` + /// columns; the two only coincide once this is raised past that floor. + /// + /// Changing it changes which columns this node custodies, so sidecars + /// already on disk belong to the old set. Nothing is corrupted by that, + /// but the node serves a set it has not finished filling until it has + /// backfilled the difference. + #[arg( + long = "custody-group-count", + default_value_t = ethlambda_types::beacon::constants::CUSTODY_REQUIREMENT, + value_parser = clap::value_parser!(u64).range( + ethlambda_types::beacon::constants::CUSTODY_REQUIREMENT + ..=ethlambda_types::beacon::constants::NUMBER_OF_CUSTODY_GROUPS + ), + )] + pub(crate) custody_group_count: u64, + + /// How far behind the wall clock a block must be before it may be imported + /// optimistically on age alone. + /// + /// `optimistic-sync.md` requires this to be operator-configurable, for + /// manual recovery from a poisoned fork choice store. It only ever gates a + /// merge transition block, which a mainnet follower anchored past the merge + /// never sees. + #[arg(long, default_value_t = SAFE_SLOTS_TO_IMPORT_OPTIMISTICALLY)] + pub(crate) safe_slots_to_import_optimistically: u64, + + /// Which network to follow: a built-in name (`mainnet`, `sepolia` or + /// `hoodi`), or a path to a directory of published network files. + /// + /// A value containing a slash is always read as a directory, so `mainnet` + /// names the built-in network and `./mainnet` names a directory. The + /// directory must hold `config.yaml` and `genesis.ssz`, and may hold + /// `bootstrap_nodes.yaml` or `bootstrap_nodes.txt`; this is the layout + /// `eth-clients` publishes and kurtosis mounts at `/network-configs`. + #[arg(long, default_value = crate::network::DEFAULT_NETWORK)] + pub(crate) network: String, +} + +/// Flags only the lean chain takes. +#[derive(Debug, clap::Args)] +pub(crate) struct LeanOptions { + /// Path to the chain genesis config (e.g., config.yaml). + #[arg(long)] + pub(crate) genesis: PathBuf, + /// Path to the validator registry (e.g., annotated_validators.yaml). + #[arg(long)] + pub(crate) validators: PathBuf, + /// Path to validator-config.yaml (validator name registry for metrics labels). + #[arg(long)] + pub(crate) validator_config: PathBuf, + /// Directory containing per-validator XMSS keys (e.g., hash-sig-keys/). + #[arg(long)] + pub(crate) hash_sig_keys_dir: PathBuf, + /// The node ID to look up in annotated_validators.yaml (e.g., "ethlambda_0") + #[arg(long)] + pub(crate) node_id: String, /// Whether this node acts as a committee aggregator. /// /// Seeds the initial value of the live aggregator flag shared by the @@ -74,6 +337,16 @@ pub(crate) struct NodeOptions { /// use the admin endpoint to rotate duties (hot-standby model). #[arg(long, default_value = "false")] pub(crate) is_aggregator: bool, + /// Run discv5 peer discovery. + /// + /// Off by default, so the node peers from `--bootnodes` alone and binds no + /// discovery socket: nothing else on a lean network speaks discv5 yet, and + /// co-located devnet nodes would otherwise all claim the default + /// `--discovery.port`. Without it the other `--discovery.*` flags are + /// accepted and ignored. `beacon` has no such flag, since discovery is + /// always on there. + #[arg(long = "discovery.enable", default_value = "false")] + pub(crate) discovery_enable: bool, /// Number of attestation committees (subnets) per slot. /// /// If unset, falls back to `config.attestation_committee_count` from @@ -122,9 +395,6 @@ pub(crate) struct NodeOptions { /// node that is down or late. #[arg(long, default_value = "false", requires = "is_aggregator")] pub(crate) skip_redundant_aggregation: bool, - /// Directory for RocksDB storage - #[arg(long, default_value = "./data")] - pub(crate) data_dir: PathBuf, /// Disable the sync-gate's suppression of validator duties. /// /// By default a node that judges itself to be syncing (local head lagging @@ -170,8 +440,6 @@ pub(crate) struct NodeOptions { /// `on_block`. #[arg(long, default_value = "3")] pub(crate) max_attestations_per_block: usize, - #[command(flatten)] - pub(crate) discovery: DiscoveryConfig, /// Shadow-simulator sim-cost + fake-XMSS flags (only under the /// `shadow-integration` feature). #[cfg(feature = "shadow-integration")] @@ -179,22 +447,18 @@ pub(crate) struct NodeOptions { pub(crate) shadow: ShadowOptions, } -/// discv5 peer discovery. Off by default: nothing else on the lean network -/// speaks discv5 yet, so enabling it only finds other ethlambda nodes. +/// The discv5 peer-discovery flags, taken by both chains and meaning the same +/// thing on each whenever discv5 runs. +/// +/// The on switch is not here: see [`Network::discovery_enabled`]. `beacon` +/// always runs discovery and so has no switch at all, and a single flattened +/// struct can carry only one default, so `--discovery.enable` lives in +/// [`LeanOptions`]. #[derive(Debug, clap::Args)] pub(crate) struct DiscoveryConfig { - /// Enable discv5 peer discovery. - /// - /// Works with the default ports. If either is overridden, `--discovery.port` - /// must still differ from `--gossipsub-port`: both are UDP sockets and they - /// cannot share one port. - #[arg(long = "discovery.enable", default_value = "false")] - pub(crate) enable: bool, - /// UDP port for the discv5 socket. - /// - /// Independent of `--gossipsub-port`, which carries libp2p QUIC and - /// defaults one port above this one. - #[arg(long = "discovery.port", default_value = "9000")] + /// UDP port for the discv5 socket. Must differ from `--gossipsub-port`: + /// both bind UDP and cannot share a port. + #[arg(long = "discovery.port", default_value_t = DEFAULT_DISCOVERY_PORT)] pub(crate) port: u16, /// IP address to advertise in the ENR. /// @@ -203,64 +467,79 @@ pub(crate) struct DiscoveryConfig { /// node on: `127.0.0.1` for a local devnet, or the host's public address. /// discv5's PONG-based IP voting may still replace it at runtime. #[arg(long = "discovery.advertise-ip")] - pub(crate) advertise_ip: Option, - /// Connected-peer count above which discovery stops dialing. + pub(crate) advertise_ip: Option, + /// How many peers this node holds. /// - /// Governs the dial loop only, not discv5's own lookup pacing. The loop - /// keeps ticking either way and resumes dialing as soon as the connected - /// count drops back below this, so 0 means "discover and serve, never - /// dial". + /// The dial loop stops above it and resumes as soon as the connected count + /// drops back below, and on `beacon` it is also what the swarm's own + /// connection limits are derived from: the ceiling itself, the share of it + /// inbound demand may hold, and the rest, reserved for peers this node + /// dials. One number, so a reservation the swarm does not keep is never one + /// the dial loop chases. See `beacon::swarm::max_connections`. + /// + /// 0 therefore means "hold no peers", dialing none and admitting none, not + /// "serve without dialing". Discv5's own lookup pacing is unaffected either + /// way: the loop keeps ticking and the crawl keeps running. #[arg(long = "discovery.target-peers", default_value_t = DEFAULT_DISCOVERY_TARGET_PEERS)] pub(crate) target_peers: usize, } -impl NodeOptions { - /// Reject port assignments that cannot all bind, before anything binds. +/// The discv5 port a node running discovery binds when `--discovery.port` is +/// absent. +/// +/// A fixed number rather than one derived from `--gossipsub-port`: devnet +/// configuration has passed this pair explicitly since discv5 landed, and +/// changing what an absent flag means would move a running network's socket. +pub(crate) const DEFAULT_DISCOVERY_PORT: u16 = 9000; + +impl CommonOptions { + /// The rules behind [`Options::validate_ports`], given whether discv5 runs. /// /// There are two clashes to catch, on two protocols. `--discovery.port` and - /// `--gossipsub-port` are both UDP. `--gossipsub-port` also binds TCP for - /// the noise+yamux listener, which puts it in the same namespace as the - /// HTTP servers: sharing that number with `--api-port` was legal while the - /// swarm bound UDP only, and is now a real collision. Without these checks - /// either surfaces at bind time as an opaque `EADDRINUSE` on whichever - /// socket loses the race. + /// `--gossipsub-port` are both UDP, which matters only while discovery + /// binds its socket. `--gossipsub-port` also binds TCP for the noise+yamux + /// listener, which puts it in the same namespace as the HTTP servers: + /// sharing that number with `--api-port` was legal while the swarm bound + /// UDP only, and is now a real collision. Without these checks either + /// surfaces at bind time as an opaque `EADDRINUSE` on whichever socket + /// loses the race. /// /// Every comparison skips `0`, which is not a port but a request for one: /// two `0` binds always land on different OS-assigned ports and can never /// collide. Rejecting a pair of them would refuse the setup that exists to /// avoid collisions, which test harnesses and several-nodes-per-host runs - /// rely on. - pub(crate) fn validate_ports(&self) -> eyre::Result<()> { - if self.discovery.enable - && self.gossipsub_port != 0 - && self.discovery.port == self.gossipsub_port - { - eyre::bail!( - "--discovery.port ({}) must differ from --gossipsub-port ({}): \ - both bind UDP and cannot share a port", - self.discovery.port, - self.gossipsub_port - ); - } - // A discovery-enabled node publishes `quic` and `tcp` entries naming - // this port. Port 0 asks the OS to pick, so the two listeners land on - // different real ports and the record advertises neither of them: a - // peer reading it finds nothing dialable. - if self.discovery.enable && self.gossipsub_port == 0 { - eyre::bail!( - "--gossipsub-port 0 cannot be used with --discovery.enable: the \ - advertised ENR would name port 0, which no peer can dial" - ); + /// rely on. With discovery on, `--gossipsub-port 0` is rejected on its own + /// grounds instead, so the UDP comparison never sees one. + fn validate_ports(&self, discovery_enabled: bool) -> eyre::Result<()> { + let gossipsub_port = self.gossipsub_port; + if discovery_enabled { + // The ENR names this port. Port 0 asks the OS to pick, so the two + // listeners land on different real ports and the record advertises + // neither of them: a peer reading it finds nothing dialable. + if gossipsub_port == 0 { + eyre::bail!( + "--gossipsub-port 0 cannot be used while discv5 runs: it \ + publishes an ENR naming that port, which no peer can dial" + ); + } + let discovery_port = self.discovery.port; + if discovery_port == gossipsub_port { + eyre::bail!( + "--discovery.port ({discovery_port}) must differ from \ + --gossipsub-port ({gossipsub_port}): both bind UDP and \ + cannot share a port" + ); + } } for (flag, port) in [ ("--api-port", self.api_port), ("--metrics-port", self.metrics_port), ] { - if self.gossipsub_port != 0 && port == self.gossipsub_port { + if gossipsub_port != 0 && port == gossipsub_port { eyre::bail!( - "{flag} ({port}) must differ from --gossipsub-port ({}): the \ - libp2p swarm binds TCP on that port as well as UDP", - self.gossipsub_port + "{flag} ({port}) must differ from --gossipsub-port \ + ({gossipsub_port}): the libp2p swarm binds TCP on that \ + port as well as UDP" ); } } @@ -310,12 +589,10 @@ mod tests { use super::*; use crate::command::{Command, try_parse_from}; - /// The required flags, so a test can vary only what it cares about. - /// - /// `NodeOptions` is a `clap::Args` group rather than a parser of its own, - /// so this parses through the real dispatch, as the binary does. - fn parse(extra: &[&str]) -> NodeOptions { - let mut argv = vec![ + /// The smallest argv that satisfies every required `node` flag, with no + /// subcommand: exactly the shape every existing caller uses. + fn base_args() -> Vec<&'static str> { + vec![ "ethlambda", "--genesis", "config.yaml", @@ -331,51 +608,132 @@ mod tests { "node.key", "--node-id", "ethlambda_0", - ]; + ] + } + + /// The required flags, so a test can vary only what it cares about. + fn parse(extra: &[&str]) -> NodeOptions { + let mut argv = base_args(); argv.extend_from_slice(extra); - match try_parse_from(argv).expect("node options parse") { + parse_node(argv) + } + + /// Parse a bare-flag argv exactly the way `main` does, through the real + /// dispatch: `NodeOptions` is a `clap::Args` group, not a parser of its own. + fn parse_node(args: Vec<&str>) -> NodeOptions { + match try_parse_from(args).expect("node options parse") { Command::Node(options) => options, - other => panic!("expected a node invocation, got {other:?}"), + other => panic!("bare flags must resolve to the node subcommand, got {other:?}"), } } - /// `--discovery.enable` on its own has to work: a default that is never - /// valid makes the flag a guaranteed startup failure. + /// Validate a `node` argv's ports the way `run_node` does: through + /// [`Options`], so the chain's own answer on discovery decides which rules + /// apply. + fn node_ports(extra: &[&str]) -> eyre::Result<()> { + Options::from(parse(extra)).validate_ports() + } + + /// [`node_ports`] for a `beacon` argv. + fn beacon_ports(args: Vec<&str>) -> eyre::Result<()> { + Options::from(parse_beacon(args)).validate_ports() + } + + #[test] + fn discovery_is_opt_in_on_node_and_always_on_on_beacon() { + assert!(!Options::from(parse(&[])).network.discovery_enabled()); + assert!( + Options::from(parse(&["--discovery.enable"])) + .network + .discovery_enabled() + ); + assert!( + Options::from(parse_beacon(beacon_args())) + .network + .discovery_enabled() + ); + } + + /// `beacon` cannot turn discovery off, so it has no switch to accept: the + /// flag is a lean one and is a usage error here, not silently ignored. + #[test] + fn beacon_has_no_discovery_enable_flag() { + let mut args = beacon_args(); + args.push("--discovery.enable"); + let err = try_parse_from(args).expect_err("--discovery.enable is lean-only"); + assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument); + } + + /// The two defaults have to work together wherever discovery runs: a + /// default discovery port equal to the default gossip port would make every + /// out-of-the-box `beacon` run, and every `node --discovery.enable` one, + /// fail the collision check. + #[test] + fn the_default_ports_do_not_collide_on_either_chain() { + let node = parse(&["--discovery.enable"]); + assert_eq!(node.common.discovery.port, DEFAULT_DISCOVERY_PORT); + assert_ne!(DEFAULT_DISCOVERY_PORT, node.common.gossipsub_port); + assert!(node_ports(&["--discovery.enable"]).is_ok()); + + let beacon = parse_beacon(beacon_args()); + assert_eq!(beacon.common.discovery.port, DEFAULT_DISCOVERY_PORT); + assert_ne!(DEFAULT_DISCOVERY_PORT, beacon.common.gossipsub_port); + assert!(beacon_ports(beacon_args()).is_ok()); + } + + /// Both sockets are UDP, so a clash cannot be left to bind time, where it + /// surfaces as an opaque `EADDRINUSE` on whichever one loses the race. #[test] - fn default_ports_let_discovery_be_enabled_alone() { - let options = parse(&["--discovery.enable"]); + fn a_discovery_port_equal_to_the_gossipsub_port_is_rejected() { + // Reached from either direction: move the gossip port onto the + // discovery default, or the discovery port onto the gossip default. + assert!(node_ports(&["--discovery.enable", "--gossipsub-port", "9000"]).is_err()); + let mut args = beacon_args(); + args.extend(["--discovery.port", "9001"]); + assert!(beacon_ports(args).is_err()); + } - assert_ne!(options.discovery.port, options.gossipsub_port); - assert!(options.validate_ports().is_ok()); + /// Without discovery nothing binds `--discovery.port`, so it cannot clash + /// with anything. This is what lets co-located devnet nodes share a host + /// without each passing its own `--discovery.port`. + #[test] + fn without_discovery_the_discovery_port_is_not_checked() { + assert!(node_ports(&["--gossipsub-port", "9000"]).is_ok()); } #[test] - fn colliding_udp_ports_are_rejected_only_when_discovery_is_enabled() { - let ports = ["--gossipsub-port", "9000", "--discovery.port", "9000"]; - let mut enabled = ports.to_vec(); - enabled.push("--discovery.enable"); + fn an_explicit_discovery_port_wins_on_both_chains() { + let node = parse(&["--discovery.enable", "--discovery.port", "9100"]); + assert_eq!(node.common.discovery.port, 9100); + assert!(node_ports(&["--discovery.enable", "--discovery.port", "9100"]).is_ok()); - assert!(parse(&ports).validate_ports().is_ok()); - assert!(parse(&enabled).validate_ports().is_err()); + let mut args = beacon_args(); + args.extend(["--discovery.port", "9100"]); + let beacon = parse_beacon(args.clone()); + assert_eq!(beacon.common.discovery.port, 9100); + assert!(beacon_ports(args).is_ok()); } - /// Unlike the UDP clash above, this one does not depend on discovery: the - /// swarm binds TCP either way, so an HTTP port sharing the number always - /// loses one of the two listeners. + /// Unlike the UDP clash above, this one has nothing to do with discovery: + /// the swarm binds TCP either way, so an HTTP port sharing the number + /// always loses one of the two listeners. #[test] fn an_http_port_sharing_the_gossipsub_port_is_rejected() { // A port no default claims, so only the flag under test collides and // the message can be checked for naming it. const SHARED: &str = "9100"; - for flag in ["--api-port", "--metrics-port"] { - let err = parse(&["--gossipsub-port", SHARED, flag, SHARED]) - .validate_ports() - .expect_err("a TCP clash with an HTTP port must be rejected"); - assert!( - err.to_string().contains(flag), - "the message must name the offending flag, got: {err}" - ); + for discovery in [&[][..], &["--discovery.enable"][..]] { + for flag in ["--api-port", "--metrics-port"] { + let mut args = vec!["--gossipsub-port", SHARED, flag, SHARED]; + args.extend_from_slice(discovery); + let err = + node_ports(&args).expect_err("a TCP clash with an HTTP port must be rejected"); + assert!( + err.to_string().contains(flag), + "the message must name the offending flag, got: {err}" + ); + } } } @@ -384,49 +742,304 @@ mod tests { /// must not sweep that up. #[test] fn api_and_metrics_may_share_a_port() { - let options = parse(&["--api-port", "5052", "--metrics-port", "5052"]); - - assert!(options.validate_ports().is_ok()); + assert!(node_ports(&["--api-port", "5052", "--metrics-port", "5052"]).is_ok()); } /// Port 0 leaves the two listeners on different OS-assigned ports, so the - /// one number the ENR publishes describes neither. + /// one number the ENR publishes describes neither. Harmful only where an + /// ENR is published, which is wherever discovery runs. #[test] - fn gossipsub_port_zero_is_rejected_with_discovery() { - assert!(parse(&["--gossipsub-port", "0"]).validate_ports().is_ok()); - assert!( - parse(&["--gossipsub-port", "0", "--discovery.enable"]) - .validate_ports() - .is_err() - ); + fn gossipsub_port_zero_is_rejected_while_discovery_runs() { + let mut args = beacon_args(); + args.extend(["--gossipsub-port", "0"]); + for result in [ + node_ports(&["--discovery.enable", "--gossipsub-port", "0"]), + beacon_ports(args), + ] { + let err = result.expect_err("gossipsub port 0 cannot be published in an ENR"); + assert!( + !err.to_string().contains("must differ"), + "0 must be rejected on its own grounds, not as a clash, got: {err}" + ); + } } - /// Two `0`s are two OS-assigned ports, so the equality checks must not read - /// them as a clash: without discovery, `--gossipsub-port 0` is a supported - /// configuration (the test above pins that), and it stays supported when an - /// HTTP port asks the OS to pick as well. + /// Without discovery no ENR is published, so an OS-picked gossip port has + /// no record to contradict. + #[test] + fn gossipsub_port_zero_is_accepted_without_discovery() { + assert!(node_ports(&["--gossipsub-port", "0"]).is_ok()); + } + + /// Two `0`s are two OS-assigned ports, so the TCP equality checks must not + /// read them as a clash: an HTTP port asking the OS to pick is a supported + /// configuration. With discovery on, `--gossipsub-port 0` still fails, but + /// on the ENR grounds above rather than as a collision that is not there. #[test] fn port_zero_never_counts_as_a_clash() { - for flag in ["--api-port", "--metrics-port", "--discovery.port"] { - let mut argv = vec!["--gossipsub-port", "0", flag, "0"]; - if flag == "--discovery.port" { - argv.push("--discovery.enable"); - // Discovery rejects a `0` gossipsub port on its own grounds: - // the ENR would publish a port no peer can dial. What must not - // happen is the pair being rejected as a collision. - let err = parse(&argv) - .validate_ports() - .expect_err("gossipsub port 0 stays invalid under discovery"); - assert!( - !err.to_string().contains("must differ"), - "0 == 0 must not be reported as a clash, got: {err}" - ); - continue; - } + for flag in ["--api-port", "--metrics-port"] { + assert!( + node_ports(&["--gossipsub-port", "0", flag, "0"]).is_ok(), + "0 == 0 must not be reported as a clash" + ); + let err = node_ports(&["--discovery.enable", "--gossipsub-port", "0", flag, "0"]) + .expect_err("gossipsub port 0 stays invalid while discv5 runs"); assert!( - parse(&argv).validate_ports().is_ok(), - "{flag} 0 alongside --gossipsub-port 0 must be accepted" + !err.to_string().contains("must differ"), + "0 == 0 must not be reported as a clash, got: {err}" ); } + assert!( + node_ports(&["--api-port", "0", "--metrics-port", "0"]).is_ok(), + "HTTP ports asking the OS to pick must be accepted" + ); + } + + #[test] + fn bare_flags_parse_as_the_node_subcommand() { + let options = parse_node(base_args()); + assert_eq!(options.lean.genesis, PathBuf::from("config.yaml")); + assert_eq!(options.lean.node_id, "ethlambda_0"); + assert_eq!(options.common.api_port, 5052); + } + + #[test] + fn an_explicit_node_subcommand_parses_the_same_flags() { + let mut args = vec!["ethlambda", "node"]; + args.extend(base_args().into_iter().skip(1)); + let Command::Node(options) = try_parse_from(args).expect("`node` parses") else { + panic!("`node` must resolve to the node subcommand"); + }; + assert_eq!(options.lean.genesis, PathBuf::from("config.yaml")); + assert_eq!(options.lean.node_id, "ethlambda_0"); + } + + #[test] + fn the_beacon_subcommand_parses() { + let Command::Beacon(options) = try_parse_from([ + "ethlambda", + "beacon", + "--node-key", + "node.key", + "--checkpoint-sync-url", + "https://checkpointz.example", + ]) + .expect("`beacon` parses") else { + panic!("`beacon` must resolve to the beacon subcommand"); + }; + assert_eq!( + options.common.checkpoint_sync_url, + ["https://checkpointz.example"] + ); + assert_eq!(options.common.node_key, Some(PathBuf::from("node.key"))); + // No flag given, so the built-in mainnet ENR list applies. + assert_eq!(options.common.bootnodes, None); + } + + /// Drop `flag` and the value after it from an argv. + fn without(args: &[&'static str], flag: &str) -> Vec<&'static str> { + let at = args + .iter() + .position(|arg| *arg == flag) + .unwrap_or_else(|| panic!("{flag} is in the argv")); + let mut args = args.to_vec(); + args.drain(at..=at + 1); + args + } + + /// The three flags that moved into [`CommonOptions`] are optional on both + /// sub-commands, so clap accepts an argv carrying none of them. What an + /// absent value *means* is each sub-command's own decision, made at + /// startup in `main`; the two tests after this one pin the answers that + /// are not simply "carry on with nothing". + #[test] + fn the_shared_flags_are_optional_on_both_sub_commands() { + let node = parse_node(without(&without(&base_args(), "--node-key"), "--bootnodes")); + assert_eq!(node.common.node_key, None); + assert_eq!(node.common.bootnodes, None); + assert!(node.common.checkpoint_sync_url.is_empty()); + + let beacon = parse_beacon(vec!["ethlambda", "beacon"]); + assert_eq!(beacon.common.node_key, None); + assert_eq!(beacon.common.bootnodes, None); + assert!(beacon.common.checkpoint_sync_url.is_empty()); + } + + #[test] + fn beacon_needs_no_checkpoint_sync_url() { + // `--checkpoint-sync-url` was once `required = true` here, because the + // fork digest every gossip topic and the ENR are keyed on came from a + // Beacon API. It comes from the genesis state built into the binary + // now, so a bare `beacon` is a complete invocation. + let options = parse_beacon(vec!["ethlambda", "beacon"]); + assert!(options.common.checkpoint_sync_url.is_empty()); + } + + #[test] + fn beacon_accepts_a_checkpoint_sync_url() { + // Declared once for both sub-commands, so `beacon` parses it the same + // way `node` does; it is now how a beacon follower gets its anchor. + let options = parse_beacon(vec![ + "ethlambda", + "beacon", + "--checkpoint-sync-url", + "https://checkpointz.example", + ]); + assert_eq!( + options.common.checkpoint_sync_url, + ["https://checkpointz.example"] + ); + } + + #[test] + fn an_absent_node_key_parses_on_both_sub_commands() { + // Both generate an ephemeral in-memory key instead (`main`'s + // `resolve_node_key`), so neither rejects the argv. On `node` that is + // a behavior change: `--node-key` used to be required there. + assert_eq!( + parse_node(without(&base_args(), "--node-key")) + .common + .node_key, + None + ); + assert_eq!( + parse_beacon(vec![ + "ethlambda", + "beacon", + "--checkpoint-sync-url", + "https://checkpointz.example", + ]) + .common + .node_key, + None + ); + } + + #[test] + fn beacon_rejects_lean_only_flags() { + let result = try_parse_from([ + "ethlambda", + "beacon", + "--node-key", + "node.key", + "--checkpoint-sync-url", + "https://checkpointz.example", + "--genesis", + "config.yaml", + ]); + assert!( + result.is_err(), + "--genesis is a lean flag and must not parse under beacon" + ); + } + + #[test] + fn lean_still_requires_its_own_flags() { + // The subcommand split is what makes this a clap error rather than a + // hand-written check over Option fields. + let result = try_parse_from(["ethlambda", "lean", "--node-key", "node.key"]); + assert!( + result.is_err(), + "lean must still require --genesis and the rest" + ); + } + + #[test] + fn the_command_tree_is_well_formed() { + // clap's own assertions: duplicate argument ids, dangling `requires` + // targets, defaults that conflict with a value parser. Flattening one + // struct into two subcommands is exactly the shape that trips them. + use clap::CommandFactory; + crate::command::Cli::command().debug_assert(); + } + + /// The smallest argv that satisfies every required `beacon` flag. + fn beacon_args() -> Vec<&'static str> { + vec![ + "ethlambda", + "beacon", + "--node-key", + "node.key", + "--checkpoint-sync-url", + "https://checkpointz.example", + ] + } + + fn parse_beacon(args: Vec<&str>) -> BeaconOptions { + match try_parse_from(args).expect("beacon argv parses") { + Command::Beacon(options) => options, + other => panic!("the beacon argv must resolve to the beacon subcommand, got {other:?}"), + } + } + + /// The invariant `From`'s `unreachable!` rests on. Each + /// flag `requires` the other, so a half-configured Engine API is a usage + /// error out of the parser rather than a panic several frames later. + /// + /// Replaces `an_endpoint_without_a_secret_is_refused`, which pinned that + /// panic. Same invariant, checked one layer earlier and over both halves + /// rather than one. + #[test] + fn execution_flags_come_as_a_pair() { + for half in [ + vec!["--execution-endpoint", "http://127.0.0.1:8551"], + vec!["--execution-jwt-secret", "jwt.hex"], + ] { + let mut args = beacon_args(); + args.extend_from_slice(&half); + let err = try_parse_from(args).expect_err("one half alone must not parse"); + assert_eq!( + err.kind(), + clap::error::ErrorKind::MissingRequiredArgument, + "a missing pair is a usage error, not a panic: {err}" + ); + } + + let mut args = beacon_args(); + args.extend_from_slice(&[ + "--execution-endpoint", + "http://127.0.0.1:8551", + "--execution-jwt-secret", + "jwt.hex", + ]); + let beacon = parse_beacon(args); + assert!(beacon.execution_endpoint.is_some()); + assert!(beacon.execution_jwt_secret.is_some()); + } + + #[test] + fn the_execution_flags_are_absent_by_default() { + let beacon = parse_beacon(beacon_args()); + + assert!(beacon.execution_endpoint.is_none()); + assert!(beacon.execution_jwt_secret.is_none()); + assert_eq!( + beacon.mainnet.safe_slots_to_import_optimistically, + SAFE_SLOTS_TO_IMPORT_OPTIMISTICALLY + ); + } + + #[test] + fn both_execution_flags_together_pair_into_one_value() { + let mut args = beacon_args(); + args.extend([ + "--execution-endpoint", + "http://127.0.0.1:8551", + "--execution-jwt-secret", + "jwt.hex", + ]); + + let options: Options = parse_beacon(args).into(); + + match options.network { + Network::Mainnet { + execution: Some(execution), + .. + } => { + assert_eq!(execution.endpoint, "http://127.0.0.1:8551"); + assert_eq!(execution.jwt_secret, PathBuf::from("jwt.hex")); + } + other => panic!("expected a paired execution client, got {other:?}"), + } } } diff --git a/bin/ethlambda/src/command.rs b/bin/ethlambda/src/command.rs index 85a61e7a6..4bb38c89a 100644 --- a/bin/ethlambda/src/command.rs +++ b/bin/ethlambda/src/command.rs @@ -1,27 +1,33 @@ //! Sub-command definition and dispatch. //! -//! `node` and `benchmark` are ordinary clap sub-commands, so clap owns their -//! help, usage lines and error messages. The one thing clap cannot express is a -//! *default* sub-command, and the node needs one: the Dockerfile, -//! lean-quickstart, the hive shim and the devnet skills all invoke the binary as -//! a bare list of node flags, from before there was anything else to run. That -//! form keeps working because a missing sub-command is filled in as `node` -//! before parsing — see [`default_subcommand`]. +//! `node`, `beacon`, `benchmark`, `validator` and `keygen` are ordinary clap +//! sub-commands, so clap owns their help, usage lines and error messages. The +//! one thing clap cannot express is a *default* sub-command, and the node +//! needs one: the Dockerfile, lean-quickstart, the hive shim and the devnet +//! skills all invoke the binary as a bare list of node flags, from before +//! there was anything else to run. That form keeps working because a missing +//! sub-command is filled in as `node` before parsing — see +//! [`default_subcommand`]. use std::ffi::OsString; use clap::Parser; use crate::benchmark::BenchmarkOptions; -use crate::cli::NodeOptions; +use crate::cli::{BeaconOptions, NodeOptions}; use crate::keygen::KeygenOptions; use crate::version; /// Tokens that already say what to run, so no default is inserted ahead of /// them. `help` is clap's own generated sub-command (`ethlambda help node`). +/// +/// Every sub-command must be listed. A missing one would have `node` inserted +/// ahead of it, making it unreachable from the command line. const EXPLICIT: &[&str] = &[ NODE, + BEACON, BENCHMARK, + VALIDATOR, KEYGEN, "help", "-h", @@ -31,7 +37,9 @@ const EXPLICIT: &[&str] = &[ ]; const NODE: &str = "node"; +const BEACON: &str = "beacon"; const BENCHMARK: &str = "benchmark"; +const VALIDATOR: &str = "validator"; const KEYGEN: &str = "keygen"; #[derive(Debug, clap::Parser)] @@ -45,18 +53,13 @@ const KEYGEN: &str = "keygen"; // the sub-commands keeps those invocations working. propagate_version = true )] -struct Cli { +pub(crate) struct Cli { #[command(subcommand)] command: Command, } /// What the command line asked the binary to do. -/// -/// The variants are lopsided — the node options are far larger than the -/// benchmark's — but exactly one is built per process and consumed immediately -/// by `main`, so the imbalance costs nothing worth a `Box` at every use site. #[derive(Debug, clap::Subcommand)] -#[allow(clippy::large_enum_variant)] pub(crate) enum Command { /// Run the consensus node (default when no sub-command is given). /// @@ -70,8 +73,12 @@ pub(crate) enum Command { // `node`. #[command(display_name = "ethlambda")] Node(NodeOptions), + /// Follow the Ethereum Beacon Chain. + Beacon(BeaconOptions), /// Benchmark block building offline against a controlled workload. Benchmark(BenchmarkOptions), + /// Run the beacon-chain validator client against a beacon node's HTTP API. + Validator(crate::validator::ValidatorOptions), /// Generate validator XMSS keys for a genesis. Keygen(KeygenOptions), } @@ -86,17 +93,31 @@ pub(crate) fn try_parse_from(args: I) -> Result where I: IntoIterator, I::Item: Into, +{ + Cli::try_parse_from(inject_default_subcommand(args)).map(|cli| cli.command) +} + +/// Rewrite argv so a bare flag list names the default sub-command. +/// +/// Split out from [`try_parse_from`] so the rule can be tested on the tokens +/// themselves: what a parsed [`Command`] shows is which sub-command won, not +/// which tokens reached clap untouched, and the second is what every existing +/// caller depends on. +pub(crate) fn inject_default_subcommand(args: I) -> Vec +where + I: IntoIterator, + T: Into, { let mut args: Vec = args.into_iter().map(Into::into).collect(); if let Some(token) = default_subcommand(&args) { args.insert(1, token.into()); } - Cli::try_parse_from(args).map(|cli| cli.command) + args } /// The sub-command to insert, if the arguments do not name one. /// -/// `NodeOptions` declares no positional arguments, so the first token after +/// Neither sub-command declares a positional argument, so the first token after /// the program name is either a flag or a sub-command — a flag *value* never /// lands there and is never mistaken for one. A leading flag therefore means /// the flat node form, and gets `node` inserted ahead of it; a bare invocation @@ -151,14 +172,49 @@ mod tests { } } + /// `validator` must be in `EXPLICIT`. Without it, `default_subcommand` + /// inserts `node` ahead of the first token and the invocation parses as + /// `ethlambda node validator`, which fails with a confusing message about + /// node flags rather than about the validator. + #[test] + fn the_validator_subcommand_is_not_given_a_default() { + let args = [ + "ethlambda", + "validator", + "--beacon-nodes", + "http://localhost:5052", + "--validators-dir", + "/tmp/validators", + "--secrets-dir", + "/tmp/secrets", + ]; + let command = try_parse_from(args.iter().map(OsString::from)).expect("invocation parses"); + let options = match command { + Command::Validator(options) => options, + other => panic!("expected a validator invocation, got {other:?}"), + }; + assert_eq!(options.beacon_nodes, vec!["http://localhost:5052"]); + assert_eq!(options.validators_dir, PathBuf::from("/tmp/validators")); + } + + /// The historical flat form must keep working, unchanged. + #[test] + fn the_flat_node_form_still_parses_after_adding_validator() { + let options = node_options(FLAT); + assert_eq!(options.lean.node_id, "ethlambda_0"); + } + #[test] fn flat_invocation_parses_unchanged() { let options = node_options(FLAT); - assert_eq!(options.genesis, PathBuf::from("config.yaml")); - assert_eq!(options.hash_sig_keys_dir, PathBuf::from("hash-sig-keys/")); - assert_eq!(options.node_id, "ethlambda_0"); - assert_eq!(options.gossipsub_port, 9001); - assert!(options.is_aggregator); + assert_eq!(options.lean.genesis, PathBuf::from("config.yaml")); + assert_eq!( + options.lean.hash_sig_keys_dir, + PathBuf::from("hash-sig-keys/") + ); + assert_eq!(options.lean.node_id, "ethlambda_0"); + assert_eq!(options.common.gossipsub_port, 9001); + assert!(options.lean.is_aggregator); } #[test] @@ -179,7 +235,7 @@ mod tests { .position(|arg| *arg == "ethlambda_0") .expect("node id value present"); args[value] = NODE; - assert_eq!(node_options(&args).node_id, NODE); + assert_eq!(node_options(&args).lean.node_id, NODE); } #[test] @@ -218,6 +274,83 @@ mod tests { } } + /// Parse `beacon` with the given extra flags and return the custody count. + fn beacon_custody_group_count(extra: &[&str]) -> Result { + let mut args = vec!["ethlambda", BEACON]; + args.extend_from_slice(extra); + match try_parse_from(args.iter().map(OsString::from))? { + Command::Beacon(options) => Ok(options.mainnet.custody_group_count), + other => panic!("`beacon` must parse as Command::Beacon, got {other:?}"), + } + } + + #[test] + fn the_custody_group_count_defaults_to_the_specifications_floor() { + // The default is what every node must custody anyway, so an operator + // who passes nothing advertises exactly what this node did before the + // flag existed. + assert_eq!( + beacon_custody_group_count(&[]).expect("no flag is a valid invocation"), + ethlambda_types::beacon::constants::CUSTODY_REQUIREMENT + ); + } + + #[test] + fn a_supernodes_custody_group_count_is_accepted() { + assert_eq!( + beacon_custody_group_count(&["--custody-group-count", "128"]) + .expect("the whole custody space is a valid choice"), + ethlambda_types::beacon::constants::NUMBER_OF_CUSTODY_GROUPS + ); + } + + #[test] + fn a_custody_group_count_outside_the_specifications_range_is_refused() { + // Below the floor this node would advertise less than the spec + // requires of it, and above the ceiling it would name custody groups + // that do not exist. Both are rejected at parse time rather than + // clamped, because silently serving a different set than the operator + // asked for is the failure that would be hardest to notice. + for bad in ["3", "0", "129"] { + let err = beacon_custody_group_count(&["--custody-group-count", bad]) + .expect_err("a count outside the range must not start a node"); + assert_eq!(err.kind(), ErrorKind::ValueValidation, "for {bad}"); + } + } + + #[test] + fn the_import_workload_parses_both_phases() { + let fetch = try_parse_from([ + "ethlambda", + "benchmark", + "import", + "fetch", + "--url", + "http://127.0.0.1:5052", + "--from", + "100", + "--to", + "200", + "--corpus", + "/tmp/c", + ]) + .expect("fetch parses"); + assert!(matches!(fetch, Command::Benchmark(_))); + + let replay = try_parse_from([ + "ethlambda", + "benchmark", + "import", + "replay", + "--corpus", + "/tmp/c", + "--data-dir", + "/tmp/d", + ]) + .expect("replay parses"); + assert!(matches!(replay, Command::Benchmark(_))); + } + #[test] fn bare_invocation_asks_for_a_sub_command() { // Nothing to default: clap prints the top-level help, which lists the @@ -277,7 +410,7 @@ mod tests { /// `ethlambda node keygen ...`, which fails on a stray positional. #[test] fn sub_command_tokens_do_not_get_the_default_inserted() { - for token in [NODE, BENCHMARK, KEYGEN] { + for token in [NODE, BEACON, BENCHMARK, VALIDATOR, KEYGEN] { let args = [OsString::from("ethlambda"), OsString::from(token)]; assert_eq!( default_subcommand(&args), @@ -345,4 +478,114 @@ mod tests { .expect_err("--help short-circuits parsing"); assert!(err.to_string().contains(NODE), "{err}"); } + + /// Render an injector result for comparison against a plain string list. + fn as_strings(args: &[OsString]) -> Vec<&str> { + args.iter() + .map(|arg| arg.to_str().expect("test argv is utf-8")) + .collect() + } + + /// The contract every existing caller depends on: the Docker ENTRYPOINT, + /// lean-quickstart's ethlambda-cmd.sh, the Hive client shim, + /// preview-config.nix, and the devnet-runner `docker run` blocks all pass + /// bare flags with no subcommand. + #[test] + fn bare_flags_get_the_node_subcommand() { + let args = inject_default_subcommand(["ethlambda", "--genesis", "config.yaml"]); + assert_eq!( + as_strings(&args), + ["ethlambda", "node", "--genesis", "config.yaml"] + ); + } + + #[test] + fn an_explicit_node_subcommand_is_left_alone() { + let args = inject_default_subcommand(["ethlambda", "node", "--genesis", "config.yaml"]); + assert_eq!( + as_strings(&args), + ["ethlambda", "node", "--genesis", "config.yaml"] + ); + } + + #[test] + fn an_explicit_beacon_subcommand_is_left_alone() { + let args = inject_default_subcommand([ + "ethlambda", + "beacon", + "--checkpoint-sync-url", + "https://checkpointz.example", + ]); + assert_eq!( + as_strings(&args), + [ + "ethlambda", + "beacon", + "--checkpoint-sync-url", + "https://checkpointz.example" + ] + ); + } + + #[test] + fn the_generated_help_subcommand_is_left_alone() { + // clap adds a `help` subcommand of its own as soon as subcommands + // exist. Injecting ahead of it would turn `ethlambda help beacon` into + // `ethlambda lean help beacon`, which prints the wrong page. + let args = inject_default_subcommand(["ethlambda", "help", "beacon"]); + assert_eq!(as_strings(&args), ["ethlambda", "help", "beacon"]); + } + + #[test] + fn help_and_version_flags_are_left_alone() { + // These four belong to the top-level command. `--version` is not + // propagated to subcommands, so injecting would turn it into an + // "unexpected argument" error. + for flag in ["-h", "--help", "-V", "--version"] { + let args = inject_default_subcommand(["ethlambda", flag]); + assert_eq!( + as_strings(&args), + ["ethlambda", flag], + "{flag} must reach the top-level command" + ); + } + } + + #[test] + fn an_argv_with_only_the_program_name_is_left_alone() { + // A bare `ethlambda` then prints clap's subcommand listing, which is + // the right answer now that there are two chains to choose from. + // Nothing regresses: today's binary already exits non-zero there, + // because --genesis and six other flags are required. + let args = inject_default_subcommand(["ethlambda"]); + assert_eq!(as_strings(&args), ["ethlambda"]); + } + + #[test] + fn an_empty_argv_is_left_alone() { + // `execve` can hand a process an argv with no program name at all. + // Indexing instead of checking would panic here. + let args = inject_default_subcommand(Vec::<&str>::new()); + assert!(args.is_empty()); + } + + #[test] + fn a_leading_double_dash_gets_the_node_subcommand() { + // `--` is not special-cased: it is not a subcommand name, so it takes + // the default like any other first token. Neither subcommand accepts + // positional arguments, so this argv is an error either way; the + // injection only decides which command the error names. + let args = inject_default_subcommand(["ethlambda", "--", "--genesis"]); + assert_eq!(as_strings(&args), ["ethlambda", "node", "--", "--genesis"]); + } + + #[test] + fn a_help_flag_after_the_first_argument_is_not_special() { + // Only the first argument is classified, so this `--help` is the node sub-command's. + let args = inject_default_subcommand(["ethlambda", "--genesis", "config.yaml", "--help"]); + assert_eq!( + as_strings(&args), + ["ethlambda", "node", "--genesis", "config.yaml", "--help"] + ); + } } diff --git a/bin/ethlambda/src/main.rs b/bin/ethlambda/src/main.rs index 92a1fd890..5d6f07db6 100644 --- a/bin/ethlambda/src/main.rs +++ b/bin/ethlambda/src/main.rs @@ -1,10 +1,13 @@ mod banner; +mod beacon; mod benchmark; mod checkpoint_sync; mod cli; mod command; mod fd_limit; mod keygen; +mod network; +mod validator; mod version; // Jemalloc causes programs to deadlock during process startup under Shadow. @@ -35,21 +38,31 @@ use std::{ }; use tokio_util::sync::CancellationToken; -use cli::NodeOptions; +use cli::{Network, Options}; use command::Command; use ethlambda_blockchain::block_builder::ProposerConfig; use ethlambda_blockchain::key_manager::{KeyRole, ValidatorKeyPair}; use ethlambda_crypto::signature::ValidatorSecretKey; -use ethlambda_network_api::{InitBlockChain, InitP2P, ToBlockChainToP2PRef, ToP2PToBlockChainRef}; +use ethlambda_engine::types::ClientVersionV1; +use ethlambda_engine::{EngineClient, JwtSecret}; +use ethlambda_network_api::{ + InitBlockChain, InitP2P, ToBlockChainToP2PRef, ToP2PToBlockChainRef, ToRpcToP2PRef, +}; use ethlambda_p2p::{ - Bootnode, P2P, PeerId, SwarmConfig, attestation_subscription_subnets, build_swarm, - discovery::DiscoverySpawnConfig, parse_enrs, + LeanWireConfig, P2P, PeerId, SwarmConfig, WireConfig, attestation_subscription_subnets, + build_swarm, discovery::DiscoverySpawnConfig, parse_enrs, }; +use ethlambda_state_transition::beacon::fork_choice; use ethlambda_types::primitives::{H256, HashTreeRoot as _}; use ethlambda_types::{ aggregator::AggregatorController, - genesis::GenesisConfig, + beacon::config::Config, + beacon::containers::{ + BeaconState, SignedBeaconBlock, altair, bellatrix, capella, deneb, electra, phase0, + }, + beacon::fork::ForkName, + genesis::{GenesisConfig, verify_state_genesis}, state::{State, ValidatorPubkeyBytes}, }; use eyre::WrapErr; @@ -60,14 +73,21 @@ use tracing_subscriber::{EnvFilter, Layer, Registry, layer::SubscriberExt}; use ethlambda_blockchain::{BlockChain, BlockChainConfig, EventBus, SyncStatusController}; use ethlambda_rpc::RpcConfig; use ethlambda_storage::{ - MAX_RESUMABLE_DB_STATE_AGE, StorageBackend, Store, backend::RocksDBBackend, + Chain, MAX_RESUMABLE_DB_STATE_AGE, StorageBackend, Store, backend::RocksDBBackend, }; fn main() -> eyre::Result<()> { match command::parse() { + // Both node sub-commands are one startup path with a different + // `Network`. The sub-command is only how the choice is spelled on the + // command line. Command::Node(options) => { init_node_logging()?; - run_node(options) + run_node(options.into()) + } + Command::Beacon(options) => { + init_node_logging()?; + run_node(options.into()) } // The benchmark is synchronous, CPU-bound work, so it runs on this // thread and the tokio runtime is never started — rather than parking @@ -76,6 +96,10 @@ fn main() -> eyre::Result<()> { init_benchmark_logging()?; benchmark::run(options) } + Command::Validator(options) => { + init_node_logging()?; + validator::run(options) + } // Key generation is synchronous, CPU-bound work whose product is files, // so it runs on this thread too and leaves stdout alone. Command::Keygen(options) => { @@ -126,35 +150,157 @@ fn init_keygen_logging() -> eyre::Result<()> { .wrap_err("failed to set global tracing subscriber") } +/// A node that has started, whichever chain it follows. +/// +/// What [`wait_for_shutdown`] needs to stop, and all [`run_node`] has left to +/// assemble once the chain-specific half is done. +struct RunningNode { + p2p: P2P, + /// The chain actor, on both lean and mainnet: the beacon follower runs one + /// too, so there is always exactly one to stop and join. `run_node` + /// spawns and wires it before building this struct, which is what keeps + /// this a plain `BlockChain` rather than an `Option` describing a state + /// the node never reaches. + blockchain: BlockChain, + /// The HTTP server task. Returns once `shutdown` is cancelled. + http: tokio::task::JoinHandle<()>, + /// Cancelled by [`wait_for_shutdown`] to stop `http`. + shutdown: CancellationToken, +} + +/// What one chain claims to serve, in the entries its ENR publishes and its +/// discv5 admission filter judges peers by. +/// +/// Split out of [`DiscoverySpawnConfig`] because the other eight fields there +/// are the node key, the ports, the bootnodes and the peer target: operator +/// input, identical on either chain. Writing the whole config in each arm meant +/// typing those eight out twice, where the compiler could not tell if the two +/// copies drifted. +struct DiscoveryWireEntries { + subscription_subnets: HashSet, + attestation_committee_count: u64, + fork_id: ethlambda_types::enr::EnrForkId, + custody_group_count: Option, +} + +/// What [`ChainSetup::chain`] hands `run_node` to spawn this chain's actor. +/// +/// Lean carries validator keys and duty configuration because +/// [`BlockChain::spawn`] needs both; beacon carries its custody columns and +/// the data-availability enforcement flag, the two values +/// [`BlockChain::spawn_beacon`] needs beyond the store, the sync-status +/// controller and the event bus that `run_node` already owns outside this +/// struct. +enum ChainActor { + /// The lean node's chain actor: its validator keys and duty configuration. + Lean(HashMap, BlockChainConfig), + /// The beacon follower's chain actor. No validator keys and no validator + /// duties, but it does need the columns this node samples, which + /// `BlockChain::spawn_beacon` uses to decide when a fulu block has its + /// data. + Beacon { + custody_columns: Vec, + /// The execution client to validate payloads against, `None` when + /// `--execution-endpoint` was not given. + engine: Option, + safe_slots_to_import_optimistically: u64, + }, +} + +/// What one chain's own setup produces, and everything [`run_node`] needs from +/// it to put a node on the wire. +/// +/// A data bag, not an abstraction: the `match` that fills it stays inline in +/// `run_node`, because moving it into a method of its own would relocate the +/// branch rather than remove it. The fields are exactly the values the two +/// chains cannot share, in the order the code below consumes them. +struct ChainSetup { + /// The wire-specific half of the swarm configuration: topics, protocol set, + /// `seen_ttl`, identify version, connection limits. + wire: WireConfig, + /// The ENR entries that describe the wire above: the only part of the discv5 + /// configuration the two chains disagree about. Everything else in + /// [`DiscoverySpawnConfig`] is operator-supplied and identical either way, + /// so it is filled in once, below the match. + discovery: DiscoveryWireEntries, + /// Backs the req/resp handlers on both chains. Both start from the + /// checkpoint anchor `fetch_initial_state`/`fetch_initial_beacon_state` + /// resumed from disk or checkpoint-synced, and both then have a + /// `BlockChain` actor (`spawn`/`spawn_beacon`) driving it forward: lean + /// through validator duties, mainnet as a duty-free follower running fork + /// choice on imported blocks. + store: Store, + /// PeerId to node name, for logs. Empty on mainnet, which has no roster. + node_names: HashMap, + /// What to spawn this chain's actor with: lean's validator keys and duty + /// configuration, or beacon's custody columns and data-availability flag. + chain: ChainActor, +} + +/// Boot the node, on whichever chain [`Options::network`] names. +/// +/// One startup path, in the order it has to happen: validate the port, register +/// the metrics, say what is running, raise the file-descriptor limit, resolve +/// the node key, then the one `match` where the two chains differ, then the +/// swarm, the discv5 server and the HTTP server, then the chain actor, which are +/// the same either way. Both chains end up with a spawned, wired-up +/// `BlockChain`, so `wait_for_shutdown` stops and joins one on either network. +/// +/// `Network::Lean` runs the full consensus node: a `BlockChain` actor with +/// validator duties, a RocksDB store, checkpoint sync and the `/lean/v0` API. +/// `Network::Mainnet` runs a beacon follower on whichever network `--network` +/// resolved to (mainnet by default): it derives that network's fork digest, +/// joins discv5, subscribes to the global gossip topics, resolves the +/// checkpoint anchor `fetch_initial_beacon_state` at startup, then hands the +/// resulting store to a `BlockChain` actor spawned with `spawn_beacon`, which +/// imports blocks through fork choice with no validator keys and no duties. +/// The `/lean/v0` API is served off the store on both chains, so one HTTP call +/// site serves both, but its endpoints still read metadata keys and state +/// variants a beacon store does not carry, so they do not yet answer for a +/// beacon directory; fixing that is its own change. +// // Shadow single-steps execution in a discrete-event simulation, so the default // multi-threaded runtime's worker threads add only scheduling noise, never // parallelism. Use a single-threaded runtime under Shadow. This is an // optimization, not a correctness requirement. #[cfg_attr(not(feature = "shadow-integration"), tokio::main)] #[cfg_attr(feature = "shadow-integration", tokio::main(flavor = "current_thread"))] -async fn run_node(options: NodeOptions) -> eyre::Result<()> { +async fn run_node(options: Options) -> eyre::Result<()> { + // Before any side effect, so a port collision aborts ahead of the metrics + // registry, the fd limit and the data directory. options.validate_ports()?; + let Options { common, network } = options; + // Read before the chain match below moves `network`. + let discovery_enabled = network.discovery_enabled(); + #[cfg(feature = "shadow-integration")] - init_shadow_cost(&options.shadow); + if let Network::Lean(lean) = &network { + init_shadow_cost(&lean.shadow); + } // Compiles the aggregation bytecode and fixes the prover's allocator. Ahead of the // test-driver branch below, which verifies signatures, and of every consensus path. - info!( - arena = options.prover_arena, - "Initializing leanVM prover and verifier" - ); - ethlambda_crypto::init_leanvm(options.prover_arena); + // Lean-only: the beacon chain signs with BLS and never reaches leanVM. + if let Network::Lean(lean) = &network { + info!( + arena = lean.prover_arena, + "Initializing leanVM prover and verifier" + ); + ethlambda_crypto::init_leanvm(lean.prover_arena); + } // Initialize metrics ethlambda_blockchain::metrics::init(); + ethlambda_p2p::metrics::init(); + ethlambda_state_transition::metrics::init(); ethlambda_blockchain::metrics::set_node_info("ethlambda", version::CLIENT_VERSION); ethlambda_blockchain::metrics::set_node_start_time(); let rpc_config = RpcConfig { - http_address: options.http_address, - api_port: options.api_port, - metrics_port: options.metrics_port, + http_address: common.http_address, + api_port: common.api_port, + metrics_port: common.metrics_port, version: version::CLIENT_VERSION, }; @@ -175,127 +321,56 @@ async fn run_node(options: NodeOptions) -> eyre::Result<()> { // simulator. Detected here before any config / key / genesis loading // so the driver run doesn't touch --node-key, --custom-network-config-dir, // or any other consensus prerequisite the hive shim doesn't bother to - // provision. - if ethlambda_rpc::test_driver::test_driver_enabled() { + // provision. Lean-only: the endpoints it serves are lean's, and it must + // still precede the key resolution below. + if matches!(network, Network::Lean(_)) && ethlambda_rpc::test_driver::test_driver_enabled() { info!("HIVE_LEAN_TEST_DRIVER detected; booting in test-driver mode"); return run_test_driver(rpc_config).await; } - let node_p2p_key = read_hex_file_bytes(&options.node_key).wrap_err_with(|| { - format!( - "failed to load node key from {}", - options.node_key.display() - ) - })?; - let p2p_socket = SocketAddr::new(IpAddr::from([0, 0, 0, 0]), options.gossipsub_port); + let node_p2p_key = resolve_node_key(common.node_key.as_deref())?; #[cfg(all(not(target_env = "msvc"), feature = "jemalloc"))] info!("Using jemalloc allocator with heap profiling enabled"); #[cfg(any(target_env = "msvc", not(feature = "jemalloc")))] info!("Using system allocator"); - info!(node_key=?options.node_key, "got node key"); - - let config_path = options.genesis; - let bootnodes_path = options.bootnodes; - let validators_path = options.validators; - let validator_config = options.validator_config; - let validator_keys_dir = options.hash_sig_keys_dir; + info!(node_key=?common.node_key, "got node key"); - let config_yaml = std::fs::read_to_string(&config_path).wrap_err_with(|| { - format!( - "failed to read genesis config from {}", - config_path.display() - ) - })?; - let genesis_config: GenesisConfig = - serde_yaml_ng::from_str(&config_yaml).wrap_err_with(|| { - format!( - "failed to parse genesis config from {}", - config_path.display() - ) - })?; - - info!( - genesis_time = genesis_config.genesis_time, - milliseconds_per_slot = genesis_config.milliseconds_per_slot, - validator_count = genesis_config.genesis_validators.len(), - "Loaded genesis configuration" - ); - - let validator_config_file = read_validator_config_file(&validator_config)?; - let node_names = load_node_names(&validator_config_file); - - // Resolve attestation_committee_count: CLI flag > validator-config.yaml > 1. - // The CLI path is bounded by clap's `range(1..)`; enforce the same lower - // bound here so a YAML value of 0 cannot bypass it. - let attestation_committee_count = options - .attestation_committee_count - .or(validator_config_file.config.attestation_committee_count) - .unwrap_or(1); - eyre::ensure!( - attestation_committee_count >= 1, - "attestation_committee_count must be >= 1 (got {attestation_committee_count})" - ); - info!( - attestation_committee_count, - "Loaded attestation committee count" - ); - // Checked here rather than in clap: the committee count is only known once - // the CLI flag and the validator config have both been consulted. - validate_aggregate_subnet_ids( - options.aggregate_subnet_ids.as_deref(), - attestation_committee_count, - )?; - ethlambda_blockchain::metrics::set_attestation_committee_count(attestation_committee_count); + let p2p_socket = SocketAddr::new(IpAddr::from([0, 0, 0, 0]), common.gossipsub_port); - let bootnodes = read_bootnodes(&bootnodes_path)?; - - let validator_keys = - read_validator_keys(&validators_path, &validator_keys_dir, &options.node_id) - .wrap_err("failed to load validator keys")?; + // Resolved once, here, above the bootnode fallback below that reads it: + // `default_bootnodes` needs the loaded network's own bootnode list, and + // loading a directory decodes a multi-megabyte genesis state, so this must + // not run twice for one process. `network` is matched by reference so it + // is still available, below, to be matched by value into `ChainSetup`. + let network_source = match &network { + Network::Lean(_) => None, + Network::Mainnet { mainnet, .. } => { + let spec = network::NetworkSpec::parse(&mainnet.network)?; + Some(network::NetworkSource::resolve(&spec)?) + } + }; - let data_dir = - std::path::absolute(&options.data_dir).unwrap_or_else(|_| options.data_dir.clone()); - info!(data_dir = %data_dir.display(), "Initializing DB"); - std::fs::create_dir_all(&data_dir) - .wrap_err_with(|| format!("failed to create data directory {}", data_dir.display()))?; - let backend = Arc::new( - RocksDBBackend::open(&data_dir) - .map_err(|err| eyre::eyre!("{err}")) - .wrap_err_with(|| format!("failed to open RocksDB at {}", data_dir.display()))?, + // The `--bootnodes` file, read and parsed once. An absent flag is not an + // empty list: `default_bootnodes` is what each chain falls back to. + let bootnodes = parse_enrs( + common + .bootnodes + .as_deref() + .map(read_bootnode_strings) + .transpose()? + .unwrap_or_else(|| default_bootnodes(network_source.as_ref())), ); - let clean_checkpoint_urls: Vec = options - .checkpoint_sync_url - .into_iter() - .map(|url| url.trim().to_string()) - .filter(|url| !url.is_empty()) - .collect(); - - let store = fetch_initial_state(&clean_checkpoint_urls, &genesis_config, backend.clone()) - .await - .inspect_err(|err| error!(%err, "Failed to initialize state"))?; - - let validator_ids: Vec = validator_keys.keys().copied().collect(); - - // Shared, runtime-mutable aggregator flag. Seeded from the CLI and - // threaded into both the blockchain actor (which reads on every tick) - // and the API server (which exposes GET/POST admin endpoints). - let aggregator = AggregatorController::new(options.is_aggregator); - - // Attestation subnets this node subscribes to, computed once and shared by - // the P2P swarm (to open gossip subscriptions) and the blockchain actor - // (to size the early-aggregation threshold), so both agree on which subnets - // feed this node's gossip groups. Subscriptions are fixed at startup and - // are not re-evaluated when the aggregator role is toggled at runtime; see - // the hot-standby note on SwarmConfig. - let subscribed_subnets = attestation_subscription_subnets( - &validator_ids, - attestation_committee_count, - options.is_aggregator, - options.aggregate_subnet_ids.as_deref(), - ); + // Shared, runtime-mutable aggregator flag, seeded from the CLI flag only + // `node` takes: mainnet has no aggregation duty. Threaded into both the + // blockchain actor, which reads it on every tick, and the API server, whose + // admin endpoints can flip it at runtime. + let aggregator = AggregatorController::new(match &network { + Network::Lean(lean) => lean.is_aggregator, + Network::Mainnet { .. } => false, + }); // Shared, runtime-readable sync status. The blockchain actor writes it each // tick (alongside the `lean_node_sync_status` metric); the RPC @@ -309,112 +384,467 @@ async fn run_node(options: NodeOptions) -> eyre::Result<()> { // receiver-count guard in `emit` makes every emission a no-op. let events = EventBus::default(); - let aggregation_duty_subnet = resolve_aggregation_duty_subnet( - options.aggregate_subnet_ids.as_deref(), - &subscribed_subnets, - ); - info!( - aggregation_duty_subnet, - assigned = options.aggregate_subnet_ids.is_some(), - "Resolved aggregation duty subnet" + // Both chains keep a RocksDB directory now, so this is resolved once + // rather than in each arm. Opening it before the match also means a bad + // `--data-dir` fails before any network configuration is derived. + let data_dir = + std::path::absolute(&common.data_dir).unwrap_or_else(|_| common.data_dir.clone()); + info!(data_dir = %data_dir.display(), "Initializing DB"); + std::fs::create_dir_all(&data_dir) + .wrap_err_with(|| format!("failed to create data directory {}", data_dir.display()))?; + let backend = Arc::new( + RocksDBBackend::open(&data_dir) + .map_err(|err| eyre::eyre!("{err}")) + .wrap_err_with(|| format!("failed to open RocksDB at {}", data_dir.display()))?, ); - if options.skip_redundant_aggregation && options.aggregate_subnet_ids.is_none() { - warn!( - aggregation_duty_subnet, - "--skip-redundant-aggregation is set but the duty subnet was derived, not assigned: \ - every co-located aggregator whose validators span all subnets derives the same duty \ - subnet, so they will sit out in lockstep in the same slot instead of taking turns, \ - and the widest level gets no producer at all in most slots. Give each aggregator a \ - distinct first --aggregate-subnet-ids value to fix this." - ); - } - let blockchain_config = BlockChainConfig { - aggregator: aggregator.clone(), - sync_status_controller: sync_status.clone(), - attestation_committee_count, - gate_duties: !options.disable_duty_sync_gate, - subscribed_subnets: subscribed_subnets.clone(), - aggregation_duty_subnet, - skip_redundant_aggregation: options.skip_redundant_aggregation, - proposer_config: ProposerConfig { - enable_proposer_aggregation: options.enable_proposer_aggregation, - max_attestations_per_block: options.max_attestations_per_block, - }, + let clean_checkpoint_urls = checkpoint_sync::clean_urls(&common.checkpoint_sync_url); + + // The one place the two chains diverge. Everything above is shared setup; + // everything below is shared startup and, in `wait_for_shutdown`, shared + // teardown. + let setup = match network { + // The full lean consensus node: a chain actor with validator duties, a + // RocksDB store, checkpoint sync and the `/lean/v0` API. + Network::Lean(lean) => { + let config_path = lean.genesis; + let validators_path = lean.validators; + let validator_config = lean.validator_config; + let validator_keys_dir = lean.hash_sig_keys_dir; + + let config_yaml = std::fs::read_to_string(&config_path).wrap_err_with(|| { + format!( + "failed to read genesis config from {}", + config_path.display() + ) + })?; + let genesis_config: GenesisConfig = serde_yaml_ng::from_str(&config_yaml) + .wrap_err_with(|| { + format!( + "failed to parse genesis config from {}", + config_path.display() + ) + })?; + + info!( + genesis_time = genesis_config.genesis_time, + milliseconds_per_slot = genesis_config.milliseconds_per_slot, + validator_count = genesis_config.genesis_validators.len(), + "Loaded genesis configuration" + ); + + let validator_config_file = read_validator_config_file(&validator_config)?; + let node_names = load_node_names(&validator_config_file); + + // Resolve attestation_committee_count: CLI flag > validator-config.yaml > 1. + // The CLI path is bounded by clap's `range(1..)`; enforce the same lower + // bound here so a YAML value of 0 cannot bypass it. + let attestation_committee_count = lean + .attestation_committee_count + .or(validator_config_file.config.attestation_committee_count) + .unwrap_or(1); + eyre::ensure!( + attestation_committee_count >= 1, + "attestation_committee_count must be >= 1 (got {attestation_committee_count})" + ); + info!( + attestation_committee_count, + "Loaded attestation committee count" + ); + // Checked here rather than in clap: the committee count is only known once + // the CLI flag and the validator config have both been consulted. + validate_aggregate_subnet_ids( + lean.aggregate_subnet_ids.as_deref(), + attestation_committee_count, + )?; + ethlambda_blockchain::metrics::set_attestation_committee_count( + attestation_committee_count, + ); + + let validator_keys = + read_validator_keys(&validators_path, &validator_keys_dir, &lean.node_id) + .wrap_err("failed to load validator keys")?; + + let store = + fetch_initial_state(&clean_checkpoint_urls, &genesis_config, backend.clone()) + .await + .inspect_err(|err| error!(%err, "Failed to initialize state"))?; + + let validator_ids: Vec = validator_keys.keys().copied().collect(); + + // Attestation subnets this node subscribes to, computed once and shared by + // the P2P swarm (to open gossip subscriptions) and the blockchain actor + // (to size the early-aggregation threshold), so both agree on which subnets + // feed this node's gossip groups. Subscriptions are fixed at startup and + // are not re-evaluated when the aggregator role is toggled at runtime; see + // the hot-standby note on SwarmConfig. + let subscribed_subnets = attestation_subscription_subnets( + &validator_ids, + attestation_committee_count, + lean.is_aggregator, + lean.aggregate_subnet_ids.as_deref(), + ); + + let aggregation_duty_subnet = resolve_aggregation_duty_subnet( + lean.aggregate_subnet_ids.as_deref(), + &subscribed_subnets, + ); + info!( + aggregation_duty_subnet, + assigned = lean.aggregate_subnet_ids.is_some(), + "Resolved aggregation duty subnet" + ); + if lean.skip_redundant_aggregation && lean.aggregate_subnet_ids.is_none() { + warn!( + aggregation_duty_subnet, + "--skip-redundant-aggregation is set but the duty subnet was derived, not assigned: \ + every co-located aggregator whose validators span all subnets derives the same duty \ + subnet, so they will sit out in lockstep in the same slot instead of taking turns, \ + and the widest level gets no producer at all in most slots. Give each aggregator a \ + distinct first --aggregate-subnet-ids value to fix this." + ); + } + + let blockchain_config = BlockChainConfig { + aggregator: aggregator.clone(), + sync_status_controller: sync_status.clone(), + attestation_committee_count, + gate_duties: !lean.disable_duty_sync_gate, + subscribed_subnets: subscribed_subnets.clone(), + aggregation_duty_subnet, + skip_redundant_aggregation: lean.skip_redundant_aggregation, + proposer_config: ProposerConfig { + enable_proposer_aggregation: lean.enable_proposer_aggregation, + max_attestations_per_block: lean.max_attestations_per_block, + }, + }; + + ChainSetup { + wire: WireConfig::Lean(LeanWireConfig { + validator_ids, + attestation_committee_count, + subscription_subnets: subscribed_subnets.clone(), + milliseconds_per_slot: genesis_config.milliseconds_per_slot, + }), + discovery: DiscoveryWireEntries { + subscription_subnets: subscribed_subnets, + attestation_committee_count, + fork_id: ethlambda_types::enr::EnrForkId::local(), + custody_group_count: None, + }, + store, + node_names, + chain: ChainActor::Lean(validator_keys, blockchain_config), + } + } + // The Ethereum Beacon Chain follower: the wire plus a duty-free chain + // actor (`ChainActor::Beacon`, filled in below) that imports blocks + // through fork choice. Every network parameter is derived rather than + // configured, from the resolved network's genesis values: the fork + // digest depends on the epoch, which depends on genesis time. See + // `crate::beacon`. + Network::Mainnet { mainnet, execution } => { + let source = network_source + .expect("network_source is Some whenever network is Network::Mainnet"); + + info!( + network = %source.name(), + bootnodes = ?common.bootnodes, + gossipsub_port = common.gossipsub_port, + http_address = %common.http_address, + metrics_port = common.metrics_port, + discovery_port = common.discovery.port, + advertise_ip = ?common.discovery.advertise_ip, + "Resolved network configuration" + ); + + // The node id is the discovery one, so what this node custodies is + // what any peer computes for it from its ENR: `spawn_discovery` + // derives its own copy from the same `node_p2p_key` bytes below, by + // the same computation, so the two cannot disagree about this + // node's identity. + let node_id = beacon::beacon_node_id(&node_p2p_key)?; + let params = beacon::wire_params( + &source, + node_id, + common.node_key.is_some(), + mainnet.custody_group_count, + )?; + + // Cloned ahead of the move into `WireConfig::Beacon` below: the + // chain actor needs its own copy to gate fulu import on, the same + // columns the wire config uses to size custody group + // advertisements and req/resp serving. + let custody_columns = params.wire.custody_columns.clone(); + + // Cloned ahead of the same move, for the ENR entry below: the + // record has to advertise exactly the subnets the swarm subscribes + // to, and both readings come from this one computation. + let attestation_subnets: HashSet = + params.wire.attestation_subnets.iter().copied().collect(); + + // The anchored beacon store. `P2PServer` holds it for the lean + // handlers, and the two beacon block handlers read it too: it is + // what `beacon_blocks_by_{range,root}/2` are answered from. + let store = + fetch_initial_beacon_state(&clean_checkpoint_urls, backend.clone(), &source) + .await + .inspect_err(|err| error!(%err, "Failed to initialize state"))?; + + let engine = match &execution { + None => { + info!( + "No execution client configured; beacon blocks import without \ + payload validation" + ); + None + } + Some(options) => { + let secret = JwtSecret::from_file(&options.jwt_secret) + .map_err(|err| eyre::eyre!("reading --execution-jwt-secret: {err}"))?; + let client = EngineClient::new(options.endpoint.clone(), secret) + .map_err(|err| eyre::eyre!("building the engine client: {err}"))?; + info!(endpoint = %options.endpoint, "Execution client configured"); + + let ours = ClientVersionV1 { + // `identification.md` reserves two-letter codes per + // client; none is assigned to ethlambda, and `XX` is + // what the document names for a client without one. + code: "XX".to_string(), + name: "ethlambda".to_string(), + version: version::CLIENT_VERSION.to_string(), + // `identification.md` types `commit` as DATA, 4 bytes, + // and geth decodes it into `hexutil.Bytes`, which + // rejects a bare hex string with "hex string without + // 0x prefix". So the prefix is not cosmetic: without + // it `engine_getClientVersionV1` comes back an RPC + // error and the handshake below never identifies + // anything. `get` rather than a slice or `take(8)`, + // since `VERGEN_GIT_SHA` is not guaranteed to be eight + // or more characters in every build configuration. + commit: format!( + "0x{}", + env!("VERGEN_GIT_SHA").get(..8).unwrap_or("00000000") + ), + }; + // A handshake failure is not a reason to refuse to run: the + // execution client may simply be starting up, and every + // call that matters has its own retry ladder. + let _ = client + .handshake(&ours) + .await + .inspect_err(|err| warn!(%err, "Engine API handshake failed")); + + Some(client) + } + }; + + ChainSetup { + wire: WireConfig::Beacon(Box::new(params.wire)), + discovery: DiscoveryWireEntries { + // The backbone subnets this node's id selects, so the ENR's + // `attnets` names what the gossip subscription actually + // holds. Both come from `wire_params`' one computation + // rather than from two, which is what keeps a peer's + // reading of this record true of the node behind it. + subscription_subnets: attestation_subnets, + attestation_committee_count: + ethlambda_p2p::beacon::constants::ATTESTATION_SUBNET_COUNT, + fork_id: params.fork_id, + custody_group_count: Some(mainnet.custody_group_count), + }, + store, + node_names: HashMap::new(), + // A beacon follower has no validator keys and no duties, but + // it does import blocks through fork choice, so it gets the + // `Beacon` chain actor below. + chain: ChainActor::Beacon { + custody_columns, + engine, + safe_slots_to_import_optimistically: mainnet + .safe_slots_to_import_optimistically, + }, + } + } }; - let blockchain = BlockChain::spawn( - store.clone(), - validator_keys, - blockchain_config, - events.clone(), - ); - - let built = build_swarm(SwarmConfig { + // The operator-supplied half of the discv5 configuration, which neither + // chain varies. Built before `build_swarm` because that moves the node key + // and the bootnode list. `None` on a lean node without `--discovery.enable`, + // which then peers from the bootnode list alone. + let discovery = discovery_enabled.then(|| DiscoverySpawnConfig { node_key: node_p2p_key.clone(), + bind_ip: p2p_socket.ip(), + discovery_port: common.discovery.port, + // Advertised as both the `quic` and `tcp` entries: TCP and UDP are + // separate namespaces, so `build_swarm` binds both from this one number. + p2p_port: p2p_socket.port(), bootnodes: bootnodes.clone(), + advertise_ip: common.discovery.advertise_ip, + target_peers: common.discovery.target_peers, + subscription_subnets: setup.discovery.subscription_subnets, + attestation_committee_count: setup.discovery.attestation_committee_count, + fork_id: setup.discovery.fork_id, + custody_group_count: setup.discovery.custody_group_count, + }); + + let built = build_swarm(SwarmConfig { + node_key: node_p2p_key, + bootnodes, listening_socket: p2p_socket, - validator_ids, - attestation_committee_count, - subscription_subnets: subscribed_subnets.clone(), - milliseconds_per_slot: genesis_config.milliseconds_per_slot, + // The same number `discovery` above carries to the dial loop: on beacon + // the connection limits are derived from it, so the swarm refuses what + // the loop has stopped asking for. + target_peers: common.discovery.target_peers, + wire: setup.wire, + agent_version: version::CLIENT_VERSION, }) .wrap_err("failed to build swarm")?; - // Capture the local peer ID before `built` is moved into the P2P actor; the - // RPC `/lean/v0/node/identity` endpoint reports it. + // Captured before `built` is moved into the P2P actor; the RPC + // `/lean/v0/node/identity` endpoint reports it. let local_peer_id = built.local_peer_id.to_string(); - // `None` when discovery is disabled; `P2P::spawn` starts the discv5 server - // from it and owns the resulting handle. - let discovery = options.discovery.enable.then(|| DiscoverySpawnConfig { - node_key: node_p2p_key, - bind_ip: p2p_socket.ip(), - discovery_port: options.discovery.port, - p2p_port: p2p_socket.port(), - subscription_subnets: subscribed_subnets, - attestation_committee_count, - bootnodes, - advertise_ip: options.discovery.advertise_ip, - target_peers: options.discovery.target_peers, + // `P2P::spawn` starts the discv5 server from this and owns the resulting + // handle. + // Filled by the Beacon API's pool endpoint and the aggregator subnets, and + // read by the aggregate endpoint and block production; unused on lean. + let attestation_pool = + ethlambda_state_transition::beacon::attestation_pool::SharedAttestationPool::default(); + let p2p = P2P::spawn( + built, + setup.store.clone(), + setup.node_names, + discovery, + attestation_pool.clone(), + ) + .await + .wrap_err("failed to start discv5 discovery")?; + + let shutdown = CancellationToken::new(); + let rpc_shutdown = shutdown.clone(); + let rpc_store = setup.store.clone(); + let rpc_aggregator = aggregator.clone(); + let rpc_sync_status = sync_status.clone(); + let rpc_events = events.clone(); + let rpc_p2p = p2p.actor_ref().to_rpc_to_p2p_ref(); + // Block production builds its payloads with the same execution client the + // chain actor validates them with. + let rpc_engine = match &setup.chain { + ChainActor::Beacon { engine, .. } => engine.clone(), + ChainActor::Lean(..) => None, + }; + + // Which HTTP surface this node serves follows from the store's own chain + // tag rather than from the sub-command, so the two can never disagree. + // A beacon node served `/lean/v0` until now, off a store those handlers + // cannot read: they reach for lean state variants and metadata keys a + // beacon directory never carries, so calling one panicked that request. + let serves_beacon_api = setup.store.chain() == ethlambda_storage::Chain::Beacon; + + let http = tokio::spawn(async move { + let served = if serves_beacon_api { + ethlambda_rpc::start_beacon_rpc_server( + rpc_config, + rpc_store, + rpc_sync_status, + ethlambda_rpc::BeaconApiHandles { + p2p: rpc_p2p, + attestation_pool: attestation_pool.clone(), + engine: rpc_engine, + }, + local_peer_id, + rpc_shutdown, + ) + .await + } else { + ethlambda_rpc::start_rpc_server( + rpc_config, + rpc_store, + rpc_aggregator, + rpc_sync_status, + local_peer_id, + rpc_events, + rpc_shutdown, + ) + .await + }; + let _ = served.inspect_err(|err| error!(%err, "RPC server failed")); }); - let p2p = P2P::spawn(built, store.clone(), node_names, discovery) - .await - .wrap_err("failed to start discv5 discovery")?; + let blockchain = match setup.chain { + ChainActor::Lean(validator_keys, config) => { + BlockChain::spawn(setup.store, validator_keys, config, events) + } + ChainActor::Beacon { + custody_columns, + engine, + safe_slots_to_import_optimistically, + } => { + check_custody_set(&custody_columns)?; + BlockChain::spawn_beacon( + setup.store, + sync_status, + events, + custody_columns, + engine, + safe_slots_to_import_optimistically, + ) + } + }; + + let p2p_ref = p2p.actor_ref(); + let p2p_to_block_chain = p2p_ref.to_block_chain_to_p2p_ref(); // Wire actors together via protocol refs blockchain .actor_ref() .recipient::() .send(InitP2P { - p2p: p2p.actor_ref().to_block_chain_to_p2p_ref(), + p2p: p2p_to_block_chain, }) .inspect_err(|err| error!(%err, "Failed to send InitP2P — actors not wired"))?; - p2p.actor_ref() + + p2p_ref .recipient::() .send(InitBlockChain { blockchain: blockchain.actor_ref().to_p2p_to_block_chain_ref(), }) .inspect_err(|err| error!(%err, "Failed to send InitBlockChain — actors not wired"))?; - let shutdown_token = CancellationToken::new(); - let rpc_shutdown = shutdown_token.clone(); + wait_for_shutdown(RunningNode { + p2p, + blockchain, + http, + shutdown, + }) + .await; + Ok(()) +} - let rpc_handle = tokio::spawn(async move { - let _ = ethlambda_rpc::start_rpc_server( - rpc_config, - store, - aggregator, - sync_status, - local_peer_id, - events, - rpc_shutdown, - ) - .await - .inspect_err(|err| error!(%err, "RPC server failed")); - }); +/// Reject a beacon node with no custody set before it spawns. +/// +/// `data_availability_for` used to refuse this per block. It cannot any more: +/// the replay benchmark is a legitimate caller with an empty set, and the +/// actor cannot tell the two apart. A node can, here, once, at startup. +fn check_custody_set(custody_columns: &[u64]) -> eyre::Result<()> { + eyre::ensure!( + !custody_columns.is_empty(), + "beacon node computed an empty custody set; it would treat every fulu \ + block as available without checking a column" + ); + Ok(()) +} +/// Wait for ctrl-c, then stop and join whatever is running. +/// +/// A 2nd, 3rd and 4th ctrl-c escalate to `std::process::exit(1)` rather than +/// leaving shutdown stuck on an actor that hangs in `stop()`/`join()`. +/// +/// Shared by both chains. Mainnet used to park on `pending()` here, so a +/// follower killed mid-write left recovery to do; it now tears down the same +/// way the lean node does. +async fn wait_for_shutdown(node: RunningNode) { info!("Node initialized"); // 1st ctrl+c: start graceful shutdown @@ -437,19 +867,79 @@ async fn run_node(options: NodeOptions) -> eyre::Result<()> { std::process::exit(1); }); - let blockchain_ref = blockchain.actor_ref().clone(); - let p2p_ref = p2p.actor_ref().clone(); + let blockchain_ref = node.blockchain.actor_ref().clone(); + let p2p_ref = node.p2p.actor_ref().clone(); + blockchain_ref.context().stop(); p2p_ref.context().stop(); - shutdown_token.cancel(); + node.shutdown.cancel(); blockchain_ref.join().await; p2p_ref.join().await; - let _ = rpc_handle.await; + let _ = node.http.await; info!("Shutdown complete"); +} - Ok(()) +/// The ENRs to start from when `--bootnodes` was not given. +/// +/// A resolved network (built-in mainnet, or a loaded directory) publishes its +/// own bootnode list, so an absent flag means "use it". A lean network's ENRs +/// are per-deployment, so there is nothing to default to and an absent flag +/// means this node reaches peers only through discv5, which a lean node runs +/// only with `--discovery.enable`, or by being dialed. That case warns, because +/// a node that then finds nobody is islanded and otherwise looks healthy. +fn default_bootnodes(source: Option<&network::NetworkSource>) -> Vec { + match source { + None => { + warn!( + "No --bootnodes file supplied: starting with no bootnodes. This node can \ + only find peers via discv5 (--discovery.enable) or by being dialed." + ); + Vec::new() + } + Some(source) => source.bootnodes(), + } +} + +/// Read a bootnode file into one ENR string per entry. +/// +/// Shared by both chains: [`run_node`] reads the file once and hands the result +/// to [`parse_enrs`], which skips an unusable record with a warning rather than +/// failing startup, and warns again if that leaves the list empty. +/// +/// YAML first, then a line-oriented fallback. The two sub-commands arrived +/// with different readers: `node` required a strict YAML sequence, `beacon` +/// used a tolerant line parser so that a list pasted from a chat message or a +/// comment left in the file would still work. Trying YAML first makes the +/// merged reader a true superset of both, which a line parser alone is not: a +/// quoted (`"enr:..."`) or flow-style (`["enr:...", ...]`) sequence is valid +/// YAML that `node` accepted before, and a line parser would hand its quotes +/// and brackets on to `parse_enrs`. +/// +/// The fallback is not an error path. Anything YAML rejects, a bare list with +/// no `- ` markers or a stray `#` comment in a file that is otherwise a flow +/// sequence, is exactly what the tolerant reader exists for. +fn read_bootnode_strings(path: &Path) -> eyre::Result> { + let contents = std::fs::read_to_string(path) + .wrap_err_with(|| format!("failed to read bootnodes from {}", path.display()))?; + Ok(parse_bootnode_strings(&contents)) +} + +/// [`read_bootnode_strings`] without the file: also what a built-in network's +/// embedded `bootstrap_nodes.yaml` is read through, so a file on disk and a +/// file in the binary cannot be parsed two different ways. +fn parse_bootnode_strings(contents: &str) -> Vec { + if let Ok(entries) = serde_yaml_ng::from_str::>(contents) { + return entries; + } + + contents + .lines() + .map(|line| line.trim().trim_start_matches("- ").trim()) + .filter(|line| !line.is_empty() && !line.starts_with('#')) + .map(|line| line.to_string()) + .collect() } /// Apply the Shadow-simulator sim-cost / fake-XMSS configuration from the CLI. @@ -560,23 +1050,6 @@ fn load_node_names(file: &ValidatorConfigFile) -> HashMap { ethlambda_p2p::derive_peer_ids(names_and_privkeys) } -fn read_bootnodes(bootnodes_path: impl AsRef) -> eyre::Result> { - let bootnodes_path = bootnodes_path.as_ref(); - let bootnodes_yaml = std::fs::read_to_string(bootnodes_path).wrap_err_with(|| { - format!( - "failed to read bootnodes file from {}", - bootnodes_path.display() - ) - })?; - let enrs: Vec = serde_yaml_ng::from_str(&bootnodes_yaml).wrap_err_with(|| { - format!( - "failed to parse bootnodes file from {}", - bootnodes_path.display() - ) - })?; - Ok(parse_enrs(enrs)) -} - /// One entry in `annotated_validators.yaml` as emitted by `lean-quickstart`'s /// genesis generator. /// @@ -745,6 +1218,38 @@ fn read_hex_file_bytes(path: impl AsRef) -> eyre::Result> { .wrap_err_with(|| format!("failed to decode hex file from {}", path.display())) } +/// Resolve a sub-command's node key: read `--node-key` if given, otherwise +/// generate a fresh secp256k1 key in memory. +/// +/// Shared by both sub-commands, since `--node-key` is optional on both. There +/// is no precedent elsewhere in this binary for writing generated key material +/// to `--data-dir`, and doing so would need file permissions this repo does +/// not otherwise establish; keeping it in memory only is the conservative +/// choice, so a generated identity does not survive a restart. +/// +/// The warning below is about `PeerId`/ENR churn, which costs a lean node its +/// place in every peer's scoring and in any ENR its neighbours cached. +/// `beacon` has no validator identity to protect, but it is not exempt +/// either: its custody columns are a function of this same node id, so an +/// unstable identity there means a different custody set on every restart. +/// `beacon::wire_params` carries its own, more specific warning about that; +/// see it for why `beacon` needs a second one. +fn resolve_node_key(node_key_path: Option<&Path>) -> eyre::Result> { + match node_key_path { + Some(path) => read_hex_file_bytes(path) + .wrap_err_with(|| format!("failed to load node key from {}", path.display())), + None => { + let generated = secp256k1::SecretKey::new(&mut secp256k1::rand::rngs::OsRng); + warn!( + "No --node-key supplied: generated an ephemeral secp256k1 key in memory for \ + this run only. This node's PeerId and ENR will be different on the next \ + start; pass --node-key with a persisted key file for a stable identity." + ); + Ok(generated.secret_bytes().to_vec()) + } + } +} + /// Fetch the initial state for the node. /// /// State already on disk wins: a previous run's DB is resumed from whenever it @@ -790,35 +1295,126 @@ async fn fetch_initial_state( ) -> Result { let validators = genesis.validators(); - // Prefer resuming from on-disk state to avoid re-downloading what we already - // have. Tried before the checkpoint-sync and genesis paths so that a restart - // without `--checkpoint-sync-url` keeps the chain instead of writing a - // slot-0 anchor over it. - if let Some(store) = Store::from_db_state(backend.clone(), genesis)? { - let now_ms = SystemTime::UNIX_EPOCH - .elapsed() - .expect("already past the unix epoch") - .as_millis() as u64; - let current_slot = - now_ms.saturating_sub(genesis.genesis_time * 1000) / genesis.milliseconds_per_slot; - let head_slot = store.head_slot(); - let gap = current_slot.saturating_sub(head_slot); - if gap <= MAX_RESUMABLE_DB_STATE_AGE { - info!(head_slot, current_slot, gap, "Resuming from existing DB"); - return Ok(store); + // Prefer resuming from on-disk state to avoid re-downloading what we + // already have. Tried before the checkpoint-sync and genesis paths so that + // a restart without `--checkpoint-sync-url` keeps the chain instead of + // writing a slot-0 anchor over it. + // + // `from_db_state` loads without judging, so the identity check is here: + // the wrong chain or the wrong genesis aborts startup rather than being + // built on top of. + 'resume: { + if let Some(mut store) = Store::from_db_state(backend.clone())? { + if store.chain() != Chain::Lean { + return Err(checkpoint_sync::CheckpointSyncError::WrongChain { + expected: Chain::Lean, + found: store.chain(), + }); + } + + // The slot duration is deliberately absent from the SSZ state, so the + // state check below cannot see it: compare the persisted config's time + // grid. A data directory built at another cadence indexes its blocks + // against a different time grid, which makes it as foreign as another + // genesis. + let persisted_grid = store.config().time_grid(); + genesis + .verify_time_config(&persisted_grid) + .inspect_err(|err| { + error!( + %err, + db_genesis_time = persisted_grid.genesis_time, + db_milliseconds_per_slot = persisted_grid.milliseconds_per_slot, + expected_genesis_time = genesis.genesis_time, + expected_milliseconds_per_slot = genesis.milliseconds_per_slot, + "Persisted DB was built on a different time grid; refusing to reuse this data directory" + ) + })?; + + // Justified and finalized must both have a persisted state before + // anything else is trusted: `repair_head` below assumes it, and a + // directory that fails this check needs a fresh anchor, not a + // storage-layer repair (see `Store::verify_anchor_states`'s doc). + // Treated exactly like a stale DB below: fall back to checkpoint + // sync if a URL is configured (`break 'resume` does that, the same + // way falling out of this `match` without returning does further + // down), otherwise fail naming the remedy. + let state = match store.verify_anchor_states() { + Ok(state) => state, + Err(err @ ethlambda_storage::Error::AnchorStateLost { checkpoint }) => { + if checkpoint_urls.is_empty() { + error!(?checkpoint, %err, "Anchor checkpoint's state is missing"); + return Err(err.into()); + } + warn!( + ?checkpoint, + "Anchor checkpoint's state is missing; checkpoint sync" + ); + break 'resume; + } + Err(err) => return Err(err.into()), + }; + + genesis.verify_state(&state).inspect_err(|err| { + error!( + %err, + db_genesis_time = state.genesis_time(), + expected_genesis_time = genesis.genesis_time, + expected_validators = genesis.genesis_validators.len(), + "Persisted DB belongs to a different network; refusing to reuse this data directory" + ) + })?; + + // The only mutation on this path, and only reached once both + // checks above have passed; see `Store::from_db_state`'s doc. + // `repair_head` can raise the same `AnchorStateLost` its own doc + // lists as one of its three outcomes (the walk reaching at or + // below finalized), so it gets the same fallback rather than a + // bare `?`: a node with a checkpoint-sync URL configured should + // resync, not abort, in exactly the situation this repair exists + // for. + match store.repair_head() { + Ok(()) => {} + Err(err @ ethlambda_storage::Error::AnchorStateLost { checkpoint }) => { + if checkpoint_urls.is_empty() { + error!(?checkpoint, %err, "Anchor checkpoint's state is missing"); + return Err(err.into()); + } + warn!( + ?checkpoint, + "Anchor checkpoint's state is missing; checkpoint sync" + ); + break 'resume; + } + Err(err) => return Err(err.into()), + } + + let now_ms = SystemTime::UNIX_EPOCH + .elapsed() + .expect("already past the unix epoch") + .as_millis() as u64; + let current_slot = + now_ms.saturating_sub(genesis.genesis_time * 1000) / genesis.milliseconds_per_slot; + let head_slot = store.head_slot(); + let gap = current_slot.saturating_sub(head_slot); + if gap <= MAX_RESUMABLE_DB_STATE_AGE { + info!(head_slot, current_slot, gap, "Resuming from existing DB"); + return Ok(store); + } + // No checkpoint URL was configured, so just run the node + // against the data directory it was given: that is the setup + // asked for, and there is no anchor to switch to. The warning + // is the point of this arm, since the DB is known to be stale + // and range sync may not be able to close a gap this large: + // peers prune block signatures past `SIGNATURE_PRUNING_RANGE`, + // so beyond that horizon they cannot serve the history the + // node is missing. + if checkpoint_urls.is_empty() { + warn!(head_slot, current_slot, gap, "DB is stale; resuming anyway"); + return Ok(store); + } + warn!(head_slot, current_slot, gap, "DB is stale; checkpoint sync"); } - // No checkpoint URL was configured, so just run the node against the - // data directory it was given: that is the setup asked for, and there - // is no anchor to switch to. The warning is the point of this arm, - // since the DB is known to be stale and range sync may not be able to - // close a gap this large: peers prune block signatures past - // `SIGNATURE_PRUNING_RANGE`, so beyond that horizon they cannot serve - // the history the node is missing. - if checkpoint_urls.is_empty() { - warn!(head_slot, current_slot, gap, "DB is stale; resuming anyway"); - return Ok(store); - } - warn!(head_slot, current_slot, gap, "DB is stale; checkpoint sync"); } if checkpoint_urls.is_empty() { @@ -837,7 +1433,7 @@ async fn fetch_initial_state( let (state, signed_block) = checkpoint_sync::fetch_anchor_with_retry( checkpoint_urls, genesis.genesis_time, - &validators, + genesis.genesis_validators_root(), ) .await?; @@ -863,12 +1459,520 @@ async fn fetch_initial_state( .inspect_err(|err| error!(%err, "Failed to initialize store from anchor state and block")) .map_err(|_| checkpoint_sync::CheckpointSyncError::AnchorPairingMismatch)?; store - .insert_signed_block(anchor_root, signed_block) + .insert_signed_block(anchor_root, SignedBeaconBlock::Lean(signed_block)) .inspect_err(|err| error!(%err, "Failed to insert anchor signed block into store")) .map_err(|_| checkpoint_sync::CheckpointSyncError::StoreInsertSignedBlock)?; Ok(store) } +/// Name the first configuration field that disagrees with the persisted one. +/// +/// A changed fork epoch leaves genesis time and the validators root untouched, +/// so `verify_state_genesis` cannot see it, while putting this node on a +/// different chain from its peers at that epoch. Comparing the whole struct +/// catches it, and naming the field is what makes the failure actionable: +/// Lighthouse reports the same situation as an SSZ decode error whose own +/// message admits it is guessing between a wrong network and a corrupt +/// database. +fn first_config_difference(persisted: &Config, supplied: &Config) -> Option { + macro_rules! compare { + ($($field:ident),+ $(,)?) => { + $( + if persisted.$field != supplied.$field { + return Some(format!( + "{}: directory has {:?}, config file says {:?}", + stringify!($field), persisted.$field, supplied.$field + )); + } + )+ + }; + } + + // Every field of `Config`, named once, with no `..`: adding a field there + // is a compile error here until it is triaged into `compare!` below or + // bound to `_` with a comment saying why it is operator tuning rather + // than chain identity. This binds nothing useful (`compare!` reads + // `persisted`/`supplied` directly); it exists purely to force that + // choice. + let Config { + // Identity. `preset_base` is compared below: `check_preset` and the + // directory's preset byte already pin the compiled preset, and this + // also pins the stored string, which `/eth/v1/config/spec` reports, to + // the file's. `config_name` is only a label with no consensus effect, + // so the caller warns about a changed one rather than refusing it: + // renaming a devnet must not cost its nodes their data directories. + preset_base: _, + config_name: _, + + // Genesis construction: read only while building a genesis state + // from Eth1 deposit history, never again once one exists. Not chain + // identity for a directory that already has a state. + min_genesis_active_validator_count: _, + min_genesis_time: _, + genesis_delay: _, + // Comes from the genesis state, not the config file; + // `verify_state_genesis` already covers it. + genesis_time: _, + + // Fork scheduling: compared below. Changes which fork a block signs + // under and when, so a mismatch here silently forks this node from + // its peers. + genesis_fork_version: _, + altair_fork_version: _, + altair_fork_epoch: _, + bellatrix_fork_version: _, + bellatrix_fork_epoch: _, + capella_fork_version: _, + capella_fork_epoch: _, + deneb_fork_version: _, + deneb_fork_epoch: _, + electra_fork_version: _, + electra_fork_epoch: _, + fulu_fork_version: _, + fulu_fork_epoch: _, + + // Time parameters: `seconds_per_slot`/`slot_duration_ms` (compared + // below) move every slot boundary. The rest are operator-visible + // timing preferences (reorg cutoffs, sync-message windows) that do + // not change which block is valid. + seconds_per_slot: _, + slot_duration_ms: _, + seconds_per_eth1_block: _, + // Compared below: bounds how many validators may enter the + // exit/activation queue and when a proposer/exiting validator is + // eligible, both state-transition rules. + min_validator_withdrawability_delay: _, + shard_committee_period: _, + eth1_follow_distance: _, + attestation_due_bps: _, + aggregate_due_bps: _, + proposer_reorg_cutoff_bps: _, + sync_message_due_bps: _, + contribution_due_bps: _, + + // Validator cycle: compared below. Churn and inactivity-leak + // parameters change which exits, activations and inactivity scores a + // block may legally carry. + inactivity_score_bias: _, + inactivity_score_recovery_rate: _, + ejection_balance: _, + min_per_epoch_churn_limit: _, + churn_limit_quotient: _, + max_per_epoch_activation_churn_limit: _, + min_per_epoch_churn_limit_electra: _, + max_per_epoch_activation_exit_churn_limit: _, + + // Fork choice: weighting/timing knobs a node applies to its own view + // of the chain. They change which head a node *prefers*, not which + // block is valid, so two nodes running different values still agree + // on validity. + proposer_score_boost: _, + reorg_head_weight_threshold: _, + reorg_parent_weight_threshold: _, + reorg_max_epochs_since_finalization: _, + + // Transition (bellatrix): mainnet crossed this in 2022 and every + // shipped network leaves it at its default; not worth chain-identity + // treatment for the same reason the fork-choice group above is not. + terminal_total_difficulty: _, + terminal_block_hash: _, + terminal_block_hash_activation_epoch: _, + + // Blob limits: compared below. Bound how many blobs a block may + // legally carry. + max_blobs_per_block_deneb: _, + max_blobs_per_block_electra: _, + blob_schedule: _, + + // Networking: describe the wire, not the state transition. A + // config.yaml carries them only so `/eth/v1/config/spec` can echo + // them back; nothing here changes which block is valid. + attestation_propagation_slot_range: _, + attestation_subnet_count: _, + attestation_subnet_extra_bits: _, + blob_sidecar_subnet_count: _, + blob_sidecar_subnet_count_electra: _, + data_column_sidecar_subnet_count: _, + epochs_per_subnet_subscription: _, + max_payload_size: _, + max_request_blocks: _, + max_request_blocks_deneb: _, + max_request_payloads: _, + maximum_gossip_clock_disparity: _, + message_domain_invalid_snappy: _, + message_domain_valid_snappy: _, + min_epochs_for_blob_sidecars_requests: _, + min_epochs_for_data_column_sidecars_requests: _, + subnets_per_node: _, + + // Deposit contract: compared below. Never read by the state + // transition itself (a deposit is processed from the block, not the + // contract), but kept as a network fingerprint: two networks sharing + // every consensus parameter while watching different Eth1 contracts + // are still different networks. + deposit_chain_id: _, + deposit_network_id: _, + deposit_contract_address: _, + + // PeerDAS custody: describes what this node samples/custodies, an + // operator/wire choice, not a state-transition rule. + balance_per_additional_custody_group: _, + custody_requirement: _, + number_of_custody_groups: _, + samples_per_slot: _, + validator_custody_requirement: _, + + // Compared below: electra's Gwei-denominated consolidation churn + // limit, alongside the other churn fields above. + consolidation_churn_limit_quotient: _, + + // Networking (added after an incomplete initial key list): the same + // wire-description reasoning as the networking group above. + attestation_subnet_prefix_bits: _, + max_request_blob_sidecars: _, + max_request_blob_sidecars_electra: _, + max_request_data_column_sidecars: _, + min_epochs_for_block_requests: _, + } = persisted; + + compare!( + preset_base, + genesis_fork_version, + altair_fork_version, + altair_fork_epoch, + bellatrix_fork_version, + bellatrix_fork_epoch, + capella_fork_version, + capella_fork_epoch, + deneb_fork_version, + deneb_fork_epoch, + electra_fork_version, + electra_fork_epoch, + fulu_fork_version, + fulu_fork_epoch, + seconds_per_slot, + slot_duration_ms, + min_validator_withdrawability_delay, + shard_committee_period, + inactivity_score_bias, + inactivity_score_recovery_rate, + ejection_balance, + min_per_epoch_churn_limit, + churn_limit_quotient, + max_per_epoch_activation_churn_limit, + min_per_epoch_churn_limit_electra, + max_per_epoch_activation_exit_churn_limit, + consolidation_churn_limit_quotient, + max_blobs_per_block_deneb, + max_blobs_per_block_electra, + blob_schedule, + deposit_chain_id, + deposit_network_id, + deposit_contract_address, + ); + None +} + +/// Fetch the initial state for a beacon node. +/// +/// The beacon twin of [`fetch_initial_state`], with the same precedence: a +/// resumable directory wins over a download, and a download wins over a +/// directory that has fallen too far behind +/// ([`MAX_RESUMABLE_DB_STATE_AGE`]). +/// +/// One row differs, and only partly. Lean initializes from its genesis config +/// when there is neither a DB nor a URL; beacon does the same for a +/// [`network::NetworkSource::Loaded`] network, since a freshly started devnet +/// has no checkpoint provider at slot 0 and this is the only way to join one. +/// A built-in network still aborts: this node imports nothing at startup, so +/// it would park at slot 0 while claiming to follow a chain that has been live +/// for years (and no built-in network carries a genesis state at all; see +/// [`network::built_in`]). See [`genesis_anchor_block`] for the block this +/// pairs with the genesis state to build that anchor. +/// +/// Staleness reuses [`MAX_RESUMABLE_DB_STATE_AGE`], which is expressed in +/// slots: 90 minutes at beacon's 12-second slots against 30 at lean's four. +/// Worth revisiting when block import lands and the cost of a gap becomes +/// real. +async fn fetch_initial_beacon_state( + checkpoint_urls: &[String], + backend: Arc, + source: &network::NetworkSource, +) -> Result { + let config = source.config().clone(); + let genesis = source.genesis(); + + 'resume: { + if let Some(mut store) = Store::from_db_state(backend.clone())? { + if store.chain() != Chain::Beacon { + return Err(checkpoint_sync::CheckpointSyncError::WrongChain { + expected: Chain::Beacon, + found: store.chain(), + }); + } + + // Justified and finalized must both have a persisted state before + // anything else is trusted: `repair_head` below assumes it, and a + // directory that fails this check needs a fresh anchor, not a + // storage-layer repair (see `Store::verify_anchor_states`'s doc). + // Treated exactly like a stale DB below: fall back to checkpoint + // sync if a URL is configured (`break 'resume` does that, the same + // way falling out of this `match` without returning does further + // down), otherwise fail naming the remedy. + let state = match store.verify_anchor_states() { + Ok(state) => state, + Err(err @ ethlambda_storage::Error::AnchorStateLost { checkpoint }) => { + if checkpoint_urls.is_empty() { + error!(?checkpoint, %err, "Anchor checkpoint's state is missing"); + return Err(err.into()); + } + warn!( + ?checkpoint, + "Anchor checkpoint's state is missing; checkpoint sync" + ); + break 'resume; + } + Err(err) => return Err(err.into()), + }; + + verify_state_genesis( + &state, + genesis.genesis_time, + genesis.genesis_validators_root, + ) + .inspect_err(|err| { + error!( + %err, + db_genesis_time = state.genesis_time(), + expected_genesis_time = genesis.genesis_time, + "Persisted DB belongs to a different network; refusing to reuse this data directory" + ) + })?; + + let persisted = store.config(); + if let Some(difference) = first_config_difference(&persisted, &config) { + error!(%difference, "Persisted config disagrees with the network config"); + return Err(checkpoint_sync::CheckpointSyncError::ConfigChanged { difference }); + } + // Not a chain value, so not refused (see `first_config_difference`). + // `Metadata["config"]` is never rewritten, so the stored name is + // the one `/eth/v1/config/spec` goes on reporting. + if persisted.config_name != config.config_name { + warn!( + stored = %persisted.config_name, + supplied = %config.config_name, + "CONFIG_NAME differs from the one this data directory was initialized with; \ + resuming, and reporting the stored name" + ); + } + + // The only mutation on this path, and only reached once both + // checks above have passed; see `Store::from_db_state`'s doc. + // Also what makes `beacon_head`'s `.expect` below safe: a + // directory whose head had no state at all would have failed + // `verify_anchor_states` above instead of reaching here (see + // "A head with no block at all" on `repair_head`'s doc), so + // this call always leaves the head naming a real block, once it + // does not fall through below. + // + // `repair_head` can raise the same `AnchorStateLost` its own doc + // lists as one of its three outcomes (the walk reaching at or + // below finalized), so it gets the same fallback rather than a + // bare `?`: a node with a checkpoint-sync URL configured should + // resync, not abort, in exactly the situation this repair exists + // for. + match store.repair_head() { + Ok(()) => {} + Err(err @ ethlambda_storage::Error::AnchorStateLost { checkpoint }) => { + if checkpoint_urls.is_empty() { + error!(?checkpoint, %err, "Anchor checkpoint's state is missing"); + return Err(err.into()); + } + warn!( + ?checkpoint, + "Anchor checkpoint's state is missing; checkpoint sync" + ); + break 'resume; + } + Err(err) => return Err(err.into()), + } + + let now = SystemTime::UNIX_EPOCH + .elapsed() + .expect("already past the unix epoch") + .as_secs(); + let current_slot = now.saturating_sub(genesis.genesis_time) / config.seconds_per_slot; + let (head_slot, _) = store + .beacon_head() + .expect("repair_head leaves the head naming a real block"); + let gap = current_slot.saturating_sub(head_slot); + + if gap <= MAX_RESUMABLE_DB_STATE_AGE { + info!(head_slot, current_slot, gap, "Resuming from existing DB"); + return Ok(store); + } + if checkpoint_urls.is_empty() { + warn!(head_slot, current_slot, gap, "DB is stale; resuming anyway"); + return Ok(store); + } + warn!(head_slot, current_slot, gap, "DB is stale; checkpoint sync"); + } + } + + // A loaded network carries its own genesis state, which is a legitimate + // anchor: a fresh devnet has no checkpoint provider at slot 0, so this is + // the only way to join one. A built-in network still refuses, because + // every one has been live for years and this follower would sit at slot 0 + // claiming to follow a live chain. + if checkpoint_urls.is_empty() { + let network::NetworkSource::Loaded(loaded) = source else { + return Err(checkpoint_sync::CheckpointSyncError::BeaconGenesisSync); + }; + + let state = loaded.genesis_state.as_ref().clone(); + let block = genesis_anchor_block(&state); + info!( + genesis_time = genesis.genesis_time, + fork = state.fork_name().as_str(), + "No checkpoint URL and no resumable directory: anchoring at this network's genesis" + ); + return fork_choice::get_forkchoice_store(backend, state, block, &config) + .inspect_err(|err| error!(%err, "Failed to initialize store from the genesis state")) + .map_err(|_| checkpoint_sync::CheckpointSyncError::AnchorPairingMismatch); + } + + info!(?checkpoint_urls, "Starting beacon checkpoint sync"); + + let (state, block) = checkpoint_sync::fetch_beacon_anchor_with_retry( + checkpoint_urls, + &config, + genesis.genesis_time, + genesis.genesis_validators_root, + ) + .await?; + + info!( + slot = state.slot(), + fork = %state.fork_name(), + validators = state.validators().len(), + finalized_epoch = state.finalized_checkpoint().epoch, + anchor_block_slot = block.slot(), + "Beacon checkpoint sync complete" + ); + + fork_choice::get_forkchoice_store(backend, state, block, &config) + .inspect_err(|err| error!(%err, "Failed to initialize store from anchor state and block")) + .map_err(|_| checkpoint_sync::CheckpointSyncError::AnchorPairingMismatch) +} + +/// The block the specification pairs with a genesis anchor state. +/// +/// `get_forkchoice_store` wants the block that produced the anchor state. At +/// genesis no such block exists, so the specification substitutes an empty +/// block carrying the genesis state root. The state's own +/// `latest_block_header` already describes that block with a zeroed state +/// root, so filling the root in and rebuilding the body is the whole +/// construction. +/// +/// The body has to be rebuilt rather than read off the state, because the +/// state does not carry one: `latest_block_header.body_root` is only ever a +/// merkle root, never the body itself. `state.fork_name()`'s empty body is +/// what that root already commits to (see `BeaconBlockBody::empty` on each +/// fork whose body cannot derive `Default`, in `ethlambda-types`), so +/// rebuilding it here and letting `get_forkchoice_store` check the header +/// hash is what proves this reconstruction matches what the state actually +/// describes, rather than assuming it. +fn genesis_anchor_block(state: &BeaconState) -> SignedBeaconBlock { + let header = state.latest_block_header(); + let slot = header.slot; + let proposer_index = header.proposer_index; + let parent_root = header.parent_root; + let state_root = state.hash_tree_root(); + + match state.fork_name() { + ForkName::Phase0 => SignedBeaconBlock::Phase0(phase0::SignedBeaconBlock { + message: phase0::BeaconBlock { + slot, + proposer_index, + parent_root, + state_root, + body: phase0::BeaconBlockBody::default(), + }, + signature: Default::default(), + }), + ForkName::Altair => SignedBeaconBlock::Altair(altair::SignedBeaconBlock { + message: altair::BeaconBlock { + slot, + proposer_index, + parent_root, + state_root, + body: altair::BeaconBlockBody::default(), + }, + signature: Default::default(), + }), + ForkName::Bellatrix => SignedBeaconBlock::Bellatrix(bellatrix::SignedBeaconBlock { + message: bellatrix::BeaconBlock { + slot, + proposer_index, + parent_root, + state_root, + body: bellatrix::BeaconBlockBody::empty(), + }, + signature: Default::default(), + }), + ForkName::Capella => SignedBeaconBlock::Capella(capella::SignedBeaconBlock { + message: capella::BeaconBlock { + slot, + proposer_index, + parent_root, + state_root, + body: capella::BeaconBlockBody::empty(), + }, + signature: Default::default(), + }), + ForkName::Deneb => SignedBeaconBlock::Deneb(deneb::SignedBeaconBlock { + message: deneb::BeaconBlock { + slot, + proposer_index, + parent_root, + state_root, + body: deneb::BeaconBlockBody::empty(), + }, + signature: Default::default(), + }), + ForkName::Electra => SignedBeaconBlock::Electra(electra::SignedBeaconBlock { + message: electra::BeaconBlock { + slot, + proposer_index, + parent_root, + state_root, + body: electra::BeaconBlockBody::empty(), + }, + signature: Default::default(), + }), + // Fulu's block is byte-for-byte electra's; see `SignedBeaconBlock::Fulu`'s + // own doc comment for why it wraps `electra::SignedBeaconBlock` instead + // of a fork-specific type. + ForkName::Fulu => SignedBeaconBlock::Fulu(electra::SignedBeaconBlock { + message: electra::BeaconBlock { + slot, + proposer_index, + parent_root, + state_root, + body: electra::BeaconBlockBody::empty(), + }, + signature: Default::default(), + }), + // Never reached: this is only called on a network's own genesis + // state, and every `NetworkSource` decodes a beacon fork there + // (`NetworkDir::load` resolves the fork from `Config::fork_at_epoch`, + // which only ever names a `ForkName::ALL` member). + ForkName::Lean => { + unreachable!("a beacon network's genesis state is never ForkName::Lean") + } + } +} + /// Reject an `--aggregate-subnet-ids` value that names no subnet. /// /// The flag feeds two consumers that read an out-of-range value differently: @@ -916,7 +2020,11 @@ fn resolve_aggregation_duty_subnet( #[cfg(test)] mod tests { use super::*; + use crate::command::{Command, try_parse_from}; + use ethlambda_storage::ForkCheckpoints; use ethlambda_storage::backend::InMemoryBackend; + use ethlambda_types::block::{Block, BlockBody, MultiMessageAggregate, SignedBlock}; + use ethlambda_types::checkpoint::Checkpoint; use ethlambda_types::constants::DEFAULT_MILLISECONDS_PER_SLOT; use ethlambda_types::genesis::GenesisValidatorEntry; use ethlambda_types::state::PUBLIC_KEY_SIZE; @@ -1084,6 +2192,20 @@ validators: assert_eq!(resolved, 1); } + #[test] + fn a_beacon_node_refuses_an_empty_custody_set() { + // The per-block fence moved here. A node that reached the actor with + // no custody set would treat every fulu block as available without + // ever checking a column. + let err = check_custody_set(&[]).expect_err("an empty set must not start a node"); + assert!( + err.to_string().contains("custody"), + "the error names what is wrong: {err}" + ); + + check_custody_set(&[0, 1, 2]).expect("a real custody set starts a node"); + } + /// Slot of the anchor seeded into the test DB. Any non-zero slot works: a /// genesis re-initialization always anchors at slot 0, so a non-zero head /// slot is what distinguishes "resumed from disk" from "started over". @@ -1215,6 +2337,120 @@ validators: ); } + /// A minimal signed lean block, the same shape the storage crate's own + /// tests use: empty body, no proof content, only the fields the diff + /// chain and the fork-choice walk actually read. + fn signed_block(slot: u64, proposer_index: u64, parent_root: H256) -> SignedBlock { + SignedBlock { + message: Block { + slot, + proposer_index, + parent_root, + state_root: H256::ZERO, + body: BlockBody::default(), + }, + proof: MultiMessageAggregate::default(), + } + } + + /// A child of `parent` at `slot`, inheriting its `config` and + /// `validators` rather than building an unrelated one: + /// `StateDiff` omits both, trusting they never change from parent to + /// child. + fn child_state(parent: &State, slot: u64, parent_root: H256) -> State { + let mut hbh = parent.historical_block_hashes.to_vec(); + hbh.push(parent_root); + let mut child = parent.clone(); + child.slot = slot; + child.latest_block_header = ethlambda_types::block::BlockHeader { + slot, + proposer_index: 0, + parent_root, + state_root: H256::ZERO, + body_root: H256::ZERO, + }; + child.historical_block_hashes = hbh.try_into().expect("within limit"); + child + } + + /// `repair_head` itself can raise `AnchorStateLost` (its own doc lists + /// the walk reaching at or below finalized as one of its three + /// outcomes), a separate path from the `verify_anchor_states` pre-check + /// covered by [`falls_through_to_checkpoint_sync_when_db_is_stale`] and + /// its siblings. This pins that it gets the same fallback rather than a + /// bare `?`: an unreachable checkpoint URL surfaces as a transport + /// error, proving checkpoint sync was actually attempted, not skipped in + /// favor of aborting. + #[tokio::test(start_paused = true)] + async fn falls_through_to_checkpoint_sync_when_repair_head_loses_the_anchor() { + let genesis = test_genesis(now_secs()); + let backend = Arc::new(InMemoryBackend::default()); + + let mut anchor = State::from_genesis(genesis.genesis_time, genesis.validators()); + anchor.slot = SEEDED_HEAD_SLOT; + anchor.latest_block_header.slot = SEEDED_HEAD_SLOT; + let mut store = Store::from_anchor_state( + backend.clone(), + anchor.clone(), + DEFAULT_MILLISECONDS_PER_SLOT, + ); + let r0 = store.head().expect("head root"); + + // A real, state-backed block one slot ahead, made both the justified + // and the finalized checkpoint: `verify_anchor_states` must pass for + // the flow to reach `repair_head` at all. + let finalized_slot = SEEDED_HEAD_SLOT + 1; + let finalized_block = signed_block(finalized_slot, 0, r0); + let r1 = finalized_block.message.hash_tree_root(); + store + .insert_signed_block(r1, SignedBeaconBlock::Lean(finalized_block)) + .expect("insert finalized block"); + store + .insert_state( + r1, + BeaconState::Lean(child_state(&anchor, finalized_slot, r0)), + ) + .expect("insert finalized state"); + let finalized_checkpoint = Checkpoint { + root: r1, + slot: finalized_slot, + }; + store + .update_checkpoints(ForkCheckpoints::new( + r1, + Some(finalized_checkpoint), + Some(finalized_checkpoint), + )) + .expect("advance justified and finalized"); + + // A sibling of the finalized block, same slot and parent, distinguished + // only by proposer index so its root differs, with no state ever + // inserted for it. Its parent is the anchor, not `r1`, so + // `repair_head`'s walk steps straight from here to the anchor's own + // slot without ever reaching `r1`; see its doc's finalized-slot bound. + let sibling_block = signed_block(finalized_slot, 1, r0); + let sibling_root = sibling_block.message.hash_tree_root(); + store + .insert_signed_block(sibling_root, SignedBeaconBlock::Lean(sibling_block)) + .expect("insert sibling block"); + store + .update_checkpoints(ForkCheckpoints::head_only(sibling_root)) + .expect("move head to the stateless sibling"); + + drop(store); + + let urls = [UNREACHABLE_CHECKPOINT_URL.to_string()]; + // `Store` is not `Debug`, so unwrap the error by pattern rather than + // with `expect_err`. + let Err(err) = fetch_initial_state(&urls, &genesis, backend).await else { + panic!("an unreachable checkpoint URL must abort startup, not silently resume"); + }; + assert!( + matches!(err, checkpoint_sync::CheckpointSyncError::Http(_)), + "expected checkpoint sync to actually be attempted (a transport error), got {err:?}" + ); + } + /// A DB from another network aborts startup rather than being re-anchored: /// writing genesis on top would leave the foreign blocks in place, and /// slot-indexed reads would serve them to peers. @@ -1231,12 +2467,12 @@ validators: }; assert!( - matches!(err, checkpoint_sync::CheckpointSyncError::DbState(_)), + matches!(err, checkpoint_sync::CheckpointSyncError::Genesis(_)), "unexpected error: {err}" ); // The foreign chain is left untouched, not overwritten with a new anchor. - let store = Store::from_db_state(backend, &seeded_genesis) - .expect("original DB still loads under its own genesis") + let store = Store::from_db_state(backend) + .expect("original DB still loads") .expect("store exists"); assert_eq!(store.head_slot(), SEEDED_HEAD_SLOT); } @@ -1257,8 +2493,456 @@ validators: }; assert!( - matches!(err, checkpoint_sync::CheckpointSyncError::DbState(_)), + matches!(err, checkpoint_sync::CheckpointSyncError::Genesis(_)), + "unexpected error: {err}" + ); + } + + /// A lean node must refuse a beacon-tagged data directory outright rather + /// than building lean rows on top of it: doing so would leave the beacon + /// chain's blocks in place, still reachable through the slot-indexed reads + /// that serve `BlocksByRange`, so peers would be served the wrong chain. + #[tokio::test] + async fn fails_when_db_holds_a_beacon_chain() { + use ethlambda_types::beacon::config::Config; + use ethlambda_types::beacon::containers::Checkpoint as BeaconCheckpoint; + + let backend = Arc::new(InMemoryBackend::default()); + // Non-zero root, the way the storage crate's own tests build a beacon + // anchor: a zero root would trip `UnanchoredDirectory` before the + // chain-tag check this test targets ever ran. + let anchor = BeaconCheckpoint { + epoch: 0, + root: H256::from([1u8; 32]), + }; + Store::init_beacon( + backend.clone(), + now_secs(), + Config::mainnet(), + anchor.root, + Store::beacon_checkpoint_as_stored(anchor), + 0, + ); + + let genesis = test_genesis(now_secs()); + let Err(err) = fetch_initial_state(&[], &genesis, backend).await else { + panic!("a beacon data directory must not be opened as lean"); + }; + + assert!( + matches!( + err, + checkpoint_sync::CheckpointSyncError::WrongChain { + expected: Chain::Lean, + found: Chain::Beacon, + } + ), "unexpected error: {err}" ); } + + /// Beacon has no genesis-sync path on the built-in network: with nothing + /// on disk and no URL, there is no anchor to start from and startup says + /// so rather than parking a node at slot 0 claiming to follow mainnet. + #[tokio::test] + async fn beacon_without_a_db_or_a_url_aborts() { + let backend = Arc::new(InMemoryBackend::default()); + let source = network::NetworkSource::built_in_mainnet().unwrap(); + + // `Store` is not `Debug`, so unwrap the error by pattern rather than + // with `expect_err`. + let Err(err) = fetch_initial_beacon_state(&[], backend, &source).await else { + panic!("no anchor is available"); + }; + + assert!(matches!( + err, + checkpoint_sync::CheckpointSyncError::BeaconGenesisSync + )); + } + + /// A lean directory is not a beacon one. Loading it would write beacon + /// rows over a lean chain's tables, and the slot-indexed reads behind + /// `BlocksByRange` would serve its blocks to beacon peers. + #[tokio::test] + async fn beacon_refuses_a_lean_data_directory() { + let genesis = test_genesis(now_secs()); + let backend = Arc::new(InMemoryBackend::default()); + seed_db(backend.clone(), &genesis); + let source = network::NetworkSource::built_in_mainnet().unwrap(); + + let urls = [UNREACHABLE_CHECKPOINT_URL.to_string()]; + // `Store` is not `Debug`, so unwrap the error by pattern rather than + // with `expect_err`. + let Err(err) = fetch_initial_beacon_state(&urls, backend, &source).await else { + panic!("a lean directory is not resumable as beacon"); + }; + + assert!(matches!( + err, + checkpoint_sync::CheckpointSyncError::WrongChain { + expected: Chain::Beacon, + found: Chain::Lean, + } + )); + } + + /// A fresh devnet has no checkpoint provider at slot 0, so a loaded + /// network anchors at its own `genesis.ssz` instead of refusing. + #[tokio::test] + async fn a_loaded_network_anchors_at_genesis_without_a_checkpoint_url() { + let dir = tempfile::tempdir().unwrap(); + std::fs::copy( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/networks/devnet/config.yaml"), + dir.path().join("config.yaml"), + ) + .unwrap(); + let state = beacon::mainnet_genesis_state().unwrap(); + std::fs::write(dir.path().join("genesis.ssz"), state.to_ssz()).unwrap(); + + let loaded = network::dir::NetworkDir::load(dir.path()).unwrap(); + let source = network::NetworkSource::Loaded(Box::new(loaded)); + let backend: Arc = Arc::new(InMemoryBackend::new()); + + let store = fetch_initial_beacon_state(&[], backend, &source) + .await + .expect("a loaded network anchors at its own genesis"); + assert_eq!(store.chain(), Chain::Beacon); + let (head_slot, _) = store + .beacon_head() + .expect("an anchored directory has a head"); + assert_eq!(head_slot, 0, "a genesis anchor is at slot 0"); + } + + /// A changed fork epoch leaves genesis time and the validators root + /// untouched, so `verify_state_genesis` cannot see it, while putting this + /// node on a different chain from its peers from that epoch on. Resuming + /// must compare the persisted config too, and name the field that moved. + #[tokio::test] + async fn a_resume_with_an_edited_config_names_the_field_that_changed() { + let dir = tempfile::tempdir().unwrap(); + std::fs::copy( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/networks/devnet/config.yaml"), + dir.path().join("config.yaml"), + ) + .unwrap(); + let state = beacon::mainnet_genesis_state().unwrap(); + std::fs::write(dir.path().join("genesis.ssz"), state.to_ssz()).unwrap(); + + let loaded = network::dir::NetworkDir::load(dir.path()).unwrap(); + let source = network::NetworkSource::Loaded(Box::new(loaded)); + let backend: Arc = Arc::new(InMemoryBackend::new()); + + // Anchor once, so the directory is resumable. + fetch_initial_beacon_state(&[], backend.clone(), &source) + .await + .expect("first run anchors"); + + // Now resume with one fork epoch moved. Genesis time and validators + // root are untouched, so the existing check cannot see this. + let mut edited = network::dir::NetworkDir::load(dir.path()).unwrap(); + edited.config.electra_fork_epoch += 1; + let tampered = network::NetworkSource::Loaded(Box::new(edited)); + + // `Store` is not `Debug`, so unwrap the error by pattern rather than + // with `unwrap_err`. + let Err(err) = fetch_initial_beacon_state(&[], backend, &tampered).await else { + panic!("an edited config must not be silently resumed"); + }; + let message = format!("{err}"); + assert!( + message.contains("electra_fork_epoch"), + "the error should name the field that changed: {message}" + ); + } + + /// `churn_limit_quotient` is one of the state-transition fields the + /// original field list omitted entirely: a directory with a different + /// churn quotient from its config file accepted a different set of + /// exits/activations as valid on each side, silently. Representative of + /// the whole group `first_config_difference` was missing. + #[test] + fn a_changed_churn_limit_quotient_is_now_caught() { + let persisted = Config::mainnet(); + let mut supplied = Config::mainnet(); + supplied.churn_limit_quotient += 1; + + let difference = first_config_difference(&persisted, &supplied) + .expect("a changed churn_limit_quotient must be caught"); + assert!( + difference.contains("churn_limit_quotient"), + "the error should name the field that changed: {difference}" + ); + } + + /// A changed `PRESET_BASE` is refused, and the error shows both names as + /// text. A changed `CONFIG_NAME` is a label with no consensus effect, so it + /// resumes (the caller only warns), unless a chain value changed with it. + #[test] + fn a_changed_preset_name_is_caught_but_a_changed_config_name_is_not() { + let persisted = Config::mainnet(); + + let mut other_preset = Config::mainnet(); + other_preset.preset_base = "minimal".try_into().unwrap(); + let difference = first_config_difference(&persisted, &other_preset) + .expect("a changed PRESET_BASE must be caught"); + assert_eq!( + difference, + r#"preset_base: directory has "mainnet", config file says "minimal""# + ); + + let mut renamed = Config::mainnet(); + renamed.config_name = "devnet-2".try_into().unwrap(); + assert_eq!(first_config_difference(&persisted, &renamed), None); + + renamed.altair_fork_epoch += 1; + let difference = first_config_difference(&persisted, &renamed) + .expect("a rename must not hide a changed chain value"); + assert!( + difference.starts_with("altair_fork_epoch:"), + "got {difference}" + ); + } + + /// Every built-in network has been live for years, and this follower + /// imports nothing at startup, so anchoring at genesis would park it at + /// slot 0 while claiming to follow a live chain. That refusal is + /// deliberate and must survive; no built-in network carries a genesis + /// state to anchor at in the first place. + #[tokio::test] + async fn the_built_in_network_still_requires_a_checkpoint_url() { + for built_in in network::BuiltInNetwork::ALL { + let spec = network::NetworkSpec::BuiltIn(built_in); + let source = network::NetworkSource::resolve(&spec).unwrap(); + let backend: Arc = Arc::new(InMemoryBackend::new()); + // `Store` is not `Debug`, so unwrap the error by pattern rather + // than with `unwrap_err`. + let Err(err) = fetch_initial_beacon_state(&[], backend, &source).await else { + panic!("{} must not anchor at its own genesis", built_in.name()); + }; + assert!( + format!("{err}").contains("checkpoint"), + "{}: the error should point at --checkpoint-sync-url: {err}", + built_in.name() + ); + } + } + + /// Pins the one invariant `get_forkchoice_store` actually checks: the + /// anchor block's message must hash to the same root as the anchor + /// state's own `latest_block_header`, once that header's placeholder + /// zero `state_root` is filled in the same way `get_forkchoice_store` + /// fills it. + #[test] + fn a_genesis_anchor_block_hashes_to_the_states_own_header() { + let state = beacon::mainnet_genesis_state().unwrap(); + let block = genesis_anchor_block(&state); + + let mut header = state.latest_block_header().clone(); + header.state_root = state.hash_tree_root(); + + assert_eq!(block.message_hash_tree_root(), header.hash_tree_root()); + } + + /// The same invariant, pinned at every fork: `BeaconBlockBody::empty()` + /// (or, pre-bellatrix, `Default`) is only exercised above through + /// mainnet's own genesis, which is phase0, so the four hand-written + /// `empty()` impls (bellatrix, capella, deneb, electra; fulu reuses + /// electra's block) had no coverage at all. + /// + /// Mainnet's genesis is the only real genesis state this binary carries, + /// and it is phase0, so a later fork's state is manufactured by chaining + /// the real `upgrade_state` functions. Those clone `latest_block_header` + /// verbatim (an upgrade is not a block import), so the header inherited + /// from genesis still names phase0's empty-body root; it is re-stamped + /// to each new fork's own empty body below, exactly as a genesis + /// generator targeting that fork directly would have to. + #[test] + fn a_genesis_anchor_block_hashes_to_the_states_own_header_at_every_fork() { + let config = Config::mainnet(); + let mut state = beacon::mainnet_genesis_state().unwrap(); + + for fork in ForkName::ALL { + if fork != ForkName::Phase0 { + state = ethlambda_state_transition::beacon::upgrade::upgrade_state( + &state, fork, &config, + ) + .unwrap_or_else(|err| panic!("upgrade to {fork:?} failed: {err}")); + } + + let empty_body_root = match fork { + ForkName::Phase0 => phase0::BeaconBlockBody::default().hash_tree_root(), + ForkName::Altair => altair::BeaconBlockBody::default().hash_tree_root(), + ForkName::Bellatrix => bellatrix::BeaconBlockBody::empty().hash_tree_root(), + ForkName::Capella => capella::BeaconBlockBody::empty().hash_tree_root(), + ForkName::Deneb => deneb::BeaconBlockBody::empty().hash_tree_root(), + ForkName::Electra | ForkName::Fulu => { + electra::BeaconBlockBody::empty().hash_tree_root() + } + ForkName::Lean => unreachable!("ForkName::ALL excludes Lean"), + }; + state.latest_block_header_mut().body_root = empty_body_root; + + let block = genesis_anchor_block(&state); + let mut header = state.latest_block_header().clone(); + header.state_root = state.hash_tree_root(); + + assert_eq!( + block.message_hash_tree_root(), + header.hash_tree_root(), + "invariant broke at fork {fork:?}" + ); + } + } + + /// A unique path under the OS temp dir, so parallel test runs cannot + /// collide on the same file. + fn temp_key_path(label: &str) -> std::path::PathBuf { + let nanos = SystemTime::UNIX_EPOCH + .elapsed() + .expect("already past the unix epoch") + .as_nanos(); + std::env::temp_dir().join(format!("ethlambda-test-node-key-{label}-{nanos}.key")) + } + + #[test] + fn a_supplied_node_key_is_read_verbatim_and_stable_across_calls() { + // A `PeerId` is a pure function of the key bytes (see + // `ethlambda_p2p::derive_peer_ids`), so two calls returning the same + // bytes for the same file is exactly what "the same PeerId across two + // runs" comes down to, without pulling libp2p's key derivation into + // this crate's tests. + let path = temp_key_path("supplied"); + std::fs::write(&path, "01".repeat(32)).expect("temp key file writes"); + + let first = resolve_node_key(Some(path.as_path())).expect("reads the supplied key"); + let second = resolve_node_key(Some(path.as_path())).expect("reads the supplied key"); + + let _ = std::fs::remove_file(&path); + assert_eq!(first, second); + } + + #[test] + fn a_missing_node_key_generates_a_valid_key_that_differs_from_a_supplied_one() { + let path = temp_key_path("baseline"); + std::fs::write(&path, "01".repeat(32)).expect("temp key file writes"); + let supplied = resolve_node_key(Some(path.as_path())).expect("reads the supplied key"); + let _ = std::fs::remove_file(&path); + + let generated_a = resolve_node_key(None).expect("generates a key"); + let generated_b = resolve_node_key(None).expect("generates a key"); + + // "Accepted": the bytes are a valid secp256k1 secret key, the same + // check `build_swarm` performs before deriving the swarm identity. + secp256k1::SecretKey::from_slice(&generated_a) + .expect("generated key is a valid secp256k1 secret key"); + + assert_ne!(generated_a, supplied); + assert_ne!(generated_a, generated_b, "two generations must not collide"); + } + + /// Write `contents` to a scratch file and read it back as a bootnode list. + fn read_bootnodes_from(label: &str, contents: &str) -> Vec { + let nanos = SystemTime::UNIX_EPOCH + .elapsed() + .expect("already past the unix epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!("ethlambda-test-enrs-{label}-{nanos}.yaml")); + std::fs::write(&path, contents).expect("temp bootnode file writes"); + let entries = read_bootnode_strings(&path).expect("bootnode file reads"); + let _ = std::fs::remove_file(&path); + entries + } + + /// The shapes `node` accepted before the two readers merged. A line parser + /// alone would hand the quotes and brackets on to `parse_enrs`, which is + /// why YAML is tried first. + #[test] + fn the_bootnode_reader_still_accepts_every_yaml_shape() { + assert_eq!( + read_bootnodes_from("block", "- enr:aaa\n- enr:bbb\n"), + ["enr:aaa", "enr:bbb"] + ); + assert_eq!( + read_bootnodes_from("quoted", "- \"enr:aaa\"\n- 'enr:bbb'\n"), + ["enr:aaa", "enr:bbb"] + ); + assert_eq!( + read_bootnodes_from("flow", "[\"enr:aaa\", \"enr:bbb\"]\n"), + ["enr:aaa", "enr:bbb"] + ); + } + + /// The shapes only `beacon`'s tolerant reader accepted. These are not YAML + /// sequences, so they reach the line-oriented fallback. + #[test] + fn the_bootnode_reader_still_accepts_a_bare_or_commented_list() { + assert_eq!( + read_bootnodes_from("bare", "enr:aaa\nenr:bbb\n"), + ["enr:aaa", "enr:bbb"] + ); + assert_eq!( + read_bootnodes_from("comments", "# peers\n- enr:aaa\n\n # aside\nenr:bbb\n"), + ["enr:aaa", "enr:bbb"] + ); + } + + #[test] + fn an_empty_bootnode_file_yields_no_entries() { + assert!(read_bootnodes_from("empty", "").is_empty()); + assert!(read_bootnodes_from("blank", "\n\n \n").is_empty()); + } + + #[test] + fn an_unreadable_bootnode_file_is_an_error_but_an_absent_one_is_not() { + // The flag is optional on both chains, so no flag is not an error; + // what an absent list *means* is each chain's own decision, made in + // `default_bootnodes`. A path that was supplied and cannot be read is + // still a hard failure: the operator named a file, so a typo must not + // look like "no peers". + let absent: Option<&Path> = None; + assert!( + absent + .map(read_bootnode_strings) + .transpose() + .expect("no flag is not an error") + .is_none() + ); + let missing = std::env::temp_dir().join("ethlambda-test-enrs-does-not-exist.yaml"); + assert!(read_bootnode_strings(&missing).is_err()); + } + + /// The two chains read the same absent flag in opposite directions, so an + /// arm that starts answering like the other one is a silent peering change: + /// mainnet with no seeds cannot bootstrap, and a lean node handed mainnet's + /// ENRs dials peers that will reject it. + #[test] + fn an_absent_bootnode_flag_falls_back_per_chain() { + let argv = [ + "ethlambda", + "node", + "--genesis", + "config.yaml", + "--validators", + "validators.yaml", + "--validator-config", + "validator-config.yaml", + "--hash-sig-keys-dir", + "keys", + "--node-id", + "ethlambda_0", + ]; + let Command::Node(node) = try_parse_from(argv).expect("`node` parses") else { + panic!("`node` must resolve to the node sub-command"); + }; + assert!(matches!(Options::from(node).network, Network::Lean(_))); + assert!(default_bootnodes(None).is_empty()); + + let mainnet_source = network::NetworkSource::built_in_mainnet().unwrap(); + let fallback = default_bootnodes(Some(&mainnet_source)); + assert!(!fallback.is_empty()); + assert_eq!(fallback, mainnet_source.bootnodes()); + } } diff --git a/bin/ethlambda/src/network/built_in.rs b/bin/ethlambda/src/network/built_in.rs new file mode 100644 index 000000000..1ff084d36 --- /dev/null +++ b/bin/ethlambda/src/network/built_in.rs @@ -0,0 +1,267 @@ +//! The networks compiled into the binary, by the name `--network` accepts. +//! +//! Every built-in chain is an [`EmbeddedChain`]: its `eth-clients` repo's +//! `metadata/config.yaml` and `metadata/bootstrap_nodes.yaml` byte for byte, +//! read through the same parsers a `--network ` goes through, plus its two +//! genesis values. Refreshing a chain is a file copy that can be diffed against +//! upstream. +//! +//! No chain carries its genesis *state*, only the two values the wire is +//! derived from (`genesis_time`, `genesis_validators_root`). A built-in network +//! never anchors at genesis (`fetch_initial_beacon_state` refuses, since every +//! one of them has been live for years and this follower would sit at slot 0), +//! so those two values are all the state would be read for; mainnet's is 5 MB +//! and Hoodi's 150 MB. A wrong constant still fails loudly rather than +//! silently: the anchor state checkpoint sync downloads is checked against +//! both, and so is a resumed data directory's. + +use ethlambda_types::beacon::config::Config; +use ethlambda_types::beacon::primitives::Root; +use eyre::WrapErr as _; + +use super::config_file::ConfigFile; +use crate::beacon::Genesis; + +/// A network compiled into the binary. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum BuiltInNetwork { + Mainnet, + Sepolia, + Hoodi, +} + +impl BuiltInNetwork { + /// Every built-in network, in the order an unknown-name error lists them. + pub(crate) const ALL: [Self; 3] = [Self::Mainnet, Self::Sepolia, Self::Hoodi]; + + /// The name `--network` accepts for this network. + pub(crate) const fn name(self) -> &'static str { + match self { + Self::Mainnet => "mainnet", + Self::Sepolia => "sepolia", + Self::Hoodi => "hoodi", + } + } + + pub(crate) fn from_name(name: &str) -> Option { + Self::ALL.into_iter().find(|network| network.name() == name) + } + + fn chain(self) -> &'static EmbeddedChain { + match self { + Self::Mainnet => &MAINNET, + Self::Sepolia => &SEPOLIA, + Self::Hoodi => &HOODI, + } + } + + /// Build this network's configuration, genesis values and bootnode list. + pub(crate) fn resolve(self) -> eyre::Result { + let chain = self.chain(); + let parsed = ConfigFile::parse(chain.config_yaml) + .wrap_err_with(|| format!("the built-in {} config.yaml did not parse", self.name()))?; + super::check_preset(parsed.config.preset_base.as_str())?; + super::check_constants(&parsed.config)?; + // A built-in config can carry a fork this build cannot process (Sepolia + // schedules gloas), and saying so at every start is the point: the + // node follows the chain only up to that fork's epoch. + parsed.warn_about_ignored_keys(); + + let genesis = chain.genesis(); + let mut config = parsed.config; + super::derive_genesis_fields(&mut config, genesis.genesis_time); + + Ok(BuiltIn { + config, + genesis, + bootnodes: crate::parse_bootnode_strings(chain.bootnodes_yaml), + }) + } +} + +/// A resolved built-in network: what [`super::NetworkSource`] answers from. +#[derive(Debug)] +pub(crate) struct BuiltIn { + pub(crate) config: Config, + pub(crate) genesis: Genesis, + pub(crate) bootnodes: Vec, +} + +/// A built-in chain's embedded files and genesis values. +struct EmbeddedChain { + /// `metadata/config.yaml` from the chain's `eth-clients` repo. + config_yaml: &'static str, + /// `metadata/bootstrap_nodes.yaml` from the chain's `eth-clients` repo. + bootnodes_yaml: &'static str, + genesis_time: u64, + /// Hex, without a `0x` prefix. + genesis_validators_root: &'static str, +} + +impl EmbeddedChain { + fn genesis(&self) -> Genesis { + let root = hex::decode(self.genesis_validators_root) + .expect("a built-in genesis_validators_root is valid hex"); + Genesis { + genesis_time: self.genesis_time, + genesis_validators_root: Root::from_slice(&root), + } + } +} + +/// Ethereum mainnet, from `eth-clients/mainnet`. +/// +/// Both genesis values are read off that repo's `metadata/genesis.ssz`, which +/// the tests carry as a fixture and check these against. +const MAINNET: EmbeddedChain = EmbeddedChain { + config_yaml: include_str!("../../assets/mainnet/config.yaml"), + bootnodes_yaml: include_str!("../../assets/mainnet/bootstrap_nodes.yaml"), + // 2020-12-01 12:00:23 UTC. + genesis_time: 1_606_824_023, + genesis_validators_root: "4b363db94e286120d76eb905340fdd4e54bfe9f06bf33ff6cf5ad27f511bfe95", +}; + +/// Sepolia, from `eth-clients/sepolia`. +/// +/// Both genesis values are the ones `eth-clients/sepolia`'s README publishes +/// and a Sepolia Beacon API's `/eth/v1/beacon/genesis` returns. +const SEPOLIA: EmbeddedChain = EmbeddedChain { + config_yaml: include_str!("../../assets/sepolia/config.yaml"), + bootnodes_yaml: include_str!("../../assets/sepolia/bootstrap_nodes.yaml"), + // 2022-06-20 14:00:00 UTC. + genesis_time: 1_655_733_600, + genesis_validators_root: "d8ea171f3c94aea21ebc42a1ed61052acf3f9209c00e4efbaaddac09ed9b8078", +}; + +/// Hoodi, from `eth-clients/hoodi`. +/// +/// The root is `metadata/genesis_validators_root.txt` from that repo; both +/// values are what a Hoodi Beacon API's `/eth/v1/beacon/genesis` returns. +const HOODI: EmbeddedChain = EmbeddedChain { + config_yaml: include_str!("../../assets/hoodi/config.yaml"), + bootnodes_yaml: include_str!("../../assets/hoodi/bootstrap_nodes.yaml"), + // 2025-03-17 12:10:00 UTC. + genesis_time: 1_742_213_400, + genesis_validators_root: "212f13fc4df078b6cb7db228f1c8307566dcecf900867401a92023d7ba99cb5f", +}; + +#[cfg(test)] +mod tests { + use super::*; + use ethlambda_p2p::parse_enrs; + use ethlambda_types::beacon::fork::ForkName; + use ethlambda_types::beacon::fork_digest::compute_fork_digest; + + #[test] + fn every_name_round_trips() { + for network in BuiltInNetwork::ALL { + assert_eq!(BuiltInNetwork::from_name(network.name()), Some(network)); + } + assert_eq!(BuiltInNetwork::from_name("holesky"), None); + } + + #[test] + fn every_built_in_network_resolves() { + for network in BuiltInNetwork::ALL { + let resolved = network + .resolve() + .unwrap_or_else(|err| panic!("{} did not resolve: {err:#}", network.name())); + // `--network ` and the `CONFIG_NAME` the node logs and + // reports must be the same name. + assert_eq!(resolved.config.config_name.as_str(), network.name()); + assert_eq!( + resolved.config.genesis_time, + resolved.genesis.genesis_time, + "{}: the config's clock must start at the network's genesis", + network.name() + ); + assert_eq!( + resolved.config.slot_duration_ms, + resolved.config.seconds_per_slot * 1_000, + "{}", + network.name() + ); + } + } + + #[test] + fn every_embedded_bootnode_parses() { + for network in BuiltInNetwork::ALL { + let bootnodes = network.resolve().unwrap().bootnodes; + assert!( + !bootnodes.is_empty(), + "{} ships no bootnodes", + network.name() + ); + assert_eq!( + parse_enrs(bootnodes.clone()).len(), + bootnodes.len(), + "a {} bootnode ENR failed to parse; parse_enrs warns per skipped entry", + network.name() + ); + } + } + + /// The configs are read from the files, not defaulted to mainnet's + /// values: an absent key falls back to mainnet silently, so a file that + /// failed to reach the parser would still "resolve". Mainnet's own config + /// cannot be told apart from that fallback by value; it is pinned against + /// `Config::mainnet()` instead (`beacon::tests`). + #[test] + fn the_sepolia_and_hoodi_configs_are_their_own() { + let sepolia = BuiltInNetwork::Sepolia.resolve().unwrap().config; + assert_eq!(sepolia.genesis_fork_version, [0x90, 0x00, 0x00, 0x69]); + assert_eq!(sepolia.fulu_fork_version, [0x90, 0x00, 0x00, 0x75]); + assert_eq!(sepolia.fulu_fork_epoch, 272_640); + assert_eq!(sepolia.deposit_chain_id, 11_155_111); + + let hoodi = BuiltInNetwork::Hoodi.resolve().unwrap().config; + assert_eq!(hoodi.genesis_fork_version, [0x10, 0x00, 0x09, 0x10]); + assert_eq!(hoodi.fork_at_epoch(0), ForkName::Deneb); + assert_eq!(hoodi.fulu_fork_version, [0x70, 0x00, 0x09, 0x10]); + assert_eq!(hoodi.fulu_fork_epoch, 50_688); + assert_eq!(hoodi.deposit_chain_id, 560_048); + } + + /// Sepolia's and Hoodi's genesis roots have no other offline check, since + /// the state each is the root of is not carried even as a test fixture + /// (mainnet's is, and `beacon::tests` checks mainnet's constants against + /// it). What pins them is a digest someone else computed + /// from it: each expected value below is the `eth2` entry of a bootnode + /// ENR in the network's own `bootstrap_nodes.yaml`, published while that + /// fork was current. The digest mixes the fork version with the root, so + /// a wrong root or a wrong fork version fails here. + #[test] + fn the_genesis_roots_reproduce_published_digests() { + // Sepolia's last listed bootnode, signed during bellatrix, before + // capella was scheduled: its `next_fork_version` repeats bellatrix's + // own and its `next_fork_epoch` is FAR_FUTURE_EPOCH. + let sepolia = BuiltInNetwork::Sepolia.resolve().unwrap(); + let bellatrix = sepolia.config.bellatrix_fork_epoch; + assert_eq!( + compute_fork_digest( + &sepolia.config, + sepolia.genesis.genesis_validators_root, + bellatrix + ), + [0x36, 0xfa, 0x50, 0x13] + ); + + // Hoodi's two Teku bootnodes, signed at genesis, which was deneb. + let hoodi = BuiltInNetwork::Hoodi.resolve().unwrap(); + assert_eq!( + compute_fork_digest(&hoodi.config, hoodi.genesis.genesis_validators_root, 0), + [0xd2, 0xf1, 0x99, 0x7f] + ); + } + + /// Sepolia's README publishes its genesis digest directly. + #[test] + fn sepolias_genesis_digest_is_the_published_one() { + let sepolia = BuiltInNetwork::Sepolia.resolve().unwrap(); + assert_eq!( + compute_fork_digest(&sepolia.config, sepolia.genesis.genesis_validators_root, 0), + [0xa8, 0xfe, 0xe8, 0xee] + ); + } +} diff --git a/bin/ethlambda/src/network/config_file.rs b/bin/ethlambda/src/network/config_file.rs new file mode 100644 index 000000000..93a1e914b --- /dev/null +++ b/bin/ethlambda/src/network/config_file.rs @@ -0,0 +1,225 @@ +//! Parsing one network's `config.yaml`. +//! +//! Two passes over the same text: [`Config`], then the document's keys alone, +//! to report the ones no field claimed. +//! +//! Two passes rather than one untyped map the typed pass reads out of, +//! because this document has no untyped representation. Both +//! `TERMINAL_TOTAL_DIFFICULTY` and an unquoted `DEPOSIT_CONTRACT_ADDRESS` +//! parse as integers wider than `u64`, and `serde_yaml_ng::Value` has no +//! variant that holds one; serde's own buffering, which a +//! `#[serde(flatten)]` catch-all field would route every scalar through, is +//! no better. The key pass therefore reads its values as [`IgnoredAny`], +//! which skips each one whole rather than typing it. The file is a few +//! kilobytes and this runs once at startup. + +use std::collections::BTreeMap; + +use ethlambda_types::beacon::config::Config; +use serde::Deserialize; +use serde::de::{IgnoredAny, Visitor}; + +/// What one `config.yaml` yields. +#[derive(Debug)] +pub(crate) struct ConfigFile { + /// The typed runtime configuration, `PRESET_BASE` and `CONFIG_NAME` + /// included. + pub(crate) config: Config, + /// Keys no field claimed: a typo, or a fork this build cannot process. + pub(crate) ignored: Vec, +} + +#[derive(Debug, thiserror::Error)] +pub(crate) enum ConfigFileError { + #[error("could not parse the network config: {0}")] + Malformed(#[from] serde_yaml_ng::Error), +} + +impl ConfigFile { + /// Parse one `config.yaml`'s text. + pub(crate) fn parse(text: &str) -> Result { + let config: Config = serde_yaml_ng::from_str(text)?; + let document: BTreeMap = serde_yaml_ng::from_str(text)?; + + let claimed = config_field_names(); + let ignored = document + .into_keys() + .filter(|key| !claimed.contains(&key.as_str())) + .collect(); + + Ok(Self { config, ignored }) + } + + /// Log what was ignored, as one line naming each key. + /// + /// One line rather than one per key: a current `config.yaml` carries the + /// gloas and heze schedule, so per-key lines would flood every valid + /// startup with one warning per ignored key and bury a real typo among + /// them. + pub(crate) fn warn_about_ignored_keys(&self) { + if self.ignored.is_empty() { + return; + } + tracing::warn!( + count = self.ignored.len(), + keys = %self.ignored.join(", "), + "Ignored config keys this build does not read" + ); + } +} + +/// The keys [`Config`]'s derived `Deserialize` accepts. +/// +/// serde's derive hands its field list to `Deserializer::deserialize_struct` +/// and nowhere else, so capturing that argument is how a caller reads it from +/// outside the macro. Asking the derive rather than keeping a list here is +/// what holds the two in step: a field added to `Config` is claimed here with +/// nothing to update. +fn config_field_names() -> &'static [&'static str] { + let mut fields: &'static [&'static str] = &[]; + // Always `Err`: the list arrives before any field is read, and there is + // nothing here to build a `Config` out of. + let _ = Config::deserialize(CaptureFields(&mut fields)); + fields +} + +/// A deserializer that answers nothing and takes only the field list. +struct CaptureFields<'a>(&'a mut &'static [&'static str]); + +impl<'de> serde::Deserializer<'de> for CaptureFields<'_> { + // Borrowed rather than declared: nothing reads the message, so the type + // only has to satisfy `de::Error`. + type Error = serde::de::value::Error; + + fn deserialize_struct( + self, + _name: &'static str, + fields: &'static [&'static str], + _visitor: V, + ) -> Result + where + V: Visitor<'de>, + { + *self.0 = fields; + Err(serde::de::Error::custom("field list captured")) + } + + fn deserialize_any(self, _visitor: V) -> Result + where + V: Visitor<'de>, + { + // Reached only if `Config` stops deserializing as a plain struct: a + // `#[serde(flatten)]` field makes the derive call `deserialize_map` + // and publish no field list at all. The capture then stays empty and + // every key reads as ignored, which the tests below fail on. + Err(serde::de::Error::custom("Config is not a plain struct")) + } + + serde::forward_to_deserialize_any! { + bool i8 i16 i32 i64 i128 u8 u16 u32 u64 u128 f32 f64 char str string + bytes byte_buf option unit unit_struct newtype_struct seq tuple + tuple_struct map enum identifier ignored_any + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const DEVNET: &str = include_str!("../../tests/fixtures/networks/devnet/config.yaml"); + const MAINNET: &str = include_str!("../../assets/mainnet/config.yaml"); + + #[test] + fn the_devnets_values_are_read() { + let parsed = ConfigFile::parse(DEVNET).unwrap(); + assert_eq!(parsed.config.config_name.as_str(), "ethlambda-devnet"); + assert_eq!(parsed.config.preset_base.as_str(), "mainnet"); + assert_eq!(parsed.config.deposit_chain_id, 3_151_908); + assert_eq!(parsed.config.seconds_per_slot, 6); + } + + #[test] + fn the_forks_this_build_cannot_process_are_reported_as_ignored() { + let parsed = ConfigFile::parse(DEVNET).unwrap(); + for key in [ + "GLOAS_FORK_VERSION", + "GLOAS_FORK_EPOCH", + "HEZE_FORK_VERSION", + "HEZE_FORK_EPOCH", + "GAS_LIMIT_SCHEDULE", + "PAYLOAD_DUE_BPS", + "INCLUSION_LIST_DUE_BPS", + ] { + assert!( + parsed.ignored.contains(&key.to_string()), + "{key} not reported" + ); + } + } + + #[test] + fn preset_base_and_config_name_are_not_reported_as_ignored() { + // Both are `Config` fields now, so the derive claims them. + let parsed = ConfigFile::parse(DEVNET).unwrap(); + assert!(!parsed.ignored.iter().any(|key| key == "PRESET_BASE")); + assert!(!parsed.ignored.iter().any(|key| key == "CONFIG_NAME")); + } + + #[test] + fn a_valid_mainnet_config_reports_nothing_ignored() { + let parsed = ConfigFile::parse(MAINNET).unwrap(); + assert!( + parsed.ignored.is_empty(), + "unexpectedly ignored: {:?}", + parsed.ignored + ); + } + + #[test] + fn a_misspelled_key_is_reported_as_ignored() { + let parsed = ConfigFile::parse("SECONDS_PER_SLOTT: 12").unwrap(); + assert_eq!(parsed.ignored, ["SECONDS_PER_SLOTT"]); + } + + #[test] + fn a_malformed_document_is_an_error() { + let err = ConfigFile::parse("ALTAIR_FORK_EPOCH: [1, 2]") + .unwrap_err() + .to_string(); + assert!(err.contains("ALTAIR_FORK_EPOCH"), "got {err}"); + } + + #[test] + fn the_claimed_keys_come_from_the_derive() { + let claimed = config_field_names(); + // A populated list is what proves the capture fired at all. + assert!(claimed.contains(&"ALTAIR_FORK_EPOCH")); + // The one renamed field is listed under its wire name, not its Rust + // one, so a rename stays claimed without a second edit here. + assert!(claimed.contains(&"MAX_BLOBS_PER_BLOCK")); + assert!(!claimed.contains(&"MAX_BLOBS_PER_BLOCK_DENEB")); + // `#[serde(skip)]` fields are not deserialized, so the derive does not + // list them: a config carrying one is ignored, and reported as such. + assert!(!claimed.contains(&"GENESIS_TIME")); + } + + #[test] + fn the_document_has_no_untyped_representation() { + // Why the key pass reads `IgnoredAny` values, and why the three passes + // cannot collapse into one map the others read out of: both of these + // parse as integers wider than `u64`, which `Value` cannot hold. + for line in [ + "TERMINAL_TOTAL_DIFFICULTY: 58750000000000000000000", + "DEPOSIT_CONTRACT_ADDRESS: 0x00000000219ab540356cBB839Cbe05303d7705Fa", + ] { + let err = serde_yaml_ng::from_str::(line).unwrap_err(); + assert!( + err.to_string().contains("invalid type: integer"), + "got {err}" + ); + } + // Skipping the values rather than typing them is what makes the same + // document readable, which is what `parse` relies on. + serde_yaml_ng::from_str::>(MAINNET).unwrap(); + } +} diff --git a/bin/ethlambda/src/network/dir.rs b/bin/ethlambda/src/network/dir.rs new file mode 100644 index 000000000..17306c21f --- /dev/null +++ b/bin/ethlambda/src/network/dir.rs @@ -0,0 +1,404 @@ +//! Reading a network directory. +//! +//! The layout is the one `eth-clients//metadata` publishes and +//! kurtosis mounts at `/network-configs`, so a devnet's artifact is consumed +//! unmodified. Only three entries are read; everything else in the directory, +//! including the execution-layer files and the deposit contract metadata, is +//! ignored. + +use std::path::{Path, PathBuf}; + +use ethlambda_types::beacon::config::Config; +use ethlambda_types::beacon::containers::BeaconState; +use ethlambda_types::beacon::fork::ForkName; + +use super::config_file::{ConfigFile, ConfigFileError}; + +pub(crate) const CONFIG_FILE: &str = "config.yaml"; +pub(crate) const GENESIS_STATE_FILE: &str = "genesis.ssz"; +pub(crate) const BOOTNODES_YAML: &str = "bootstrap_nodes.yaml"; +pub(crate) const BOOTNODES_TXT: &str = "bootstrap_nodes.txt"; + +/// One loaded network directory. +#[derive(Debug)] +pub(crate) struct NetworkDir { + pub(crate) config: Config, + pub(crate) genesis_state: Box, + pub(crate) bootnodes: Vec, +} + +#[derive(Debug, thiserror::Error)] +pub(crate) enum NetworkDirError { + #[error("{} is required but was not found in the network directory", path.display())] + Missing { path: PathBuf }, + #[error("could not read {}: {source}", path.display())] + Unreadable { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("{}: {source}", path.display())] + Config { + path: PathBuf, + #[source] + source: ConfigFileError, + }, + #[error("{}: {source}", path.display())] + Preset { + path: PathBuf, + #[source] + source: super::PresetCheckError, + }, + #[error("{}: {source}", path.display())] + Constants { + path: PathBuf, + #[source] + source: super::ConstantsMismatch, + }, + // `{fork:?}` rather than `{fork}`: `ForkName` exposes `as_str` and does not + // implement `Display`. + #[error("{} did not decode as a {fork:?} BeaconState: {reason}", path.display())] + Genesis { + path: PathBuf, + fork: ForkName, + reason: String, + }, + #[error("could not read bootnodes from {}: {reason}", path.display())] + Bootnodes { path: PathBuf, reason: String }, + #[error( + "{}: SECONDS_PER_SLOT is 0, which the wall-clock-to-slot arithmetic divides by", + path.display() + )] + ZeroSecondsPerSlot { path: PathBuf }, +} + +impl NetworkDir { + /// Read `base`'s three consumed entries. + pub(crate) fn load(base: &Path) -> Result { + let config_path = base.join(CONFIG_FILE); + let text = read_required(&config_path)?; + let parsed = ConfigFile::parse(&text).map_err(|source| NetworkDirError::Config { + path: config_path.clone(), + source, + })?; + parsed.warn_about_ignored_keys(); + + // Before `genesis.ssz` is decoded: its container bounds are the + // compiled preset's, so a directory built for the other preset would + // otherwise fail as an SSZ error rather than naming the cargo feature + // that fixes it. + super::check_preset(parsed.config.preset_base.as_str()).map_err(|source| { + NetworkDirError::Preset { + path: config_path.clone(), + source, + } + })?; + super::check_constants(&parsed.config).map_err(|source| NetworkDirError::Constants { + path: config_path.clone(), + source, + })?; + + let genesis_path = base.join(GENESIS_STATE_FILE); + let genesis_bytes = read_required_bytes(&genesis_path)?; + // The fork comes from this network's own schedule at epoch 0, never + // hardcoded. Mainnet's genesis is phase0 because its altair epoch is + // far in the future, but a devnet typically schedules every fork at + // epoch 0 and ships a genesis state in the newest one, so decoding as + // phase0 would fail on exactly the networks this flag exists for. + let fork = parsed.config.fork_at_epoch(0); + let genesis_state = BeaconState::from_ssz(fork, &genesis_bytes).map_err(|err| { + NetworkDirError::Genesis { + path: genesis_path, + fork, + reason: format!("{err:?}"), + } + })?; + + let bootnodes = read_bootnodes(base)?; + + // A zero divides in `epoch_at` and `milliseconds_per_interval` (both + // key wall-clock time off `seconds_per_slot`/`slot_duration_ms`), so + // it must be rejected here rather than loading cleanly into a config + // that panics the first time a duty fires. + if parsed.config.seconds_per_slot == 0 { + return Err(NetworkDirError::ZeroSecondsPerSlot { + path: config_path.clone(), + }); + } + // Two fields cannot come from the file and must be reconciled here, + // once, before anything reads the config. + let mut config = parsed.config; + super::derive_genesis_fields(&mut config, genesis_state.genesis_time()); + + Ok(Self { + config, + genesis_state: Box::new(genesis_state), + bootnodes, + }) + } +} + +/// Read the first bootnode file present, preferring the YAML spelling. +/// +/// Both spellings appear: `eth-clients` publishes `bootstrap_nodes.yaml` and +/// kurtosis ships that plus `bootstrap_nodes.txt`. The existing `--bootnodes` +/// parser (`read_bootnode_strings`, in `main.rs`) already accepts either +/// shape, so this only picks a file and reuses it rather than re-parsing. +fn read_bootnodes(base: &Path) -> Result, NetworkDirError> { + for name in [BOOTNODES_YAML, BOOTNODES_TXT] { + let path = base.join(name); + if !path.exists() { + continue; + } + return crate::read_bootnode_strings(&path).map_err(|source| NetworkDirError::Bootnodes { + path, + reason: source.to_string(), + }); + } + Ok(Vec::new()) +} + +fn read_required(path: &Path) -> Result { + if !path.exists() { + return Err(NetworkDirError::Missing { + path: path.to_path_buf(), + }); + } + std::fs::read_to_string(path).map_err(|source| NetworkDirError::Unreadable { + path: path.to_path_buf(), + source, + }) +} + +fn read_required_bytes(path: &Path) -> Result, NetworkDirError> { + if !path.exists() { + return Err(NetworkDirError::Missing { + path: path.to_path_buf(), + }); + } + std::fs::read(path).map_err(|source| NetworkDirError::Unreadable { + path: path.to_path_buf(), + source, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture(name: &str) -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/networks") + .join(name) + } + + /// Build a complete directory in a temp dir: the devnet fixture plus a + /// genesis state, which is too large to check in. + fn complete_dir() -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + std::fs::copy( + fixture("devnet").join("config.yaml"), + dir.path().join("config.yaml"), + ) + .unwrap(); + std::fs::copy( + fixture("devnet").join("bootstrap_nodes.txt"), + dir.path().join("bootstrap_nodes.txt"), + ) + .unwrap(); + let state = crate::beacon::mainnet_genesis_state().unwrap(); + std::fs::write(dir.path().join("genesis.ssz"), state.to_ssz()).unwrap(); + dir + } + + /// The same directory, but with a genesis state whose `genesis_time` is + /// `genesis_time` rather than mainnet's. + fn complete_dir_with_genesis_time(genesis_time: u64) -> tempfile::TempDir { + let dir = complete_dir(); + let mut state = crate::beacon::mainnet_genesis_state().unwrap(); + *state.genesis_time_mut() = genesis_time; + std::fs::write(dir.path().join("genesis.ssz"), state.to_ssz()).unwrap(); + dir + } + + #[test] + fn a_complete_directory_loads() { + let dir = complete_dir(); + let loaded = NetworkDir::load(dir.path()).unwrap(); + assert_eq!(loaded.config.config_name.as_str(), "ethlambda-devnet"); + assert_eq!(loaded.config.deposit_chain_id, 3_151_908); + assert_eq!(loaded.bootnodes.len(), 2); + } + + #[test] + fn the_two_derived_fields_are_reconciled_against_the_genesis_state() { + // The genesis state here MUST carry a genesis_time different from + // `Config::mainnet().genesis_time`. `genesis_time` is `#[serde(skip)]` + // under a container-level `default`, so an unreconciled config inherits + // mainnet's 1606824023; writing mainnet's own state would make this + // assertion pass even with the reconciliation deleted. + const DEVNET_GENESIS_TIME: u64 = 1_700_000_000; + let dir = complete_dir_with_genesis_time(DEVNET_GENESIS_TIME); + let loaded = NetworkDir::load(dir.path()).unwrap(); + + assert_eq!( + loaded.config.genesis_time, DEVNET_GENESIS_TIME, + "genesis_time must come from the state, not from the serde default" + ); + assert_ne!( + loaded.config.genesis_time, + Config::mainnet().genesis_time, + "the test is only meaningful if the state differs from the default" + ); + + // The devnet fixture deliberately carries an inconsistent pair: + // SECONDS_PER_SLOT is 6 while SLOT_DURATION_MS is still mainnet's + // 12000. The seconds field is authoritative on a beacon chain, so the + // derivation has to win over what the file says. + assert_eq!(loaded.config.seconds_per_slot, 6); + assert_eq!(loaded.config.slot_duration_ms, 6_000); + } + + #[test] + fn a_missing_config_names_the_path() { + let dir = complete_dir(); + std::fs::remove_file(dir.path().join("config.yaml")).unwrap(); + let err = NetworkDir::load(dir.path()).unwrap_err().to_string(); + assert!(err.contains("config.yaml"), "got {err}"); + } + + #[test] + fn a_zero_seconds_per_slot_is_rejected_naming_the_field() { + let dir = complete_dir(); + let devnet_text = std::fs::read_to_string(fixture("devnet").join("config.yaml")).unwrap(); + let zeroed_text = devnet_text.replacen("SECONDS_PER_SLOT: 6", "SECONDS_PER_SLOT: 0", 1); + assert_ne!( + zeroed_text, devnet_text, + "fixture no longer carries SECONDS_PER_SLOT in the expected form" + ); + std::fs::write(dir.path().join("config.yaml"), zeroed_text).unwrap(); + + let err = NetworkDir::load(dir.path()).unwrap_err().to_string(); + assert!(err.contains("SECONDS_PER_SLOT"), "got {err}"); + } + + /// Write `dir`'s `config.yaml` as the devnet fixture's with one line + /// replaced. + fn replace_config_line(dir: &tempfile::TempDir, from: &str, to: &str) { + let devnet_text = std::fs::read_to_string(fixture("devnet").join("config.yaml")).unwrap(); + let text = devnet_text.replacen(from, to, 1); + assert_ne!(text, devnet_text, "fixture no longer carries {from:?}"); + std::fs::write(dir.path().join("config.yaml"), text).unwrap(); + } + + /// A directory built for the other preset must fail naming the preset, + /// not as an SSZ error from decoding its genesis state against this + /// build's container bounds. + #[test] + fn a_preset_mismatch_is_reported_before_the_genesis_state_is_decoded() { + let dir = complete_dir(); + let other = if super::super::compiled_preset() == "mainnet" { + "minimal" + } else { + "mainnet" + }; + replace_config_line( + &dir, + "PRESET_BASE: 'mainnet'", + &format!("PRESET_BASE: '{other}'"), + ); + // No preset decodes these bytes, so reaching the decode would fail as + // `Genesis` rather than `Preset`. + std::fs::write(dir.path().join("genesis.ssz"), b"not a state").unwrap(); + + let err = NetworkDir::load(dir.path()).unwrap_err(); + assert!(matches!(err, NetworkDirError::Preset { .. }), "got {err}"); + assert!( + err.to_string().contains("Rebuild with --features"), + "got {err}" + ); + } + + #[test] + fn a_changed_compiled_constant_is_refused_naming_the_key() { + let dir = complete_dir(); + replace_config_line(&dir, "CUSTODY_REQUIREMENT: 4", "CUSTODY_REQUIREMENT: 8"); + + let err = NetworkDir::load(dir.path()).unwrap_err(); + assert!( + matches!(err, NetworkDirError::Constants { .. }), + "got {err}" + ); + let err = err.to_string(); + assert!(err.contains("config.yaml"), "got {err}"); + assert!(err.contains("CUSTODY_REQUIREMENT is 8"), "got {err}"); + } + + #[test] + fn a_missing_genesis_state_names_the_path() { + let dir = complete_dir(); + std::fs::remove_file(dir.path().join("genesis.ssz")).unwrap(); + let err = NetworkDir::load(dir.path()).unwrap_err().to_string(); + assert!(err.contains("genesis.ssz"), "got {err}"); + } + + #[test] + fn bootnodes_are_optional() { + let dir = complete_dir(); + std::fs::remove_file(dir.path().join("bootstrap_nodes.txt")).unwrap(); + let loaded = NetworkDir::load(dir.path()).unwrap(); + assert!(loaded.bootnodes.is_empty()); + } + + /// A genesis state encoded in a later fork's shape must load through the + /// fork the config schedules for epoch 0, not through phase0 (see the + /// `fork` comment in [`NetworkDir::load`]). Nothing exercised that path: + /// `devnet`'s fixture keeps mainnet's fork schedule, so its `genesis.ssz` + /// always decodes as `Phase0`. + #[test] + fn a_genesis_state_at_a_later_fork_decodes_through_its_own_schedule() { + let dir = tempfile::tempdir().unwrap(); + std::fs::copy( + fixture("devnet-electra").join("config.yaml"), + dir.path().join("config.yaml"), + ) + .unwrap(); + + // A real electra state, produced by chaining the actual upgrade + // functions off mainnet's phase0 genesis, rather than a hand-built + // one: this exercises the same SSZ shape a devnet's genesis + // generator would ship. + let config = Config::mainnet(); + let mut state = crate::beacon::mainnet_genesis_state().unwrap(); + for fork in [ + ForkName::Altair, + ForkName::Bellatrix, + ForkName::Capella, + ForkName::Deneb, + ForkName::Electra, + ] { + state = + ethlambda_state_transition::beacon::upgrade::upgrade_state(&state, fork, &config) + .unwrap_or_else(|err| panic!("upgrade to {fork:?} failed: {err}")); + } + assert_eq!(state.fork_name(), ForkName::Electra); + std::fs::write(dir.path().join("genesis.ssz"), state.to_ssz()).unwrap(); + + let loaded = NetworkDir::load(dir.path()).unwrap(); + assert_eq!(loaded.genesis_state.fork_name(), ForkName::Electra); + } + + #[test] + fn the_yaml_bootnode_file_wins_over_the_text_one() { + // kurtosis ships both. Reading either is correct; reading one + // deterministically is what makes the behaviour testable. + let dir = complete_dir(); + std::fs::write( + dir.path().join("bootstrap_nodes.yaml"), + "- enr:-Iu4QLm7bZGdAt9NSeJG0cEnJohWcQTQaI9wFLu3Q7eHIDfrI4cwtzvEW3F3VbG9XdFXlrHyFGeXPn9snTCQJ9bnMRABgmlkgnY0gmlwhAOTJQCJc2VjcDI1NmsxoQIZdZD6tDYpkpEfVo5bgiU8MGRjhcOmHGD2nErK0UKRrIN0Y3CCIyiDdWRwgiMo\n", + ) + .unwrap(); + let loaded = NetworkDir::load(dir.path()).unwrap(); + assert_eq!(loaded.bootnodes.len(), 1, "the yaml file should have won"); + } +} diff --git a/bin/ethlambda/src/network/mod.rs b/bin/ethlambda/src/network/mod.rs new file mode 100644 index 000000000..10875fea9 --- /dev/null +++ b/bin/ethlambda/src/network/mod.rs @@ -0,0 +1,436 @@ +//! Resolving which network `ethlambda beacon` follows. +//! +//! A `--network` value is either a built-in name or a path. The two are told +//! apart by a slash rather than by probing the filesystem, so a directory +//! named `mainnet` can never shadow the built-in and a mistyped name fails +//! saying what names exist rather than saying a path is missing. + +pub(crate) mod built_in; +pub(crate) mod config_file; +pub(crate) mod dir; + +use std::path::PathBuf; + +use ethlambda_types::beacon::config::Config; + +pub(crate) use built_in::BuiltInNetwork; + +/// The default when `--network` is absent. +pub(crate) const DEFAULT_NETWORK: &str = BuiltInNetwork::Mainnet.name(); + +/// What a `--network` value named. +#[derive(Debug, Clone)] +pub(crate) enum NetworkSpec { + /// A network compiled into the binary. + BuiltIn(BuiltInNetwork), + /// A directory of published files. + Directory(PathBuf), +} + +#[derive(Debug, thiserror::Error)] +#[error( + "unknown network {name:?}; known networks are {known}. \ + To load a network from disk, give a path containing a slash, \ + for example ./{name}" +)] +pub(crate) struct UnknownNetwork { + name: String, + known: String, +} + +impl NetworkSpec { + /// Classify one `--network` value. + pub(crate) fn parse(value: &str) -> Result { + if value.contains('/') { + return Ok(Self::Directory(PathBuf::from(value))); + } + if let Some(network) = BuiltInNetwork::from_name(value) { + return Ok(Self::BuiltIn(network)); + } + let known: Vec<&str> = BuiltInNetwork::ALL.iter().map(|n| n.name()).collect(); + Err(UnknownNetwork { + name: value.to_string(), + known: known.join(", "), + }) + } +} + +/// The preset this binary's containers were compiled against. +/// +/// Read from `ethlambda-types`, never re-derived here with a local +/// `cfg!(feature = "preset-minimal")`. That feature belongs to +/// `ethlambda-types`; `bin/ethlambda` does not declare it, so a `cfg!` in this +/// crate is **always false** and would report "mainnet" even in a minimal +/// build. The check would then wave through exactly the configuration it +/// exists to refuse. +pub(crate) fn compiled_preset() -> &'static str { + ethlambda_types::beacon::preset::Preset::ACTIVE.name() +} + +#[derive(Debug, thiserror::Error)] +#[error( + "this build serves the {compiled} preset, but the network config declares \ + PRESET_BASE: {declared}. Rebuild with --features ethlambda-types/preset-{declared} \ + to follow this network." +)] +pub(crate) struct PresetMismatch { + compiled: &'static str, + declared: String, +} + +#[derive(Debug, thiserror::Error)] +pub(crate) enum PresetCheckError { + /// `Config::preset_base` defaults to an empty name when `PRESET_BASE` is + /// absent, precisely so this can fail closed rather + /// than assume "mainnet". Reported on its own, distinctly from + /// [`PresetMismatch`]: with no declared preset, `{declared}` in that + /// error's message would render as an empty string, reading as "declares + /// PRESET_BASE: " with no indication anything is actually missing. + #[error( + "the network config does not declare PRESET_BASE, so this build cannot check its \ + preset against the network's" + )] + Missing, + #[error(transparent)] + Mismatch(#[from] PresetMismatch), +} + +/// Refuse a configuration whose preset this build cannot serve. +/// +/// A hard error rather than a warning. The preset sets SSZ container bounds, so +/// running anyway would produce a chain that is not spec compliant while +/// looking healthy: Prysm's equivalent check half-works for exactly this +/// reason, changing its timing constants but not its container shapes. +pub(crate) fn check_preset(declared: &str) -> Result<(), PresetCheckError> { + if declared.is_empty() { + return Err(PresetCheckError::Missing); + } + if declared == compiled_preset() { + return Ok(()); + } + Err(PresetMismatch { + compiled: compiled_preset(), + declared: declared.to_string(), + } + .into()) +} + +/// One `config.yaml` key that the node runs on a compile-time constant for, +/// set to another value. +#[derive(Debug)] +pub(crate) struct ConstantMismatch { + /// The key as the file spells it. + key: String, + declared: String, + compiled: String, +} + +/// Every [`ConstantMismatch`] one configuration has, reported together so an +/// operator fixes them in one pass rather than one restart per key. +#[derive(Debug)] +pub(crate) struct ConstantsMismatch(Vec); + +impl std::fmt::Display for ConstantsMismatch { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "the network config sets values this build cannot run with, since it uses \ + compile-time constants for them:" + )?; + for ConstantMismatch { + key, + declared, + compiled, + } in &self.0 + { + write!(f, " {key} is {declared}, this build uses {compiled};")?; + } + Ok(()) + } +} + +impl std::error::Error for ConstantsMismatch {} + +/// Refuse a configuration that sets a value this build runs on a compile-time +/// constant for to anything else. +/// +/// `Config` carries these keys so that `/eth/v1/config/spec` can report them, +/// but the networking and custody code reads the constants, some of which size +/// a type (the `attnets` bitfield is an `SszBitvector` of +/// `ATTESTATION_SUBNET_COUNT` bits). Refusing the mismatch at startup is what +/// keeps the endpoint truthful: every `Config` that reaches the store equals +/// the constants on these keys. It is a hard error rather than a warning for +/// the reason [`check_preset`] is one. A node that disagrees with its peers +/// about custody or subnet counts would still look healthy while failing to +/// serve or verify what they expect. +/// +/// The list is every `Config` field whose key names a value this build also +/// defines as a constant; a field the node does not act on at all (such as +/// `SUBNETS_PER_NODE`, since it subscribes to no attestation subnet) has no +/// constant to disagree with and is reported as the file sets it. +pub(crate) fn check_constants(config: &Config) -> Result<(), ConstantsMismatch> { + use ethlambda_p2p::beacon::{constants as p2p, protocols}; + use ethlambda_types::beacon::constants; + + let mut mismatches = Vec::new(); + macro_rules! check { + ($($field:ident == $compiled:expr),+ $(,)?) => { + $( + if config.$field != $compiled { + mismatches.push(ConstantMismatch { + key: stringify!($field).to_ascii_uppercase(), + declared: format!("{:?}", config.$field), + compiled: format!("{:?}", $compiled), + }); + } + )+ + }; + } + check!( + attestation_subnet_count == p2p::ATTESTATION_SUBNET_COUNT, + data_column_sidecar_subnet_count == constants::DATA_COLUMN_SIDECAR_SUBNET_COUNT, + number_of_custody_groups == constants::NUMBER_OF_CUSTODY_GROUPS, + custody_requirement == constants::CUSTODY_REQUIREMENT, + samples_per_slot == constants::SAMPLES_PER_SLOT, + min_epochs_for_data_column_sidecars_requests + == constants::MIN_EPOCHS_FOR_DATA_COLUMN_SIDECARS_REQUESTS, + maximum_gossip_clock_disparity == constants::MAXIMUM_GOSSIP_CLOCK_DISPARITY, + max_request_blocks == protocols::MAX_REQUEST_BLOCKS, + max_request_blocks_deneb == protocols::MAX_REQUEST_BLOCKS_DENEB, + max_request_data_column_sidecars == protocols::max_request_data_column_sidecars(), + max_payload_size == ethlambda_p2p::MAX_PAYLOAD_SIZE as u64, + message_domain_invalid_snappy == ethlambda_p2p::MESSAGE_DOMAIN_INVALID_SNAPPY, + message_domain_valid_snappy == ethlambda_p2p::MESSAGE_DOMAIN_VALID_SNAPPY, + ); + + if mismatches.is_empty() { + Ok(()) + } else { + Err(ConstantsMismatch(mismatches)) + } +} + +/// Fill in the two [`Config`] fields a `config.yaml` cannot be trusted for. +/// +/// Shared by a loaded directory and the built-in networks, whose configs both +/// come out of [`config_file::ConfigFile::parse`]. +/// +/// `genesis_time` is `#[serde(skip)]`, so parsing leaves it at whatever +/// `Config::default()` carries, which is mainnet's 2020 genesis. It is a +/// property of the genesis state: mainnet's `MIN_GENESIS_TIME` is 23 seconds +/// before its actual genesis, so reading it from the file would put every slot +/// boundary off by that much. +/// +/// `slot_duration_ms` is always derived, never read from the file. A beacon +/// chain's slots are a whole number of seconds, so `SECONDS_PER_SLOT` is +/// authoritative and the millisecond field exists for lean's sub-second +/// cadence. A config carrying `SECONDS_PER_SLOT: 6` and no `SLOT_DURATION_MS` +/// would otherwise keep mainnet's 12000 by default, and every duty would fire +/// at the wrong time while the second-resolution field looked correct. +fn derive_genesis_fields(config: &mut Config, genesis_time: u64) { + config.genesis_time = genesis_time; + config.slot_duration_ms = config.seconds_per_slot * 1_000; +} + +/// A resolved network: everything startup needs before it can build a swarm. +/// +/// The built-in arm holds what the binary carries; the loaded arm holds what +/// a directory supplied. Both answer the same questions, so everything +/// downstream reads this rather than branching on where the values came from. +/// +/// Both arms are boxed: `Config` is large enough that an inline copy would +/// make one variant far bigger than the other's pointer, which is what +/// `clippy::large_enum_variant` (denied by `make lint`) catches. +#[derive(Debug)] +pub(crate) enum NetworkSource { + BuiltIn(Box), + Loaded(Box), +} + +impl NetworkSource { + /// Resolve a classified `--network` value. + pub(crate) fn resolve(spec: &NetworkSpec) -> eyre::Result { + match spec { + NetworkSpec::BuiltIn(network) => { + tracing::info!(network = network.name(), "Using the built-in network"); + Ok(Self::BuiltIn(Box::new(network.resolve()?))) + } + NetworkSpec::Directory(path) => { + // `load` checks the preset and the constants itself, before it + // decodes `genesis.ssz`, whose container bounds the preset sets. + let loaded = dir::NetworkDir::load(path)?; + tracing::info!( + network = %loaded.config.config_name, + path = %path.display(), + "Loaded network from directory" + ); + Ok(Self::Loaded(Box::new(loaded))) + } + } + } + + /// The built-in mainnet network, for the tests that want a real network + /// without writing a directory. + #[cfg(test)] + pub(crate) fn built_in_mainnet() -> eyre::Result { + Ok(Self::BuiltIn(Box::new(BuiltInNetwork::Mainnet.resolve()?))) + } + + pub(crate) fn config(&self) -> &Config { + match self { + Self::BuiltIn(built_in) => &built_in.config, + Self::Loaded(loaded) => &loaded.config, + } + } + + /// The resolved network's `CONFIG_NAME`, for logging. A built-in + /// network's is its own name, which `every_built_in_network_resolves` + /// checks. + pub(crate) fn name(&self) -> &str { + self.config().config_name.as_str() + } + + /// The two genesis values the fork digest is derived from. + /// + /// Read off the genesis state for a loaded network. A built-in network + /// carries no state at all (see [`built_in`]), so it answers from the + /// pair it was resolved with. + pub(crate) fn genesis(&self) -> crate::beacon::Genesis { + match self { + Self::BuiltIn(built_in) => built_in.genesis, + Self::Loaded(loaded) => crate::beacon::Genesis::of(&loaded.genesis_state), + } + } + + /// The bootnodes this network ships, before `--bootnodes` overrides them. + pub(crate) fn bootnodes(&self) -> Vec { + match self { + Self::BuiltIn(built_in) => built_in.bootnodes.clone(), + Self::Loaded(loaded) => loaded.bootnodes.clone(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_bare_known_name_resolves_to_the_built_in() { + for (name, expected) in [ + ("mainnet", BuiltInNetwork::Mainnet), + ("sepolia", BuiltInNetwork::Sepolia), + ("hoodi", BuiltInNetwork::Hoodi), + ] { + assert!( + matches!( + NetworkSpec::parse(name), + Ok(NetworkSpec::BuiltIn(network)) if network == expected + ), + "{name} should name its built-in network" + ); + } + } + + #[test] + fn the_default_network_is_built_in_mainnet() { + assert!(matches!( + NetworkSpec::parse(DEFAULT_NETWORK), + Ok(NetworkSpec::BuiltIn(BuiltInNetwork::Mainnet)) + )); + } + + #[test] + fn a_bare_unknown_name_errors_listing_the_known_ones() { + let err = NetworkSpec::parse("holesky").unwrap_err().to_string(); + assert!(err.contains("holesky"), "got {err}"); + for network in BuiltInNetwork::ALL { + assert!( + err.contains(network.name()), + "the error should list every known network: {err}" + ); + } + } + + /// Each name resolves to its own chain rather than to mainnet's, which is + /// what a list of names with a single dispatch arm behind it would do. + #[test] + fn each_built_in_network_resolves_to_its_own_chain() { + let mut roots = Vec::new(); + for network in BuiltInNetwork::ALL { + let source = NetworkSource::resolve(&NetworkSpec::BuiltIn(network)).unwrap(); + assert_eq!(source.name(), network.name()); + roots.push(source.genesis().genesis_validators_root); + } + roots.sort(); + roots.dedup(); + assert_eq!(roots.len(), BuiltInNetwork::ALL.len()); + } + + #[test] + fn a_value_with_a_slash_is_always_a_directory() { + // Never looked up as a name, so a directory called `mainnet` cannot + // shadow the built-in. + assert!(matches!( + NetworkSpec::parse("./mainnet"), + Ok(NetworkSpec::Directory(_)) + )); + assert!(matches!( + NetworkSpec::parse("/network-configs"), + Ok(NetworkSpec::Directory(_)) + )); + } + + #[test] + fn the_compiled_preset_is_what_a_config_must_declare() { + assert!(check_preset(compiled_preset()).is_ok()); + + let other = if compiled_preset() == "mainnet" { + "minimal" + } else { + "mainnet" + }; + let err = check_preset(other).unwrap_err().to_string(); + assert!(err.contains(other), "got {err}"); + assert!( + err.contains("preset-minimal") || err.contains("preset-mainnet"), + "name the cargo feature: {err}" + ); + } + + /// The specification's own configs must pass, or every built-in network + /// and every devnet derived from one would be refused. + #[test] + fn the_shipped_configs_match_the_compiled_constants() { + check_constants(&Config::mainnet()).unwrap(); + check_constants(&Config::minimal()).unwrap(); + } + + #[test] + fn a_config_that_changes_a_compiled_constant_is_refused_naming_each_key() { + let mut config = Config::mainnet(); + config.custody_requirement = 8; + config.message_domain_valid_snappy = [0x02, 0x00, 0x00, 0x00]; + + let err = check_constants(&config).unwrap_err().to_string(); + assert!( + err.contains("CUSTODY_REQUIREMENT is 8, this build uses 4"), + "got {err}" + ); + assert!( + err.contains("MESSAGE_DOMAIN_VALID_SNAPPY is [2, 0, 0, 0]"), + "got {err}" + ); + } + + #[test] + fn an_absent_preset_base_is_a_distinct_error_from_a_mismatch() { + // `Config::preset_base` defaults an absent PRESET_BASE to "". + let err = check_preset("").unwrap_err().to_string(); + assert!(err.contains("PRESET_BASE"), "got {err}"); + assert!( + !err.contains("Rebuild with --features"), + "a missing PRESET_BASE must not read as a preset mismatch: {err}" + ); + } +} diff --git a/bin/ethlambda/src/validator.rs b/bin/ethlambda/src/validator.rs new file mode 100644 index 000000000..15285c904 --- /dev/null +++ b/bin/ethlambda/src/validator.rs @@ -0,0 +1,285 @@ +//! `ethlambda validator`: the beacon-chain validator client. +//! +//! It talks to a beacon node over the standard REST Beacon API and depends on +//! nothing else in this binary, so it runs against any conformant node. + +use std::path::PathBuf; + +// `Bytes32` is an alias for `H256`, so the value is built through `H256`; the +// alias names the field's role and is what the config field is typed as. +use ethlambda_types::beacon::primitives::{Bytes32, ExecutionAddress, H160, H256}; +use ethlambda_validator::ValidatorConfig; + +/// A block's graffiti field is exactly this wide. +const GRAFFITI_BYTES: usize = 32; + +/// An execution address is exactly this wide. +const ADDRESS_BYTES: usize = 20; + +#[derive(Debug, clap::Args)] +pub(crate) struct ValidatorOptions { + /// Base URL(s) of the beacon node's HTTP API (e.g. http://localhost:5052). + /// + /// Several may be supplied, comma-separated or by repeating the flag. They + /// are tried in order and the first that answers is used, so the order is + /// a preference, not a load-balancing policy. + #[arg(long, value_delimiter = ',', required = true)] + pub(crate) beacon_nodes: Vec, + + /// Directory holding the EIP-2335 keystores and `validator_definitions.yml`. + #[arg(long)] + pub(crate) validators_dir: PathBuf, + + /// Directory holding one password file per keystore. + #[arg(long)] + pub(crate) secrets_dir: PathBuf, + + /// Bind address for the metrics and keymanager servers. + #[arg(long, default_value = "127.0.0.1")] + pub(crate) http_address: std::net::IpAddr, + + /// Port for the keymanager API. Only bound with `--enable-keymanager`. + #[arg(long, default_value = "5062")] + pub(crate) keymanager_port: u16, + + /// Port for the Prometheus metrics server. + #[arg(long, default_value = "5064")] + pub(crate) metrics_port: u16, + + /// Text to put in the graffiti field of every block this client proposes. + /// + /// At most 32 bytes once encoded as UTF-8, right-padded with zeros. + /// Consensus never reads it. + /// + /// Empty by default. Most clients default to their own name and version; + /// this one does not, because doing so tells anyone reading the chain which + /// software built a block, and an operator who wants that can ask for it. + #[arg(long, default_value = "")] + pub(crate) graffiti: String, + + /// Execution address to receive block rewards from blocks this client + /// proposes, as `0x`-prefixed hex. + /// + /// Optional, and it should not be. Without it the beacon node picks an + /// address of its own, which will not be yours, and every block this client + /// proposes pays its execution-layer rewards there. It stays optional + /// because a client running only attester duties has no use for it, and + /// startup warns loudly when it is absent. + #[arg(long)] + pub(crate) suggested_fee_recipient: Option, + + /// Serve the keymanager API. + /// + /// Off by default because it mutates key material. It binds to + /// `--http-address` with bearer-token auth; a deployment that exposes it + /// beyond localhost must front it with TLS. + #[arg(long, default_value = "false")] + pub(crate) enable_keymanager: bool, +} + +impl ValidatorOptions { + /// The graffiti bytes, or an error naming the limit if the text is too + /// long. + /// + /// Truncating instead would be worse than refusing: the operator asked for + /// a string, and a silently clipped one appears in every block they + /// propose, where they are least likely to be looking for it. + /// + /// Measured in bytes rather than characters, because that is what the field + /// holds. A 32-character string of anything outside ASCII does not fit, and + /// saying so in bytes is the only way the message helps. + pub(crate) fn graffiti(&self) -> eyre::Result { + let text = self.graffiti.as_bytes(); + if text.len() > GRAFFITI_BYTES { + eyre::bail!( + "--graffiti is {} bytes encoded as UTF-8; the field holds {GRAFFITI_BYTES}", + text.len() + ); + } + let mut bytes = [0u8; GRAFFITI_BYTES]; + bytes[..text.len()].copy_from_slice(text); + Ok(H256(bytes)) + } + + /// The configured fee recipient, or `None` if the operator named none. + /// + /// Rejected here rather than at first use: a malformed address is a startup + /// mistake, and finding out about it when a proposal duty finally arrives, + /// possibly days later, is the worst possible time. + pub(crate) fn suggested_fee_recipient(&self) -> eyre::Result> { + let Some(text) = &self.suggested_fee_recipient else { + return Ok(None); + }; + let digits = text.strip_prefix("0x").unwrap_or(text); + let bytes = hex::decode(digits) + .map_err(|err| eyre::eyre!("--suggested-fee-recipient is not hex: {err}"))?; + let bytes: [u8; ADDRESS_BYTES] = bytes.try_into().map_err(|got: Vec| { + eyre::eyre!( + "--suggested-fee-recipient is {} bytes, expected {ADDRESS_BYTES}", + got.len() + ) + })?; + Ok(Some(H160(bytes))) + } + + /// Reject a port clash before anything binds, the way the node's own + /// options do. + pub(crate) fn validate_ports(&self) -> eyre::Result<()> { + if self.enable_keymanager && self.keymanager_port == self.metrics_port { + eyre::bail!( + "--keymanager-port and --metrics-port are both {}; they must differ", + self.keymanager_port + ); + } + Ok(()) + } +} + +/// Boot its own tokio runtime and run until the process is stopped. +/// +/// `main` stays synchronous, the same way it does for the `node` sub-command: +/// each sub-command picks its own concurrency model rather than being forced +/// onto one runtime shared with the others. +#[tokio::main] +pub(crate) async fn run(options: ValidatorOptions) -> eyre::Result<()> { + options.validate_ports()?; + let graffiti = options.graffiti()?; + let suggested_fee_recipient = options.suggested_fee_recipient()?; + ethlambda_validator::run(ValidatorConfig { + beacon_nodes: options.beacon_nodes, + graffiti, + suggested_fee_recipient, + validators_dir: options.validators_dir, + secrets_dir: options.secrets_dir, + metrics: std::net::SocketAddr::new(options.http_address, options.metrics_port), + keymanager: options + .enable_keymanager + .then(|| std::net::SocketAddr::new(options.http_address, options.keymanager_port)), + }) + .await + .map_err(Into::into) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn options(graffiti: &str) -> ValidatorOptions { + ValidatorOptions { + beacon_nodes: vec!["http://localhost:5052".to_string()], + validators_dir: PathBuf::from("/tmp/validators"), + secrets_dir: PathBuf::from("/tmp/secrets"), + http_address: "127.0.0.1".parse().expect("valid address"), + keymanager_port: 5062, + metrics_port: 5064, + graffiti: graffiti.to_string(), + suggested_fee_recipient: None, + enable_keymanager: false, + } + } + + #[test] + fn the_default_graffiti_is_empty() { + let bytes = options("").graffiti().expect("valid"); + assert_eq!(bytes, H256([0u8; GRAFFITI_BYTES])); + } + + #[test] + fn graffiti_text_is_right_padded_with_zeros() { + let bytes = options("hello").graffiti().expect("valid"); + assert_eq!(&bytes.0[..5], b"hello"); + assert!( + bytes.0[5..].iter().all(|byte| *byte == 0), + "the rest of the field must be zero" + ); + } + + #[test] + fn graffiti_of_exactly_the_field_width_is_accepted() { + let text = "a".repeat(GRAFFITI_BYTES); + let bytes = options(&text).graffiti().expect("32 bytes fits exactly"); + assert_eq!(bytes.0, text.as_bytes()); + } + + #[test] + fn graffiti_one_byte_too_long_is_refused_rather_than_truncated() { + let text = "a".repeat(GRAFFITI_BYTES + 1); + let err = options(&text) + .graffiti() + .expect_err("a clipped graffiti in every block is worse than a refusal at startup"); + assert!( + err.to_string().contains("33 bytes"), + "the message must say how long the input actually was, got {err}" + ); + } + + fn with_fee_recipient(text: &str) -> ValidatorOptions { + ValidatorOptions { + suggested_fee_recipient: Some(text.to_string()), + ..options("") + } + } + + #[test] + fn no_fee_recipient_flag_means_none() { + assert_eq!( + options("").suggested_fee_recipient().expect("valid"), + None, + "an absent flag is not an error; startup warns instead" + ); + } + + #[test] + fn a_fee_recipient_is_read_with_or_without_the_hex_prefix() { + let expected = H160([0xab; ADDRESS_BYTES]); + let prefixed = format!("0x{}", "ab".repeat(ADDRESS_BYTES)); + assert_eq!( + with_fee_recipient(&prefixed) + .suggested_fee_recipient() + .expect("valid"), + Some(expected) + ); + assert_eq!( + with_fee_recipient(&"ab".repeat(ADDRESS_BYTES)) + .suggested_fee_recipient() + .expect("valid"), + Some(expected) + ); + } + + /// A truncated or over-long address is refused at startup rather than at + /// first use. A proposal duty can be days away, which is the worst moment + /// to discover a typo in a flag. + #[test] + fn a_fee_recipient_of_the_wrong_length_is_refused_at_startup() { + let err = with_fee_recipient("0xabab") + .suggested_fee_recipient() + .expect_err("two bytes is not an address"); + assert!(err.to_string().contains("2 bytes"), "got {err}"); + } + + #[test] + fn a_fee_recipient_that_is_not_hex_is_refused() { + let err = with_fee_recipient("0xzz") + .suggested_fee_recipient() + .expect_err("not hex"); + assert!(err.to_string().contains("not hex"), "got {err}"); + } + + /// The limit is bytes, not characters. Thirty-two multi-byte characters + /// look like they fit and do not, and an operator told "32 characters" + /// would have no way to work out why. + #[test] + fn a_multibyte_string_is_measured_in_bytes() { + let text = "\u{00e9}".repeat(17); // 17 characters, 34 bytes. + assert_eq!(text.chars().count(), 17); + assert!(text.len() > GRAFFITI_BYTES); + let err = options(&text) + .graffiti() + .expect_err("34 bytes does not fit"); + assert!( + err.to_string().contains("34 bytes"), + "the message must count bytes, not characters, got {err}" + ); + } +} diff --git a/bin/ethlambda/tests/fixtures/networks/devnet-electra/config.yaml b/bin/ethlambda/tests/fixtures/networks/devnet-electra/config.yaml new file mode 100644 index 000000000..c71f28a00 --- /dev/null +++ b/bin/ethlambda/tests/fixtures/networks/devnet-electra/config.yaml @@ -0,0 +1,223 @@ +# Devnet config, scheduled to start directly in electra +# +# Same as ../devnet/config.yaml, except every fork through electra is +# scheduled at epoch 0: this is what a devnet whose genesis.ssz is already an +# electra BeaconState looks like, and it is the fixture +# `a_genesis_state_at_a_later_fork_decodes_through_its_own_schedule` (in +# `network/dir.rs`) loads against. Fulu is left unscheduled (FAR_FUTURE_EPOCH) +# so `Config::fork_at_epoch(0)` resolves to `Electra`, not `Fulu`. + +# Extends the mainnet preset +PRESET_BASE: 'mainnet' + +# Free-form short name of the network that this configuration applies to - known +# canonical network names include: +# * 'mainnet' - there can be only one +# * 'sepolia' - testnet +# * 'holesky' - testnet +# * 'hoodi' - testnet +# Must match the regex: [a-z0-9\-] +CONFIG_NAME: 'ethlambda-devnet-electra' + +# Transition +# --------------------------------------------------------------- +# Estimated on Sept 15, 2022 +TERMINAL_TOTAL_DIFFICULTY: 58750000000000000000000 +# By default, don't use these params +TERMINAL_BLOCK_HASH: 0x0000000000000000000000000000000000000000000000000000000000000000 +TERMINAL_BLOCK_HASH_ACTIVATION_EPOCH: 18446744073709551615 + + +# Genesis +# --------------------------------------------------------------- +# `2**14` (= 16,384) +MIN_GENESIS_ACTIVE_VALIDATOR_COUNT: 16384 +# Dec 1, 2020, 12pm UTC +MIN_GENESIS_TIME: 1606824000 +# Mainnet initial fork version, recommend altering for testnets +GENESIS_FORK_VERSION: 0x00000000 +# 604800 seconds (7 days) +GENESIS_DELAY: 604800 + + +# Forking +# --------------------------------------------------------------- +# Every fork through electra is scheduled at genesis, so this devnet's +# genesis.ssz is already an electra BeaconState. Fulu stays unscheduled. + +# Altair +ALTAIR_FORK_VERSION: 0x01000000 +ALTAIR_FORK_EPOCH: 0 +# Bellatrix +BELLATRIX_FORK_VERSION: 0x02000000 +BELLATRIX_FORK_EPOCH: 0 +# Capella +CAPELLA_FORK_VERSION: 0x03000000 +CAPELLA_FORK_EPOCH: 0 +# Deneb +DENEB_FORK_VERSION: 0x04000000 +DENEB_FORK_EPOCH: 0 +# Electra +ELECTRA_FORK_VERSION: 0x05000000 +ELECTRA_FORK_EPOCH: 0 +# Fulu +FULU_FORK_VERSION: 0x06000000 +FULU_FORK_EPOCH: 411392 # December 3, 2025, 09:49:11pm UTC + + +# Time parameters +# --------------------------------------------------------------- +# 12 seconds (*deprecated*) +SECONDS_PER_SLOT: 6 +# 12000 milliseconds +SLOT_DURATION_MS: 12000 +# 14 (estimate from Eth1 mainnet) +SECONDS_PER_ETH1_BLOCK: 14 +# 2**8 (= 256) epochs +MIN_VALIDATOR_WITHDRAWABILITY_DELAY: 256 +# 2**8 (= 256) epochs +SHARD_COMMITTEE_PERIOD: 256 +# 2**11 (= 2,048) Eth1 blocks +ETH1_FOLLOW_DISTANCE: 2048 +# 1667 basis points, ~17% of SLOT_DURATION_MS +PROPOSER_REORG_CUTOFF_BPS: 1667 +# 3333 basis points, ~33% of SLOT_DURATION_MS +ATTESTATION_DUE_BPS: 3333 +# 6667 basis points, ~67% of SLOT_DURATION_MS +AGGREGATE_DUE_BPS: 6667 + +# Altair +# 3333 basis points, ~33% of SLOT_DURATION_MS +SYNC_MESSAGE_DUE_BPS: 3333 +# 6667 basis points, ~67% of SLOT_DURATION_MS +CONTRIBUTION_DUE_BPS: 6667 + + +# Validator cycle +# --------------------------------------------------------------- +# 2**2 (= 4) +INACTIVITY_SCORE_BIAS: 4 +# 2**4 (= 16) +INACTIVITY_SCORE_RECOVERY_RATE: 16 +# 2**4 * 10**9 (= 16,000,000,000) Gwei +EJECTION_BALANCE: 16000000000 +# 2**2 (= 4) validators +MIN_PER_EPOCH_CHURN_LIMIT: 4 +# 2**16 (= 65,536) +CHURN_LIMIT_QUOTIENT: 65536 + +# Deneb +# 2**3 (= 8) (*deprecated*) +MAX_PER_EPOCH_ACTIVATION_CHURN_LIMIT: 8 + +# Electra +# 2**7 * 10**9 (= 128,000,000,000) Gwei +MIN_PER_EPOCH_CHURN_LIMIT_ELECTRA: 128000000000 +# 2**8 * 10**9 (= 256,000,000,000) Gwei +MAX_PER_EPOCH_ACTIVATION_EXIT_CHURN_LIMIT: 256000000000 + +# Fork choice +# --------------------------------------------------------------- +# 40% +PROPOSER_SCORE_BOOST: 40 +# 20% +REORG_HEAD_WEIGHT_THRESHOLD: 20 +# 160% +REORG_PARENT_WEIGHT_THRESHOLD: 160 +# 2 epochs +REORG_MAX_EPOCHS_SINCE_FINALIZATION: 2 + + +# Deposit contract +# --------------------------------------------------------------- +# Ethereum PoW Mainnet +DEPOSIT_CHAIN_ID: 3151908 +DEPOSIT_NETWORK_ID: 3151908 +DEPOSIT_CONTRACT_ADDRESS: 0x00000000219ab540356cBB839Cbe05303d7705Fa + + +# Networking +# --------------------------------------------------------------- +# 10 * 2**20 (= 10,485,760) bytes, 10 MiB +MAX_PAYLOAD_SIZE: 10485760 +# 2**10 (= 1,024) blocks +MAX_REQUEST_BLOCKS: 1024 +# 2**8 (= 256) epochs +EPOCHS_PER_SUBNET_SUBSCRIPTION: 256 +# MIN_VALIDATOR_WITHDRAWABILITY_DELAY + CHURN_LIMIT_QUOTIENT // 2 (= 33,024) epochs +MIN_EPOCHS_FOR_BLOCK_REQUESTS: 33024 +# 2**5 (= 32) slots +ATTESTATION_PROPAGATION_SLOT_RANGE: 32 +# 500ms +MAXIMUM_GOSSIP_CLOCK_DISPARITY: 500 +MESSAGE_DOMAIN_INVALID_SNAPPY: 0x00000000 +MESSAGE_DOMAIN_VALID_SNAPPY: 0x01000000 +# 2 subnets per node +SUBNETS_PER_NODE: 2 +# 2**6 (= 64) subnets +ATTESTATION_SUBNET_COUNT: 64 +# 0 bits +ATTESTATION_SUBNET_EXTRA_BITS: 0 +# ceillog2(ATTESTATION_SUBNET_COUNT) + ATTESTATION_SUBNET_EXTRA_BITS (= 6 + 0) bits +ATTESTATION_SUBNET_PREFIX_BITS: 6 + +# Deneb +# 2**7 (= 128) blocks +MAX_REQUEST_BLOCKS_DENEB: 128 +# 2**12 (= 4,096) epochs +MIN_EPOCHS_FOR_BLOB_SIDECARS_REQUESTS: 4096 +# 6 subnets +BLOB_SIDECAR_SUBNET_COUNT: 6 +# 6 blobs +MAX_BLOBS_PER_BLOCK: 6 +# MAX_REQUEST_BLOCKS_DENEB * MAX_BLOBS_PER_BLOCK (= 128 * 6) sidecars +MAX_REQUEST_BLOB_SIDECARS: 768 + +# Electra +# 9 subnets +BLOB_SIDECAR_SUBNET_COUNT_ELECTRA: 9 +# 9 blobs +MAX_BLOBS_PER_BLOCK_ELECTRA: 9 +# MAX_REQUEST_BLOCKS_DENEB * MAX_BLOBS_PER_BLOCK_ELECTRA (= 128 * 9) sidecars +MAX_REQUEST_BLOB_SIDECARS_ELECTRA: 1152 + +# Fulu +# 2**7 (= 128) groups +NUMBER_OF_CUSTODY_GROUPS: 128 +# 2**7 (= 128) subnets +DATA_COLUMN_SIDECAR_SUBNET_COUNT: 128 +# MAX_REQUEST_BLOCKS_DENEB * NUMBER_OF_COLUMNS (= 128 * 128) sidecars +MAX_REQUEST_DATA_COLUMN_SIDECARS: 16384 +# 2**3 (= 8) samples +SAMPLES_PER_SLOT: 8 +# 2**2 (= 4) sidecars +CUSTODY_REQUIREMENT: 4 +# 2**3 (= 8) sidecars +VALIDATOR_CUSTODY_REQUIREMENT: 8 +# 2**5 * 10**9 (= 32,000,000,000) Gwei +BALANCE_PER_ADDITIONAL_CUSTODY_GROUP: 32000000000 +# 2**12 (= 4,096) epochs +MIN_EPOCHS_FOR_DATA_COLUMN_SIDECARS_REQUESTS: 4096 + + +# Blob Scheduling +# --------------------------------------------------------------- + +BLOB_SCHEDULE: + - EPOCH: 412672 # December 9, 2025, 02:21:11pm UTC + MAX_BLOBS_PER_BLOCK: 15 + - EPOCH: 419072 # January 7, 2026, 01:01:11am UTC + MAX_BLOBS_PER_BLOCK: 21 + +# Keys a kurtosis-generated config carries that mainnet's does not. The gloas +# and heze entries are deliberately present: this build cannot process either +# fork, so they must land in the ignored-key warning rather than in Config. +MAX_REQUEST_PAYLOADS: 128 +CONSOLIDATION_CHURN_LIMIT_QUOTIENT: 65536 +GAS_LIMIT_SCHEDULE: [] +GLOAS_FORK_VERSION: 0x07000000 +GLOAS_FORK_EPOCH: 18446744073709551615 +HEZE_FORK_VERSION: 0x08000000 +HEZE_FORK_EPOCH: 18446744073709551615 +PAYLOAD_DUE_BPS: 5000 +INCLUSION_LIST_DUE_BPS: 8500 diff --git a/bin/ethlambda/tests/fixtures/networks/devnet/bootstrap_nodes.txt b/bin/ethlambda/tests/fixtures/networks/devnet/bootstrap_nodes.txt new file mode 100644 index 000000000..2711b3388 --- /dev/null +++ b/bin/ethlambda/tests/fixtures/networks/devnet/bootstrap_nodes.txt @@ -0,0 +1,2 @@ +enr:-Iu4QLm7bZGdAt9NSeJG0cEnJohWcQTQaI9wFLu3Q7eHIDfrI4cwtzvEW3F3VbG9XdFXlrHyFGeXPn9snTCQJ9bnMRABgmlkgnY0gmlwhAOTJQCJc2VjcDI1NmsxoQIZdZD6tDYpkpEfVo5bgiU8MGRjhcOmHGD2nErK0UKRrIN0Y3CCIyiDdWRwgiMo +enr:-Iu4QEDJ4Wa_UQNbK8Ay1hFEkXvd8psolVK6OhfTL9irqz3nbXxxWyKwEplPfkju4zduVQj6mMhUCm9R2Lc4YM5jPcIBgmlkgnY0gmlwhANrfESJc2VjcDI1NmsxoQJCYz2-nsqFpeEj6eov9HSi9QssIVIVNr0I89J1vXM9foN0Y3CCIyiDdWRwgiMo diff --git a/bin/ethlambda/tests/fixtures/networks/devnet/config.yaml b/bin/ethlambda/tests/fixtures/networks/devnet/config.yaml new file mode 100644 index 000000000..b027ee509 --- /dev/null +++ b/bin/ethlambda/tests/fixtures/networks/devnet/config.yaml @@ -0,0 +1,217 @@ +# Mainnet config + +# Extends the mainnet preset +PRESET_BASE: 'mainnet' + +# Free-form short name of the network that this configuration applies to - known +# canonical network names include: +# * 'mainnet' - there can be only one +# * 'sepolia' - testnet +# * 'holesky' - testnet +# * 'hoodi' - testnet +# Must match the regex: [a-z0-9\-] +CONFIG_NAME: 'ethlambda-devnet' + +# Transition +# --------------------------------------------------------------- +# Estimated on Sept 15, 2022 +TERMINAL_TOTAL_DIFFICULTY: 58750000000000000000000 +# By default, don't use these params +TERMINAL_BLOCK_HASH: 0x0000000000000000000000000000000000000000000000000000000000000000 +TERMINAL_BLOCK_HASH_ACTIVATION_EPOCH: 18446744073709551615 + + +# Genesis +# --------------------------------------------------------------- +# `2**14` (= 16,384) +MIN_GENESIS_ACTIVE_VALIDATOR_COUNT: 16384 +# Dec 1, 2020, 12pm UTC +MIN_GENESIS_TIME: 1606824000 +# Mainnet initial fork version, recommend altering for testnets +GENESIS_FORK_VERSION: 0x00000000 +# 604800 seconds (7 days) +GENESIS_DELAY: 604800 + + +# Forking +# --------------------------------------------------------------- +# Some forks are disabled for now: +# - These may be re-assigned to another fork-version later +# - Temporarily set to max uint64 value: 2**64 - 1 + +# Altair +ALTAIR_FORK_VERSION: 0x01000000 +ALTAIR_FORK_EPOCH: 74240 # Oct 27, 2021, 10:56:23am UTC +# Bellatrix +BELLATRIX_FORK_VERSION: 0x02000000 +BELLATRIX_FORK_EPOCH: 144896 # Sept 6, 2022, 11:34:47am UTC +# Capella +CAPELLA_FORK_VERSION: 0x03000000 +CAPELLA_FORK_EPOCH: 194048 # April 12, 2023, 10:27:35pm UTC +# Deneb +DENEB_FORK_VERSION: 0x04000000 +DENEB_FORK_EPOCH: 269568 # March 13, 2024, 01:55:35pm UTC +# Electra +ELECTRA_FORK_VERSION: 0x05000000 +ELECTRA_FORK_EPOCH: 364032 # May 7, 2025, 10:05:11am UTC +# Fulu +FULU_FORK_VERSION: 0x06000000 +FULU_FORK_EPOCH: 411392 # December 3, 2025, 09:49:11pm UTC + + +# Time parameters +# --------------------------------------------------------------- +# 12 seconds (*deprecated*) +SECONDS_PER_SLOT: 6 +# 12000 milliseconds +SLOT_DURATION_MS: 12000 +# 14 (estimate from Eth1 mainnet) +SECONDS_PER_ETH1_BLOCK: 14 +# 2**8 (= 256) epochs +MIN_VALIDATOR_WITHDRAWABILITY_DELAY: 256 +# 2**8 (= 256) epochs +SHARD_COMMITTEE_PERIOD: 256 +# 2**11 (= 2,048) Eth1 blocks +ETH1_FOLLOW_DISTANCE: 2048 +# 1667 basis points, ~17% of SLOT_DURATION_MS +PROPOSER_REORG_CUTOFF_BPS: 1667 +# 3333 basis points, ~33% of SLOT_DURATION_MS +ATTESTATION_DUE_BPS: 3333 +# 6667 basis points, ~67% of SLOT_DURATION_MS +AGGREGATE_DUE_BPS: 6667 + +# Altair +# 3333 basis points, ~33% of SLOT_DURATION_MS +SYNC_MESSAGE_DUE_BPS: 3333 +# 6667 basis points, ~67% of SLOT_DURATION_MS +CONTRIBUTION_DUE_BPS: 6667 + + +# Validator cycle +# --------------------------------------------------------------- +# 2**2 (= 4) +INACTIVITY_SCORE_BIAS: 4 +# 2**4 (= 16) +INACTIVITY_SCORE_RECOVERY_RATE: 16 +# 2**4 * 10**9 (= 16,000,000,000) Gwei +EJECTION_BALANCE: 16000000000 +# 2**2 (= 4) validators +MIN_PER_EPOCH_CHURN_LIMIT: 4 +# 2**16 (= 65,536) +CHURN_LIMIT_QUOTIENT: 65536 + +# Deneb +# 2**3 (= 8) (*deprecated*) +MAX_PER_EPOCH_ACTIVATION_CHURN_LIMIT: 8 + +# Electra +# 2**7 * 10**9 (= 128,000,000,000) Gwei +MIN_PER_EPOCH_CHURN_LIMIT_ELECTRA: 128000000000 +# 2**8 * 10**9 (= 256,000,000,000) Gwei +MAX_PER_EPOCH_ACTIVATION_EXIT_CHURN_LIMIT: 256000000000 + +# Fork choice +# --------------------------------------------------------------- +# 40% +PROPOSER_SCORE_BOOST: 40 +# 20% +REORG_HEAD_WEIGHT_THRESHOLD: 20 +# 160% +REORG_PARENT_WEIGHT_THRESHOLD: 160 +# 2 epochs +REORG_MAX_EPOCHS_SINCE_FINALIZATION: 2 + + +# Deposit contract +# --------------------------------------------------------------- +# Ethereum PoW Mainnet +DEPOSIT_CHAIN_ID: 3151908 +DEPOSIT_NETWORK_ID: 3151908 +DEPOSIT_CONTRACT_ADDRESS: 0x00000000219ab540356cBB839Cbe05303d7705Fa + + +# Networking +# --------------------------------------------------------------- +# 10 * 2**20 (= 10,485,760) bytes, 10 MiB +MAX_PAYLOAD_SIZE: 10485760 +# 2**10 (= 1,024) blocks +MAX_REQUEST_BLOCKS: 1024 +# 2**8 (= 256) epochs +EPOCHS_PER_SUBNET_SUBSCRIPTION: 256 +# MIN_VALIDATOR_WITHDRAWABILITY_DELAY + CHURN_LIMIT_QUOTIENT // 2 (= 33,024) epochs +MIN_EPOCHS_FOR_BLOCK_REQUESTS: 33024 +# 2**5 (= 32) slots +ATTESTATION_PROPAGATION_SLOT_RANGE: 32 +# 500ms +MAXIMUM_GOSSIP_CLOCK_DISPARITY: 500 +MESSAGE_DOMAIN_INVALID_SNAPPY: 0x00000000 +MESSAGE_DOMAIN_VALID_SNAPPY: 0x01000000 +# 2 subnets per node +SUBNETS_PER_NODE: 2 +# 2**6 (= 64) subnets +ATTESTATION_SUBNET_COUNT: 64 +# 0 bits +ATTESTATION_SUBNET_EXTRA_BITS: 0 +# ceillog2(ATTESTATION_SUBNET_COUNT) + ATTESTATION_SUBNET_EXTRA_BITS (= 6 + 0) bits +ATTESTATION_SUBNET_PREFIX_BITS: 6 + +# Deneb +# 2**7 (= 128) blocks +MAX_REQUEST_BLOCKS_DENEB: 128 +# 2**12 (= 4,096) epochs +MIN_EPOCHS_FOR_BLOB_SIDECARS_REQUESTS: 4096 +# 6 subnets +BLOB_SIDECAR_SUBNET_COUNT: 6 +# 6 blobs +MAX_BLOBS_PER_BLOCK: 6 +# MAX_REQUEST_BLOCKS_DENEB * MAX_BLOBS_PER_BLOCK (= 128 * 6) sidecars +MAX_REQUEST_BLOB_SIDECARS: 768 + +# Electra +# 9 subnets +BLOB_SIDECAR_SUBNET_COUNT_ELECTRA: 9 +# 9 blobs +MAX_BLOBS_PER_BLOCK_ELECTRA: 9 +# MAX_REQUEST_BLOCKS_DENEB * MAX_BLOBS_PER_BLOCK_ELECTRA (= 128 * 9) sidecars +MAX_REQUEST_BLOB_SIDECARS_ELECTRA: 1152 + +# Fulu +# 2**7 (= 128) groups +NUMBER_OF_CUSTODY_GROUPS: 128 +# 2**7 (= 128) subnets +DATA_COLUMN_SIDECAR_SUBNET_COUNT: 128 +# MAX_REQUEST_BLOCKS_DENEB * NUMBER_OF_COLUMNS (= 128 * 128) sidecars +MAX_REQUEST_DATA_COLUMN_SIDECARS: 16384 +# 2**3 (= 8) samples +SAMPLES_PER_SLOT: 8 +# 2**2 (= 4) sidecars +CUSTODY_REQUIREMENT: 4 +# 2**3 (= 8) sidecars +VALIDATOR_CUSTODY_REQUIREMENT: 8 +# 2**5 * 10**9 (= 32,000,000,000) Gwei +BALANCE_PER_ADDITIONAL_CUSTODY_GROUP: 32000000000 +# 2**12 (= 4,096) epochs +MIN_EPOCHS_FOR_DATA_COLUMN_SIDECARS_REQUESTS: 4096 + + +# Blob Scheduling +# --------------------------------------------------------------- + +BLOB_SCHEDULE: + - EPOCH: 412672 # December 9, 2025, 02:21:11pm UTC + MAX_BLOBS_PER_BLOCK: 15 + - EPOCH: 419072 # January 7, 2026, 01:01:11am UTC + MAX_BLOBS_PER_BLOCK: 21 + +# Keys a kurtosis-generated config carries that mainnet's does not. The gloas +# and heze entries are deliberately present: this build cannot process either +# fork, so they must land in the ignored-key warning rather than in Config. +MAX_REQUEST_PAYLOADS: 128 +CONSOLIDATION_CHURN_LIMIT_QUOTIENT: 65536 +GAS_LIMIT_SCHEDULE: [] +GLOAS_FORK_VERSION: 0x07000000 +GLOAS_FORK_EPOCH: 18446744073709551615 +HEZE_FORK_VERSION: 0x08000000 +HEZE_FORK_EPOCH: 18446744073709551615 +PAYLOAD_DUE_BPS: 5000 +INCLUSION_LIST_DUE_BPS: 8500 diff --git a/bin/ethlambda/tests/fixtures/networks/mainnet/genesis.ssz b/bin/ethlambda/tests/fixtures/networks/mainnet/genesis.ssz new file mode 100644 index 000000000..f68d0e967 Binary files /dev/null and b/bin/ethlambda/tests/fixtures/networks/mainnet/genesis.ssz differ diff --git a/crates/blockchain/Cargo.toml b/crates/blockchain/Cargo.toml index 32df42c23..8e55fc0e0 100644 --- a/crates/blockchain/Cargo.toml +++ b/crates/blockchain/Cargo.toml @@ -12,6 +12,7 @@ autotests = false [dependencies] ethlambda-network-api.workspace = true +ethlambda-engine.workspace = true ethlambda-storage.workspace = true ethlambda-state-transition.workspace = true ethlambda-fork-choice.workspace = true diff --git a/crates/blockchain/src/aggregation.rs b/crates/blockchain/src/aggregation.rs index ed2e1ea1b..1a4805f8f 100644 --- a/crates/blockchain/src/aggregation.rs +++ b/crates/blockchain/src/aggregation.rs @@ -1272,6 +1272,7 @@ mod tests { use ethlambda_storage::backend::InMemoryBackend; use ethlambda_types::constants::DEFAULT_MILLISECONDS_PER_SLOT; use ethlambda_types::{ + beacon::containers::SignedBeaconBlock, block::{Block, BlockBody, BlockHeader, MultiMessageAggregate, SignedBlock}, checkpoint::Checkpoint, state::{JustificationValidators, JustifiedSlots, State, StateConfig}, @@ -1846,7 +1847,7 @@ mod tests { proof: MultiMessageAggregate::default(), }; store - .insert_signed_block(root, signed_block) + .insert_signed_block(root, SignedBeaconBlock::Lean(signed_block)) .expect("insert test block should succeed"); } @@ -2461,7 +2462,7 @@ mod tests { let mut store = new_test_store(head_state); insert_test_block(&mut store, hashes[0], 0, H256::ZERO); store - .insert_signed_block(carrier_root, carrier) + .insert_signed_block(carrier_root, SignedBeaconBlock::Lean(carrier)) .expect("insert block carrying the vote"); let hashed = HashedAttestationData::new(att_data); diff --git a/crates/blockchain/src/beacon_aggregates.rs b/crates/blockchain/src/beacon_aggregates.rs new file mode 100644 index 000000000..a4d65002e --- /dev/null +++ b/crates/blockchain/src/beacon_aggregates.rs @@ -0,0 +1,406 @@ +//! The chain actor's state for `beacon_aggregate_and_proof`. +//! +//! Two things that have to live beside the store rather than in the p2p +//! layer, and one reason each. +//! +//! **The applied-bits gate**, a running union of the aggregation bits this +//! actor has already applied to fork choice per `(target_epoch, +//! hash_tree_root(data), committee_index)`. `ethlambda-p2p`'s gossip +//! validation owns the specification's own `Seen` sets now (a garbage +//! aggregate can no longer censor a genuine one, since gossip only records a +//! key once its signatures verify), so this gate is not about spec conformance +//! at all: it is what keeps a committee's other aggregators, each carrying the +//! same votes and each individually accepted by gossip, from paying +//! [`fork_choice::apply_verified_aggregate`]'s own work more than once. +//! +//! **The deferral queue**, because `validate_on_attestation` requires +//! `get_current_slot(store) >= attestation.data.slot + 1` and aggregates are +//! published two thirds of the way through the slot they vote for. Every +//! aggregate therefore arrives one slot too early to be applied. Without a +//! queue this topic would contribute nothing at all, which is why the queue is +//! not an optimization: it is the difference between the feature working and +//! not. The specification licenses it directly ("consider scheduling it for +//! later processing in such case"), and lighthouse does the same thing in +//! `process_attestation_queue`. +//! +//! # What bounds this +//! +//! A slot carries at most +//! [`aggregate::MAX_AGGREGATES_PER_SLOT`](ethlambda_state_transition::beacon::aggregate::MAX_AGGREGATES_PER_SLOT) +//! aggregates, so the queue is capped at a small multiple of that. The +//! applied-bits gate is pruned by the store's own clock, not by finality: see +//! [`AggregateGossip::prune`]. + +use std::collections::{HashMap, VecDeque}; + +use ethlambda_state_transition::beacon::aggregate::{ + MAX_AGGREGATES_PER_SLOT, is_non_strict_superset, +}; +use ethlambda_types::beacon::containers::SignedAggregateAndProof; +use ethlambda_types::beacon::primitives::{ + CommitteeIndex, Epoch, HashTreeRoot as _, Root, Slot, ValidatorIndex, +}; + +/// How many slots' worth of aggregates the deferral queue may hold. +/// +/// Two rather than one: an aggregate for slot N becomes applicable at slot +/// N+1, so a queue draining once per slot holds one slot's worth in the steady +/// state and needs room for a second while the first is still being drained. +/// Anything past that is a backlog the next drain would not clear either. +const DEFERRED_SLOTS: u64 = 2; + +/// The deferral queue's hard cap. +const MAX_DEFERRED: usize = (MAX_AGGREGATES_PER_SLOT * DEFERRED_SLOTS) as usize; + +/// One aggregate held for a slot that has not passed yet. +pub(crate) struct Deferred { + pub aggregate: Box, + /// The attesting indices gossip validation resolved for this aggregate; + /// carried alongside it so a drain has no state left to rebuild before + /// calling [`fork_choice::apply_verified_aggregate`]. + pub attesting_indices: Vec, + /// The slot this becomes applicable at: the aggregate's own slot plus one, + /// cached so a drain compares numbers rather than reaching back into the + /// container. + pub applicable_at: Slot, +} + +/// Why an aggregate was not applied, for the metric that counts outcomes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Dropped { + /// A valid aggregate whose bits are already covered for this + /// `(AttestationData, committee)`. + KnownSubset, + /// The queue was full when this arrived. + QueueFull, +} + +impl Dropped { + /// The label this outcome is counted under. + pub(crate) fn label(self) -> &'static str { + match self { + Self::KnownSubset => "known_subset", + Self::QueueFull => "queue_full", + } + } +} + +/// The actor's `beacon_aggregate_and_proof` state. Always empty on lean, which +/// subscribes to no such topic. +#[derive(Default)] +pub(crate) struct AggregateGossip { + /// The union of aggregation bits applied for each + /// `(hash_tree_root(data), committee_index)`, by target epoch. + /// + /// A running union rather than every bitfield seen, which is what makes + /// the superset test one comparison and the memory one bitfield per + /// distinct attestation rather than one per aggregate. EIP-7549 keys this + /// by committee as well as by data root, because electra's + /// `aggregation_bits` is only meaningful against the committee it covers. + seen_bits: HashMap>>, + /// Aggregates waiting for their own slot to pass. + deferred: VecDeque, +} + +impl AggregateGossip { + /// Whether this aggregate adds nothing that has not already been applied. + /// + /// Read before [`fork_choice::apply_verified_aggregate`] and written only + /// after it succeeds; see the module documentation. The specification's + /// own `[IGNORE]` for this condition is gossip's to answer now (p2p's + /// `SeenAggregates` records it before this aggregate is even forwarded), + /// so a caller here treats a `Some` purely as "this would cost work with + /// no effect", not as misbehaviour. + pub(crate) fn already_covered(&self, aggregate: &SignedAggregateAndProof) -> Option { + let data = aggregate.data(); + let epoch = data.target.epoch; + + // An aggregate naming no single committee is rejected downstream by + // the gossip conditions; there is no key to look it up under here, so + // it simply is not covered. + let committee_index = aggregate.committee_index()?; + let key = (data.hash_tree_root(), committee_index); + let seen = self.seen_bits.get(&epoch)?.get(&key)?; + is_non_strict_superset(seen, &aggregate.aggregation_bits()).then_some(Dropped::KnownSubset) + } + + /// Record an aggregate that was applied, so a later one covering no more + /// is dropped before it reaches fork choice at all. + pub(crate) fn record(&mut self, aggregate: &SignedAggregateAndProof) { + let data = aggregate.data(); + let epoch = data.target.epoch; + + let Some(committee_index) = aggregate.committee_index() else { + return; + }; + let bits = aggregate.aggregation_bits(); + let union = self + .seen_bits + .entry(epoch) + .or_default() + .entry((data.hash_tree_root(), committee_index)) + .or_insert_with(|| vec![false; bits.len()]); + // A later aggregate for the same committee has the same width, but a + // malformed one that reached here would not, so grow rather than + // index out of bounds. + if union.len() < bits.len() { + union.resize(bits.len(), false); + } + for (slot, bit) in union.iter_mut().zip(bits.iter()) { + *slot |= *bit; + } + } + + /// Hold an aggregate whose own slot has not passed yet. + /// + /// Returns [`Dropped::QueueFull`] if the queue is at its cap, which is + /// what keeps a peer from choosing how much memory this costs. The oldest + /// entry is the one refused rather than evicted: an entry already in the + /// queue is closer to being applicable than one just arriving, so + /// dropping it to make room would trade a nearly-ready vote for a newer + /// one that still has to wait. + pub(crate) fn defer( + &mut self, + aggregate: Box, + attesting_indices: Vec, + ) -> Option { + if self.deferred.len() >= MAX_DEFERRED { + return Some(Dropped::QueueFull); + } + let applicable_at = aggregate.slot().saturating_add(1); + self.deferred.push_back(Deferred { + aggregate, + attesting_indices, + applicable_at, + }); + None + } + + /// Take every held aggregate whose slot has now passed. + /// + /// Drains the whole queue and puts back what is still early, rather than + /// draining a prefix: arrivals are not ordered by slot, since a peer may + /// send an aggregate for an older slot at any time. + pub(crate) fn take_ready(&mut self, current_slot: Slot) -> Vec { + let mut ready = Vec::new(); + let mut still_early = VecDeque::with_capacity(self.deferred.len()); + for entry in self.deferred.drain(..) { + if current_slot >= entry.applicable_at { + ready.push(entry); + } else { + still_early.push_back(entry); + } + } + self.deferred = still_early; + ready + } + + /// How many aggregates are held, for the gauge that makes a backlog + /// visible. + pub(crate) fn deferred_len(&self) -> usize { + self.deferred.len() + } + + /// Drop the applied-bits gate's entries for any epoch but the current and + /// the previous one, and any held aggregate for a finalized slot. + /// + /// Driven by the store's own clock (`current_epoch`), not by finality + /// (`finalized_slot` still prunes the deferral queue, which is a + /// different bound): the gossip conditions this gate backs only ever ask + /// about the current or the previous epoch + /// (`is_current_or_previous_epoch`), and finality can stall for far longer + /// than that window while gossip keeps arriving, which is exactly the + /// unbounded growth a peer must not get to cause. + pub(crate) fn prune(&mut self, current_epoch: Epoch, finalized_slot: Slot) { + let floor = current_epoch.saturating_sub(1); + self.seen_bits.retain(|epoch, _| *epoch >= floor); + // A held aggregate for a finalized slot can no longer change the head, + // and nothing else would ever remove it: its slot has passed, so a + // drain would take it, but a drain only runs while the actor ticks. + self.deferred + .retain(|entry| entry.aggregate.slot() > finalized_slot); + } +} + +#[cfg(test)] +mod tests { + use ethlambda_types::beacon::containers::phase0; + use ethlambda_types::beacon::containers::{AttestationData, Checkpoint}; + use libssz_types::SszBitlist; + + use super::*; + + /// An aggregate at `slot` from `aggregator`, covering `bits` of a + /// four-member committee. Only the fields the seen-sets and the queue read + /// are meaningful; nothing here is signature-valid. + fn aggregate( + slot: Slot, + aggregator: ValidatorIndex, + bits: [bool; 4], + ) -> SignedAggregateAndProof { + let mut aggregation_bits: SszBitlist<2048> = SszBitlist::with_length(4).unwrap(); + for (index, bit) in bits.iter().enumerate() { + aggregation_bits.set(index, *bit).unwrap(); + } + SignedAggregateAndProof::Phase0(phase0::SignedAggregateAndProof { + message: phase0::AggregateAndProof { + aggregator_index: aggregator, + aggregate: phase0::Attestation { + aggregation_bits, + data: AttestationData { + slot, + index: 0, + beacon_block_root: Root::ZERO, + source: Checkpoint::default(), + target: Checkpoint { + epoch: slot / 32, + root: Root::ZERO, + }, + }, + signature: Default::default(), + }, + selection_proof: Default::default(), + }, + signature: Default::default(), + }) + } + + #[test] + fn an_unseen_aggregate_is_not_covered() { + let gossip = AggregateGossip::default(); + assert_eq!( + gossip.already_covered(&aggregate(0, 1, [true, false, false, false])), + None + ); + } + + /// The gate that actually pays for itself: a committee's other aggregators + /// publish the same votes, and once the union covers them their aggregates + /// cost no signature verification at all. + #[test] + fn an_aggregate_adding_no_bits_is_dropped() { + let mut gossip = AggregateGossip::default(); + gossip.record(&aggregate(0, 1, [true, true, true, false])); + // A different aggregator, same data, a subset of the bits. + assert_eq!( + gossip.already_covered(&aggregate(0, 2, [true, false, true, false])), + Some(Dropped::KnownSubset) + ); + } + + #[test] + fn an_aggregate_adding_a_bit_is_kept() { + let mut gossip = AggregateGossip::default(); + gossip.record(&aggregate(0, 1, [true, true, false, false])); + // Position 3 is new, so this carries a vote the union does not have. + assert_eq!( + gossip.already_covered(&aggregate(0, 2, [true, false, false, true])), + None + ); + } + + /// Two aggregators' partial coverage has to accumulate, or the third + /// aggregate covering their union would be applied again. + #[test] + fn coverage_accumulates_across_aggregates() { + let mut gossip = AggregateGossip::default(); + gossip.record(&aggregate(0, 1, [true, true, false, false])); + gossip.record(&aggregate(0, 2, [false, false, true, true])); + assert_eq!( + gossip.already_covered(&aggregate(0, 3, [true, false, true, false])), + Some(Dropped::KnownSubset) + ); + } + + #[test] + fn a_held_aggregate_is_taken_once_its_slot_has_passed() { + let mut gossip = AggregateGossip::default(); + assert_eq!( + gossip.defer(Box::new(aggregate(5, 1, [true; 4])), vec![1]), + None + ); + // Still slot 5: the aggregate votes at 5 and needs 6. + assert!(gossip.take_ready(5).is_empty()); + assert_eq!(gossip.deferred_len(), 1); + let ready = gossip.take_ready(6); + assert_eq!(ready.len(), 1); + assert_eq!(ready[0].attesting_indices, vec![1]); + assert_eq!(gossip.deferred_len(), 0); + } + + /// Arrivals are not ordered by slot, so a drain has to consider the whole + /// queue rather than a prefix of it. + #[test] + fn a_drain_takes_ready_entries_from_anywhere_in_the_queue() { + let mut gossip = AggregateGossip::default(); + gossip.defer(Box::new(aggregate(9, 1, [true; 4])), vec![]); + gossip.defer(Box::new(aggregate(2, 2, [true; 4])), vec![]); + gossip.defer(Box::new(aggregate(9, 3, [true; 4])), vec![]); + + let ready = gossip.take_ready(5); + assert_eq!(ready.len(), 1, "only the slot-2 aggregate is applicable"); + assert_eq!(ready[0].aggregate.slot(), 2); + assert_eq!(gossip.deferred_len(), 2); + } + + #[test] + fn the_queue_refuses_rather_than_growing_without_bound() { + let mut gossip = AggregateGossip::default(); + for index in 0..MAX_DEFERRED { + assert_eq!( + gossip.defer(Box::new(aggregate(9, index as u64, [true; 4])), vec![]), + None + ); + } + assert_eq!( + gossip.defer(Box::new(aggregate(9, 0, [true; 4])), vec![]), + Some(Dropped::QueueFull) + ); + assert_eq!(gossip.deferred_len(), MAX_DEFERRED); + } + + #[test] + fn pruning_drops_old_epochs_and_finalized_holds() { + let mut gossip = AggregateGossip::default(); + // Epoch 0, via slot 0. + gossip.record(&aggregate(0, 1, [true; 4])); + // Epoch 10, via slot 320. + gossip.record(&aggregate(320, 2, [true; 4])); + gossip.defer(Box::new(aggregate(100, 3, [true; 4])), vec![]); + + // Current epoch 10: the window keeps only epochs 9 and 10. + gossip.prune(10, 200); + + // Epoch 0 is outside the current-or-previous-epoch window. + assert_eq!( + gossip.already_covered(&aggregate(0, 1, [true; 4])), + None, + "the old epoch's coverage should have been forgotten" + ); + // Epoch 10 is still within the window. + assert_eq!( + gossip.already_covered(&aggregate(320, 2, [true; 4])), + Some(Dropped::KnownSubset) + ); + // The held aggregate votes at slot 100, below the finalized slot. + assert_eq!(gossip.deferred_len(), 0); + } + + /// The applied-bits window follows the store's clock, not finality: a + /// finality stall must not let coverage from an old epoch keep costing an + /// LRU-free `HashMap` entry forever. + #[test] + fn pruning_follows_the_current_epoch_even_when_finality_has_not_moved() { + let mut gossip = AggregateGossip::default(); + gossip.record(&aggregate(0, 1, [true; 4])); + + // Finality is still at slot 0, but the clock has moved to epoch 50. + gossip.prune(50, 0); + + assert_eq!( + gossip.already_covered(&aggregate(0, 1, [true; 4])), + None, + "coverage far behind the current epoch must not survive finality stalling" + ); + } +} diff --git a/crates/blockchain/src/beacon_engine.rs b/crates/blockchain/src/beacon_engine.rs new file mode 100644 index 000000000..7943603a1 --- /dev/null +++ b/crates/blockchain/src/beacon_engine.rs @@ -0,0 +1,264 @@ +//! Turning a beacon block into an Engine API question, and an answer into a +//! fork choice verdict. +//! +//! This module is the seam between two crates that must not depend on each +//! other. `ethlambda-engine` is pure wire and must not pull in `blst` and +//! `c-kzg` through the state transition; `ethlambda-state-transition` must not +//! pull in `reqwest`. This crate already depends on both, so the assembly that +//! needs a helper from each lives here. +//! +//! # Everything a request needs comes from the block alone +//! +//! `NewPayloadRequest` in the specification reads `parent_beacon_block_root` +//! off `state.latest_block_header.parent_root`, which after +//! `process_block_header` is the block's own `parent_root`. The other three +//! fields are body fields. So the whole question is answerable before the state +//! transition runs, which is what lets the engine round trip happen outside the +//! state transition and the state transition stay synchronous. + +use ethlambda_engine::types::PayloadStatusValue; +use ethlambda_engine::{EngineClient, EngineError, PayloadStatusV1}; +use ethlambda_state_transition::beacon::containers::{self, SignedBeaconBlock}; +use ethlambda_state_transition::beacon::fork_choice::PayloadValidity; +use ethlambda_state_transition::beacon::primitives::{Bytes32, Root}; +use ethlambda_state_transition::beacon::stf::deneb::kzg_commitment_to_versioned_hash; +use ethlambda_state_transition::beacon::stf::electra::get_execution_requests_list; + +/// Everything `engine_newPayloadV4` takes, derived from one block. +pub struct NewPayloadRequest<'a> { + pub execution_payload: &'a containers::deneb::ExecutionPayload, + pub versioned_hashes: Vec, + pub parent_beacon_block_root: Root, + pub execution_requests: Vec>, +} + +/// The question to ask about `block`, or `None` if there is nothing to ask. +/// +/// `None` for phase0 and altair, which predate the merge and carry no payload, +/// and for a lean block, which is not a Beacon Chain shape at all. Bellatrix +/// through deneb are also `None` here for a different reason: this node +/// checkpoint-syncs onto a mainnet far past those forks and never imports one, +/// and `engine_newPayloadV4` would reject their payloads as an unsupported fork +/// anyway. Supporting them would mean the V1 through V3 methods too, for chains +/// this follower cannot reach. +pub fn new_payload_request(block: &SignedBeaconBlock) -> Option> { + let inner = match block { + SignedBeaconBlock::Electra(inner) | SignedBeaconBlock::Fulu(inner) => inner, + _ => return None, + }; + + let versioned_hashes = inner + .message + .body + .blob_kzg_commitments + .iter() + .map(kzg_commitment_to_versioned_hash) + .collect(); + + Some(NewPayloadRequest { + execution_payload: &inner.message.body.execution_payload, + versioned_hashes, + parent_beacon_block_root: inner.message.parent_root, + execution_requests: get_execution_requests_list(&inner.message.body.execution_requests), + }) +} + +/// Reads an execution client's status as the verdict `fork_choice::on_block` +/// takes. +/// +/// A thin wrapper over the state transition's own `payload_validity`, +/// converting between the wire crate's status enum and the consensus crate's. +/// The two are separate types on purpose: the wire one is a JSON shape that +/// changes when the Engine API changes, and the consensus one is what +/// `optimistic-sync.md` defines. +pub fn verdict(status: &PayloadStatusV1) -> PayloadValidity { + use ethlambda_state_transition::beacon::fork_choice::{ + PayloadStatusEnum, PayloadStatusV1 as ConsensusStatus, payload_validity, + }; + + let consensus_status = match status.status { + PayloadStatusValue::Valid => PayloadStatusEnum::Valid, + PayloadStatusValue::Invalid => PayloadStatusEnum::Invalid, + PayloadStatusValue::Syncing => PayloadStatusEnum::Syncing, + PayloadStatusValue::Accepted => PayloadStatusEnum::Accepted, + PayloadStatusValue::InvalidBlockHash => PayloadStatusEnum::InvalidBlockHash, + }; + payload_validity(&ConsensusStatus { + status: consensus_status, + latest_valid_hash: status.latest_valid_hash, + validation_error: status.validation_error.clone(), + }) +} + +/// Asks the execution client about `block`'s payload. +/// +/// `Ok(None)` means there was nothing to ask, which is not a failure: +/// [`PayloadValidity::NotRequired`] is the right verdict and the block imports. +/// `Err` means no answer was obtained after the client's whole retry ladder, and +/// `optimistic-sync.md` requires the caller not to import the block and not to +/// touch fork choice. +pub async fn ask( + client: &EngineClient, + block: &SignedBeaconBlock, +) -> Result, EngineError> { + let Some(request) = new_payload_request(block) else { + return Ok(None); + }; + let status = client + .new_payload( + request.execution_payload, + &request.versioned_hashes, + request.parent_beacon_block_root, + &request.execution_requests, + ) + .await?; + Ok(Some(verdict(&status))) +} + +#[cfg(test)] +mod tests { + use ethlambda_state_transition::beacon::preset; + use ethlambda_state_transition::beacon::primitives::{ + BlsSignature, ExecutionAddress, ExecutionBlockHash, KzgCommitment, Uint256, + }; + + use super::*; + + /// An otherwise-zero electra signed block with `parent_root` set. + /// + /// Hand-built because the consensus containers deliberately do not derive + /// `Default`, and `logs_bloom` is a fixed-length vector that has none even + /// among its `SszList` neighbours. Matches the idiom in `stf/fulu.rs` and + /// `containers/bellatrix.rs`. + fn electra_block(parent_root: Root) -> containers::electra::SignedBeaconBlock { + let execution_payload = containers::deneb::ExecutionPayload { + parent_hash: ExecutionBlockHash::ZERO, + fee_recipient: ExecutionAddress::ZERO, + state_root: Bytes32::ZERO, + receipts_root: Bytes32::ZERO, + logs_bloom: containers::bellatrix::LogsBloom::try_from(vec![ + 0u8; + preset::BYTES_PER_LOGS_BLOOM + ]) + .expect("built at exactly BYTES_PER_LOGS_BLOOM"), + prev_randao: Bytes32::ZERO, + block_number: 0, + gas_limit: 0, + gas_used: 0, + timestamp: 0, + extra_data: Default::default(), + base_fee_per_gas: Uint256::ZERO, + block_hash: ExecutionBlockHash::ZERO, + transactions: Default::default(), + withdrawals: Default::default(), + blob_gas_used: 0, + excess_blob_gas: 0, + }; + + containers::electra::SignedBeaconBlock { + message: containers::electra::BeaconBlock { + slot: 0, + proposer_index: 0, + parent_root, + state_root: Root::ZERO, + body: containers::electra::BeaconBlockBody { + randao_reveal: BlsSignature::default(), + eth1_data: Default::default(), + graffiti: Bytes32::ZERO, + proposer_slashings: Default::default(), + attester_slashings: Default::default(), + attestations: Default::default(), + deposits: Default::default(), + voluntary_exits: Default::default(), + sync_aggregate: Default::default(), + execution_payload, + bls_to_execution_changes: Default::default(), + blob_kzg_commitments: Default::default(), + execution_requests: containers::electra::ExecutionRequests { + deposits: Default::default(), + withdrawals: Default::default(), + consolidations: Default::default(), + }, + }, + }, + signature: BlsSignature::default(), + } + } + + #[test] + fn a_pre_bellatrix_block_has_nothing_to_ask_about() { + let block = SignedBeaconBlock::Phase0(containers::phase0::SignedBeaconBlock { + message: containers::phase0::BeaconBlock { + slot: 0, + proposer_index: 0, + parent_root: Root::ZERO, + state_root: Root::ZERO, + body: containers::phase0::BeaconBlockBody { + randao_reveal: BlsSignature::default(), + eth1_data: Default::default(), + graffiti: Bytes32::ZERO, + proposer_slashings: Default::default(), + attester_slashings: Default::default(), + attestations: Default::default(), + deposits: Default::default(), + voluntary_exits: Default::default(), + }, + }, + signature: BlsSignature::default(), + }); + + assert!(new_payload_request(&block).is_none()); + } + + #[test] + fn a_request_takes_its_parent_beacon_block_root_from_the_blocks_parent() { + let block = SignedBeaconBlock::Fulu(electra_block(Root::repeat_byte(9))); + + let request = new_payload_request(&block).expect("a fulu block carries a payload"); + + assert_eq!(request.parent_beacon_block_root, Root::repeat_byte(9)); + } + + #[test] + fn versioned_hashes_are_one_per_blob_commitment_and_versioned() { + let mut inner = electra_block(Root::ZERO); + inner + .message + .body + .blob_kzg_commitments + .push(KzgCommitment::default()) + .expect("one commitment fits"); + let block = SignedBeaconBlock::Fulu(inner); + + let request = new_payload_request(&block).expect("a fulu block carries a payload"); + + assert_eq!(request.versioned_hashes.len(), 1); + // EIP-4844 stamps the version byte over the first byte of the hash. + assert_eq!( + request.versioned_hashes[0].0[0], + ethlambda_state_transition::beacon::constants::VERSIONED_HASH_VERSION_KZG + ); + } + + #[test] + fn an_execution_status_becomes_the_matching_consensus_verdict() { + let syncing = PayloadStatusV1 { + status: PayloadStatusValue::Syncing, + latest_valid_hash: None, + validation_error: None, + }; + assert_eq!(verdict(&syncing), PayloadValidity::Optimistic); + + let invalid = PayloadStatusV1 { + status: PayloadStatusValue::Invalid, + latest_valid_hash: Some(ExecutionBlockHash::repeat_byte(3)), + validation_error: Some("bad".to_string()), + }; + assert_eq!( + verdict(&invalid), + PayloadValidity::Invalidated { + latest_valid_hash: Some(ExecutionBlockHash::repeat_byte(3)), + } + ); + } +} diff --git a/crates/blockchain/src/events.rs b/crates/blockchain/src/events.rs index 5977bab63..d60d6c4cc 100644 --- a/crates/blockchain/src/events.rs +++ b/crates/blockchain/src/events.rs @@ -247,18 +247,18 @@ impl ChainEventSnapshot { pub(crate) fn diff_and_emit(&self, store: &Store, events: &EventBus, wall_clock_slot: u64) { let head = store.head().expect("head block exists"); if head != self.head { - // Read the header once and reuse it for slot and state root so they - // stay consistent. - if let Some(header) = store - .get_block_header(&head) - .expect("block header read should succeed") - { + // Read the block once and reuse it for slot and state root so they + // stay consistent. Through `block_slot_and_state_root` rather than + // `Store::get_block_header`, which decodes a lean `BlockHeader` + // and so is lean-only: a beacon directory keeps the whole signed + // block in that table, and this diff runs on both chains. + if let Some((slot, state_root)) = store.block_slot_and_state_root(&head) { // Skip stale heads (catch-up/backfill): see HEAD_EVENT_RECENCY_SLOTS. - if header.slot + HEAD_EVENT_RECENCY_SLOTS >= wall_clock_slot { + if slot + HEAD_EVENT_RECENCY_SLOTS >= wall_clock_slot { events.emit(ChainEvent::Head { - slot: header.slot, + slot, block: head, - state: header.state_root, + state: state_root, }); } } else { @@ -308,14 +308,16 @@ impl ChainEventSnapshot { } /// Look up the state root of a checkpoint's block for the `{block, state}` -/// event shape. Returns `None` if the header is absent so the caller can skip -/// emission; finalized/justified block headers are never pruned, so this only -/// fails on genuine store inconsistency. +/// event shape. Returns `None` if the block is absent so the caller can skip +/// emission; finalized/justified blocks are never pruned from +/// `Table::BlockHeaders`, so this only fails on genuine store inconsistency. +/// +/// Chain-generic, for the reason [`ChainEventSnapshot::diff_and_emit`] gives +/// where it reads the head's own pair. fn checkpoint_state_root(store: &Store, root: H256) -> Option { store - .get_block_header(&root) - .expect("block header read should succeed") - .map(|header| header.state_root) + .block_slot_and_state_root(&root) + .map(|(_, state_root)| state_root) } #[cfg(test)] @@ -324,6 +326,7 @@ mod tests { use ethlambda_storage::{ForkCheckpoints, backend::InMemoryBackend}; use ethlambda_types::constants::DEFAULT_MILLISECONDS_PER_SLOT; use ethlambda_types::{ + beacon::containers::SignedBeaconBlock, block::{Block, BlockBody, MultiMessageAggregate, SignedBlock}, state::State, }; @@ -463,7 +466,7 @@ mod tests { proof: MultiMessageAggregate::default(), }; store - .insert_signed_block(root, signed_block) + .insert_signed_block(root, SignedBeaconBlock::Lean(signed_block)) .expect("insert test block should succeed"); } diff --git a/crates/blockchain/src/import_timing.rs b/crates/blockchain/src/import_timing.rs new file mode 100644 index 000000000..57181f7ea --- /dev/null +++ b/crates/blockchain/src/import_timing.rs @@ -0,0 +1,975 @@ +//! End-to-end timing for one block's journey from the wire to a post-state. +//! +//! # Instants, not durations +//! +//! Every field here is an [`Instant`]: the moment a boundary was crossed. +//! Nothing in this module's capture path subtracts anything. A duration is an +//! interpretation of two timings, and only the consumer knows which pairs are +//! meaningful for the chain, the outcome and the holds a particular block +//! actually went through, so all the arithmetic lives in [`BlockImportReport`] +//! and nowhere else. +//! +//! `None` means the boundary was never crossed. That is what lets one report +//! serve both chains: a lean block leaves every beacon-only instant `None`, a +//! beacon block leaves `verify_*` `None`, and the printed tree simply omits +//! the rows that did not run rather than showing a column of zeros. +//! +//! # Why the holds are `_start`/`_end` pairs +//! +//! A block that is held does not walk the timeline once, it loops. Guards run +//! again for a block held for its parent; the availability check runs again +//! for one held for its custody columns. A single instant per boundary assumes +//! each boundary is crossed exactly once, so a second pass would overwrite the +//! first and the wait would disappear into whichever section straddled it. +//! +//! The three holds therefore carry explicit pairs, and so does every other +//! section, so that no row's meaning depends on which other rows happen to be +//! `Some`. A section that repeats keeps its first start and its last end; the +//! per-section timings of an abandoned pass are overwritten by the final pass, +//! which is why a held block's rows sum to slightly less than its end-to-end +//! time. +//! +//! # What the clock covers +//! +//! End to end is measured from the first instant recorded through to the last. +//! It deliberately spans holds: a block that waited two slots for its parent +//! reports those two slots, with a `parent_wait` row accounting for them. +//! +//! Where that first instant is depends on how the block arrived, because only +//! one path decodes a block itself: +//! +//! - **gossip** starts at `decode_start`, the moment the payload came off the +//! wire, and so reports a `decode` row covering decompression and SSZ. +//! - **req/resp** starts at `queue_start`, the hand-off to this actor. Its +//! codec had already turned the bytes into a block before any handler saw +//! one, so there is no decode boundary left to take and the path reports no +//! `decode` row at all. A zero would be worse than nothing: it reads as free +//! work rather than as unmeasured work, and it would drag the decode +//! histogram down with samples that measured nothing. +//! - **storage** (an ancestor walk pulling a block out of RocksDB, or a +//! locally built one) starts at the pull, since no earlier moment is +//! knowable. +//! +//! One consequence worth keeping in mind when reading the numbers: a gossip +//! block's total and a fetched block's total do not start at the same point in +//! the block's life, and a fetched block's total excludes the request round +//! trip entirely. + +use std::time::{Duration, Instant}; + +use ethlambda_network_api::BlockSource; + +use crate::metrics; +use ethlambda_types::{ShortRoot, primitives::H256}; +use tracing::info; + +/// Timings taken inside lean's `store::on_block`. +/// +/// Separate from [`ImportTimings`] so that `store.rs` fills in only the +/// boundaries it owns and knows nothing about the arrival, hold and beacon +/// sections wrapped around it. The caller folds these in with +/// [`ImportTimings::absorb_store`]. +#[derive(Debug, Clone, Copy, Default)] +pub struct StoreTimings { + pub guards_start: Option, + pub guards_end: Option, + pub verify_structural_start: Option, + pub verify_structural_end: Option, + pub verify_crypto_start: Option, + pub verify_crypto_end: Option, + pub stf_start: Option, + pub stf_end: Option, + pub db_write_start: Option, + pub db_write_end: Option, + pub fc_head_start: Option, + pub fc_head_end: Option, +} + +/// Timings taken inside `store::verify_block_signatures`. +/// +/// Returned rather than logged so the one caller that is not the import path +/// (the Hive test driver's `verify_signatures` endpoint) can ignore them. +#[derive(Debug, Clone, Copy, Default)] +pub struct VerifyTimings { + pub structural_start: Option, + pub structural_end: Option, + pub crypto_start: Option, + pub crypto_end: Option, +} + +/// Every boundary one block crosses on its way to a post-state. +/// +/// Carried alongside the block itself: through the actor mailbox in +/// `NewBlock`, through a hold in `BlockChainServer::held_timings`, and through +/// the import cascade's own queue. See the module documentation for why the +/// fields are timings rather than durations, and why the holds are pairs. +#[derive(Debug, Clone, Copy, Default)] +pub struct ImportTimings { + /// How the block reached this node. Not a mark, but it travels with them + /// for the same reason they do: only the arriving message knows it, and + /// the report is built long after that message is gone. + pub source: Option, + + // --- Off-actor: the p2p side and the mailbox hop. --- + /// The payload came off the wire, before decompression. + pub decode_start: Option, + /// The block is decoded and about to be handed to the chain actor. + pub decode_end: Option, + /// Handed to the actor. + pub queue_start: Option, + /// The actor took the message off its mailbox. + pub queue_end: Option, + + // --- Hold: the block's slot has not started yet (beacon). --- + pub defer_start: Option, + pub defer_end: Option, + + /// What the mailbox handler did before handing the block to the import + /// path: the gossip bookkeeping, the early-slot decision, and the + /// store-clock catch-up, which can run a whole tick. + pub admit_start: Option, + pub admit_end: Option, + + // --- Hold: the parent has no post-state. --- + pub parent_wait_start: Option, + pub parent_wait_end: Option, + + /// From the parent's import completing to this block being popped off the + /// cascade queue. Nonzero when one arrival unblocks several children and + /// this one was not first in line. + pub cascade_wait_start: Option, + pub cascade_wait_end: Option, + + // --- The import pass proper. Overwritten by each pass, so these are the + // --- final, successful pass's timings. + /// Finality and future-slot guards, the already-imported check and the + /// parent-state lookup, all of which run before the import call. + /// + /// Re-marked from scratch on every pass, not carried: a block held for its + /// parent runs these again when it comes back, and keeping the first + /// pass's start would stretch this section across the whole hold and + /// double-count what `parent_wait` already reports. + pub guards_start: Option, + pub guards_end: Option, + + /// Lean: the checks `store::on_block` makes of its own before verifying + /// anything, including loading the parent state and scanning the block for + /// duplicate attestation data. + pub preamble_start: Option, + pub preamble_end: Option, + + /// Beacon: the custody-column availability check itself, not the wait. + pub da_check_start: Option, + pub da_check_end: Option, + + // --- Hold: custody columns have not all arrived (beacon). --- + pub columns_wait_start: Option, + pub columns_wait_end: Option, + + /// Beacon: the `engine_newPayload` round trip, including its retry ladder. + /// I/O wait, not work. + pub engine_start: Option, + pub engine_end: Option, + + /// Lean: participant bounds checks and pubkey resolution. + pub verify_structural_start: Option, + pub verify_structural_end: Option, + /// Lean: the leanVM multi-message aggregate verification. + pub verify_crypto_start: Option, + pub verify_crypto_end: Option, + + /// The state transition. On beacon this is `fork_choice::on_block`, which + /// bundles the transition, the state root and the state write, so + /// `db_write` stays `None` there. + pub stf_start: Option, + pub stf_end: Option, + + /// Lean: the block write (`insert_signed_block`) and the state hand-off + /// (`insert_state`, which since the storage crate moved state writes to a + /// background thread only enqueues the state — cache, buffer and a + /// channel send). The state's own encode/diff/commit cost is no longer in + /// this row; it is `lean_state_write_seconds` on the storage crate's + /// writer thread instead. + pub db_write_start: Option, + pub db_write_end: Option, + + /// Lean: `update_head`. + pub fc_head_start: Option, + pub fc_head_end: Option, + + /// Beacon: replaying the block's own attestations and slashings into fork + /// choice, including the `LiveChain` scan they share. + pub block_atts_start: Option, + pub block_atts_end: Option, + + /// Whether every custody column for this block was already present the + /// first time the block was looked at, before the parent check. + /// + /// This is what separates the two readings of an absent `columns_wait` + /// row. `Some(true)`: the columns were never missing. `Some(false)` with + /// no `columns_wait`: they landed while the block was held for its parent, + /// so availability finished first. `None` on lean, which custodies none. + pub da_complete_on_arrival: Option, +} + +impl ImportTimings { + /// The `source` label this block reports under, or `None` for a block + /// that is not reported at all. + /// + /// Two values reach the metric on a node, `gossip` and `sync`, and the two + /// that do not are deliberate. A third, `replay`, only ever appears in the + /// offline import benchmark's own process, which reads its per-phase + /// numbers back from these observations. + /// + /// [`BlockSource::Deferred`] never appears. It says how a block reached + /// the actor this time, not how it reached the node, and a deferred block + /// is a gossip or sync block that waited: reporting it separately would + /// take it out of the population it belongs to, and the wait is already + /// its own `defer` section. `Handler` resolves it back to the + /// source the block first arrived on before these timings are built, so a + /// `Deferred` reaching here would be a defect rather than a case to label. + /// + /// A block this node built itself is not reported either: it crossed no + /// wire, so its `decode` and `queue` sections are zeroes taken at the + /// moment the import began, and mixing those into a wire source's + /// percentiles would understate it. + pub fn source_label(&self) -> Option<&'static str> { + match self.source { + Some(BlockSource::Gossip) => Some("gossip"), + Some(BlockSource::Sync) => Some("sync"), + Some(BlockSource::Replay) => Some("replay"), + Some(BlockSource::Deferred) | None => None, + } + } + + /// What the log calls this block's source, which unlike the metric label + /// has a name for every case, a line costing nothing to write. + pub fn source_name(&self) -> &'static str { + match self.source { + Some(BlockSource::Gossip) => "gossip", + Some(BlockSource::Sync) => "sync", + Some(BlockSource::Deferred) => "deferred", + Some(BlockSource::Replay) => "replay", + None => "local", + } + } + + /// Timings for a block whose earliest knowable moment is now. + /// + /// Used where a block enters the import path from storage rather than the + /// wire: the ancestor walk in `process_or_pend_block`, and a locally built + /// block being imported by its own proposer. + pub fn starting_now() -> Self { + let now = Instant::now(); + Self { + // No decode: the block was already a block. Only `queue`, and an + // empty one, so the report has a clock to start from. + queue_start: Some(now), + queue_end: Some(now), + ..Self::default() + } + } + + /// Charge the bookkeeping that follows an import to whichever section it + /// follows, by moving that section's end. + /// + /// Event emission, the finality eviction sweep and the gauge refresh used + /// to be three sections of their own. Across 5248 imports on a mainnet + /// follower none of them ever reached a millisecond, so they were three + /// rows that never moved in every tree printed, and three label values on + /// a histogram that never said anything. The work still happens and is + /// still counted; it is simply counted where it happens, at the end of the + /// last section that ran, which is `fc_head` on lean and `block_atts` on + /// beacon. + /// + /// Extending the last section rather than the first keeps the sections + /// contiguous: whatever ran last is what this follows. + pub fn absorb_tail(&mut self, end: Instant) { + let last = [ + &mut self.block_atts_end, + &mut self.fc_head_end, + &mut self.db_write_end, + &mut self.stf_end, + ] + .into_iter() + .filter(|slot| slot.is_some()) + .max_by_key(|slot| slot.expect("just filtered")); + if let Some(slot) = last { + *slot = Some(end); + } + } + + /// Fold in the timings lean's `store::on_block` took. + pub fn absorb_store(&mut self, store: StoreTimings) { + // Into `preamble`, not `guards`: the outer guards are this actor's own + // and have already been marked by the time the store is called. + self.preamble_start = store.guards_start; + self.preamble_end = store.guards_end; + self.verify_structural_start = store.verify_structural_start; + self.verify_structural_end = store.verify_structural_end; + self.verify_crypto_start = store.verify_crypto_start; + self.verify_crypto_end = store.verify_crypto_end; + self.stf_start = store.stf_start; + self.stf_end = store.stf_end; + self.db_write_start = store.db_write_start; + self.db_write_end = store.db_write_end; + self.fc_head_start = store.fc_head_start; + self.fc_head_end = store.fc_head_end; + } + + /// Fold in the timings `store::verify_block_signatures` took. + pub fn absorb_verify(&mut self, verify: VerifyTimings) { + self.verify_structural_start = verify.structural_start; + self.verify_structural_end = verify.structural_end; + self.verify_crypto_start = verify.crypto_start; + self.verify_crypto_end = verify.crypto_end; + } + + /// The first moment recorded, which is where end to end starts. + /// + /// Not always `decode_start`: only the gossip path decodes the block + /// itself, so a fetched or replayed block's timeline begins at the moment + /// it was handed to this actor. + fn first_instant(&self) -> Option { + self.decode_start.or(self.queue_start) + } + + /// The last moment recorded, whichever section it belongs to. + fn last_instant(&self) -> Option { + self.rows() + .into_iter() + .filter_map(|row| row.end) + .max() + .or_else(|| self.first_instant()) + } + + /// Every section, in the order a block crosses them, whether or not it + /// crossed this one. + fn rows(&self) -> Vec { + vec![ + Row::new("decode", self.decode_start, self.decode_end), + Row::new("queue", self.queue_start, self.queue_end), + Row::new("defer", self.defer_start, self.defer_end), + Row::new("admit", self.admit_start, self.admit_end), + Row::new("guards", self.guards_start, self.guards_end), + Row::new("preamble", self.preamble_start, self.preamble_end), + Row::new("parent_wait", self.parent_wait_start, self.parent_wait_end), + Row::new( + "cascade_wait", + self.cascade_wait_start, + self.cascade_wait_end, + ), + Row::new("da_check", self.da_check_start, self.da_check_end), + Row::new( + "columns_wait", + self.columns_wait_start, + self.columns_wait_end, + ), + Row::new("engine", self.engine_start, self.engine_end), + Row::new( + "verify_struct", + self.verify_structural_start, + self.verify_structural_end, + ), + Row::new( + "verify_crypto", + self.verify_crypto_start, + self.verify_crypto_end, + ), + Row::new("stf", self.stf_start, self.stf_end), + Row::new("db_write", self.db_write_start, self.db_write_end), + Row::new("fc_head", self.fc_head_start, self.fc_head_end), + Row::new("block_atts", self.block_atts_start, self.block_atts_end), + ] + } +} + +/// One section of the timeline, resolved from its two timings. +#[derive(Debug, Clone, Copy)] +struct Row { + name: &'static str, + end: Option, + elapsed: Option, +} + +impl Row { + fn new(name: &'static str, start: Option, end: Option) -> Self { + let elapsed = match (start, end) { + // `saturating_duration_since` rather than `-`: a pair of instants can + // arrive out of order if a section's end was recorded on an + // earlier pass than its start, and a panic in a logging path would + // be a far worse outcome than a zero. + (Some(start), Some(end)) => Some(end.saturating_duration_since(start)), + _ => None, + }; + Self { name, end, elapsed } + } +} + +/// One block's timings plus the context needed to make sense of them. +/// +/// This is where every subtraction happens. Built at the end of an import, +/// consumed immediately by [`Self::log`]. +pub struct BlockImportReport { + pub slot: u64, + pub block_root: H256, + pub attestations: usize, + /// What became of the block: `"imported"`, `"held"` or `"failed"`. + pub outcome: &'static str, + /// Milliseconds into its own slot at which the import finished, when the + /// caller could work it out. + pub slot_offset_ms: Option, + pub timings: ImportTimings, +} + +impl BlockImportReport { + /// Total wall time from the payload leaving the wire to the last mark. + pub fn end_to_end(&self) -> Option { + match (self.timings.first_instant(), self.timings.last_instant()) { + (Some(start), Some(end)) => Some(end.saturating_duration_since(start)), + _ => None, + } + } + + /// The tree's child lines, one per section that ran. + /// + /// Split from [`Self::log`] so the rendering can be asserted without a + /// tracing subscriber: this is a pure function of the timings, and it is + /// where every subtraction that reaches an operator's eyes happens. + pub fn lines(&self) -> Vec { + let Some(e2e) = self.end_to_end() else { + return Vec::new(); + }; + let rows: Vec = self + .timings + .rows() + .into_iter() + .filter(|row| row.elapsed.is_some()) + .collect(); + let Some(bottleneck) = rows + .iter() + .max_by_key(|row| row.elapsed.unwrap_or_default()) + .map(|row| row.name) + else { + return Vec::new(); + }; + + let e2e_ms = ms(e2e); + let last = rows.len() - 1; + rows.iter() + .enumerate() + .map(|(index, row)| { + let elapsed_ms = ms(row.elapsed.unwrap_or_default()); + let share = if e2e_ms > 0.0 { + (elapsed_ms / e2e_ms * 100.0).round() as u64 + } else { + 0 + }; + let branch = if index == last { '`' } else { '|' }; + let marker = if row.name == bottleneck { + " << BOTTLENECK" + } else { + "" + }; + format!( + " {branch}- {name:<13} {elapsed_ms:>9.2} ms ({share:>2}%){marker}", + name = row.name, + ) + }) + .collect() + } + + /// The section that took longest, which is the one worth reading first. + fn bottleneck(&self) -> Option<&'static str> { + self.timings + .rows() + .into_iter() + .filter(|row| row.elapsed.is_some()) + .max_by_key(|row| row.elapsed.unwrap_or_default()) + .map(|row| row.name) + } + + /// Publish this block's sections to Prometheus. + /// + /// Separate from [`Self::log`] so the two can be reasoned about apart: the + /// log is for the operator reading one block, this is for the dashboard + /// reading a million. A section that did not run writes nothing rather + /// than a zero, so a lean node never creates the beacon-only series. + pub fn observe(&self) { + let Some(source) = self.timings.source_label() else { + return; + }; + for row in self.timings.rows() { + if let Some(elapsed) = row.elapsed { + metrics::observe_block_import_phase(row.name, source, elapsed); + } + } + // Only a finished import has a total. A held block's sections are all + // real and are published above, but the span from the wire to wherever + // it stopped is not an import time, and publishing it as one is what an + // `outcome` label would have had to exist to undo. + if self.outcome == "imported" + && let Some(e2e) = self.end_to_end() + { + metrics::observe_block_import_phase(metrics::BLOCK_IMPORT_TOTAL_PHASE, source, e2e); + } + } + + /// Emit the tree. + /// + /// The header carries the fields worth querying; the child lines are for + /// reading. Rows that never ran are omitted, so a lean block prints no + /// beacon sections and vice versa. + pub fn log(&self) { + let Some(e2e) = self.end_to_end() else { + return; + }; + let lines = self.lines(); + if lines.is_empty() { + return; + } + + info!( + slot = self.slot, + block_root = %ShortRoot(&self.block_root.0), + source = self.timings.source_name(), + outcome = self.outcome, + attestations = self.attestations, + e2e_ms = format_args!("{:.2}", ms(e2e)), + slot_offset_ms = self.slot_offset_ms, + bottleneck = self.bottleneck(), + da_complete_on_arrival = self.timings.da_complete_on_arrival, + "Block import timing" + ); + for line in lines { + info!("{line}"); + } + } +} + +/// Timings taken once per arrival rather than once per block, so a cascade that +/// imports six children charges them once. +#[derive(Debug, Clone, Copy, Default)] +pub struct CascadeTimings { + /// The `source` of the block whose arrival opened this cascade. Set by + /// `on_block` from the arriving block's own timings. + pub source: Option, + pub cascade_start: Option, + pub cascade_end: Option, + pub prune_start: Option, + pub prune_end: Option, + pub head_start: Option, + pub head_end: Option, + pub fcu_start: Option, + pub fcu_end: Option, +} + +/// Timings taken while re-running beacon fork choice after an import. +/// +/// Returned by `recompute_beacon_head` rather than written through a borrow, +/// because its other caller is the tick, which has no report to put them in. +#[derive(Debug, Clone, Copy, Default)] +pub struct HeadTimings { + pub head_start: Option, + pub head_end: Option, + pub fcu_start: Option, + pub fcu_end: Option, +} + +impl CascadeTimings { + pub fn absorb_head(&mut self, head: HeadTimings) { + self.head_start = head.head_start; + self.head_end = head.head_end; + self.fcu_start = head.fcu_start; + self.fcu_end = head.fcu_end; + } + + pub fn starting_now() -> Self { + Self { + cascade_start: Some(Instant::now()), + ..Self::default() + } + } + + /// Publish the arrival-scope sections. + /// + /// `cascade` is the whole drain, which is why it is a phase here rather + /// than the sum of the per-block metrics: those are per block, this is per + /// arrival, and dividing one by the other's count is the mistake keeping + /// them in separate metrics prevents. + pub fn observe(&self, blocks: usize) { + let source = match self.source { + Some(BlockSource::Gossip) => "gossip", + Some(BlockSource::Sync) => "sync", + Some(BlockSource::Replay) => "replay", + // Same two exclusions a block's own sections make; see + // `ImportTimings::source_label`. + Some(BlockSource::Deferred) | None => return, + }; + metrics::observe_block_import_cascade_blocks(blocks); + + // The whole time this arrival held the actor: from the cascade opening + // to the last thing done on its behalf, which is the head recomputation + // when there is one and the prune otherwise. + let last = [ + self.fcu_end, + self.head_end, + self.prune_end, + self.cascade_end, + ] + .into_iter() + .flatten() + .max(); + for (phase, elapsed) in [ + ("arrival", span(self.cascade_start, last)), + ("cascade", span(self.cascade_start, self.cascade_end)), + ("prune", span(self.prune_start, self.prune_end)), + ("get_head", span(self.head_start, self.head_end)), + ("fcu", span(self.fcu_start, self.fcu_end)), + ] { + if let Some(elapsed) = elapsed { + metrics::observe_block_import_phase(phase, source, elapsed); + } + } + } + + /// Emit the one-line arrival summary. + /// + /// Skipped for the ordinary case of a single block with nothing after it: + /// the per-block tree already said everything, and one line per block is + /// enough without a second that repeats it. + pub fn log(&self, blocks: usize) { + let cascade = span(self.cascade_start, self.cascade_end); + let prune = span(self.prune_start, self.prune_end); + let head = span(self.head_start, self.head_end); + let fcu = span(self.fcu_start, self.fcu_end); + if blocks <= 1 && prune.is_none() && head.is_none() && fcu.is_none() { + return; + } + let Some(cascade) = cascade else { + return; + }; + info!( + blocks, + cascade_ms = format_args!("{:.2}", ms(cascade)), + prune_ms = prune.map(|d| format!("{:.2}", ms(d))), + get_head_ms = head.map(|d| format!("{:.2}", ms(d))), + fcu_ms = fcu.map(|d| format!("{:.2}", ms(d))), + "Block arrival timing" + ); + } +} + +fn span(start: Option, end: Option) -> Option { + match (start, end) { + (Some(start), Some(end)) => Some(end.saturating_duration_since(start)), + _ => None, + } +} + +fn ms(duration: Duration) -> f64 { + duration.as_secs_f64() * 1000.0 +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A pair of instants whose start is `offset_ms` after `base` and which lasts + /// `len_ms`. + fn pair(base: Instant, offset_ms: u64, len_ms: u64) -> (Option, Option) { + let start = base + Duration::from_millis(offset_ms); + (Some(start), Some(start + Duration::from_millis(len_ms))) + } + + #[test] + fn rows_omit_sections_that_never_ran() { + let base = Instant::now(); + let mut timings = ImportTimings::default(); + (timings.decode_start, timings.decode_end) = pair(base, 0, 3); + (timings.stf_start, timings.stf_end) = pair(base, 3, 90); + + let present: Vec<&str> = timings + .rows() + .into_iter() + .filter(|row| row.elapsed.is_some()) + .map(|row| row.name) + .collect(); + assert_eq!(present, vec!["decode", "stf"]); + } + + #[test] + fn end_to_end_spans_a_parent_hold() { + let base = Instant::now(); + let mut timings = ImportTimings::default(); + (timings.decode_start, timings.decode_end) = pair(base, 0, 3); + // Held two whole slots waiting for a parent. + (timings.parent_wait_start, timings.parent_wait_end) = pair(base, 3, 8_000); + (timings.stf_start, timings.stf_end) = pair(base, 8_003, 90); + + let report = report(timings); + let e2e = report.end_to_end().expect("both ends are marked"); + assert_eq!(e2e, Duration::from_millis(8_093)); + } + + #[test] + fn the_two_waits_are_reported_separately() { + let base = Instant::now(); + let mut timings = ImportTimings::default(); + (timings.decode_start, timings.decode_end) = pair(base, 0, 1); + (timings.parent_wait_start, timings.parent_wait_end) = pair(base, 1, 1_200); + (timings.columns_wait_start, timings.columns_wait_end) = pair(base, 1_300, 800); + + let waits: Vec<(&str, u128)> = timings + .rows() + .into_iter() + .filter(|row| row.name.ends_with("_wait")) + .filter_map(|row| row.elapsed.map(|d| (row.name, d.as_millis()))) + .collect(); + assert_eq!( + waits, + vec![("parent_wait", 1_200), ("columns_wait", 800)], + "a parent hold and a column hold must never collapse into one row" + ); + } + + #[test] + fn an_out_of_order_pair_reports_zero_rather_than_panicking() { + let base = Instant::now(); + let timings = ImportTimings { + stf_start: Some(base + Duration::from_millis(10)), + stf_end: Some(base), + ..ImportTimings::default() + }; + + let row = timings + .rows() + .into_iter() + .find(|row| row.name == "stf") + .expect("stf row exists"); + assert_eq!(row.elapsed, Some(Duration::ZERO)); + } + + #[test] + fn a_block_with_no_timings_produces_no_report() { + let report = report(ImportTimings::default()); + assert!(report.end_to_end().is_none()); + } + + #[test] + fn starting_now_anchors_the_clock_without_claiming_a_decode() { + let timings = ImportTimings::starting_now(); + assert!(timings.decode_start.is_none()); + assert!(timings.queue_start.is_some()); + assert!(report(timings).end_to_end().is_some()); + } + + /// Only gossip decodes the block itself. A fetched one has no decode to + /// report, and reporting a zero would put it in the decode histogram as + /// free work rather than leaving it out. + #[test] + fn a_block_this_node_did_not_decode_starts_its_clock_at_the_mailbox() { + let base = Instant::now(); + let mut timings = ImportTimings { + source: Some(BlockSource::Sync), + ..Default::default() + }; + (timings.queue_start, timings.queue_end) = pair(base, 0, 40); + (timings.stf_start, timings.stf_end) = pair(base, 40, 15); + + let report = report(timings); + assert_eq!(report.end_to_end(), Some(Duration::from_millis(55))); + let names: Vec<&str> = report + .timings + .rows() + .into_iter() + .filter(|row| row.elapsed.is_some()) + .map(|row| row.name) + .collect(); + assert_eq!(names, vec!["queue", "stf"]); + } + + #[test] + fn the_tail_extends_the_last_section_that_ran() { + let base = Instant::now(); + let tail_end = base + Duration::from_millis(110); + + // Lean: the last section is fc_head. + let mut lean = ImportTimings::default(); + (lean.stf_start, lean.stf_end) = pair(base, 0, 90); + (lean.db_write_start, lean.db_write_end) = pair(base, 90, 10); + (lean.fc_head_start, lean.fc_head_end) = pair(base, 100, 5); + lean.absorb_tail(tail_end); + assert_eq!( + lean.fc_head_end, + Some(tail_end), + "lean charges it to fc_head" + ); + assert_eq!( + lean.db_write_end, + Some(base + Duration::from_millis(100)), + "the sections before it are untouched" + ); + + // Beacon: the last section is block_atts, and there is no db_write or + // fc_head to confuse it with. + let mut beacon = ImportTimings::default(); + (beacon.stf_start, beacon.stf_end) = pair(base, 0, 90); + (beacon.block_atts_start, beacon.block_atts_end) = pair(base, 90, 15); + beacon.absorb_tail(tail_end); + assert_eq!(beacon.block_atts_end, Some(tail_end)); + assert_eq!(beacon.stf_end, Some(base + Duration::from_millis(90))); + } + + #[test] + fn the_tail_of_an_import_that_ran_nothing_lands_nowhere() { + // A re-delivered block whose post-state the store already held runs no + // section at all, so there is nothing for the tail to extend and it + // must not invent one. + let mut timings = ImportTimings::default(); + timings.absorb_tail(Instant::now()); + assert!(timings.rows().into_iter().all(|row| row.elapsed.is_none())); + } + + #[test] + fn every_row_is_a_declared_phase_label() { + let rows: Vec<&str> = ImportTimings::default() + .rows() + .into_iter() + .map(|row| row.name) + .collect(); + assert_eq!( + rows, + crate::metrics::BLOCK_IMPORT_PHASES, + "the tree's sections and the metric's per-block `phase` labels are the same list \ + in the same order: a row added to one without the other either logs a section no \ + dashboard can find or declares a series nothing writes" + ); + } + + #[test] + fn a_locally_built_block_is_logged_but_not_measured() { + let local = ImportTimings::default(); + assert_eq!(local.source_label(), None, "nothing to compare it against"); + assert_eq!(local.source_name(), "local", "still worth printing"); + } + + #[test] + fn a_deferred_block_never_reaches_the_metric_as_its_own_source() { + // `Handler` resolves the re-delivery back to the source the + // block first arrived on, so this state should not occur; if it ever + // does, it must not open a third series. + let deferred = ImportTimings { + source: Some(BlockSource::Deferred), + ..ImportTimings::default() + }; + assert_eq!(deferred.source_label(), None); + assert_eq!(deferred.source_name(), "deferred"); + } + + #[test] + fn a_held_block_does_not_charge_its_wait_to_guards_as_well() { + let base = Instant::now(); + let mut timings = ImportTimings::default(); + (timings.decode_start, timings.decode_end) = pair(base, 0, 1); + // First pass: guards ran, then the block was held for its parent. + (timings.guards_start, timings.guards_end) = pair(base, 1, 2); + (timings.parent_wait_start, timings.parent_wait_end) = pair(base, 3, 8_000); + // Second pass re-marks guards from scratch rather than keeping the + // first pass's start, which would otherwise span the whole hold. + (timings.guards_start, timings.guards_end) = pair(base, 8_003, 2); + (timings.stf_start, timings.stf_end) = pair(base, 8_005, 90); + + let rows: Vec<(&str, u128)> = timings + .rows() + .into_iter() + .filter_map(|row| row.elapsed.map(|d| (row.name, d.as_millis()))) + .collect(); + assert!( + rows.contains(&("guards", 2)), + "guards is one pass's worth, not the span across the hold: {rows:?}" + ); + assert!(rows.contains(&("parent_wait", 8_000))); + let total: u128 = rows.iter().map(|(_, ms)| ms).sum(); + assert!( + total <= 8_095, + "the sections must not sum past the end-to-end span: {rows:?}" + ); + } + + #[test] + fn only_a_finished_import_reports_a_total() { + let base = Instant::now(); + let mut timings = ImportTimings { + source: Some(BlockSource::Gossip), + ..ImportTimings::default() + }; + (timings.decode_start, timings.decode_end) = pair(base, 0, 3); + (timings.columns_wait_start, timings.columns_wait_end) = pair(base, 3, 4_000); + + let mut held = report(timings); + held.outcome = "held"; + assert!( + held.end_to_end().is_some(), + "the span exists and the log prints it" + ); + // What `observe` does with it is the point: a held block publishes its + // sections and no total, which is what keeps a four-second wait out of + // the import-cost percentiles without an `outcome` label. + assert_ne!(held.outcome, "imported"); + } + + #[test] + fn renders_a_tree_with_the_slowest_section_marked() { + let base = Instant::now(); + let mut timings = ImportTimings { + source: Some(BlockSource::Gossip), + ..ImportTimings::default() + }; + (timings.decode_start, timings.decode_end) = pair(base, 0, 3); + (timings.queue_start, timings.queue_end) = pair(base, 3, 18); + (timings.verify_crypto_start, timings.verify_crypto_end) = pair(base, 21, 609); + (timings.stf_start, timings.stf_end) = pair(base, 630, 94); + + let lines = report(timings).lines(); + assert_eq!(lines.len(), 4, "one line per section that ran"); + assert!(lines[0].contains("decode"), "sections keep timeline order"); + assert!( + lines[0].starts_with(" |-"), + "every line but the last branches" + ); + assert!( + lines[3].starts_with(" `-"), + "the last line closes the tree" + ); + assert!( + lines[2].contains("verify_crypto") && lines[2].ends_with("<< BOTTLENECK"), + "the slowest section is the marked one, got: {}", + lines[2] + ); + assert_eq!( + lines + .iter() + .filter(|line| line.contains("BOTTLENECK")) + .count(), + 1, + "exactly one section is marked" + ); + } + + #[test] + fn a_report_with_nothing_to_say_renders_nothing() { + assert!(report(ImportTimings::default()).lines().is_empty()); + } + + fn report(timings: ImportTimings) -> BlockImportReport { + BlockImportReport { + slot: 12_345, + block_root: H256::ZERO, + attestations: 42, + outcome: "imported", + slot_offset_ms: Some(2_812), + timings, + } + } +} diff --git a/crates/blockchain/src/lib.rs b/crates/blockchain/src/lib.rs index e4a63b829..0044889d6 100644 --- a/crates/blockchain/src/lib.rs +++ b/crates/blockchain/src/lib.rs @@ -1,17 +1,32 @@ -use std::collections::{HashMap, HashSet, VecDeque}; -use std::time::{Duration, Instant, SystemTime}; - -use ethlambda_network_api::{BlockChainToP2PRef, BlockSource, InitP2P}; +use ethlambda_engine::{EngineClient, ForkchoiceStateV1, PayloadStatusV1 as EnginePayloadStatus}; +use ethlambda_network_api::{ + AggregateArrival, BlockArrival, BlockChainToP2PRef, BlockSource, DeferredFrom, FetchRequest, + InitP2P, +}; +use ethlambda_state_transition::beacon::error::Error as BeaconError; +use ethlambda_state_transition::beacon::fork_choice; +use ethlambda_state_transition::beacon::helpers::accessors::CommitteeCacheExt; use ethlambda_state_transition::is_proposer; -use ethlambda_storage::{ALL_TABLES, Store}; +use ethlambda_storage::{ALL_TABLES, CacheKey, Chain, Store}; use ethlambda_types::{ ShortRoot, aggregator::AggregatorController, attestation::{SignedAggregatedAttestation, SignedAttestation}, + beacon::{ + config::Config, + constants, + containers::{SignedAggregateAndProof, SignedBeaconBlock, fulu}, + preset, + primitives::ValidatorIndex, + }, block::SignedBlock, chain_config::ChainConfig, primitives::{H256, HashTreeRoot as _}, + time::unix_now_ms, }; +use libssz::{SszDecode as _, SszEncode as _}; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::time::{Duration, Instant, SystemTime}; use crate::aggregation::{ AggregateProduced, AggregationDeadline, AggregationDone, AggregationSession, @@ -23,21 +38,27 @@ use crate::sync_status::SyncStatusTracker; use spawned_concurrency::actor; use spawned_concurrency::error::ActorError; use spawned_concurrency::protocol; -use spawned_concurrency::tasks::{Actor, ActorRef, ActorStart, Context, Handler, send_after}; +use spawned_concurrency::tasks::{ + Actor, ActorRef, ActorStart, Backend, Context, Handler, send_after, +}; use tokio_util::sync::CancellationToken; use tracing::{debug, error, info, trace, warn}; use crate::block_builder::ProposerConfig; use crate::events::ChainEventSnapshot; +use crate::import_timing::{BlockImportReport, CascadeTimings, HeadTimings, ImportTimings}; use crate::store::StoreError; pub use events::{ChainEvent, EventBus, Topic, UnknownTopic}; pub mod aggregation; +mod beacon_aggregates; +pub mod beacon_engine; pub mod block_builder; pub(crate) mod coverage; pub mod events; pub(crate) mod fork_choice_tree; +pub mod import_timing; pub mod key_manager; pub mod metrics; pub mod reaggregate; @@ -77,7 +98,7 @@ pub struct BlockChainConfig { } // The interval grid lives in `ethlambda-types` because `ethlambda-storage` also -// derives slots from `store.time()` and must not carry a second copy of a +// derives slots from the store clock and must not carry a second copy of a // consensus-critical constant. pub use ethlambda_types::block::MAX_ATTESTATIONS_DATA; pub use ethlambda_types::constants::{DEFAULT_MILLISECONDS_PER_SLOT, INTERVALS_PER_SLOT}; @@ -87,11 +108,44 @@ pub use sync_status::SyncStatusController; /// Bounds the clock skew the time check is willing to absorb when admitting a /// vote whose slot has not yet started locally. One interval is a fifth of the /// configured slot, the lean analogue of mainnet's -/// `MAXIMUM_GOSSIP_CLOCK_DISPARITY`. +/// [`MAXIMUM_GOSSIP_CLOCK_DISPARITY`]. /// /// See: leanSpec PR #682. pub const GOSSIP_DISPARITY_INTERVALS: u64 = 1; +/// How far ahead of the wall clock a beacon block may sit and still be held +/// for its slot rather than rejected. +/// +/// The phase0 p2p-interface constant of the same name, which is what +/// [`GOSSIP_DISPARITY_INTERVALS`] is lean's analogue of. Mainnet clients +/// admit a block this far early and queue it to its slot; past it the block +/// is not early, it is wrong. +/// +/// It is also what keeps [`BlockChainServer::defer_early_block`] from being a +/// flood target: a hold keeps the whole block in memory until its slot +/// starts, so holding anything merely "in the future" would let one peer +/// spend this node's memory on blocks for slots years away. +/// +/// Wraps [`ethlambda_types::beacon::constants::MAXIMUM_GOSSIP_CLOCK_DISPARITY`] +/// as a `Duration`, rather than defining the number here: the p2p crate's own +/// data-column gossip check needs the same value, and a `Duration` is no more +/// use to it than a bare millisecond count is to anything in this module that +/// converts one to the other anyway. +pub const MAXIMUM_GOSSIP_CLOCK_DISPARITY: Duration = + Duration::from_millis(ethlambda_types::beacon::constants::MAXIMUM_GOSSIP_CLOCK_DISPARITY); + +/// Where a parked data column sidecar was put. +/// +/// The three fields are exactly `Table::PendingDataColumns`'s key, so reading +/// the sidecar back needs nothing else. `slot` is also what the finality sweep +/// compares against. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +struct ParkedColumn { + slot: u64, + block_root: H256, + index: u64, +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum SlotInterval { BlockPublication, @@ -138,36 +192,64 @@ impl SlotInterval { } } -/// Milliseconds until the next interval boundary, measured relative to genesis. -fn ms_until_next_interval(now_ms: u64, config: &ChainConfig) -> u64 { - let genesis_time_ms = config.genesis_time_ms(); - // Before genesis: wait until genesis itself. +/// Milliseconds until the next `cadence_ms` boundary, measured relative to +/// genesis. Before genesis, milliseconds until genesis itself. +/// +/// Both tick cadences run through this: lean's is one fifth of a slot +/// ([`ms_until_next_interval`]), beacon's a whole slot +/// ([`ms_until_next_beacon_slot`]). One body, so the pre-genesis case and the +/// "a sample exactly on a boundary waits a whole cadence rather than zero" +/// property cannot hold on one grid and not the other. +fn ms_until_next_boundary(now_ms: u64, genesis_time_ms: u64, cadence_ms: u64) -> u64 { let Some(ms_since_genesis) = now_ms.checked_sub(genesis_time_ms) else { return genesis_time_ms - now_ms; }; - let ms_per_interval = config.milliseconds_per_interval(); - ms_per_interval - (ms_since_genesis % ms_per_interval) + cadence_ms - (ms_since_genesis % cadence_ms) +} + +/// Milliseconds until the next interval boundary: lean's tick cadence, one +/// fifth of a slot. +fn ms_until_next_interval(now_ms: u64, config: &ChainConfig) -> u64 { + ms_until_next_boundary( + now_ms, + config.genesis_time_ms(), + config.milliseconds_per_interval(), + ) } -/// Current UNIX timestamp in milliseconds. -fn unix_now_ms() -> u64 { - SystemTime::UNIX_EPOCH - .elapsed() - .expect("already past the unix epoch") - .as_millis() as u64 +/// Milliseconds until the next slot boundary: beacon's tick cadence. +/// +/// A beacon follower has no sub-slot duties (see [`ChainDuties::Beacon`]'s +/// documentation), so it ticks once per slot rather than once per lean +/// interval. +fn ms_until_next_beacon_slot(now_ms: u64, genesis_time_ms: u64, slot_duration_ms: u64) -> u64 { + ms_until_next_boundary(now_ms, genesis_time_ms, slot_duration_ms) } impl BlockChain { - /// Spawn the blockchain actor. + /// Spawn the blockchain actor for the lean chain. /// /// `events` is the chain-event publication bus: the spawned actor is its /// sole publisher; consumers subscribe read-only receivers. + /// + /// Asserts `store.chain() == Chain::Lean`: pairing a lean-shaped + /// `BlockChainConfig` (validator keys, aggregator role, proposer policy) + /// with a beacon store would corrupt the directory the moment any duty + /// touched it, so this is a programming error to catch here rather than + /// a runtime condition to branch on. Use [`Self::spawn_beacon`] for a + /// beacon store. pub fn spawn( store: Store, validator_keys: HashMap, config: BlockChainConfig, events: EventBus, ) -> BlockChain { + assert_eq!( + store.chain(), + Chain::Lean, + "BlockChain::spawn requires a lean store; use BlockChain::spawn_beacon for a beacon one" + ); + let BlockChainConfig { aggregator, sync_status_controller, @@ -181,33 +263,24 @@ impl BlockChain { metrics::set_is_aggregator(aggregator.is_enabled()); metrics::set_node_sync_status(metrics::SyncStatus::Idle); - let time_config = *store.config(); - let genesis_time = time_config.genesis_time; + let time_config = store.config().time_grid(); let key_manager = key_manager::KeyManager::new(validator_keys); - let server = BlockChainServer { - store, - p2p: None, + let lean = LeanDuties { key_manager, - pending_blocks: HashMap::new(), aggregator, - pending_block_parents: HashMap::new(), current_aggregation: None, - last_tick_instant: None, attestation_committee_count, subscribed_subnets, aggregation_duty_subnet, skip_redundant_aggregation, proposer_config, pre_merge_coverage: None, - sync_status: SyncStatusTracker::new(gate_duties), - sync_status_controller, - events, }; // Warm the XMSS signing caches for the next duties before the first // tick, which fires right away and runs the current interval's duty. - // store.time() doesn't work here: after an offline gap it lags + // The store clock doesn't work here: after an offline gap it lags // wall-clock by exactly the gap the first duty will be at. let ms_since_genesis = unix_now_ms().saturating_sub(time_config.genesis_time_ms()); let current_slot = ms_since_genesis / time_config.milliseconds_per_slot; @@ -217,9 +290,7 @@ impl BlockChain { // interval 4, before we started, and the interval-1 tick warms the // next slot's. SlotInterval::BlockPublication | SlotInterval::AttestationProduction => { - server - .key_manager - .prepare_keys_for(current_slot as u32, None); + lean.key_manager.prepare_keys_for(current_slot as u32, None); } // This slot's attestations are behind us, so the next signatures // are the next slot's block, built at this slot's interval 4, and @@ -227,16 +298,133 @@ impl BlockChain { SlotInterval::Aggregation | SlotInterval::SafeTargetUpdate | SlotInterval::EndOfSlot => { - let num_validators = server.store.head_state().validators.len() as u64; + let num_validators = store.head_state().validators.len() as u64; let next_slot = current_slot + 1; - let proposer = server.get_our_proposer(next_slot, num_validators); - server - .key_manager + let proposer = lean.our_proposer(next_slot, num_validators); + lean.key_manager .prepare_keys_for(next_slot as u32, proposer); } } - let handle = server.start(); + Self::start_actor( + store, + SyncStatusTracker::new(gate_duties), + sync_status_controller, + events, + ChainDuties::Lean(Box::new(lean)), + Vec::new(), + None, + constants::SAFE_SLOTS_TO_IMPORT_OPTIMISTICALLY, + ) + } + + /// Spawn the blockchain actor for the beacon chain, as a follower with no + /// validator duties. + /// + /// No validator keys, no key advance, no aggregator role: a beacon + /// follower only imports blocks and runs fork choice (see + /// [`ChainDuties::Beacon`]). Feeding it gossip and calling this from + /// `run_node` are later slices; this constructor only builds the actor. + /// + /// Uses [`SyncStatusTracker::new`]`(false)`: the tracker still drives the + /// `lean_node_sync_status` metric (the name predates beacon support), but + /// there are no duties on this arm for it to gate. + /// + /// Asserts `store.chain() == Chain::Beacon`, the mirror image of + /// [`Self::spawn`]'s assertion, for the same reason: a mismatch here is a + /// programming error, not a condition to recover from. + /// + /// `custody_columns` feeds the data-availability gate in `process_block`. + /// It is computed once at startup from this node's id (see + /// `das::custody_columns`). + /// + /// `engine` is the execution client to validate payloads against, `None` + /// when `--execution-endpoint` was not given, and + /// `safe_slots_to_import_optimistically` is + /// `--safe-slots-to-import-optimistically`. + pub fn spawn_beacon( + store: Store, + sync_status_controller: SyncStatusController, + events: EventBus, + custody_columns: Vec, + engine: Option, + safe_slots_to_import_optimistically: u64, + ) -> BlockChain { + assert_eq!( + store.chain(), + Chain::Beacon, + "BlockChain::spawn_beacon requires a beacon store; use BlockChain::spawn for a lean one" + ); + + metrics::set_node_sync_status(metrics::SyncStatus::Idle); + + Self::start_actor( + store, + SyncStatusTracker::new(false), + sync_status_controller, + events, + ChainDuties::Beacon, + custody_columns, + engine, + safe_slots_to_import_optimistically, + ) + } + + /// Start the actor and arm its first tick: everything the two public + /// constructors above do identically, once. + /// + /// The first `Tick` is armed for genesis, or immediately when genesis is + /// already past (`unwrap_or_default` on a negative duration). That is the + /// contract both chains' tick loops are entered through, which is why it + /// is stated in one place rather than per chain. + /// + /// `custody_columns` and `engine` are both meaningless on + /// [`ChainDuties::Lean`]: lean carries no + /// `DataAvailability::Columns` evidence to gate on and has no execution + /// layer, so [`BlockChain::spawn`] passes an empty vector and `None`. It + /// passes the specification's own default for + /// `safe_slots_to_import_optimistically`, which nothing on that arm reads. + #[allow(clippy::too_many_arguments)] + fn start_actor( + store: Store, + sync_status: SyncStatusTracker, + sync_status_controller: SyncStatusController, + events: EventBus, + duties: ChainDuties, + custody_columns: Vec, + engine: Option, + safe_slots_to_import_optimistically: u64, + ) -> BlockChain { + let genesis_time = store.config().genesis_time; + + // `sidecars_awaiting_parent` starts empty below, and it is the only + // index into `Table::PendingDataColumns`. Anything a previous run + // parked there is unreachable from here on, so it goes now rather than + // sitting unverified and unread until the directory is deleted. + let _ = store + .clear_pending_data_column_sidecars() + .inspect_err(|err| error!(%err, "Failed to clear parked data column sidecars")); + + let handle = BlockChainServer { + store, + p2p: None, + pending_blocks: HashMap::new(), + pending_block_parents: HashMap::new(), + blocks_awaiting_columns: HashMap::new(), + held_timings: HashMap::new(), + sidecars_awaiting_parent: HashMap::new(), + beacon_aggregates: Default::default(), + custody_columns, + engine, + safe_slots_to_import_optimistically, + last_tick_instant: None, + sync_status, + sync_status_controller, + events, + duties, + } + // Own thread: these handlers are long synchronous CPU that starves a shared runtime. + .start_with_backend(Backend::Thread); let time_until_genesis = (SystemTime::UNIX_EPOCH + Duration::from_secs(genesis_time)) .duration_since(SystemTime::now()) .unwrap_or_default(); @@ -265,8 +453,6 @@ pub struct BlockChainServer { // P2P protocol ref (set via InitP2P message) p2p: Option, - key_manager: key_manager::KeyManager, - // Pending block roots waiting for their parent (block data stored in DB) pending_blocks: HashMap>, // Maps pending block_root → its cached missing ancestor. Resolved by walking the @@ -274,6 +460,166 @@ pub struct BlockChainServer { // a deeper missing parent after the entry was created. pending_block_parents: HashMap, + /// Beacon blocks admitted past the parent check (see + /// [`Self::process_or_pend_block`]) but held from fork choice because a + /// column this node custodies for them had not yet arrived, keyed by root + /// with the block's own slot cached alongside so + /// [`Self::release_block_if_columns_complete`] can re-check presence + /// without decoding the block back out of storage first. A separate map + /// from `pending_blocks` above: a held block already has a known parent, + /// which is the one thing that map tracks the absence of. Always empty on + /// lean, which carries no `DataAvailability::Columns` evidence to gate on. + blocks_awaiting_columns: HashMap, + + /// Timing timings for every block currently held, keyed by root. + /// + /// One map for all three hold kinds rather than a field widening each of + /// the hold maps, because what has to survive a hold is the same thing + /// whichever hold it is: the block's original arrival, so that its + /// end-to-end time still starts where it really started. Entries are taken + /// on release and removed by [`Self::discard_pending_subtree`], the funnel + /// both eviction paths already use for `pending_blocks` and + /// `blocks_awaiting_columns`, so a block nobody ever redelivers cannot + /// leave one behind. + held_timings: HashMap, + + /// Sidecars whose block's parent this node cannot yet transition from, + /// keyed by that parent's root and replayed when it gains a post-state. + /// + /// The specification's gossip rule for a sidecar whose parent is not + /// usable is `[IGNORE]`, and it says so with an explicit licence to come + /// back to it: "MAY be queued for processing once the parent block is + /// retrieved". Dropping instead is what deadlocks a follower running the + /// availability gate, because the gate manufactures exactly this + /// condition: a held block never reaches `on_block`, so it never writes a + /// post-state, so every sidecar of every *child* of it fails the parent + /// lookup. Held block and un-arrived parent are indistinguishable here and + /// both are temporary, so both queue. + /// + /// Only the keys live here. The sidecar's own bytes go straight into + /// `Table::PendingDataColumns` and are read back on replay, because a + /// sidecar carries a cell per blob and a queue of them is the one + /// structure on this actor whose size a peer gets to choose. + /// + /// Uncapped, and swept only by + /// [`Self::evict_sidecars_awaiting_parent_at_or_below_finality`], on the + /// same schedule held blocks are. A peer naming parents this node will + /// never have can therefore grow it until finality reclaims the slots; + /// see [`Self::queue_sidecar_awaiting_parent`]. Always empty on lean. + /// + /// A set per parent, so "the same column is never parked twice" is the + /// container's own rule rather than a scan every arrival pays for. Order + /// is not one: a replay checks and stores each sidecar on its own, and a + /// held block is released by its last column arriving, whichever that is. + sidecars_awaiting_parent: HashMap>, + + /// The columns this node samples, computed once at startup from its node + /// id (see `das::custody_columns`). Empty on lean. + custody_columns: Vec, + + /// The execution client this follower validates payloads against, when one + /// is configured. `None` is `--execution-endpoint` absent, in which case + /// every block gets [`fork_choice::PayloadValidity::NotRequired`] and the + /// follower behaves exactly as it did before any engine existed. + /// + /// Always `None` on lean, which has no execution layer. + engine: Option, + + /// `--safe-slots-to-import-optimistically`. Bounds + /// [`fork_choice::is_optimistic_candidate_block`]'s age condition; the + /// specification requires the value to be operator-configurable. + safe_slots_to_import_optimistically: u64, + + /// Last tick instant for measuring interval duration. + last_tick_instant: Option, + + /// Stateful sync heuristic used by `lean_node_sync_status`. Also gates + /// validator duties while syncing, unless that gating was disabled at + /// startup via `--disable-duty-sync-gate` (then it is metric-only). On a + /// beacon follower ([`BlockChain::spawn_beacon`]) there are no duties to + /// gate, so it is always constructed observe-only there. + sync_status: SyncStatusTracker, + + /// Shared, read-only mirror of `sync_status` for readers outside the actor + /// (the RPC `/lean/v0/node/syncing` endpoint). Written from + /// `update_sync_status` with the same `SyncStatus` fed to the metric. + sync_status_controller: SyncStatusController, + + /// Chain-event publication bus. The actor is the sole publisher; consumers + /// only subscribe, preserving the one-directional write flow. + events: EventBus, + + /// The `beacon_aggregate_and_proof` seen-sets and deferral queue. Always + /// empty on lean, which subscribes to no such topic. See + /// [`crate::beacon_aggregates`] for why all three live on the actor rather + /// than in the p2p layer that first sees an aggregate. + beacon_aggregates: crate::beacon_aggregates::AggregateGossip, + + /// The lean-only or beacon-only half of this actor's state. See + /// [`ChainDuties`]. + duties: ChainDuties, +} + +/// The lean-only or beacon-only half of [`BlockChainServer`]'s state. +/// +/// Every field a lean validator needs (key material, aggregator role, the +/// in-flight aggregation session, proposer policy, ...) means nothing on a +/// beacon follower: it has no validator keys, casts no votes, and builds no +/// blocks of its own. Splitting them behind this enum, rather than leaving +/// them on [`BlockChainServer`] directly and trusting every call site to +/// check the chain before touching one, turns "beacon has no validator +/// duties" into a compile-time fact instead of a convention: the field +/// simply is not there to read on that arm. +/// +/// A lean-only *message handler* opens with +/// `let ChainDuties::Lean(_) = &self.duties else { return };`: dropping a +/// message that does not apply to this chain is a legitimate runtime outcome, +/// and the handler is the actor's outer boundary where that decision belongs. +/// +/// Everything below that boundary reads the payload through +/// [`BlockChainServer::lean`]/[`BlockChainServer::lean_mut`], which panic +/// rather than return. Since [`BlockChain::spawn`] and +/// [`BlockChain::spawn_beacon`] each assert their store's chain tag matches +/// the duties they build, reaching one from a beacon follower is a +/// programming error, not a condition to absorb: a silent `return` in the +/// middle of a duty would leave it half-done and look exactly like a real +/// early exit. +/// +/// Boxes the `Lean` payload: `LeanDuties` carries a key manager, a subnet +/// set and an aggregation session, and clippy's `large_enum_variant` flags +/// the gap against a `Beacon` arm that carries nothing at all. +enum ChainDuties { + /// Validator duties: signing, committee aggregation, proposing. See + /// [`LeanDuties`]. + Lean(Box), + /// Chain-following only: import blocks, run fork choice, tick once per + /// slot boundary. A follower holds no state of its own beyond the store: + /// even a block that arrives before its slot waits in a timer rather than + /// in a field here (see [`BlockChainServer::defer_early_block`]). + Beacon, +} + +/// Panics, naming the validator duty a beacon follower reached. +/// +/// The same shape as `state_transition`'s `lean_boundary` helpers, and for +/// the mirror-image reason: `ChainDuties::Beacon` carries no validator state, +/// so a duty asking for it means the caller dispatched on the wrong chain. +/// `#[cold]` and `#[track_caller]` so the panic still reports the duty's own +/// file and line, the way an `unreachable!` written inline there would have. +#[cold] +#[track_caller] +fn beacon_has_no_duties() -> ! { + unreachable!( + "a beacon follower reached a lean validator duty; \ + BlockChain::spawn_beacon builds ChainDuties::Beacon, so the caller \ + must dispatch on the chain before this point" + ) +} + +/// Validator-duty state, live only on [`ChainDuties::Lean`]. +struct LeanDuties { + key_manager: key_manager::KeyManager, + /// Whether this node acts as a committee aggregator. /// /// Read fresh on every tick and gossip event so runtime toggles via the @@ -283,14 +629,11 @@ pub struct BlockChainServer { /// The slot's one committee-signature aggregation session (started at /// interval 2, or early via the 2/3 trigger). Deliberately persists after - /// the worker finishes — that persistence is the once-per-slot latch the - /// early trigger and the interval-2 skip both check — until the next + /// the worker finishes (that persistence is the once-per-slot latch the + /// early trigger and the interval-2 skip both check) until the next /// session start replaces it. current_aggregation: Option, - /// Last tick instant for measuring interval duration. - last_tick_instant: Option, - /// Number of attestation committees (= subnet count). Used by the /// attestation aggregate coverage emission and the early-aggregation /// threshold. @@ -321,25 +664,228 @@ pub struct BlockChainServer { /// single-threaded message loop, so no synchronization is needed. /// Observability-only. pre_merge_coverage: Option, +} - /// Stateful sync heuristic used by `lean_node_sync_status`. Also gates - /// validator duties while syncing, unless that gating was disabled at - /// startup via `--disable-duty-sync-gate` (then it is metric-only). - sync_status: SyncStatusTracker, +impl LeanDuties { + /// Returns the validator ID if any of our validators is the proposer for + /// this slot. + fn our_proposer(&self, slot: u64, num_validators: u64) -> Option { + self.key_manager + .validator_ids() + .into_iter() + .find(|&vid| is_proposer(vid, slot, num_validators)) + } +} - /// Shared, read-only mirror of `sync_status` for readers outside the actor - /// (the RPC `/lean/v0/node/syncing` endpoint). Written from - /// `update_sync_status` with the same `SyncStatus` fed to the metric. - sync_status_controller: SyncStatusController, +/// Error from importing a block, whichever chain it belongs to. +/// +/// [`BlockChainServer::process_block`] runs one of two import calls +/// depending on the block's variant: lean's [`store::on_block`] returns +/// [`StoreError`], beacon's [`fork_choice::on_block`] returns +/// [`BeaconError`]. Wrapping both here, rather than picking one chain's +/// error type to stand in for both, keeps each chain's own error type +/// exactly as its own module defines it. +#[derive(Debug, thiserror::Error)] +enum ImportError { + #[error(transparent)] + Lean(#[from] StoreError), + #[error(transparent)] + Beacon(#[from] BeaconError), +} - /// Chain-event publication bus. The actor is the sole publisher; consumers - /// only subscribe, preserving the one-directional write flow. - events: EventBus, +/// What [`BlockChainServer::process_block`] did with the block it was given. +/// +/// The distinction exists for [`BlockChainServer::process_or_pend_block`]: +/// only [`ImportOutcome::Imported`] means a post-state now exists under +/// `block_root`, so only it may unblock anything pending on that root. +/// `process_block` used to return a bare `Ok(())` for a held block too, which +/// made its caller call `collect_pending_children` as if the hold had +/// produced a state to build on. A child block naming a held block as parent +/// would then have its ancestor walk find the held block's row in +/// `BlockHeaders` (written by `hold_block_for_columns`'s own +/// `insert_pending_block`) and re-enqueue it into the very cascade the child +/// arrived on; reprocessing re-held it, which called +/// `collect_pending_children` again, which re-enqueued the same child again — +/// forever, inside `run_import_cascade`'s synchronous loop, with no yield +/// point and a duplicate column fetch to peers on every turn. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ImportOutcome { + /// A post-state now exists under the block's root, whether this call + /// wrote it or it already had one. Safe to unblock anything pending on + /// this root. + Imported, + /// The block left the cascade without a post-state under its root, so + /// nothing pending on that root may be unblocked. + /// + /// Two reasons reach this, and they differ in what happens next: + /// + /// - A fulu block whose custody columns have not all arrived. The block is + /// persisted and readable back by root, and + /// [`BlockChainServer::release_block_if_columns_complete`] eventually + /// re-imports it and reaches `collect_pending_children` for real. + /// - No verdict from the execution client, or a `NOT_VALIDATED` verdict on + /// a block that is not an optimistic candidate. Nothing is recorded and + /// the block is simply dropped: unlike a column hold, nothing re-drives + /// it, which is what the terminal retry policy on the engine ladder + /// means. See `EngineClient::call`'s own documentation. + /// + /// The name reads as "held for columns" for historical reasons; it is the + /// general "produced no post-state" outcome. + Held, +} + +/// The availability evidence for `block`, or `None` if a column this node +/// custodies has not arrived yet. +/// +/// `None` is not "unavailable": it means the question cannot be answered yet, +/// which is why the caller holds the block rather than rejecting it. A partial +/// set must never be passed on as evidence, because +/// `is_data_available_columns` is vacuously true over an empty list and would +/// import a block whose data nobody has. +/// +/// Only fulu blocks reach the column shape. A deneb or electra block carrying +/// blobs would need the blob-and-proof shape, which this node has no source +/// for, so it is admitted with a log rather than held forever against a +/// pipeline that does not exist. +/// +/// `ethlambda_storage::Table::DataColumns` is never pruned today (see its own +/// doc comment), which is what lets the sidecar-collecting `.expect()`s below +/// assume a column confirmed present a moment ago by `custody_columns_present` +/// is still there to decode; a future pruner has to keep that window safe too. +/// Whether a block at `block_slot` still falls inside the window this node may +/// insist on data availability for. +/// +/// The boundary is the specification's own +/// `max(current_epoch - MIN_EPOCHS_FOR_DATA_COLUMN_SIDECARS_REQUESTS, +/// FULU_FORK_EPOCH)`: the epoch range peers "MUST support serving requests of +/// data columns on". Below it a peer "MAY respond with error code +/// `3: ResourceUnavailable` or not include the data column sidecar in the +/// response", so a block there can be unavailable through no fault of anyone, +/// and holding it would stall the chain against data the network is entitled to +/// have dropped. Above it, refusing to import without the columns is the point. +/// +/// Pre-fulu blocks are never gated: the column matrix does not exist for them, +/// and their own blob shape has no pipeline here (see [`data_availability_for`]). +/// +/// Mirrors lighthouse's `da_check_required_for_epoch`, which asks the same +/// question of the same boundary. +fn da_check_required_for_slot(block_slot: u64, current_slot: u64, config: &Config) -> bool { + let block_epoch = block_slot / preset::SLOTS_PER_EPOCH; + let current_epoch = current_slot / preset::SLOTS_PER_EPOCH; + let boundary = current_epoch + .saturating_sub(constants::MIN_EPOCHS_FOR_DATA_COLUMN_SIDECARS_REQUESTS) + .max(config.fulu_fork_epoch); + block_epoch >= boundary +} + +fn data_availability_for( + store: &Store, + block: &SignedBeaconBlock, + custody_columns: &[u64], +) -> Option { + match block { + SignedBeaconBlock::Deneb(inner) => { + if !inner.message.body.blob_kzg_commitments.is_empty() { + warn_no_blob_pipeline(block); + } + Some(fork_choice::DataAvailability::NotRequired) + } + SignedBeaconBlock::Electra(inner) => { + if !inner.message.body.blob_kzg_commitments.is_empty() { + warn_no_blob_pipeline(block); + } + Some(fork_choice::DataAvailability::NotRequired) + } + SignedBeaconBlock::Fulu(inner) => { + if inner.message.body.blob_kzg_commitments.is_empty() { + return Some(fork_choice::DataAvailability::NotRequired); + } + + // An empty custody set means there is nothing outstanding, not + // that the question is unanswerable: `custody_columns_present` + // below is vacuously true and the resulting `Columns(vec![])` is + // vacuously available, which is the right answer for a caller + // that supplied every block itself. Only the replay harness + // (`BlockChainServer::for_replay`) is in that position. A node + // cannot be: `run_node` asserts a non-empty set before spawning, + // `MainnetOptions::custody_group_count` floors at + // `CUSTODY_REQUIREMENT`, and `das::custody_columns` never returns + // an empty set. + let slot = block.slot(); + let block_root = block.message_hash_tree_root(); + + // Presence first, over the full set: a column this node has not + // yet verified must not even be looked up, since silently + // dropping the missing ones would hand back a shorter-but-still- + // non-empty list that reads as complete evidence to the caller. + if !custody_columns_present(store, slot, &block_root, custody_columns) { + return None; + } + + let sidecars = custody_columns + .iter() + .map(|&index| { + let encoded = store + .get_data_column_sidecar(slot, &block_root, index) + .expect("DB read should succeed") + .expect("presence just confirmed above"); + fulu::DataColumnSidecar::from_ssz_bytes(&encoded) + .expect("a sidecar this node verified before storing decodes") + }) + .collect(); + + Some(fork_choice::DataAvailability::Columns(sidecars)) + } + _ => Some(fork_choice::DataAvailability::NotRequired), + } +} + +/// Whether every column in `custody_columns` is present for `block_root` at +/// `slot`, without reading any of them back. +/// +/// Shared by [`data_availability_for`] and +/// [`BlockChainServer::release_block_if_columns_complete`], which both need +/// the same "is the set complete" question answered before paying for a read: +/// the former before collecting evidence, the latter before deciding whether +/// a held block is worth fetching back out of storage at all. +fn custody_columns_present( + store: &Store, + slot: u64, + block_root: &H256, + custody_columns: &[u64], +) -> bool { + let present = store + .data_column_indices_for(slot, block_root) + .expect("DB read should succeed"); + custody_columns.iter().all(|index| present.contains(index)) +} + +/// Warns that `block` is being admitted with no availability check: this node +/// has no blob-and-proof pipeline to source `DataAvailability::Blobs` +/// evidence from for a deneb or electra block, and holding such a block +/// forever against a source that will never fill in would be worse than +/// admitting it unchecked. +/// +/// Logged on every occurrence, with the fork and the block root, rather than +/// on the first one only: the held side of this gate is fully observable +/// (`lean_blocks_held_for_columns` plus `hold_block_for_columns`'s own log +/// line), and this is the admitted-without-checking side of the same gate, so +/// an operator needs to be able to tell how often it fires and for which block +/// just as much. +fn warn_no_blob_pipeline(block: &SignedBeaconBlock) { + let fork = block.fork_name().as_str(); + let block_root = block.message_hash_tree_root(); + warn!( + fork, + block_root = %ShortRoot(&block_root.0), + "Admitting a block carrying blobs with no availability check: this node \ + has no blob-and-proof pipeline" + ); } impl BlockChainServer { async fn on_tick(&mut self, timestamp_ms: u64, ctx: &Context) { - let time_config = *self.store.config(); + let time_config = self.store.config().time_grid(); // Calculate current slot and interval from milliseconds let time_since_genesis_ms = timestamp_ms.saturating_sub(time_config.genesis_time_ms()); @@ -352,14 +898,28 @@ impl BlockChainServer { // by the monotonic clock (`tokio::sleep`). The wall clock can drift behind it // inside VMs, so a tick scheduled for the next interval boundary can fire // while the wall clock still reads the previous interval. - let tick_interval = time_since_genesis_ms / time_config.milliseconds_per_interval(); - let store_time = self.store.time().expect("store time exists"); + // + // The store clock is one Unix millisecond row on either chain, but the + // grids differ: lean counts intervals since genesis, beacon Unix + // seconds. This tick's own position and the store's own reading are + // both derived in whichever unit that chain keeps, so the comparison + // below has one unit per arm rather than one across both. + let (tick_time, store_time) = match self.store.chain() { + Chain::Lean => ( + time_since_genesis_ms / time_config.milliseconds_per_interval(), + self.store.intervals_since_genesis(), + ), + Chain::Beacon => ( + timestamp_ms / 1000, + self.store.time_ms().expect("store time exists") / 1_000, + ), + }; - if store_time > 0 && tick_interval <= store_time { + if store_time > 0 && tick_time <= store_time { debug!( %slot, ?interval, - tick_interval, + tick_time, store_time, "Skipping already-processed tick" ); @@ -367,10 +927,16 @@ impl BlockChainServer { } // Fail fast: a state with zero validators is invalid and would cause - // panics in proposer selection and attestation processing. Read once - // per tick, since `head_state` clones the whole state. - let num_validators = self.store.head_state().validators.len() as u64; - if num_validators == 0 { + // panics in proposer selection and attestation processing. Lean-only: + // `head_state` peels a lean `State` and panics on a beacon store, which + // has no validator set of its own to check. Read once per tick, since + // `head_state` clones the whole state. Zero on a beacon follower, where + // nothing reads it: `get_our_proposer` answers `None` there first. + let num_validators = match self.store.chain() { + Chain::Lean => self.store.head_state().validators.len() as u64, + Chain::Beacon => 0, + }; + if self.store.chain() == Chain::Lean && num_validators == 0 { error!("Head state has no validators, skipping tick"); return; } @@ -383,7 +949,8 @@ impl BlockChainServer { // the tick see a consistent value even if the admin API toggles it // mid-tick. Mirror it to the gauge from the actor side so // `lean_is_aggregator` reflects the value the actor is acting on. - let is_aggregator = self.aggregator.is_enabled(); + // Always false on a beacon follower, which holds no such role. + let is_aggregator = self.is_aggregator(); metrics::set_is_aggregator(is_aggregator); // ==== interval 4 (pre-tick) ==== @@ -401,12 +968,14 @@ impl BlockChainServer { // observability. if interval == SlotInterval::EndOfSlot && let Some(snapshot) = coverage::snapshot_new_payloads(&self.store) + && let ChainDuties::Lean(lean) = &mut self.duties { - self.pre_merge_coverage = Some(snapshot); + lean.pre_merge_coverage = Some(snapshot); } // Whether one of our validators proposes this slot. Drives the store's - // interval-0 attestation acceptance. + // interval-0 attestation acceptance. `get_our_proposer` answers `None` + // on a beacon follower, which carries no validator keys. let is_proposer = (interval == SlotInterval::BlockPublication && slot > 0) .then(|| self.get_our_proposer(slot, num_validators)) .flatten() @@ -415,12 +984,107 @@ impl BlockChainServer { // Tick the store first - this accepts attestations at interval 0 if we have a proposal. // Snapshot/diff around the call so attestation-driven head or // finalization moves surface as chain events. + // + // Which call that is depends on the chain. Lean's `store::on_tick` + // carries its own fork-choice work; beacon's clock advance does not, so + // the head recompute follows it here. It is still needed for a slot in + // which nothing arrived, the block path having one of its own (see + // [`Self::recompute_beacon_head`], which both share). let pre_tick = ChainEventSnapshot::capture(&self.store); - store::on_tick(&mut self.store, timestamp_ms, is_proposer); + match self.store.chain() { + Chain::Lean => store::on_tick(&mut self.store, timestamp_ms, is_proposer), + Chain::Beacon => { + let config = self.store.config(); + // Loops `on_tick_per_slot` over every boundary crossed since the + // last call, so a tick delayed by a long import still resets + // proposer boost and pulls up unrealized checkpoints for each + // slot it skipped, not just the latest one. + fork_choice::on_tick(&mut self.store, timestamp_ms / 1000, &config); + // Between the clock and the head: an aggregate for the slot + // that just ended becomes applicable exactly now, and its + // votes have to be in fork choice before the head this tick + // reports is chosen. + self.drain_deferred_aggregates(); + self.recompute_beacon_head().await; + } + } // `slot` above is already derived from `timestamp_ms` (the wall clock // at tick time), so it doubles as the wall-clock slot for the gate. pre_tick.diff_and_emit(&self.store, &self.events, slot); + // The other of the two places (with a genuine `process_block` import) + // beacon finality can move; see the method's own documentation for + // why nothing else evicts a held block nobody redelivers. + self.evict_held_blocks_at_or_below_finality(); + self.evict_sidecars_awaiting_parent_at_or_below_finality(); + self.redrive_held_blocks().await; + + // Per-interval duties for this tick. Lean-only, so this is where a + // beacon follower's tick ends: it has no validator duties (see + // [`ChainDuties::Beacon`]), and everything a tick owes it happened + // above. + self.run_interval_duties(interval, slot, num_validators, is_aggregator, ctx) + .await; + + // Update safe target slot metric (updated by store.on_tick at interval 3). + // Lean-only: a beacon store keeps no safe target. + if self.store.chain() == Chain::Lean { + metrics::update_safe_target_slot(self.store.safe_target_slot()); + } + + // Head may change when attestations are promoted at intervals 0/4. + // Beacon moves the justified and finalized pair without importing + // anything, when the clock advance above crosses an epoch boundary and + // pulls up unrealized checkpoints. + self.refresh_chain_metrics(); + } + + /// Push the head, justified and finalized slots to their gauges. + /// + /// The two places a tick or an import can move any of the three + /// ([`Self::on_tick`] and [`Self::process_block`]) refresh all three, so + /// they read the chain the same way: head through [`Self::head_slot`], + /// which is the only spelling of that dispatch. + fn refresh_chain_metrics(&self) { + metrics::update_head_slot(self.head_slot()); + let latest_justified_slot = self + .store + .latest_justified() + .expect("Error: Latest justified checkpoint does not exist") + .slot; + metrics::update_latest_justified_slot(latest_justified_slot); + let latest_finalized_slot = self + .store + .latest_finalized() + .expect("Error: Latest finalized checkpoint does not exist") + .slot; + metrics::update_latest_finalized_slot(latest_finalized_slot); + } + + /// Run this tick's validator duties, the interval grid `on_tick` sits on. + /// + /// Lean-only, and it says so itself rather than making the caller ask: + /// [`ChainDuties::Beacon`] carries no validator state for any of these to + /// read, and a beacon follower ticks once per slot, which lands it on + /// [`SlotInterval::BlockPublication`] where there is nothing to do anyway. + /// + /// `is_aggregator` is passed in rather than read here so every duty in the + /// tick acts on the one value the tick started with, even if the admin API + /// toggles the role underneath it. `num_validators` is the head state's, + /// read once by `on_tick` since `head_state` clones the whole state. + async fn run_interval_duties( + &mut self, + interval: SlotInterval, + slot: u64, + num_validators: u64, + is_aggregator: bool, + ctx: &Context, + ) { + if !matches!(self.duties, ChainDuties::Lean(_)) { + return; + } + let time_config = self.store.config().time_grid(); + // Per-interval duties for this tick. Intervals 0 (block publish) and 3 // (safe-target update) are driven inside `store::on_tick` above, so they // carry only a note below. @@ -449,14 +1113,14 @@ impl BlockChainServer { if slot > 0 { coverage::emit_post_block_coverage( &self.store, - self.pre_merge_coverage.as_ref(), - self.attestation_committee_count, + self.lean().pre_merge_coverage.as_ref(), + self.lean().attestation_committee_count, slot - 1, ); } if self.sync_status.duties_allowed() { self.produce_attestations(slot, is_aggregator); - } else if !self.key_manager.validator_ids().is_empty() { + } else if !self.lean().key_manager.validator_ids().is_empty() { info!(%slot, "Skipping attestations while syncing"); } @@ -483,7 +1147,8 @@ impl BlockChainServer { // doesn't stall the tick. let next_slot = slot + 1; let proposer = self.get_our_proposer(next_slot, num_validators); - self.key_manager + self.lean_mut() + .key_manager .prepare_keys_in_background(next_slot as u32, proposer); } @@ -494,6 +1159,7 @@ impl BlockChainServer { // session (running or finished) — it IS the slot's session, // so don't start a second one. let already_started = self + .lean() .current_aggregation .as_ref() .is_some_and(|session| session.session_id == slot); @@ -530,11 +1196,52 @@ impl BlockChainServer { } } } + } + + /// This chain's head slot: `Store::head_slot` on lean, + /// `Store::beacon_head` on beacon, which decode different tables. Zero on + /// a beacon store with no head recorded yet. + fn head_slot(&self) -> u64 { + match self.store.chain() { + Chain::Lean => self.store.head_slot(), + Chain::Beacon => self.store.beacon_head().map_or(0, |(slot, _)| slot), + } + } + + /// Whether this node acts as a committee aggregator, read fresh so a + /// runtime toggle takes effect without a restart. Always false on a beacon + /// follower: the role is a lean validator duty. + fn is_aggregator(&self) -> bool { + matches!(&self.duties, ChainDuties::Lean(lean) if lean.aggregator.is_enabled()) + } + + /// This node's validator-duty state, for a caller that has already + /// established it is running the lean chain. + /// + /// Panics on a beacon follower, through the same reasoning as + /// `state_transition`'s `lean_boundary` pair: the two spawn constructors + /// assert the store's chain tag against the duties they build, so a + /// beacon follower reaching a validator duty is a dispatch bug above this + /// method. `#[track_caller]` so the panic names the duty that asked + /// rather than this accessor. A lean-only *message* is dropped at its + /// handler instead, which is the boundary where that is a real outcome; + /// see [`ChainDuties`]. + #[track_caller] + fn lean(&self) -> &LeanDuties { + match &self.duties { + ChainDuties::Lean(lean) => lean, + ChainDuties::Beacon => beacon_has_no_duties(), + } + } - // Update safe target slot metric (updated by store.on_tick at interval 3) - metrics::update_safe_target_slot(self.store.safe_target_slot()); - // Update head slot metric (head may change when attestations are promoted at intervals 0/4) - metrics::update_head_slot(self.store.head_slot()); + /// [`Self::lean`] for a duty that mutates its own state (the key manager, + /// the aggregation session). Same panic, same reason. + #[track_caller] + fn lean_mut(&mut self) -> &mut LeanDuties { + match &mut self.duties { + ChainDuties::Lean(lean) => lean, + ChainDuties::Beacon => beacon_has_no_duties(), + } } /// Kick off a committee-signature aggregation session: @@ -546,9 +1253,9 @@ impl BlockChainServer { /// /// Both entry points land here — the interval-2 tick and the early /// 2/3-threshold trigger — so the proposer cap applies to whichever one - /// starts the slot's session. + /// starts the slot's session. Lean-only. async fn start_aggregation_session(&mut self, slot: u64, ctx: &Context) { - if let Some(prior) = self.current_aggregation.take() { + if let Some(prior) = self.lean_mut().current_aggregation.take() { prior.cancel.cancel(); if !prior.worker.is_finished() { warn!( @@ -567,7 +1274,8 @@ impl BlockChainServer { } } - coverage::emit_agg_start_new_coverage(&self.store, self.attestation_committee_count); + let attestation_committee_count = self.lean().attestation_committee_count; + coverage::emit_agg_start_new_coverage(&self.store, attestation_committee_count); // Limit ourselves to a single round of aggregation if we propose next round. // This buys us time to build the block before the next slot's interval-0 tick. @@ -581,10 +1289,11 @@ impl BlockChainServer { MAX_AGGREGATION_JOBS }; + let lean = self.lean(); let window_config = aggregation::AggregationWindowConfig { - duty_subnet: self.aggregation_duty_subnet, - committee_count: self.attestation_committee_count, - skip_redundant: self.skip_redundant_aggregation, + duty_subnet: lean.aggregation_duty_subnet, + committee_count: attestation_committee_count, + skip_redundant: lean.skip_redundant_aggregation, }; let Some(snapshot) = aggregation::snapshot_aggregation_inputs(&self.store, slot, max_jobs, window_config) @@ -594,7 +1303,7 @@ impl BlockChainServer { }; let session_id = slot; - let time_config = *self.store.config(); + let time_config = self.store.config().time_grid(); let t2_ms = time_config.genesis_time_ms() + SlotInterval::Aggregation.to_ms_since_genesis(slot, &time_config); // Interval-2 boundary as a wall-clock instant; the worker holds each @@ -638,7 +1347,7 @@ impl BlockChainServer { AggregationDeadline { session_id }, ); - self.current_aggregation = Some(AggregationSession { + self.lean_mut().current_aggregation = Some(AggregationSession { session_id, early, cancel, @@ -657,14 +1366,16 @@ impl BlockChainServer { /// yields no jobs (possible only when no signer's pubkey resolves, i.e. a /// corrupted validator registry), no session is installed and the check /// retries on later inserts — each retry is a no-op session attempt. + /// Lean-only. async fn maybe_start_early_aggregation(&mut self, ctx: &Context) { - if !self.aggregator.is_enabled() { + let lean = self.lean(); + if !lean.aggregator.is_enabled() { return; } // Only fire inside the early-aggregation window // `[T2 - EARLY_AGGREGATION_WINDOW, T2)`, where T2 is the current // slot's interval-2 boundary; the slot is derived from the wall clock. - let time_config = *self.store.config(); + let time_config = self.store.config().time_grid(); let Some(ms_since_genesis) = unix_now_ms().checked_sub(time_config.genesis_time_ms()) else { return; @@ -677,7 +1388,7 @@ impl BlockChainServer { return; } let slot = ms_since_genesis / time_config.milliseconds_per_slot; - if self + if lean .current_aggregation .as_ref() .is_some_and(|session| session.session_id == slot) @@ -694,12 +1405,12 @@ impl BlockChainServer { // committees, subnet `s` holds `N / C` validators, plus one more when // `s < N % C`. (0 only when there are no such validators, which never // triggers.) - let min_group_sigs = if self.attestation_committee_count == 0 { + let min_group_sigs = if lean.attestation_committee_count == 0 { 0 } else { let validator_count = self.store.head_state().validators.len() as u64; - let committee_count = self.attestation_committee_count; - let expected_votes: u64 = self + let committee_count = lean.attestation_committee_count; + let expected_votes: u64 = lean .subscribed_subnets .iter() .filter(|&&subnet| subnet < committee_count) @@ -722,24 +1433,34 @@ impl BlockChainServer { self.start_aggregation_session(slot, ctx).await; } - /// Returns the validator ID if any of our validators is the proposer for this slot. + /// Returns the validator ID if any of our validators is the proposer for + /// this slot. + /// + /// Answers `None` on a beacon follower rather than panicking through + /// [`Self::lean`], and that is load-bearing: `on_tick` is chain-generic + /// and asks this on every [`SlotInterval::BlockPublication`], which is the + /// one interval a beacon follower's once-per-slot tick lands on. fn get_our_proposer(&self, slot: u64, num_validators: u64) -> Option { - self.key_manager - .validator_ids() - .into_iter() - .find(|&vid| is_proposer(vid, slot, num_validators)) + let ChainDuties::Lean(lean) = &self.duties else { + return None; + }; + lean.our_proposer(slot, num_validators) } + /// Lean-only. fn produce_attestations(&mut self, slot: u64, is_aggregator: bool) { + let validator_ids = self.lean().key_manager.validator_ids(); + let _timing = metrics::time_attestations_production(); // Produce attestation data once for all validators let attestation_data = store::produce_attestation_data(&self.store, slot); // For each registered validator, produce and publish attestation - for validator_id in self.key_manager.validator_ids() { + for validator_id in validator_ids { // Sign the attestation let Ok(signature) = self + .lean_mut() .key_manager .sign_attestation(validator_id, &attestation_data) .inspect_err( @@ -790,13 +1511,18 @@ impl BlockChainServer { /// common case under load) we publish at once. The whole proposal is /// self-contained here, so it never depends on the interval-0 tick — which /// `handle_tick` skips whenever this build overruns its interval. + /// + /// Lean-only: a beacon follower has no validator duties, so it never + /// proposes. async fn propose_block(&mut self, slot: u64, validator_id: u64) { info!(%slot, %validator_id, "We are the proposer for this slot"); - let time_config = *self.store.config(); + let time_config = self.store.config().time_grid(); let slot_start_ms = time_config.genesis_time_ms() + SlotInterval::BlockPublication.to_ms_since_genesis(slot, &time_config); + let proposer_config = self.lean().proposer_config; + // Build the block. `produce_block_with_signatures` advances the store to // this slot's interval 0 (accepting attestations) before building — one // interval ahead of the interval-4 tick we are running in — so the block @@ -818,7 +1544,7 @@ impl BlockChainServer { &mut self.store, slot, validator_id, - self.proposer_config, + proposer_config, ) .inspect_err(|err| error!(%slot, %validator_id, %err, "Failed to build block")); @@ -838,7 +1564,7 @@ impl BlockChainServer { coverage::emit_proposal_coverage( &self.store, - self.attestation_committee_count, + self.lean().attestation_committee_count, block.body.attestations.iter(), ); @@ -848,7 +1574,7 @@ impl BlockChainServer { let head_state = self.store.head_state(); let Ok(signed_block) = block_builder::seal_block( &head_state, - &mut self.key_manager, + &mut self.lean_mut().key_manager, block, single_message_aggregates, ) @@ -873,18 +1599,28 @@ impl BlockChainServer { tokio::time::sleep(Duration::from_millis(wait_ms)).await; } - self.process_and_publish_block(slot, validator_id, signed_block); + self.process_and_publish_block(slot, validator_id, signed_block) + .await; } /// Import a freshly built block locally, then publish it to gossip. On /// import failure, logs and counts it, and returns without publishing. - fn process_and_publish_block( + /// Lean-only: the block this builds and imports is always a lean + /// [`SignedBlock`]. + async fn process_and_publish_block( &mut self, slot: u64, validator_id: u64, signed_block: SignedBlock, ) { - if let Err(err) = self.process_block(signed_block.clone()) { + let block_root = signed_block.message.hash_tree_root(); + let attestations = signed_block.message.body.attestations.len(); + let timings = ImportTimings::starting_now(); + let (timings, outcome) = self + .process_block(SignedBeaconBlock::Lean(signed_block.clone()), timings) + .await; + self.log_import_report(slot, block_root, attestations, &outcome, timings); + if let Err(err) = outcome { error!(%slot, %validator_id, %err, "Failed to process built block"); metrics::inc_block_building_failures(); return; @@ -901,19 +1637,236 @@ impl BlockChainServer { info!(%slot, %validator_id, "Published block"); } - /// Run block import, emit the resulting chain events, and refresh metrics. - fn process_block(&mut self, signed_block: SignedBlock) -> Result<(), StoreError> { - // `on_block` returns Ok early for an already-imported block, so gate - // the `block` event on whether this root is actually new. - let slot = signed_block.message.slot; - let block_root = signed_block.message.hash_tree_root(); + /// Run block import, emit the resulting chain events, and refresh + /// metrics. Chain-generic: `signed_block`'s own variant selects which + /// chain's import call runs. + async fn process_block( + &mut self, + signed_block: SignedBeaconBlock, + mut timings: ImportTimings, + ) -> (ImportTimings, Result) { + // Gate the `block` event on whether this root is actually new, so a + // re-delivery does not announce the same block twice. + // + // Only lean's `store::on_block` returns early for an already-imported + // block. Beacon's `fork_choice::on_block` does not: it goes from + // cloning the parent state straight into `state_transition`, so a + // known root would pay the whole import again. `process_or_pend_block` + // is what keeps that from happening, by skipping a beacon block whose + // post-state the store already holds before ever reaching here. + let slot = signed_block.slot(); + let block_root = signed_block.message_hash_tree_root(); let is_new = !self .store .has_state(&block_root) .expect("DB read should succeed"); let pre_import = ChainEventSnapshot::capture(&self.store); - store::on_block(&mut self.store, signed_block)?; + let outcome = match signed_block { + SignedBeaconBlock::Lean(lean_block) => { + match store::on_block(&mut self.store, lean_block) { + Ok(store_timings) => { + timings.absorb_store(store_timings); + ImportOutcome::Imported + } + Err(err) => return (timings, Err(err.into())), + } + } + // Already imported: skip the whole transition rather than redo + // it. Lean's `store::on_block` makes exactly this `has_state` + // check itself and returns `Ok` early; beacon's `on_block` has no + // such guard, so without this a re-delivered block pays a full + // state transition (two whole-state merkleizations) and a state + // write to reach the same store it already produced. Range sync + // and gossip overlap at the tip make that the common case, not a + // rare one. + _ if !is_new => ImportOutcome::Imported, + beacon_block => { + let config = self.store.config(); + // Cloned out before `fork_choice::on_block` below takes + // `&mut self.store`: an owned `Arc` handle, rather than a + // borrow through `self.store.committee_cache()`, is what lets + // this call also pass `&mut self.store` in the same + // expression, since the two would otherwise both borrow + // `self.store` at once. + let committees = self.store.committee_cache(); + // Extracted before `beacon_block` moves into `fork_choice::on_block` + // below, which takes ownership of it. + let (attestations, slashings) = fork_choice::block_operations(&beacon_block); + + // Gate on this node's own custody columns before `on_block` + // ever runs `state_transition`: an unavailable block is not + // worth transitioning. `None` holds rather than rejects, + // since the columns may simply not have arrived yet; see + // `data_availability_for`'s own documentation for why a + // partial set must never reach `on_block` as evidence. + // + // Only inside the availability boundary, though: below it no + // peer is obliged to answer for a column at all, so gating + // there would hold a block against data the network has + // legitimately forgotten. See `da_check_required_for_slot`. + let current_slot = fork_choice::get_current_slot(&self.store, &config); + let within_da_window = + da_check_required_for_slot(beacon_block.slot(), current_slot, &config); + let evidence = if within_da_window { + timings.da_check_start = Some(Instant::now()); + let evidence = + data_availability_for(&self.store, &beacon_block, &self.custody_columns); + timings.da_check_end = Some(Instant::now()); + match evidence { + Some(evidence) => evidence, + None => { + timings.columns_wait_start = + timings.columns_wait_start.or(timings.da_check_end); + self.hold_block_for_columns(beacon_block, current_slot, timings); + return (timings, Ok(ImportOutcome::Held)); + } + } + } else { + fork_choice::DataAvailability::NotRequired + }; + + // The engine round trip sits here, between the + // data-availability gate above and `fork_choice::on_block` + // below. That order is the specification's: + // `is_data_available` runs before `state_transition`, so a + // block about to be held for its custody columns is never one + // this node asks an execution client about. + timings.engine_start = Some(Instant::now()); + let validity = match &self.engine { + None => fork_choice::PayloadValidity::NotRequired, + Some(client) => match beacon_engine::ask(client, &beacon_block).await { + Ok(None) => fork_choice::PayloadValidity::NotRequired, + Ok(Some(validity)) => validity, + // No answer after the whole ladder. + // `optimistic-sync.md`: a consensus engine MUST NOT + // import the block and MUST NOT apply it to the fork + // choice store. Returning `Held` leaves the block out + // of the store without marking it imported, so the + // cascade does not treat it as having produced a + // post-state. + Err(err) => { + warn!( + %slot, + block_root = %ShortRoot(&block_root.0), + %err, + "No verdict from the execution client; not importing" + ); + metrics::inc_engine_no_verdict(); + timings.engine_end = Some(Instant::now()); + return (timings, Ok(ImportOutcome::Held)); + } + }, + }; + timings.engine_end = Some(Instant::now()); + + // An optimistic import is only permitted for a block that + // qualifies. A block that does not, and got a NOT_VALIDATED + // answer, is not imported at all. + if matches!(validity, fork_choice::PayloadValidity::Optimistic) { + let current_slot = self.wall_clock_slot(); + let parent_root = beacon_block.parent_root(); + if !fork_choice::is_optimistic_candidate_block( + &self.store, + current_slot, + slot, + parent_root, + self.safe_slots_to_import_optimistically, + ) { + warn!( + %slot, + block_root = %ShortRoot(&block_root.0), + "Not importing: the execution client has not validated this \ + block and it is not an optimistic candidate" + ); + metrics::inc_engine_not_optimistic_candidate(); + return (timings, Ok(ImportOutcome::Held)); + } + } + + timings.stf_start = Some(Instant::now()); + let imported = fork_choice::on_block( + &mut self.store, + beacon_block, + &config, + &evidence, + &validity, + &committees, + ); + timings.stf_end = Some(Instant::now()); + if let Err(err) = imported { + return (timings, Err(err.into())); + } + + // The block is already in the store whatever the rest of this + // arm does with its body, so nothing below may turn into an + // `Err` that fails the import: that would make this function's + // caller (`run_import_cascade`) stop the pending-block cascade, + // leaving every held descendant stuck behind a block that in + // fact did import. + // + // `get_state(&block_root)` is a direct hit rather than a + // `fork_choice::block_state`-style lookup: `on_block` just + // wrote this block's post-state under `block_root`, and the + // committee source for a block's own attestations is that + // post-state, not each attestation's target checkpoint state. + // See `fork_choice::on_block_attestation`'s documentation for + // why the two name the same committees, and for what asking + // the checkpoint instead costs. + timings.block_atts_start = Some(Instant::now()); + let block_state = self + .store + .get_state(&block_root) + .expect("DB read should succeed"); + match block_state { + Some(block_state) => { + // One `Table::LiveChain` scan for the whole body, not + // one per attestation: the table carries a row per + // block this node imported and is never pruned on + // beacon, so the scan is the expensive part and every + // attestation in the block asks the same question of + // it. + let index = self.store.block_index(); + for attestation in &attestations { + let _ = fork_choice::on_block_attestation( + &mut self.store, + attestation, + &block_state, + &config, + &index, + &committees, + ) + .inspect_err(|err| { + trace!(%slot, ?err, "Ignoring an unusable attestation from a block") + }); + } + } + // A checkpoint-synced follower hits this legitimately for + // the first epochs after its anchor: an attestation may + // name a target up to `SLOTS_PER_EPOCH` slots back, and a + // target below the anchor was never fetched, so + // `validate_on_attestation`'s "target.root in store.blocks" + // check rejects it. Expected, not a defect, so this warns + // once and skips the body rather than failing the import. + None => { + warn!( + %slot, + block_root = %ShortRoot(&block_root.0), + "Skipping a block's attestations: its own post-state is unreachable" + ); + } + } + for slashing in &slashings { + let _ = fork_choice::on_attester_slashing(&mut self.store, slashing) + .inspect_err( + |err| trace!(%slot, ?err, "Ignoring an unusable slashing from a block"), + ); + } + timings.block_atts_end = Some(Instant::now()); + + ImportOutcome::Imported + } + }; // `block` goes out first so subscribers see it ahead of the // justified/head/finalized moves its import triggers. @@ -924,65 +1877,712 @@ impl BlockChainServer { }); } // Block import has no ready-made "now" slot like `on_tick`'s, so - // compute the wall-clock slot fresh for the head-recency gate. - let time_config = *self.store.config(); - let wall_clock_slot = unix_now_ms().saturating_sub(time_config.genesis_time_ms()) - / time_config.milliseconds_per_slot; - pre_import.diff_and_emit(&self.store, &self.events, wall_clock_slot); + // read the wall-clock slot fresh for the head-recency gate. + pre_import.diff_and_emit(&self.store, &self.events, self.wall_clock_slot()); - metrics::update_head_slot(self.store.head_slot()); - let latest_justified_slot = self - .store - .latest_justified() - .expect("Error: Latest justified checkpoint does not exist") - .slot; - metrics::update_latest_justified_slot(latest_justified_slot); - let latest_finalized_slot = self - .store - .latest_finalized() - .expect("Error: Latest finalized checkpoint does not exist") - .slot; - metrics::update_latest_finalized_slot(latest_finalized_slot); - metrics::update_validators_count(self.key_manager.validator_ids().len() as u64); + // A genuine import is one of the two places (with `on_tick`) beacon + // finality can move, and finality is the only thing that evicts a + // held block nobody redelivers; see the method's own documentation. + self.evict_held_blocks_at_or_below_finality(); + self.evict_sidecars_awaiting_parent_at_or_below_finality(); + + self.refresh_chain_metrics(); + + // Lean-only: a beacon follower tracks no validator keys of its own. + if let ChainDuties::Lean(lean) = &self.duties { + metrics::update_validators_count(lean.key_manager.validator_ids().len() as u64); + } for table in ALL_TABLES { metrics::update_table_bytes(table.name(), self.store.estimate_table_bytes(table)); } - Ok(()) + // Everything since the import call returned is bookkeeping it caused: + // the events, the finality sweep and the gauge refresh. It is charged + // to the section it follows rather than to sections of its own. + timings.absorb_tail(Instant::now()); + (timings, Ok(outcome)) + } + + /// Build an unspawned server for offline replay. + /// + /// No mailbox, no tick loop, no p2p: the caller drives every import + /// itself with [`Self::import_block`], on its own task. Every field is + /// what `start_actor` would give it, with three deliberate differences: + /// `p2p` is `None` (every use of it is guarded, so nothing here needs a + /// stub), the custody set is empty (a corpus supplies every block, so + /// there are no columns to wait on; see `data_availability_for`), and no + /// first tick is armed. + /// + /// The store clock is not placed here. Fork choice rejects a block from + /// the future, and a beacon store's clock starts at zero, so the caller + /// sets it per block through its own `Store` clone: the clock is a row in + /// the shared backend's metadata, not per-handle state. + pub fn for_replay( + store: Store, + engine: Option, + safe_slots_to_import_optimistically: u64, + ) -> Self { + assert_eq!( + store.chain(), + Chain::Beacon, + "BlockChainServer::for_replay requires a beacon store" + ); + + Self { + store, + p2p: None, + pending_blocks: HashMap::new(), + pending_block_parents: HashMap::new(), + blocks_awaiting_columns: HashMap::new(), + held_timings: HashMap::new(), + sidecars_awaiting_parent: HashMap::new(), + custody_columns: Vec::new(), + engine, + safe_slots_to_import_optimistically, + last_tick_instant: None, + sync_status: SyncStatusTracker::new(false), + sync_status_controller: SyncStatusController::default(), + events: EventBus::default(), + duties: ChainDuties::Beacon, + beacon_aggregates: Default::default(), + } + } + + /// Import one block on the caller's task. + /// + /// [`ImportOutcome::Imported`] means a post-state now exists under the + /// block's root; `Held` means none does; `None` means the block was + /// rejected. That return value is the completion signal, so no caller + /// needs to subscribe to the event bus and time out. + /// + /// Timed with [`ImportTimings::starting_now`], which is what that + /// constructor documents for a block entering from storage rather than + /// the wire, and tagged [`BlockSource::Replay`]. The tag is what gets the + /// sections published at all (a sourceless import publishes nothing, and + /// the replay harness reads its phases back from the histogram), and it + /// keeps them under a label of their own: a replayed block crossed no + /// wire, so its zero decode section folded into `gossip` or `sync` would + /// understate either one. + pub async fn import_block(&mut self, block: SignedBeaconBlock) -> Option { + let timings = ImportTimings { + source: Some(BlockSource::Replay), + ..ImportTimings::starting_now() + }; + self.on_block(block, timings).await } - /// Process a newly received block. - fn on_block(&mut self, signed_block: SignedBlock) { + /// Process a newly received block, whichever chain it belongs to. + /// + /// For beacon this is also where fork choice is re-run and the two clock + /// gauges are republished, because an import records no head of its own + /// (see [`Self::recompute_beacon_head`]) and the tick that used to be the + /// sole writer of both is starved by the very imports whose progress they + /// are meant to report. Once per arrival rather than once per block in the + /// cascade, matching what `Handler` already does with the store + /// clock: a cascade's blocks are all processed at one instant. + /// + /// Returns what became of `signed_block` itself, for the one caller that + /// has to know: see [`Self::release_block_if_columns_complete`]. `None` + /// means it never reached [`Self::process_block`], so some other structure + /// is now responsible for it (a pending-parent entry, a fresh column hold) + /// or it was deliberately discarded. + async fn on_block( + &mut self, + signed_block: SignedBeaconBlock, + timings: ImportTimings, + ) -> Option { + let mut cascade = CascadeTimings { + source: timings.source, + ..CascadeTimings::starting_now() + }; let mut queue = VecDeque::new(); - queue.push_back(signed_block); + queue.push_back((signed_block, timings)); + let (outcome, blocks) = self.run_import_cascade(queue, &mut cascade).await; + + if self.store.chain() == Chain::Beacon { + // `lean_current_slot` had the same single writer the head did, so + // both gauges went stale together on a catching-up follower and + // `lean_current_slot - lean_head_slot` was a difference between + // two stale numbers rather than the head lag every panel and + // alert reads it as. Published from the wall clock rather than + // the store clock, which only `on_tick` and an early arrival + // advance. + metrics::update_current_slot(self.wall_clock_slot()); + let head = self.recompute_beacon_head().await; + cascade.absorb_head(head); + } + + cascade.observe(blocks); + cascade.log(blocks); + outcome + } - // A new block can trigger a cascade of pending blocks becoming processable. - // Here we process blocks iteratively, to avoid recursive calls that could - // cause a stack overflow. - while let Some(block) = queue.pop_front() { - self.process_or_pend_block(block, &mut queue); + /// Drain `queue`, importing each block and enqueuing any pending children + /// its import unblocks, iteratively rather than recursively so a long + /// chain of arrivals cannot overflow the stack. + /// + /// Reports on the block the cascade was handed, and only that one: a + /// caller re-delivering a block asks about that block, while the children + /// its import unblocks are the cascade's own business and each have their + /// own tracking already. The count beside it is every block the cascade + /// imported, which is what the timing tree reports. + async fn run_import_cascade( + &mut self, + mut queue: VecDeque<(SignedBeaconBlock, ImportTimings)>, + cascade: &mut CascadeTimings, + ) -> (Option, usize) { + let mut first = None; + let mut is_first = true; + let mut blocks = 0; + while let Some((block, mut timings)) = queue.pop_front() { + // A block released by its parent's import waited here, behind + // whatever siblings were ahead of it in the same cascade. + if timings.cascade_wait_start.is_some() { + timings.cascade_wait_end = Some(Instant::now()); + } + blocks += 1; + let outcome = self.process_or_pend_block(block, timings, &mut queue).await; + if is_first { + first = outcome; + is_first = false; + } } + cascade.cascade_end = Some(Instant::now()); // Prune old states and blocks AFTER the entire cascade completes. // Running this mid-cascade would delete states that pending children // still need, causing re-processing loops when fallback pruning is active. - self.store - .prune_old_data() - .expect("DB pruning should succeed"); + // + // Lean-only: `prune_old_data` prunes `BlockProof`, a table beacon + // never writes, and deciding whether there is anything to prune costs + // a whole-block decode (`Store::get_block_header`) on a beacon + // directory. + if self.store.chain() == Chain::Lean { + cascade.prune_start = Some(Instant::now()); + self.store + .prune_old_data() + .expect("DB pruning should succeed"); + cascade.prune_end = Some(Instant::now()); + } + + (first, blocks) + } + + /// Re-deliver `block` to this actor once its own slot has started. + /// + /// Beacon's `on_block` requires a block's slot to already be in the past; + /// the specification says an early block's consideration "must be delayed + /// until they are in the past", not that the block should be dropped. A + /// single dropped early block wedged a live follower permanently on + /// 2026-09-03: every later block became an orphan of a root the node + /// would never obtain. + /// + /// The block rides in the message rather than through the DB, so the hold + /// costs one timer and leaves nothing behind to reconcile if this process + /// stops before the slot arrives. [`MAXIMUM_GOSSIP_CLOCK_DISPARITY`] is + /// what bounds how many such holds one peer can buy, and how long each + /// one lasts. + /// + /// Re-delivery goes through `NewBlock`, the same message the p2p layer + /// uses, tagged [`BlockSource::Deferred`] so that the arrival bookkeeping + /// its handler does for a genuine arrival is skipped for a block that + /// already arrived once. + /// + /// Beacon-only: lean absorbs an early block with a margin instead of a + /// wait, since `store::on_block` admits any block up to a whole slot + /// ahead. Beacon cannot copy the margin, as its own `on_block` carries + /// the specification's assertion and the fork-choice fixture suite tests + /// it. + fn defer_early_block( + &self, + block: SignedBeaconBlock, + timings: ImportTimings, + ctx: &Context, + ) { + let delay = Duration::from_millis(self.ms_until_slot_start(block.slot())); + let now = Instant::now(); + // The re-delivery carries the block's original arrival rather than a + // fresh one, so the hold shows up as the `defer` row of one report + // instead of vanishing between two. + let redelivery = NewBlock { + block, + source: BlockSource::Deferred, + arrival: BlockArrival { + decode_start: timings.decode_start, + // The original hand-off, so the first mailbox wait stays the + // `queue` row and the hold becomes `defer` rather than more + // queue. + handed_off: timings.queue_start.unwrap_or(now), + // The source travels with the hold so the re-delivery reports + // under it rather than under `Deferred`. + deferred_from: Some(DeferredFrom { + at: now, + source: timings.source.unwrap_or(BlockSource::Gossip), + }), + }, + }; + send_after(delay, ctx.clone(), redelivery); + } + + /// Print one block's import tree. + /// + /// The single consumer of [`ImportTimings`], and therefore the only place + /// any of them is subtracted from another. `outcome` is borrowed rather + /// than taken because the caller still has to act on it. + fn log_import_report( + &self, + slot: u64, + block_root: H256, + attestations: usize, + outcome: &Result, + timings: ImportTimings, + ) { + let report = BlockImportReport { + slot, + block_root, + attestations, + outcome: match outcome { + Ok(ImportOutcome::Imported) => "imported", + Ok(ImportOutcome::Held) => "held", + Err(_) => "failed", + }, + slot_offset_ms: Some(self.ms_into_slot(slot)), + timings, + }; + report.observe(); + report.log(); + } + + /// Milliseconds since `slot` started on the wall clock, negative while it + /// has not. + /// + /// The deadline number: an import that finishes past the interval its + /// chain expects attestations at was late whatever its sections say. + fn ms_into_slot(&self, slot: u64) -> i64 { + let time_config = self.store.config().time_grid(); + let slot_start_ms = time_config + .genesis_time_ms() + .saturating_add(SlotInterval::BlockPublication.to_ms_since_genesis(slot, &time_config)); + unix_now_ms() as i64 - slot_start_ms as i64 + } + + /// Milliseconds from now until `slot` starts on the wall clock, zero once + /// it has. + /// + /// Goes through the `time_grid()` [`ChainConfig`], the same grid the tick + /// cadence and `propose_block`'s own slot-start read use, so "the slot has + /// started" means the same thing to a held block as it does to the tick + /// that will import it. + fn ms_until_slot_start(&self, slot: u64) -> u64 { + let time_config = self.store.config().time_grid(); + let slot_start_ms = time_config + .genesis_time_ms() + .saturating_add(SlotInterval::BlockPublication.to_ms_since_genesis(slot, &time_config)); + slot_start_ms.saturating_sub(unix_now_ms()) + } + + /// The slot the wall clock is in right now. + /// + /// Distinct from the store clock, which advances only when something + /// advances it (`on_tick`, or an arrival whose slot has already started), + /// and from `on_tick`'s own `slot`, which is derived from the timestamp + /// that tick was scheduled for. The block path has neither, so anything + /// there that needs "now" reads it here. + /// + /// One grid for both chains: `slot_duration_ms` is authoritative on + /// either, since `Config::lean` takes it from the network config file + /// rather than leaving it a placeholder beside a compile-time constant. + fn wall_clock_slot(&self) -> u64 { + let time_config = self.store.config().time_grid(); + unix_now_ms().saturating_sub(time_config.genesis_time_ms()) + / time_config.milliseconds_per_slot + } + + /// Re-run beacon fork choice and republish the head gauge. + /// + /// `fork_choice::on_block` does not compute a head, and `Store::beacon_head` + /// only reads back whatever the last [`fork_choice::get_head`] recorded, so + /// without this an import moves no head at all: it just adds a block and a + /// post-state. Until the block path called this too, [`Self::on_tick`] was + /// the only caller, and it is one message per slot in the same mailbox as + /// every arriving block. A follower catching up imports back-to-back and + /// never drains that mailbox, so the tick did not run, the head stayed + /// pinned at the checkpoint-sync anchor, and `lean_head_slot` sat flat for + /// the entire catch-up even while imports were landing every few seconds. + /// That is what "the head is not advancing" looked like on the dashboard. + /// + /// A failure here means fork choice could not find a head (for instance + /// every known block is unjustifiable), which is a condition to log and + /// wait out, not a reason to crash a follower. + async fn recompute_beacon_head(&mut self) -> HeadTimings { + let mut timings = self.update_head_from_fork_choice(); + if let Some((start, end)) = self.notify_forkchoice_updated().await { + timings.fcu_start = Some(start); + timings.fcu_end = Some(end); + } + timings + } + + /// Apply an aggregate `ethlambda-p2p`'s gossip validation already + /// accepted, or hold it until its own slot has passed. + /// + /// The applied-bits gate runs first, before anything else: a valid + /// aggregate whose votes are already covered is the common case on this + /// topic, since a committee's sixteen aggregators mostly converge on the + /// same bits, and dropping one here costs a hash and two lookups instead + /// of another `apply_verified_aggregate` call. + /// + /// The hold is not an optimization. `validate_on_attestation` requires + /// `get_current_slot(store) >= data.slot + 1`, and aggregates are + /// published two thirds of the way through the slot they vote for, so + /// every one of them arrives too early. Applying only what is already late + /// would be applying almost nothing. + fn on_gossip_beacon_aggregate( + &mut self, + aggregate: Box, + attesting_indices: Vec, + arrival: AggregateArrival, + ) { + if let Some(dropped) = self.beacon_aggregates.already_covered(&aggregate) { + metrics::inc_beacon_aggregate_outcome(dropped.label()); + return; + } + + let config = self.store.config(); + let current_slot = fork_choice::get_current_slot(&self.store, &config); + if current_slot < aggregate.slot().saturating_add(1) { + if let Some(dropped) = self.beacon_aggregates.defer(aggregate, attesting_indices) { + metrics::inc_beacon_aggregate_outcome(dropped.label()); + } + metrics::update_beacon_aggregates_deferred(self.beacon_aggregates.deferred_len()); + return; + } + + let index = self.store.block_index(); + self.apply_beacon_aggregate(&aggregate, &attesting_indices, &index, Some(arrival)); + } + + /// Apply one already-verified aggregate to fork choice and record what + /// became of it. + /// + /// The applied-bits gate is written here, on success only: an aggregate + /// [`fork_choice::apply_verified_aggregate`] refused (a target this node + /// has since finalized past, say) must not be able to mark its bits + /// covered, or a forged claim of coverage would suppress a later, + /// applicable aggregate for the same committee. + /// + /// `index` is [`ethlambda_storage::Store::block_index`], taken as a + /// parameter so [`Self::drain_deferred_aggregates`] builds it once for the + /// whole drain rather than once per aggregate; see that function's own + /// documentation. + /// + /// `arrival` is `Some` only on the path that applies an aggregate as it + /// arrives. A drained one waits a deliberate slot for its own slot to + /// pass, so reporting its end-to-end time would report that design as + /// latency. + fn apply_beacon_aggregate( + &mut self, + aggregate: &SignedAggregateAndProof, + attesting_indices: &[ValidatorIndex], + index: &HashMap, + arrival: Option, + ) { + let started = Instant::now(); + let config = self.store.config(); + let outcome = fork_choice::apply_verified_aggregate( + &mut self.store, + aggregate.data(), + attesting_indices, + &config, + index, + ); + metrics::observe_beacon_aggregate_processing(started.elapsed()); + if let Some(arrival) = arrival { + metrics::observe_beacon_aggregate_end_to_end(arrival.decode_start.elapsed()); + } + + match outcome { + Ok(()) => { + self.beacon_aggregates.record(aggregate); + metrics::inc_beacon_aggregate_outcome("applied"); + } + // Expected in normal operation rather than a defect: a + // checkpoint-synced follower sees aggregates naming targets below + // its anchor, and any peer may send one for a block this node has + // not imported yet. + Err(err) => { + trace!( + slot = aggregate.slot(), + aggregator = aggregate.aggregator_index(), + ?err, + "Ignoring an unusable gossip aggregate" + ); + metrics::inc_beacon_aggregate_outcome("invalid"); + } + } + } + + /// Apply every held aggregate whose slot has now passed, and prune what + /// the clock and finality have put out of reach. + /// + /// Called once per beacon tick, between the store clock advancing and the + /// head being recomputed, so the votes released here are in fork choice + /// before the head this tick reports is chosen. + /// + /// Builds [`ethlambda_storage::Store::block_index`] once for the whole + /// drain: it is a full `Table::LiveChain` scan, and paying for it once per + /// aggregate here would undo the reason `on_block`'s own attestations + /// already share one. Skipped entirely when nothing is ready, since most + /// beacon ticks find no deferred aggregate and a scan has nothing to serve. + fn drain_deferred_aggregates(&mut self) { + let config = self.store.config(); + let current_slot = fork_choice::get_current_slot(&self.store, &config); + let current_epoch = fork_choice::get_current_store_epoch(&self.store, &config); + + let finalized_slot = self + .store + .latest_finalized() + .expect("finalized checkpoint exists") + .slot; + self.beacon_aggregates.prune(current_epoch, finalized_slot); + + let ready = self.beacon_aggregates.take_ready(current_slot); + if !ready.is_empty() { + let index = self.store.block_index(); + for entry in ready { + // Re-checked rather than trusted from when it was held: an + // aggregate applied in the meantime may already cover this one, and + // that is the whole point of the gate. + if let Some(dropped) = self.beacon_aggregates.already_covered(&entry.aggregate) { + metrics::inc_beacon_aggregate_outcome(dropped.label()); + continue; + } + self.apply_beacon_aggregate( + &entry.aggregate, + &entry.attesting_indices, + &index, + None, + ); + } + } + + metrics::update_beacon_aggregates_deferred(self.beacon_aggregates.deferred_len()); + } + + /// Re-run beacon fork choice, write the head it finds, and republish the + /// gauge, without telling the execution client about it. + /// + /// The half of [`Self::recompute_beacon_head`] that touches only this + /// node's own store. Split out because [`Self::apply_forkchoice_verdict`] + /// runs *inside* `forkchoiceUpdated`'s own response handling and must not + /// re-enter the call it is answering; the execution client hears about the + /// new head on the next cascade or tick, the same cadence every other head + /// move is announced on. + /// + /// A failure here means fork choice could not find a head (for instance + /// every known block is unjustifiable), which is a condition to log and + /// wait out, not a reason to crash a follower. + fn update_head_from_fork_choice(&mut self) -> HeadTimings { + let _timing = metrics::time_beacon_head_compute(); + let mut timings = HeadTimings { + head_start: Some(Instant::now()), + ..HeadTimings::default() + }; + let config = self.store.config(); + if let Err(err) = fork_choice::get_head(&mut self.store, &config) { + // An invalidated justified checkpoint reaches here: + // `filter_block_tree` fails its `block_root in store.blocks` assert + // once the justified root's row is gone. `optimistic-sync.md` + // sanctions alerting and refusing rather than degrading, which is + // what this does: the head simply does not move. + warn!(%err, "Failed to compute beacon head"); + timings.head_end = Some(Instant::now()); + return timings; + } + timings.head_end = Some(Instant::now()); + if let Some((head_slot, _)) = self.store.beacon_head() { + metrics::update_head_slot(head_slot); + } + self.pin_head_shufflings(); + timings + } + + /// Point the committee cache's eviction at the head fork choice just + /// recorded, so the shufflings that head's children will ask for are + /// never the ones a full cache drops; see `CommitteeCache::update_head`. + /// + /// Reads the head's post-state from the store's state cache only, never + /// reconstructing it: pinning only steers which entry a full cache evicts, + /// and the head is almost always a block this node just imported, whose + /// post-state `insert_state` left resident. A head whose state is no + /// longer cached (a reorg back to an old block) keeps the previous head's + /// pinning until a later head is resident, which at worst lets one of its + /// shufflings be evicted and rebuilt, never serves a wrong committee. + fn pin_head_shufflings(&mut self) { + let Some((_, head_root)) = self.store.beacon_head() else { + return; + }; + let committees = self.store.committee_cache(); + if committees.head_root() == Some(head_root) { + return; + } + if let Some(head_state) = self.store.cached_state(CacheKey::BlockState(head_root)) { + committees.update_head(head_root, &head_state); + } + } + + /// Tell the execution client where the chain's head, safe and finalized + /// blocks are. + /// + /// Sent once per cascade and once per tick, not once per block: the caller + /// already runs after the cascade has drained. Sent even when nothing moved, + /// because an execution client doing state sync needs to keep being fed a + /// recent head or its sync cannot converge. + /// + /// The response carries a `PayloadStatusV1` of its own, which is the channel + /// by which a block imported on `SYNCING` later becomes `VALID` or is found + /// to be `INVALID`. + async fn notify_forkchoice_updated(&mut self) -> Option<(Instant, Instant)> { + let client = self.engine.clone()?; + let (_head_slot, head_root) = self.store.beacon_head()?; + + let justified_root = self.store.beacon_justified_checkpoint().root; + let finalized_root = self.store.beacon_finalized_checkpoint().root; + + // `H256::ZERO` explicitly rather than `unwrap_or_default()`: the zero + // hash is a meaningful value here, not an absence. EIP-3675 requires + // `finalized_block_hash` to be zero before a post-transition block is + // finalized, and the specification's own + // `get_safe_execution_block_hash` returns zero when no payload is + // justified yet. + let state = ForkchoiceStateV1 { + head_block_hash: self + .store + .beacon_el_block_hash(head_root) + .unwrap_or(H256::ZERO), + safe_block_hash: self + .store + .beacon_el_block_hash(justified_root) + .unwrap_or(H256::ZERO), + finalized_block_hash: self + .store + .beacon_el_block_hash(finalized_root) + .unwrap_or(H256::ZERO), + }; + + // Nothing to say yet: a follower whose head has no cached payload hash + // is still on its checkpoint anchor. + if state.head_block_hash.is_zero() { + return None; + } + + // `head_root` stays valid across the await: this is a single-threaded + // actor, so no other message is handled until this one returns. An + // `Invalidated` verdict leaves `KEY_HEAD` and `Table::BlockRoots` + // naming roots whose `LiveChain` rows are gone, which is a stale store + // and not merely a stale gauge: the req/resp handlers advertise the + // head in `Status` and serve blocks out of `BlockRoots`. Which is why + // `apply_forkchoice_verdict` recomputes the head itself rather than + // leaving it to the next tick. + let start = Instant::now(); + match client.forkchoice_updated(&state).await { + Ok(status) => self.apply_forkchoice_verdict(head_root, &status), + Err(err) => warn!(%err, "forkchoiceUpdated failed"), + } + Some((start, Instant::now())) + } + + /// Apply a `forkchoiceUpdated` response to the optimistic bookkeeping. + /// + /// `VALID` clears the head and every optimistic ancestor; `INVALID` cuts the + /// condemned branch out of fork choice. `SYNCING` and `ACCEPTED` say the + /// execution client is still working and change nothing. + fn apply_forkchoice_verdict(&mut self, head_root: H256, status: &EnginePayloadStatus) { + match beacon_engine::verdict(status) { + fork_choice::PayloadValidity::Validated => { + fork_choice::mark_validated(&mut self.store, head_root); + } + fork_choice::PayloadValidity::Invalidated { latest_valid_hash } => { + let index = self.store.block_index(); + let parent_root = index + .get(&head_root) + .map(|(_slot, parent)| *parent) + .unwrap_or(H256::ZERO); + let condemned = fork_choice::resolve_invalid_block( + &self.store, + &index, + head_root, + parent_root, + latest_valid_hash, + ); + // Unlike the `newPayload` path, `head_root` *is* in the index + // here: this verdict is about a block already imported, which is + // why an invalidation reached through `forkchoiceUpdated` can + // remove the head itself rather than only its descendants. + let removed = fork_choice::invalidate_subtree(&mut self.store, condemned); + warn!( + condemned = %ShortRoot(&condemned.0), + removed, + "Execution client invalidated the head's branch" + ); + + // The rows are gone from fork choice, but `KEY_HEAD` and + // `Table::BlockRoots` still name them, and the p2p req/resp + // handlers read both: `Status` would advertise the + // invalidated root, and `BlocksByRange`/`BlocksByRoot` would + // serve the invalidated block to peers, until the next tick or + // cascade recomputed the head. Do it here instead, so no peer + // is handed a block this node has just refused. + // + // Head only, deliberately not `recompute_beacon_head`: this + // runs inside `forkchoiceUpdated`'s own response handling, and + // announcing the new head from here would re-enter the call + // being answered. + if removed > 0 { + self.update_head_from_fork_choice(); + } + } + fork_choice::PayloadValidity::Optimistic + | fork_choice::PayloadValidity::NotRequired => {} + } } /// Try to process a single block. If its parent state is missing, store it /// as pending. On success, collect any unblocked children into `queue` for /// the caller to process next (iteratively, avoiding deep recursion). - fn process_or_pend_block( + /// + /// `None` is every route that does not reach [`Self::process_block`]: a + /// block discarded as final or early, one parked on a missing parent, or + /// one whose import failed outright. What they have in common is that this + /// block is either already tracked somewhere else or deliberately gone, so + /// nothing upstream should put it back. `Some` is `process_block`'s own + /// verdict, which is the only case that distinguishes "imported" from + /// "dropped with nothing left holding it". + async fn process_or_pend_block( &mut self, - signed_block: SignedBlock, - queue: &mut VecDeque, - ) { - let slot = signed_block.message.slot; - let block_root = signed_block.message.hash_tree_root(); - let parent_root = signed_block.message.parent_root; - let proposer = signed_block.message.proposer_index; + signed_block: SignedBeaconBlock, + mut timings: ImportTimings, + queue: &mut VecDeque<(SignedBeaconBlock, ImportTimings)>, + ) -> Option { + let slot = signed_block.slot(); + let block_root = signed_block.message_hash_tree_root(); + let parent_root = signed_block.parent_root(); + let proposer = signed_block.proposer_index(); + timings.guards_start = Some(Instant::now()); + + // Asked before the parent check, so that an absent `columns_wait` row + // can be read two ways rather than one: the columns were never + // missing, or they landed while this block was held for its parent. + // Beacon-only, and only on the first pass, since a later pass would + // answer for a different moment than the one the field names. + if timings.da_complete_on_arrival.is_none() && !self.custody_columns.is_empty() { + timings.da_complete_on_arrival = Some(custody_columns_present( + &self.store, + slot, + &block_root, + &self.custody_columns, + )); + } // Never process blocks at or below the finalized slot — they are // already part of the canonical chain and cannot affect fork choice. @@ -995,28 +2595,56 @@ impl BlockChainServer { .slot; if slot <= latest_finalized_slot { self.discard_pending_subtree(block_root); - return; + return None; } - // Reject blocks whose slot has not started locally, mirroring the - // attestation time check in `validate_attestation_data`. The disparity - // bound is in intervals, not slots: a whole-slot margin would let an - // adversary pre-publish next-slot blocks ahead of any honest proposer. - // Catching this early also avoids persisting bogus future blocks to - // RocksDB and triggering BlocksByRoot fan-out for fabricated parents. - let block_start_interval = slot.saturating_mul(INTERVALS_PER_SLOT); - let store_time = self.store.time().expect("store time exists"); - if block_start_interval > store_time + GOSSIP_DISPARITY_INTERVALS { - warn!( + // Beacon: a block whose post-state is already here needs no work. + // `fork_choice::on_block` does not short-circuit on a known root: it + // goes straight from cloning the parent state to `state_transition`, + // so a re-delivery pays the entire import a second time. On mainnet + // 2026-09-08 that was 37 of 116 imports, a third of the actor's import + // budget, spent recomputing post-states the store already held. + // Children are still collected: this root did import, so anything + // pending on it is ready whether or not this delivery is the one that + // imported it. Beacon-only, because lean's `store::on_block` has its + // own already-imported early return. + if self.store.chain() == Chain::Beacon + && self + .store + .has_state(&block_root) + .expect("DB read should succeed") + { + debug!( %slot, - store_time, - proposer, block_root = %ShortRoot(&block_root.0), - parent_root = %ShortRoot(&parent_root.0), - "Rejecting block: slot is too far in future" + "Skipping a beacon block already in the store" ); - self.discard_pending_subtree(block_root); - return; + self.collect_pending_children(block_root, queue); + return Some(ImportOutcome::Imported); + } + + // Lean rejects a block for a slot that has not started outright, + // mirroring the attestation time check in `validate_attestation_data` + // with the same whole-slot-margin reasoning: a wider bound would let + // an adversary pre-publish next-slot blocks ahead of any honest + // proposer. Beacon holds one instead, and does it at arrival rather + // than here (see `Handler`), so nothing reaching this point + // on that chain is still early. + if self.store.chain() == Chain::Lean { + let block_start_interval = slot.saturating_mul(INTERVALS_PER_SLOT); + let store_time = self.store.intervals_since_genesis(); + if block_start_interval > store_time + GOSSIP_DISPARITY_INTERVALS { + warn!( + %slot, + store_time, + proposer, + block_root = %ShortRoot(&block_root.0), + parent_root = %ShortRoot(&parent_root.0), + "Rejecting block: slot is too far in future" + ); + self.discard_pending_subtree(block_root); + return None; + } } // Check if parent state exists before attempting to process @@ -1026,6 +2654,9 @@ impl BlockChainServer { .expect("DB read should succeed") { info!(%slot, %parent_root, %block_root, "Block parent missing, storing as pending"); + timings.guards_end = Some(Instant::now()); + timings.parent_wait_start = timings.parent_wait_start.or(timings.guards_end); + self.held_timings.insert(block_root, timings); // Resolve the actual missing ancestor by walking the chain. A stale entry // can occur when a cached ancestor was itself received and became pending @@ -1051,49 +2682,88 @@ impl BlockChainServer { // Walk up through DB: if missing_root is already stored from a previous // session, the actual missing block is further up the chain. // Note: this loop always terminates — blocks reference parents by hash, - // so a cycle would require a hash collision. - while let Some(header) = self - .store - .get_block_header(&missing_root) - .expect("DB read should succeed") - { + // so a cycle would require a hash collision. `block_entry` reads just + // the two fields this walk needs and decodes per chain, unlike + // `get_block_header`, which is lean-only. + while let Some((_, ancestor_parent_root)) = self.store.block_entry(&missing_root) { if self .store - .has_state(&header.parent_root) + .has_state(&ancestor_parent_root) .expect("DB read should succeed") { + // Held for its custody columns: its parent has a state + // because it already reached the availability gate, and + // re-importing it would only hold it again. Its release + // (`release_block_if_columns_complete`) is what imports it + // and cascades to this block through `pending_blocks`. + // Without this, every child of a held block re-ran its + // import: on a mainnet follower's first range batch after + // a checkpoint sync, the 68 blocks behind the first one + // re-held it 68 times over 54 s while its columns were + // already on their way. + if self.blocks_awaiting_columns.contains_key(&missing_root) { + return None; + } // Parent state available — enqueue for processing, cascade // handles the rest via the outer loop. - let block = self + let fetched = self .store .get_signed_block(&missing_root) .expect("header and parent state exist, so the full signed block must too") .unwrap(); - queue.push_back(block); - return; + // A block taken back out of storage has no arrival to + // report, so its clock starts at the moment it was taken. + let mut fetched_timings = + self.held_timings.remove(&missing_root).unwrap_or_else(|| { + let mut fresh = ImportTimings::starting_now(); + fresh.source = timings.source; + fresh + }); + fetched_timings.parent_wait_end = Some(Instant::now()); + queue.push_back((fetched, fetched_timings)); + return None; } // Block exists but parent doesn't have state — register as pending // so the cascade works when the true ancestor arrives self.pending_blocks - .entry(header.parent_root) + .entry(ancestor_parent_root) .or_default() .insert(missing_root); self.pending_block_parents - .insert(missing_root, header.parent_root); - missing_root = header.parent_root; + .insert(missing_root, ancestor_parent_root); + missing_root = ancestor_parent_root; } // Request the actual missing block from network self.request_missing_block(missing_root); - return; + return None; } // Parent exists, proceed with processing. Clone the block so we // can run post-import reaggregation against its merged proof — // `process_block` consumes the original for the storage layer. - let block_for_reaggregate = signed_block.clone(); - match self.process_block(signed_block) { - Ok(()) => { + // + // Only when that pass will actually run: a beacon block carries no + // lean attestations to reaggregate, and a backfilling node discards + // the result rather than spamming gossip with it, so cloning either + // one would be a whole block body copied and dropped unread. Beacon + // bodies carry an execution payload, which makes that the largest + // single allocation on the import path. + let block_for_reaggregate = match &signed_block { + SignedBeaconBlock::Lean(lean_block) if self.sync_status.duties_allowed() => { + Some(lean_block.clone()) + } + _ => None, + }; + timings.guards_end = Some(Instant::now()); + let attestations = match &signed_block { + SignedBeaconBlock::Lean(lean) => lean.message.body.attestations.len(), + _ => 0, + }; + let (timings, outcome) = self.process_block(signed_block, timings).await; + self.log_import_report(slot, block_root, attestations, &outcome, timings); + match outcome { + Ok(ImportOutcome::Imported) => { info!( %slot, proposer, @@ -1103,16 +2773,43 @@ impl BlockChainServer { ); // Recover per-attestation single-message aggregates from the - // block's merged multi-message aggregate and fold them into the - // local pool. Only - // run when the chain is in sync — backfilling nodes must - // not spam gossip with rederived aggregates. - if self.sync_status.duties_allowed() { - self.run_reaggregate_from_block(&block_for_reaggregate); + // block's merged multi-message aggregate and fold them into + // the local pool. `Some` only for a lean block imported while + // in sync, which is what selects this pass; see the clone + // above. + if let Some(ref lean_block) = block_for_reaggregate { + self.run_reaggregate_from_block(lean_block); } // Enqueue any pending blocks that were waiting for this parent self.collect_pending_children(block_root, queue); + + // This root now has a post-state, which is the one thing every + // sidecar parked under it was waiting for. + self.drain_sidecars_awaiting_parent(block_root); + + Some(ImportOutcome::Imported) + } + // A hold writes no post-state, so nothing pending on this root is + // actually unblocked yet. Calling `collect_pending_children` here + // regardless, as a bare `Ok(())` from `process_block` used to make + // this arm do, re-queues children that can only pend again. It + // was an infinite cycle, with no yield point, while a child's + // ancestor walk (see the "Block parent missing" branch above) + // still re-fetched and re-enqueued a held block: that walk now + // stops at a block in `blocks_awaiting_columns`, but collecting + // here would still be work for nothing. + // `release_block_if_columns_complete` is what reaches + // `collect_pending_children` for real, once this root actually + // has a post-state to unblock anything with. + Ok(ImportOutcome::Held) => { + debug!( + %slot, + block_root = %ShortRoot(&block_root.0), + "Block held pending its custody columns; nothing pending on it is unblocked yet" + ); + + Some(ImportOutcome::Held) } Err(err) => { warn!( @@ -1123,19 +2820,26 @@ impl BlockChainServer { %err, "Failed to process block" ); + + // Deliberately not `Held`: an import that failed on this + // block's own contents fails the same way every time it is + // retried, so re-driving it once a slot until finality evicts + // it would buy a state transition per slot and nothing else. + None } } } /// Run the post-import reaggregation pass and publish the resulting - /// aggregates when this node is in the aggregator role. + /// aggregates when this node is in the aggregator role. Lean-only: the + /// caller only invokes this for a [`SignedBeaconBlock::Lean`] import. fn run_reaggregate_from_block(&mut self, signed_block: &SignedBlock) { let aggregates = reaggregate::reaggregate_from_block(&mut self.store, signed_block); if aggregates.is_empty() { return; } let count = aggregates.len(); - let is_aggregator = self.aggregator.is_enabled(); + let is_aggregator = self.lean().aggregator.is_enabled(); info!( count, is_aggregator, "Reaggregated block-borne attestations" @@ -1153,11 +2857,23 @@ impl BlockChainServer { } } + /// Ask the network for a block this node is missing an ancestor of. + /// + /// Chain-agnostic, and deliberately so: the request carries a root and + /// what is missing under it, and the p2p layer picks the protocol from the + /// wire it already speaks, so neither this method nor the actor protocol + /// grows a chain argument. Deduplication is the p2p layer's too, keyed on + /// the root. fn request_missing_block(&mut self, block_root: H256) { - // Send request to P2P layer (deduplication handled by P2P module) if let Some(ref p2p) = self.p2p { let _ = p2p - .fetch_block(block_root) + .fetch_block(FetchRequest { + block_root, + needs_block: true, + // Nothing to name: a block this node has never seen has + // told it nothing about what it committed to. + columns: Vec::new(), + }) .inspect(|_| info!(%block_root, "Requested missing block from network")) .inspect_err( |err| error!(%block_root, %err, "Failed to send FetchBlock message to P2P"), @@ -1165,9 +2881,32 @@ impl BlockChainServer { } } + /// Ask the network for the custody columns of a block this node already has. + /// + /// The partner of [`Self::request_missing_block`], and the reason + /// `needs_block` is a field of its own rather than "the column list is + /// empty": the block is in this node's DB, so asking for it again would put + /// a redundant by-root lookup on the wire behind every column request. + fn request_missing_columns(&self, block_root: H256, missing: Vec) { + if let Some(ref p2p) = self.p2p { + let request = FetchRequest { + block_root, + needs_block: false, + columns: missing, + }; + let _ = p2p.fetch_block(request).inspect_err( + |err| error!(%block_root, %err, "Failed to request a held block's missing data columns"), + ); + } + } + /// Move pending children of `parent_root` into the work queue for iterative /// processing. This replaces the old recursive `process_pending_children`. - fn collect_pending_children(&mut self, parent_root: H256, queue: &mut VecDeque) { + fn collect_pending_children( + &mut self, + parent_root: H256, + queue: &mut VecDeque<(SignedBeaconBlock, ImportTimings)>, + ) { let Some(child_roots) = self.pending_blocks.remove(&parent_root) else { return; }; @@ -1180,7 +2919,7 @@ impl BlockChainServer { self.pending_block_parents.remove(&block_root); // Load block data from DB - let Ok(Some(child_block)) = self.store.get_signed_block(&block_root) else { + let Ok(Some(fetched)) = self.store.get_signed_block(&block_root) else { warn!( block_root = %ShortRoot(&block_root.0), "Pending block missing from DB, skipping" @@ -1188,11 +2927,98 @@ impl BlockChainServer { continue; }; - let slot = child_block.message.slot; + let slot = fetched.slot(); trace!(%parent_root, %slot, "Processing pending child block"); - queue.push_back(child_block); + // The parent's import is what ended this child's wait. Whatever + // passes between here and the child being popped is the cascade's + // own queueing, which is a separate row. + let released = Instant::now(); + let mut timings = self + .held_timings + .remove(&block_root) + .unwrap_or_else(ImportTimings::starting_now); + timings.parent_wait_end = Some(released); + timings.cascade_wait_start = Some(released); + queue.push_back((fetched, timings)); + } + } + + /// Keep `block` until every column this node custodies for it has arrived. + /// + /// The same shape as a block held for a missing parent: the block itself is + /// already in the DB, so only its root is remembered here, and the map is + /// cleared by the finality eviction the pending path already performs. + /// There is no timer on the block. Neither das-core nor lighthouse puts one + /// there: das-core leaves the timing question open, and lighthouse prunes + /// its pending components at `max(finalized_epoch + 1, the availability + /// boundary)` instead. + /// + /// Nothing is asked for here for a block still at or ahead of + /// `current_slot`. A block's columns are published alongside it, so a + /// block that reaches the gate short of them almost always has the rest + /// in flight on gossip, and asking peers at this moment races that + /// delivery: the peers asked usually do not have the columns yet either, + /// so they answer empty and burn the lookup's attempts. Measured on + /// mainnet followers at the tip, gossip completed a held block within + /// 0.3 s at p99, and every stored column came from gossip. Each arriving + /// column releases the block through + /// [`Self::release_block_if_columns_complete`]; whatever is still missing + /// at the next slot's [`Self::redrive_held_blocks`] is asked for there. + /// + /// A block older than `current_slot` gets no such courtesy: whatever + /// gossiped it did so long before this node held it, so there is no + /// delivery left in flight to race, and asking immediately is strictly + /// better than waiting out a redrive. Range-synced catch-up is exactly + /// this case, since every synced block is already older than the slot it + /// arrives in; leaving it to the redrive cadence instead made a follower + /// recovering from a checkpoint sync import roughly one such block per + /// slot. + fn hold_block_for_columns( + &mut self, + block: SignedBeaconBlock, + current_slot: u64, + timings: ImportTimings, + ) { + let slot = block.slot(); + let block_root = block.message_hash_tree_root(); + + let present = self + .store + .data_column_indices_for(slot, &block_root) + .expect("DB read should succeed"); + let missing: Vec = self + .custody_columns + .iter() + .copied() + .filter(|index| !present.contains(index)) + .collect(); + + info!( + %slot, + block_root = %ShortRoot(&block_root.0), + missing = ?missing, + "Holding block: its custody columns have not all arrived yet" + ); + + // Written the same way a parent-missing block is (see + // `process_or_pend_block`): no `LiveChain` entry, so it stays + // invisible to fork choice until it is re-admitted, but readable back + // by root once its columns land. + self.store + .insert_pending_block(block_root, block) + .expect("DB insert should succeed"); + + // See the doc comment above: an old block's columns are not still + // arriving on gossip, so this is its first ask rather than the next + // redrive's. + if slot < current_slot { + self.request_missing_columns(block_root, missing); } + + self.blocks_awaiting_columns.insert(block_root, slot); + self.held_timings.insert(block_root, timings); + metrics::set_blocks_held_for_columns(self.blocks_awaiting_columns.len() as u64); } /// Recursively discard a block and all its pending descendants. @@ -1200,6 +3026,23 @@ impl BlockChainServer { /// Used when a block is rejected (e.g., at/below finalized slot) to clean up /// children that would otherwise remain stuck in the pending maps indefinitely. fn discard_pending_subtree(&mut self, block_root: H256) { + // A block held for its custody columns already had a known parent + // when it was held (see `hold_block_for_columns`), so it carries no + // entry of its own in `pending_blocks` and would not be reached by + // the early return below. This has to run unconditionally, ahead of + // that guard, so both of this function's callers reach it: a + // redelivery of this exact root finding it at or below the finalized + // slot (see `process_or_pend_block`), and the periodic sweep in + // `evict_held_blocks_at_or_below_finality`, which calls this directly + // on a root with no redelivery in sight. + if self.blocks_awaiting_columns.remove(&block_root).is_some() { + metrics::set_blocks_held_for_columns(self.blocks_awaiting_columns.len() as u64); + } + + // Both eviction paths reach this function, so removing here is what + // keeps `held_timings` from outliving the blocks it describes. + self.held_timings.remove(&block_root); + let Some(child_roots) = self.pending_blocks.remove(&block_root) else { return; }; @@ -1209,11 +3052,86 @@ impl BlockChainServer { } } + /// Drop every held block whose slot is now at or below the finalized + /// slot, via [`Self::discard_pending_subtree`] so any pending children + /// still waiting on one as their parent are cleaned up too, not just the + /// hold itself. + /// + /// Needed because nothing else evicts a withheld hold. A block missing + /// its parent needs a genuine gap in this node's own chain to end up + /// pending; a block held for its columns needs only a claim, since + /// nothing about a block's signature or contents is checked before the + /// gate can hold it (`data_availability_for` runs on the bare + /// `blob_kzg_commitments` field ahead of `fork_choice::on_block`'s own + /// verification). A peer can therefore name any known, unfinalized parent, + /// claim commitments, and simply never answer the resulting + /// `FetchRequest`, pinning the claimed block in this node's memory + /// and in its `BlockHeaders`/`BlockBodies` rows (written by + /// `hold_block_for_columns`'s `insert_pending_block`) for as long as it + /// likes, at no cost to itself. Calling this after every tick and every + /// import — the two places beacon finality can move — bounds that by the + /// unfinalized window rather than by this node's uptime. + /// + /// Also bounds the two beacon scratch caches with the same horizon, the + /// execution-hash cache and the optimistic-root set, which is why the + /// finalized slot is read before the "nothing is held" early return rather + /// than after it: all three share a horizon and these two call sites, but + /// the caches fill on every beacon import whether or not anything is being + /// held for its columns. + /// + /// The held-block half is a no-op whenever nothing is held, which is always + /// true on lean; the cache halves are no-ops there too, since only a beacon + /// import ever writes either. + fn evict_held_blocks_at_or_below_finality(&mut self) { + let finalized = self + .store + .latest_finalized() + .expect("finalized checkpoint exists"); + let finalized_slot = finalized.slot; + + // Bound the execution-hash cache to the unfinalized window, so it + // cannot grow without limit on a long-running follower. Strictly + // below, and the checkpoint root exempted by name, so the finalized + // block's own hash survives for `forkchoiceUpdated`'s + // `finalized_block_hash` even when the epoch boundary slot this + // checkpoint is stored as was itself skipped. + self.store + .prune_beacon_el_block_hashes(finalized_slot, finalized.root); + + // Same horizon, same reason. An execution client doing a long state + // sync answers `NOT_VALIDATED` to every block, so the optimistic set + // takes one root per import and neither `mark_validated` nor + // `invalidate_subtree` ever comes for them. + self.store.prune_beacon_optimistic_roots(finalized_slot); + + if self.blocks_awaiting_columns.is_empty() { + return; + } + let stale: Vec = self + .blocks_awaiting_columns + .iter() + .filter(|&(_, &slot)| slot <= finalized_slot) + .map(|(&root, _)| root) + .collect(); + if stale.is_empty() { + return; + } + info!( + finalized_slot, + count = stale.len(), + "Evicting held blocks that finality has superseded" + ); + for root in stale { + self.discard_pending_subtree(root); + } + } + + /// Lean-only. fn on_gossip_attestation(&mut self, attestation: &SignedAttestation) { // Read fresh here too: a gossip event can arrive between ticks, and // if the admin API just toggled, the first gossip after the toggle // should already use the new value. - let is_aggregator = self.aggregator.is_enabled(); + let is_aggregator = self.lean().aggregator.is_enabled(); let accepted = store::on_gossip_attestation(&mut self.store, attestation, is_aggregator) .inspect_err(|err| warn!(%err, "Failed to process gossiped attestation")) .is_ok(); @@ -1248,20 +3166,485 @@ impl BlockChainServer { } } - fn update_sync_status(&mut self, current_slot: u64) { - let head_slot = self.store.head_slot(); - let max_seen_slot = self - .store - .max_live_chain_slot() - .expect("max live chain slot exists") - .unwrap_or(head_slot); - let status = self - .sync_status - .update(current_slot, head_slot, max_seen_slot); + /// Keep sidecars the p2p layer has already checked. + /// + /// Nothing here judges them. Every rule ran in the p2p layer, off this + /// actor's single thread: gossip validation for a sidecar gossip + /// accepted, and the chain checks + /// (`ethlambda_state_transition::beacon::gossip::column::chain_checks`) + /// for any other. Those cost a KZG batch and a BLS verification per + /// sidecar, which on this thread delayed every block import behind them. + /// + /// Debug builds run the chain checks once more, so a p2p path that + /// forwards a sidecar it never checked fails a test instead of reaching + /// the store. A sidecar that has become a duplicate or fallen below + /// finality since it was checked (an `Ignore`) is not a disagreement: the + /// verdict was right when it was given. + /// + /// Beacon-only: a lean node subscribes to no column subnet, so nothing + /// ever delivers this message there. + async fn on_checked_data_columns(&mut self, sidecars: Vec) { + for sidecar in sidecars { + #[cfg(debug_assertions)] + { + use ethlambda_state_transition::beacon::gossip::{ + Outcome, + column::{ChainVerdict, chain_checks}, + }; + let verdict = chain_checks(&self.store, &sidecar, unix_now_ms()); + assert!( + matches!( + verdict, + ChainVerdict::Keep | ChainVerdict::Drop(Outcome::Ignore(_)) + ), + "the p2p layer sent a data column sidecar the chain checks refuse: {verdict:?}" + ); + } + self.keep_data_column(sidecar).await; + } + } + + /// Store a checked sidecar and release the held block it may complete. + async fn keep_data_column(&mut self, sidecar: fulu::DataColumnSidecar) { + let header = &sidecar.signed_block_header.message; + let slot = header.slot; + let block_root = header.hash_tree_root(); + + // Two copies of one column can pass the checks at once (from gossip + // and from a fetch, say). The second write would store the same row + // and count it, and re-check the held block, for nothing. + let stored = self + .store + .data_column_indices_for(slot, &block_root) + .expect("DB read should succeed"); + if stored.contains(&sidecar.index) { + return; + } + + if let Err(err) = + self.store + .put_data_column_sidecar(slot, &block_root, sidecar.index, sidecar.to_ssz()) + { + error!(%err, "Failed to store a data column sidecar"); + return; + } + metrics::inc_data_column_stored(); + + // A held block may now be complete. + self.release_block_if_columns_complete(block_root).await; + } + + /// Park sidecars the chain checks found no parent post-state for, or send + /// them straight back to be checked if the parent has one by now. + /// + /// The second case is a race this actor has to close, because the checks + /// run elsewhere: the p2p layer looked for the parent's post-state, found + /// none and sent these, and if the parent imported in between, its + /// [`Self::drain_sidecars_awaiting_parent`] has already run and will not + /// run again, so a sidecar parked now would wait for nothing until + /// finality evicts it. Asked with the same `get_state` the checks use, so + /// a sidecar sent back is one they will find a parent state for. + fn park_data_columns(&mut self, sidecars: Vec) { + let mut ready = Vec::new(); + for sidecar in sidecars { + let parent_root = sidecar.signed_block_header.message.parent_root; + if matches!(self.store.get_state(&parent_root), Ok(Some(_))) { + ready.push(sidecar); + continue; + } + let block_root = sidecar.signed_block_header.message.hash_tree_root(); + self.queue_sidecar_awaiting_parent(block_root, sidecar); + } + self.send_data_columns_for_checks(ready); + } + + /// Hand sidecars to the p2p layer's chain checks, which send back the + /// ones that pass through `new_data_column_sidecars`. + fn send_data_columns_for_checks(&self, sidecars: Vec) { + if sidecars.is_empty() { + return; + } + let Some(ref p2p) = self.p2p else { + return; + }; + let _ = p2p.check_data_column_sidecars(sidecars).inspect_err( + |err| error!(%err, "Failed to send data column sidecars to the p2p layer for checks"), + ); + } + + /// Park `sidecar` against the parent root it could not be checked against. + /// + /// Counted as `queued_for_parent` rather than as a rejection: nothing about + /// the sidecar has been judged yet, and conflating the two is what made the + /// deadlock invisible in the metrics (every column read as + /// `rejected{reason="unknown_parent"}` while the real fault was upstream). + /// + /// Nothing is refused here. The queue used to hold whole sidecars and so + /// carried a count cap, which a follower behind the tip hit constantly: + /// it receives gossip for the tip continuously, so the queue filled with + /// sidecars for blocks it would not reach for minutes and then refused + /// the ones for the block it was about to import. Measured on the eth-4 + /// follower a hundred slots behind, the queue sat pinned at its cap and + /// dropped 2,561 sidecars in ten minutes while the chain ground through + /// by-root lookups for slots whose columns gossip had already delivered + /// and this function had thrown away. Evicting the furthest-ahead entry + /// instead of the newest fixed which sidecar was lost, not that one was. + /// + /// The cap is gone now that the sidecars live in + /// `Table::PendingDataColumns` and only their keys are held here, so what + /// grows is disk rather than this actor's memory. + /// [`Self::evict_sidecars_awaiting_parent_at_or_below_finality`] is what + /// bounds it, which bounds how *long* an entry lives but not how fast + /// they arrive: the chain checks do not require `parent_root` to name a + /// block this node knows. Every sidecar reaching here, gossiped or + /// fetched, has had its header's signature checked against the head state + /// by those checks (`queue_unless_forged`, in + /// `ethlambda_state_transition::beacon::gossip::column`), but only when a + /// head state is already cached *and* the header's `proposer_index` names + /// a validator in it: with no cached head state, or a proposer index that + /// names none (`u64::MAX`, say), that check is skipped and a made-up + /// header still reaches here and parks a row. A peer exploiting either + /// gap can still park rows as fast as it can invent a slot, proposer and + /// index, until finality catches up. + fn queue_sidecar_awaiting_parent( + &mut self, + block_root: H256, + sidecar: fulu::DataColumnSidecar, + ) { + let header = &sidecar.signed_block_header.message; + let parent_root = header.parent_root; + let parked = ParkedColumn { + slot: header.slot, + block_root, + index: sidecar.index, + }; + + // A re-delivery of something already parked. The by-root and by-range + // fetch paths skip gossip's seen cache entirely, so they never touch + // the p2p actor's `SeenColumns` (which in any case only records an + // Accept, never a park); a re-delivery reaching here is ordinary, and + // without this check the same column would take a second slot in the + // queue and leave a stale key behind after the first replay took its + // row. Asked before the write rather than left to the set below, + // because the write is what costs. + if self + .sidecars_awaiting_parent + .get(&parent_root) + .is_some_and(|parked_columns| parked_columns.contains(&parked)) + { + return; + } + + // The bytes go to disk before the key goes in the map, so a failed + // write leaves no key pointing at a row that is not there. + if let Err(err) = self.store.put_pending_data_column_sidecar( + parked.slot, + &parked.block_root, + parked.index, + sidecar.to_ssz(), + ) { + error!(%err, "Failed to park a data column sidecar"); + return; + } + + trace!( + slot = parked.slot, + column = parked.index, + parent_root = %ShortRoot(&parent_root.0), + "Queueing a data column sidecar until its parent has a post-state" + ); + self.sidecars_awaiting_parent + .entry(parent_root) + .or_default() + .insert(parked); + self.publish_sidecars_awaiting_parent(); + } + + /// Republish how many sidecars are parked, from the map that decides it. + fn publish_sidecars_awaiting_parent(&self) { + let total: usize = self + .sidecars_awaiting_parent + .values() + .map(HashSet::len) + .sum(); + metrics::set_sidecars_awaiting_parent(total as u64); + } + + /// Send every sidecar parked against `block_root` back to the p2p layer's + /// chain checks, now that it has a post-state to be checked against. + /// + /// Called from the one arm that means "this root now has a post-state". + /// The ones that pass come back through `new_data_column_sidecars` as a + /// new message, so nothing here re-enters the import path. + fn drain_sidecars_awaiting_parent(&mut self, block_root: H256) { + let Some(parked_columns) = self.sidecars_awaiting_parent.remove(&block_root) else { + return; + }; + debug!( + parent_root = %ShortRoot(&block_root.0), + count = parked_columns.len(), + "Replaying data column sidecars whose parent just imported" + ); + self.publish_sidecars_awaiting_parent(); + + let mut sidecars = Vec::with_capacity(parked_columns.len()); + for parked in parked_columns { + // Taken, not read: the row has served its purpose either way. A + // replay that passes is written to `DataColumns`, and one that + // fails a check has been judged, so neither leaves anything worth + // keeping here. + let encoded = match self.store.take_pending_data_column_sidecar( + parked.slot, + &parked.block_root, + parked.index, + ) { + Ok(Some(encoded)) => encoded, + Ok(None) => { + error!( + slot = parked.slot, + column = parked.index, + block_root = %ShortRoot(&parked.block_root.0), + "A parked data column sidecar has no row to replay from" + ); + continue; + } + Err(err) => { + error!(%err, "Failed to read back a parked data column sidecar"); + continue; + } + }; + let Ok(sidecar) = fulu::DataColumnSidecar::from_ssz_bytes(&encoded) else { + error!( + slot = parked.slot, + column = parked.index, + "A parked data column sidecar did not decode" + ); + continue; + }; + sidecars.push(sidecar); + } + self.send_data_columns_for_checks(sidecars); + } + + /// Drop parked sidecars whose block finality has superseded. + /// + /// The counterpart of [`Self::evict_held_blocks_at_or_below_finality`] and + /// run beside it, for the same reason: a parent root that never arrives + /// would otherwise pin its children's sidecars for this node's whole + /// uptime. A sidecar at or below the finalized slot can never be needed + /// again, since the chain checks would drop it outright now. + fn evict_sidecars_awaiting_parent_at_or_below_finality(&mut self) { + if self.sidecars_awaiting_parent.is_empty() { + return; + } + let finalized_slot = self + .store + .latest_finalized() + .expect("finalized checkpoint exists") + .slot; + + let mut dropped: Vec = Vec::new(); + self.sidecars_awaiting_parent.retain(|_, parked_columns| { + parked_columns.retain(|parked| { + let keep = parked.slot > finalized_slot; + if !keep { + dropped.push(*parked); + } + keep + }); + !parked_columns.is_empty() + }); + + if !dropped.is_empty() { + info!( + finalized_slot, + count = dropped.len(), + "Evicting parked data column sidecars that finality has superseded" + ); + // The rows go with the keys, so a key dropped from the map never + // leaves its bytes on disk with nothing left to read them. + let keys = dropped + .iter() + .map(|parked| (parked.slot, parked.block_root, parked.index)); + let _ = self + .store + .delete_pending_data_column_sidecars(keys) + .inspect_err(|err| error!(%err, "Failed to drop parked data column sidecars")); + self.publish_sidecars_awaiting_parent(); + } + } + + /// Once a slot, revisit every held block: release the ones whose columns + /// have quietly completed, and ask for whatever the rest are still + /// missing. + /// + /// The only repeat asker. [`Self::hold_block_for_columns`] already asks + /// once, immediately, for a block old enough that gossip has nothing left + /// to deliver; a block still at or ahead of the current slot when held + /// gets no such ask and is left to gossip instead, so its first ask is + /// the first tick after it was held, by which time a column still + /// missing is unlikely to be on its way. Either way, every tick from here + /// on asks again, which is what a lookup that fails needs: the only + /// other thing that revisits a hold is a sidecar for that exact block + /// arriving. A block whose missing columns no connected peer custodies + /// gets neither: every peer answers `DataColumnsByRoot` with an empty + /// list, the lookup spends its retry ladder against the peer set in a few + /// seconds, and without this the hold would be left with nothing that + /// will ever disturb it again while the chain stops behind it. + /// + /// Seen following mainnet with the gate on: every connected peer + /// advertised the minimum `custody_group_count`, so a dozen peers between + /// them custodied a small fraction of the columns, and two of the eight + /// this node samples were not among them. Peers churn constantly, and one + /// that connects a minute later may custody exactly the column that was + /// missing, so an ask that fails now is worth repeating. A slot is the + /// cadence blocks arrive at, and the held set is normally empty and + /// bounded by finality when it is not, so repeating it costs one store + /// read per held block per slot. + /// + /// A no-op whenever nothing is held, which is always true on lean. + async fn redrive_held_blocks(&mut self) { + if self.blocks_awaiting_columns.is_empty() { + return; + } + + // Collected up front: releasing re-enters the import path, which + // mutates the very map this would otherwise be iterating. + let held: Vec<(H256, u64)> = self + .blocks_awaiting_columns + .iter() + .map(|(&block_root, &slot)| (block_root, slot)) + .collect(); + + for (block_root, slot) in held { + let present = self + .store + .data_column_indices_for(slot, &block_root) + .expect("DB read should succeed"); + let missing: Vec = self + .custody_columns + .iter() + .copied() + .filter(|index| !present.contains(index)) + .collect(); + + // Complete and still held: whatever completed it did not reach + // `release_block_if_columns_complete`. Release it here rather than + // leave a block waiting on columns this node already has. + if missing.is_empty() { + self.release_block_if_columns_complete(block_root).await; + continue; + } + + self.request_missing_columns(block_root, missing); + } + } + + /// Re-import a held block once its last missing column lands. + async fn release_block_if_columns_complete(&mut self, block_root: H256) { + let Some(&slot) = self.blocks_awaiting_columns.get(&block_root) else { + return; + }; + + // Cheap presence check before paying for a DB read and decode of the + // whole block: most sidecar arrivals are not the last column of a + // held block, and this is the same check `data_availability_for`'s + // fulu arm makes. + if !custody_columns_present(&self.store, slot, &block_root, &self.custody_columns) { + return; + } + + self.blocks_awaiting_columns.remove(&block_root); + let mut timings = self + .held_timings + .remove(&block_root) + .unwrap_or_else(ImportTimings::starting_now); + timings.columns_wait_end = Some(Instant::now()); + metrics::set_blocks_held_for_columns(self.blocks_awaiting_columns.len() as u64); + + let Ok(Some(block)) = self.store.get_signed_block(&block_root) else { + error!( + block_root = %ShortRoot(&block_root.0), + "A held block vanished from the store before its columns completed" + ); + return; + }; + + info!( + %slot, + block_root = %ShortRoot(&block_root.0), + "Held block's custody columns are complete; re-importing" + ); + // The same path a new block takes (`Handler` ends with this + // same call): iterative, not recursive, so a released block's own + // pending children still cascade through `run_import_cascade`'s loop + // rather than growing the stack. + // + let outcome = self.on_block(block, timings).await; + + // The re-import can fail for a reason that has nothing to do with + // columns: no verdict from the execution client, or a `NOT_VALIDATED` + // verdict on a block that is not yet an optimistic candidate. Both + // record nothing, so without this the hold removed above is simply + // gone, and with it every route back to the block: + // `redrive_held_blocks` and `evict_held_blocks_at_or_below_finality` + // would both stop seeing it, and it and all its descendants would wait + // for a restart. Both reasons resolve on their own: the execution + // client comes back, and the horizon passes, so the block belongs back + // in the held set where the per-slot redrive can try it again. + // + // Safe to re-insert here rather than racing the cycle: the hold came + // off before the call, so the re-entry this edge allows has already + // unwound by the time this line runs. It cannot re-drive the edge, it + // only puts the block back where the redrive and the finality eviction + // can still find it. A `process_block` that re-held the block for its + // columns has already inserted the same entry, and writing it twice + // costs nothing. + if outcome == Some(ImportOutcome::Held) { + self.blocks_awaiting_columns.insert(block_root, slot); + metrics::set_blocks_held_for_columns(self.blocks_awaiting_columns.len() as u64); + debug!( + %slot, + block_root = %ShortRoot(&block_root.0), + "Re-import produced no post-state; the block stays held for the next redrive" + ); + } + } + + /// Refresh the sync-status tracker and its two outputs (the + /// `lean_node_sync_status` metric and [`SyncStatusController`]). + /// + /// Reads the head through [`Self::head_slot`], which is where the + /// per-chain part of that lives (lean's `Store::head_slot` and beacon's + /// `Store::beacon_head` decode different tables); everything past that + /// point is chain-agnostic. + fn update_sync_status(&mut self, current_slot: u64) { + let head_slot = self.head_slot(); + let max_seen_slot = self + .store + .max_live_chain_slot() + .expect("max live chain slot exists") + .unwrap_or(head_slot); + let status = self + .sync_status + .update(current_slot, head_slot, max_seen_slot); metrics::set_node_sync_status(status); self.sync_status_controller.set(status); } + /// Milliseconds until this actor's next tick, dispatched by chain: lean's + /// interval grid via [`ms_until_next_interval`], beacon's once-per-slot + /// cadence via [`ms_until_next_beacon_slot`]. + fn ms_until_next_tick(&self, now_ms: u64) -> u64 { + let config = self.store.config(); + match &self.duties { + ChainDuties::Lean(_) => ms_until_next_interval(now_ms, &config.time_grid()), + ChainDuties::Beacon => { + ms_until_next_beacon_slot(now_ms, config.genesis_time_ms(), config.slot_duration_ms) + } + } + } + /// Whether `slot` is close enough to the store clock for its arrival to be /// worth measuring. /// @@ -1274,7 +3657,7 @@ impl BlockChainServer { /// lifetime of the process. fn is_arrival_observable(&self, slot: u64) -> bool { let slot_start_interval = slot.saturating_mul(INTERVALS_PER_SLOT); - let store_time = self.store.time().expect("store time exists"); + let store_time = self.store.intervals_since_genesis(); slot_start_interval <= store_time + GOSSIP_DISPARITY_INTERVALS } } @@ -1313,8 +3696,7 @@ impl BlockChainServer { let now_ms = unix_now_ms(); self.on_tick(now_ms, ctx).await; - let time_config = *self.store.config(); - let remaining_at_entry = ms_until_next_interval(now_ms, &time_config); + let remaining_at_entry = self.ms_until_next_tick(now_ms); let now_after_tick = unix_now_ms(); let elapsed = now_after_tick.saturating_sub(now_ms); @@ -1324,7 +3706,7 @@ impl BlockChainServer { 0 } else { // Schedule the next tick at the next interval boundary - ms_until_next_interval(now_after_tick, &time_config) + self.ms_until_next_tick(now_after_tick) }; send_after( Duration::from_millis(ms_to_next_interval), @@ -1337,9 +3719,13 @@ impl BlockChainServer { /// before the actor is fully stopped. We cancel the session's token and /// wait up to PRIOR_WORKER_JOIN_TIMEOUT for the worker's current /// `aggregate_job` call to finish (the proof itself cannot be interrupted). + /// Lean-only: a beacon follower never starts an aggregation session. #[stopped] async fn on_stopped(&mut self, _ctx: &Context) { - let Some(session) = self.current_aggregation.take() else { + let ChainDuties::Lean(lean) = &mut self.duties else { + return; + }; + let Some(session) = lean.current_aggregation.take() else { return; }; session.cancel.cancel(); @@ -1362,7 +3748,8 @@ impl BlockChainServer { // --- Manual Handler impls for network-api messages --- use ethlambda_network_api::p2p_to_block_chain::{ - NewAggregatedAttestation, NewAttestation, NewBlock, + DataColumnSidecarsAwaitingParent, NewAggregatedAttestation, NewAttestation, NewBeaconAggregate, + NewBlock, NewDataColumnSidecars, }; impl Handler for BlockChainServer { @@ -1373,42 +3760,132 @@ impl Handler for BlockChainServer { } impl Handler for BlockChainServer { - async fn handle(&mut self, msg: NewBlock, _ctx: &Context) { + async fn handle(&mut self, msg: NewBlock, ctx: &Context) { let arrival_ms = unix_now_ms(); - // Gate both the event and the arrival metric on BlockSource::Gossip for - // two reasons: `ChainEvent::BlockGossip` is documented (events.rs) as "a - // block seen on gossip, before import", yet without this gate it also - // fired for req/resp sync blocks; and sync backfill delivers blocks many - // slots after they were due, which would swamp the arrival histogram - // with stale deltas that reflect catch-up speed, not gossip timeliness. - // `self.on_block(msg.block)` still runs for every source below: it is - // the import path and must not be gated. + // The mailbox hop ends here. Everything before it happened in the p2p + // actor and rode in on the message, because nothing on this side can + // see how long the block waited to be picked up. + let picked_up = Instant::now(); + // A re-delivery reports under the source the block first arrived on, + // not under `Deferred`. `Deferred` describes this hop, and the hold it + // names is already a section of the import; letting it stand as the + // source would move a gossip block out of the gossip population for + // every section it has left to cross. + let deferred = msg.arrival.deferred_from; + let mut timings = ImportTimings { + source: Some(deferred.map_or(msg.source, |from| from.source)), + // Gossip decodes the block itself and reports both ends; the + // req/resp codec has already decoded by the time a handler sees a + // block, so that path reports no decode at all rather than a zero. + decode_start: msg.arrival.decode_start, + decode_end: msg.arrival.decode_start.map(|_| msg.arrival.handed_off), + queue_start: Some(msg.arrival.handed_off), + // A re-delivered block waited in this mailbox twice. The first wait + // is the queue; the second is part of the hold, and ends here. + queue_end: Some(deferred.map_or(picked_up, |from| from.at)), + defer_start: deferred.map(|from| from.at), + defer_end: deferred.map(|_| picked_up), + admit_start: Some(picked_up), + ..ImportTimings::default() + }; + // If message came from gossip, emit event and metric. if msg.source == BlockSource::Gossip { - let slot = msg.block.message.slot; + let slot = msg.block.slot(); self.events.emit(ChainEvent::BlockGossip { slot, - block: msg.block.message.hash_tree_root(), + block: msg.block.message_hash_tree_root(), }); if self.is_arrival_observable(slot) { - metrics::observe_gossip_block_arrival(arrival_ms, self.store.config(), slot); + metrics::observe_gossip_block_arrival( + arrival_ms, + &self.store.config().time_grid(), + slot, + ); + } + } + + // Beacon decides here, at arrival, what to do with a block whose + // slot has not started: hold it if it is early only by clock + // disparity, reject it otherwise. Arrival is the only place holding + // it is still possible, since by the time the import cascade has the + // block its caller is a loop with nowhere to put it back. See + // `Self::defer_early_block`. Lean makes its own future-slot decision + // inside that cascade, where rejecting is all it has to do. + if self.store.chain() == Chain::Beacon { + let slot = msg.block.slot(); + let ms_early = self.ms_until_slot_start(slot); + if ms_early > 0 { + let block_root = msg.block.message_hash_tree_root(); + if Duration::from_millis(ms_early) > MAXIMUM_GOSSIP_CLOCK_DISPARITY { + warn!( + %slot, + ms_early, + block_root = %ShortRoot(&block_root.0), + "Rejecting block: its slot starts further ahead than clock disparity allows" + ); + self.discard_pending_subtree(block_root); + return; + } + info!( + %slot, + ms_early, + block_root = %ShortRoot(&block_root.0), + "Deferring block: its slot has not started yet" + ); + self.defer_early_block(msg.block, timings, ctx); + return; + } + // The slot has started on the wall clock. Make the store clock + // agree before importing, since that is the clock `on_block` + // asserts against and only the tick advances it: a tick still + // owed for this slot (it is armed for the same instant a held + // block is, and an import ahead of it can run long) would + // otherwise turn a perfectly good block into a rejected one. + // `on_tick` is idempotent through the store-clock guard in + // `begin_tick`, and the comparison here keeps sync backfill, whose + // blocks are never near the clock, from paying for the check at + // all. + let config = self.store.config(); + if fork_choice::get_current_slot(&self.store, &config) < slot { + self.on_tick(unix_now_ms(), ctx).await; } } - self.on_block(msg.block); + + // The import path itself is common to every source and both chains. + // Everything above ran on this block's behalf between the mailbox and + // the import, the catch-up tick included, so it is its own section + // rather than the head of the first pass's guards. + timings.admit_end = Some(Instant::now()); + self.on_block(msg.block, timings).await; } } impl Handler for BlockChainServer { async fn handle(&mut self, msg: NewAttestation, ctx: &Context) { + // Lean-only. A beacon node subscribes to no attestation subnet, so + // nothing delivers this message there; fork choice learns its votes + // from block bodies inside `on_block` instead. `current_slot` below is + // lean-only regardless, since it reads the store clock in intervals. + let ChainDuties::Lean(_) = &self.duties else { + return; + }; let arrival_ms = unix_now_ms(); let data_slot = msg.attestation.data.slot; if self.is_arrival_observable(data_slot) { - metrics::observe_gossip_attestation_arrival(arrival_ms, self.store.config(), data_slot); + metrics::observe_gossip_attestation_arrival( + arrival_ms, + &self.store.config().time_grid(), + data_slot, + ); } self.on_gossip_attestation(&msg.attestation); // Early aggregation only advances the current slot's group counts, so a // late- or future-slot attestation can never cross the threshold; skip // the check unless this attestation is for the store's current slot. - let current_slot = self.store.current_slot(); + // From the interval clock, the one the tick pipeline drives: this + // gates on the slot the store has actually ticked into, not on the one + // the wall clock has reached. + let current_slot = self.store.intervals_since_genesis() / INTERVALS_PER_SLOT; if data_slot == current_slot { self.maybe_start_early_aggregation(ctx).await; } @@ -1417,24 +3894,66 @@ impl Handler for BlockChainServer { impl Handler for BlockChainServer { async fn handle(&mut self, msg: NewAggregatedAttestation, _ctx: &Context) { + // Lean-only: beacon gossip aggregates are out of scope for this actor. + let ChainDuties::Lean(_) = &self.duties else { + return; + }; let arrival_ms = unix_now_ms(); - metrics::observe_gossip_aggregation_arrival(arrival_ms, self.store.config()); + metrics::observe_gossip_aggregation_arrival(arrival_ms, &self.store.config().time_grid()); self.on_gossip_aggregated_attestation(msg.attestation); } } +impl Handler for BlockChainServer { + async fn handle(&mut self, msg: NewDataColumnSidecars, _ctx: &Context) { + self.on_checked_data_columns(msg.sidecars).await; + } +} + +impl Handler for BlockChainServer { + async fn handle(&mut self, msg: DataColumnSidecarsAwaitingParent, _ctx: &Context) { + self.park_data_columns(msg.sidecars); + } +} + +impl Handler for BlockChainServer { + async fn handle(&mut self, msg: NewBeaconAggregate, _ctx: &Context) { + // Beacon-only: nothing subscribes a lean node to this topic, so a + // message here would be a dispatch bug rather than a chain that has + // nothing to do with it. Dropped rather than panicked on, the way + // every other handler treats a message for the other chain. + let ChainDuties::Beacon = &self.duties else { + return; + }; + // Read before anything else: this is the wait no timing on the far + // side of the mailbox can see, and it is where a backlog would show. + metrics::observe_beacon_aggregate_mailbox_wait(msg.arrival.handed_off.elapsed()); + self.on_gossip_beacon_aggregate(msg.aggregate, msg.attesting_indices, msg.arrival); + } +} + // ------------------------------------------------------------------------- // Aggregation message handlers (worker → actor, actor → self for deadline) // ------------------------------------------------------------------------- +// +// All four are lean-only: a beacon follower never starts an aggregation +// session, so none of these messages are ever sent to one in practice. Each +// still opens with the `else { return }` guard rather than reaching for +// `lean()`, because a handler is the actor's outer boundary: dropping a +// message that does not apply to this chain is a real outcome there, while +// below it the same situation is a dispatch bug. See `ChainDuties`. impl Handler for BlockChainServer { async fn handle(&mut self, msg: AggregateProduced, _ctx: &Context) { + let ChainDuties::Lean(lean) = &self.duties else { + return; + }; let arrival_ms = unix_now_ms(); // Drop results from a prior session (or from an unexpected late worker). // Current session may be None if the actor already cleaned it up; accept // the message only when ids match. - let current = self.current_aggregation.as_ref().map(|s| s.session_id); + let current = lean.current_aggregation.as_ref().map(|s| s.session_id); if current != Some(msg.session_id) { trace!( incoming_session_id = msg.session_id, @@ -1452,7 +3971,7 @@ impl Handler for BlockChainServer { // and costs little in practice: a late aggregate is late for every node // at once, so both populations are dominated by production time rather // than propagation and their distributions look alike. - metrics::observe_gossip_aggregation_arrival(arrival_ms, self.store.config()); + metrics::observe_gossip_aggregation_arrival(arrival_ms, &self.store.config().time_grid()); // Publish alignment is enforced upstream: the worker delays delivery of // this message until the interval-2 boundary, so by the time it lands @@ -1481,17 +4000,23 @@ impl Handler for BlockChainServer { impl Handler for BlockChainServer { async fn handle(&mut self, _msg: EarlyAggregationCheck, ctx: &Context) { + let ChainDuties::Lean(_) = &self.duties else { + return; + }; self.maybe_start_early_aggregation(ctx).await; } } impl Handler for BlockChainServer { async fn handle(&mut self, msg: AggregationDone, _ctx: &Context) { + let ChainDuties::Lean(lean) = &self.duties else { + return; + }; aggregation::finalize_aggregation_session(&self.store); metrics::observe_committee_signatures_aggregation(msg.total_elapsed); let aggregation_elapsed = msg.total_elapsed; - let early = self + let early = lean .current_aggregation .as_ref() .is_some_and(|s| s.session_id == msg.session_id && s.early); @@ -1514,7 +4039,10 @@ impl Handler for BlockChainServer { impl Handler for BlockChainServer { async fn handle(&mut self, msg: AggregationDeadline, _ctx: &Context) { - if let Some(session) = &self.current_aggregation + let ChainDuties::Lean(lean) = &self.duties else { + return; + }; + if let Some(session) = &lean.current_aggregation && session.session_id == msg.session_id { session.cancel.cancel(); @@ -1524,7 +4052,17 @@ impl Handler for BlockChainServer { #[cfg(test)] mod tests { + use std::sync::Arc; + use super::*; + use ethlambda_state_transition::beacon::fork_choice::seconds_to_milliseconds; + use ethlambda_storage::backend::InMemoryBackend; + use ethlambda_types::beacon::config::Config; + use ethlambda_types::beacon::containers::{BeaconState, deneb, electra, phase0, shared}; + use ethlambda_types::beacon::fork::ForkName; + use ethlambda_types::beacon::preset; + use ethlambda_types::checkpoint::Checkpoint; + use ethlambda_types::state::State; const GENESIS_TIME: u64 = 1_000; @@ -1622,4 +4160,1396 @@ mod tests { Duration::from_millis(1_600) ); } + + // ----------------------------------------------------------------- + // ms_until_next_interval / ms_until_next_beacon_slot + // + // Both helpers share one shape (elapsed-since-genesis modulo a + // cadence, with a special case before genesis); these mirror each + // other's cases so a regression in one grid shows up the same way + // as in the other. + // ----------------------------------------------------------------- + + #[test] + fn ms_until_next_interval_returns_a_whole_interval_exactly_on_a_boundary() { + // A sample taken right on an interval boundary must still schedule a + // full interval ahead. Regression guard: the beacon slice refactored + // this function's only caller (`ms_until_next_tick`) to dispatch by + // chain, and a boundary sample returning zero here would spin the + // tick loop instead of waiting out the interval. + let cfg = config(DEFAULT_MILLISECONDS_PER_SLOT); + assert_eq!( + ms_until_next_interval(cfg.genesis_time_ms(), &cfg), + cfg.milliseconds_per_interval() + ); + } + + #[test] + fn ms_until_next_interval_returns_the_remainder_mid_interval() { + let cfg = config(DEFAULT_MILLISECONDS_PER_SLOT); + let elapsed_into_interval = 300; + let now_ms = cfg.genesis_time_ms() + elapsed_into_interval; + assert_eq!( + ms_until_next_interval(now_ms, &cfg), + cfg.milliseconds_per_interval() - elapsed_into_interval + ); + } + + #[test] + fn ms_until_next_interval_waits_for_genesis_itself_when_called_early() { + let cfg = config(DEFAULT_MILLISECONDS_PER_SLOT); + let now_ms = cfg.genesis_time_ms() - 600; + assert_eq!( + ms_until_next_interval(now_ms, &cfg), + cfg.genesis_time_ms() - now_ms + ); + } + + #[test] + fn ms_until_next_beacon_slot_returns_a_whole_slot_exactly_on_a_boundary() { + // Beacon's counterpart to the interval case above: a boundary sample + // must schedule a whole slot ahead, since the beacon tick loop has no + // sub-slot grid to fall back on if this ever returned less. + let genesis_time_ms = 1_000; + let slot_duration_ms = Config::mainnet().slot_duration_ms; + assert_eq!( + ms_until_next_beacon_slot(genesis_time_ms, genesis_time_ms, slot_duration_ms), + slot_duration_ms + ); + } + + #[test] + fn ms_until_next_beacon_slot_returns_the_remainder_mid_slot() { + let genesis_time_ms = 1_000; + let slot_duration_ms = Config::mainnet().slot_duration_ms; + let elapsed_into_slot = 5_000; + let now_ms = genesis_time_ms + elapsed_into_slot; + assert_eq!( + ms_until_next_beacon_slot(now_ms, genesis_time_ms, slot_duration_ms), + slot_duration_ms - elapsed_into_slot + ); + } + + #[test] + fn ms_until_next_beacon_slot_waits_for_genesis_itself_when_called_early() { + let genesis_time_ms = 1_000; + let slot_duration_ms = Config::mainnet().slot_duration_ms; + let now_ms = 400; + assert_eq!( + ms_until_next_beacon_slot(now_ms, genesis_time_ms, slot_duration_ms), + genesis_time_ms - now_ms + ); + } + + /// A beacon store anchored at a zero root, with `genesis_time` (Unix + /// seconds) and both realized checkpoints at `finalized_slot`. + /// + /// No anchor state is written: its only caller asserts on the chain tag + /// `Store::init_beacon` sets, which is seeded before any state is. + fn beacon_store(genesis_time: u64, finalized_slot: u64) -> Store { + beacon_store_with_config(genesis_time, finalized_slot, Config::mainnet()) + } + + /// A beacon store whose fulu activation is genesis, so a fulu-shaped block + /// at a single-digit slot is a coherent fixture rather than one sitting + /// thousands of epochs before its own fork. + /// + /// Needed by anything exercising the availability gate: that gate stops at + /// the fulu fork (see `da_check_required_for_slot`), so under + /// [`Config::mainnet`]'s real schedule a slot-2 block is simply not a + /// block the gate has any business holding. + fn beacon_store_fulu_at_genesis(genesis_time: u64, finalized_slot: u64) -> Store { + beacon_store_with_config( + genesis_time, + finalized_slot, + Config::mainnet().with_fork_epoch(ForkName::Fulu, 0), + ) + } + + fn beacon_store_with_config(genesis_time: u64, finalized_slot: u64, config: Config) -> Store { + let backend = Arc::new(InMemoryBackend::default()); + let anchor_root = H256::ZERO; + let anchor_checkpoint = Checkpoint { + root: anchor_root, + slot: finalized_slot, + }; + Store::init_beacon( + backend, + genesis_time, + config, + anchor_root, + anchor_checkpoint, + finalized_slot, + ) + } + + // ----------------------------------------------------------------- + // spawn / spawn_beacon chain assertions + // + // Both panic on their very first line, before either constructor + // touches anything that would need a running actor context, so a + // plain #[test] (no tokio runtime) is enough to observe them. + // ----------------------------------------------------------------- + + #[test] + #[should_panic(expected = "BlockChain::spawn requires a lean store")] + fn spawn_panics_when_handed_a_beacon_store() { + // Guards against pairing a lean-shaped BlockChainConfig (validator + // keys, aggregator role, proposer policy) with a beacon store, which + // would corrupt the directory the moment any duty touched it. + let store = beacon_store(0, 0); + let config = BlockChainConfig { + aggregator: AggregatorController::new(false), + sync_status_controller: SyncStatusController::default(), + attestation_committee_count: 0, + gate_duties: true, + subscribed_subnets: HashSet::new(), + aggregation_duty_subnet: 0, + skip_redundant_aggregation: false, + proposer_config: ProposerConfig { + enable_proposer_aggregation: false, + max_attestations_per_block: 0, + }, + }; + + let _ = BlockChain::spawn(store, HashMap::new(), config, EventBus::default()); + } + + #[test] + #[should_panic(expected = "BlockChain::spawn_beacon requires a beacon store")] + fn spawn_beacon_panics_when_handed_a_lean_store() { + // Mirror of the assertion above: a beacon follower's spawn path must + // never run against lean-shaped state either. + let backend = Arc::new(InMemoryBackend::default()); + let store = Store::from_anchor_state( + backend, + State::from_genesis(0, Vec::new()), + DEFAULT_MILLISECONDS_PER_SLOT, + ); + + let _ = BlockChain::spawn_beacon( + store, + SyncStatusController::default(), + EventBus::default(), + Vec::new(), + None, + constants::SAFE_SLOTS_TO_IMPORT_OPTIMISTICALLY, + ); + } + + // ----------------------------------------------------------------- + // Data column sidecars: keeping, parking and replaying them + // + // These call the methods directly on a plain `BlockChainServer`, built + // the way `start_actor` builds one but never spawned: there is no mailbox + // to drive. The checks themselves run in the p2p layer and are tested + // with `beacon::gossip::column`. + // ----------------------------------------------------------------- + + fn beacon_server(store: Store) -> BlockChainServer { + BlockChainServer { + store, + p2p: None, + pending_blocks: HashMap::new(), + pending_block_parents: HashMap::new(), + blocks_awaiting_columns: HashMap::new(), + held_timings: HashMap::new(), + sidecars_awaiting_parent: HashMap::new(), + beacon_aggregates: Default::default(), + custody_columns: Vec::new(), + engine: None, + safe_slots_to_import_optimistically: constants::SAFE_SLOTS_TO_IMPORT_OPTIMISTICALLY, + last_tick_instant: None, + sync_status: SyncStatusTracker::new(false), + sync_status_controller: SyncStatusController::default(), + events: EventBus::default(), + duties: ChainDuties::Beacon, + } + } + + /// A sidecar naming `slot` and `parent_root`, structurally valid enough to + /// clear `verify_data_column_sidecar` (one commitment, one proof, one + /// column cell, all the same length), so the chain checks judge it on its + /// parent rather than its shape; every other field is its type's default, + /// since nothing under test reads past the header. + fn sidecar_at(slot: u64, parent_root: H256) -> fulu::DataColumnSidecar { + let cell: fulu::Cell = libssz_types::SszVector::try_from(vec![0u8; preset::BYTES_PER_CELL]) + .expect("exact cell size"); + fulu::DataColumnSidecar { + index: 0, + column: vec![cell].try_into().expect("within the per-block limit"), + kzg_commitments: vec![ethlambda_types::beacon::primitives::KzgCommitment::default()] + .try_into() + .expect("within the per-block limit"), + kzg_proofs: vec![ethlambda_types::beacon::primitives::KzgProof::default()] + .try_into() + .expect("within the per-block limit"), + signed_block_header: shared::SignedBeaconBlockHeader { + message: shared::BeaconBlockHeader { + slot, + parent_root, + ..Default::default() + }, + signature: Default::default(), + }, + // `SszVector` carries its length in the type, so unlike the + // `SszList` fields above there is no length-zero default for it. + kzg_commitments_inclusion_proof: vec![ + H256::ZERO; + preset::KZG_COMMITMENTS_INCLUSION_PROOF_DEPTH + ] + .try_into() + .expect("exactly the required depth"), + } + } + + /// A phase0 block good for nothing but its `(slot, parent_root)` pair: + /// what `Store::insert_signed_block` writes into `LiveChain`, which is + /// all `get_checkpoint_block`'s ancestry walk ever reads. The fork is + /// irrelevant to that walk, so the cheapest shape to build stands in. + fn bare_block(slot: u64, parent_root: H256) -> SignedBeaconBlock { + SignedBeaconBlock::Phase0(phase0::SignedBeaconBlock { + message: phase0::BeaconBlock { + slot, + proposer_index: 0, + parent_root, + state_root: H256::ZERO, + body: phase0::BeaconBlockBody { + randao_reveal: Default::default(), + eth1_data: Default::default(), + graffiti: H256::ZERO, + proposer_slashings: Default::default(), + attester_slashings: Default::default(), + attestations: Default::default(), + deposits: Default::default(), + voluntary_exits: Default::default(), + }, + }, + signature: Default::default(), + }) + } + + /// A structurally minimal phase0 state, good for nothing but existing. + /// The parent check under test never reads a state's contents, only + /// whether `Store::get_state` finds one at all, so this only needs to + /// satisfy the type checker and `Store::insert_state`. + /// + /// `latest_block_header.parent_root` is set to a root nothing else in a + /// test ever writes, so `Store::block_entry` reports it unknown and + /// `insert_state` takes that as "first state on record" and stores a + /// plain snapshot, rather than trying to diff against a parent state + /// this helper never creates. + fn bare_state() -> BeaconState { + let block_roots = vec![H256::ZERO; preset::SLOTS_PER_HISTORICAL_ROOT] + .try_into() + .expect("exactly the preset length"); + let state_roots = vec![H256::ZERO; preset::SLOTS_PER_HISTORICAL_ROOT] + .try_into() + .expect("exactly the preset length"); + let randao_mixes = vec![H256::ZERO; preset::EPOCHS_PER_HISTORICAL_VECTOR] + .try_into() + .expect("exactly the preset length"); + let slashings = vec![0u64; preset::EPOCHS_PER_SLASHINGS_VECTOR] + .try_into() + .expect("exactly the preset length"); + BeaconState::Phase0(phase0::BeaconState { + genesis_time: 0, + genesis_validators_root: H256::ZERO, + slot: 0, + fork: Default::default(), + latest_block_header: shared::BeaconBlockHeader { + parent_root: H256::repeat_byte(0xee), + ..Default::default() + }, + block_roots, + state_roots, + historical_roots: Default::default(), + eth1_data: Default::default(), + eth1_data_votes: Default::default(), + eth1_deposit_index: 0, + validators: Default::default(), + balances: Default::default(), + randao_mixes, + slashings, + previous_epoch_attestations: Default::default(), + current_epoch_attestations: Default::default(), + justification_bits: Default::default(), + previous_justified_checkpoint: Default::default(), + current_justified_checkpoint: Default::default(), + finalized_checkpoint: Default::default(), + }) + } + + #[test] + fn the_availability_gate_stops_at_the_window_peers_must_serve() { + let config = Config::mainnet(); + let fulu_start = config.fulu_fork_epoch * preset::SLOTS_PER_EPOCH; + // Far enough past fulu that the retention window, not the fork, is + // what sets the boundary. + let current_epoch = + config.fulu_fork_epoch + constants::MIN_EPOCHS_FOR_DATA_COLUMN_SIDECARS_REQUESTS + 100; + let current_slot = current_epoch * preset::SLOTS_PER_EPOCH; + let boundary_epoch = + current_epoch - constants::MIN_EPOCHS_FOR_DATA_COLUMN_SIDECARS_REQUESTS; + + // Inside the window: peers MUST be able to serve these, so insisting + // is fair. + assert!(da_check_required_for_slot( + current_slot, + current_slot, + &config + )); + assert!(da_check_required_for_slot( + boundary_epoch * preset::SLOTS_PER_EPOCH, + current_slot, + &config + )); + + // One epoch below it a peer MAY answer ResourceUnavailable, so holding + // a block there would stall the chain against data the network is + // entitled to have dropped. + assert!(!da_check_required_for_slot( + (boundary_epoch - 1) * preset::SLOTS_PER_EPOCH, + current_slot, + &config + )); + + // Before fulu there is no column matrix to be available at all, and + // the boundary must not walk below the fork even when the retention + // window reaches past it. + let just_after_fork = fulu_start + preset::SLOTS_PER_EPOCH; + assert!(da_check_required_for_slot( + just_after_fork, + just_after_fork, + &config + )); + assert!(!da_check_required_for_slot( + fulu_start - 1, + just_after_fork, + &config + )); + } + + /// Every `BlockChainToP2P` message the chain actor sends, kept for a test + /// to read back. Only `check_data_column_sidecars` and `fetch_block` are + /// recorded: nothing under test here sends the others. + #[derive(Default)] + struct RecordingP2P { + checks: std::sync::Mutex>>, + fetches: std::sync::Mutex>, + } + + impl ethlambda_network_api::BlockChainToP2P for RecordingP2P { + fn publish_block( + &self, + _block: ethlambda_types::block::SignedBlock, + ) -> Result<(), spawned_concurrency::error::ActorError> { + Ok(()) + } + fn publish_attestation( + &self, + _attestation: ethlambda_types::attestation::SignedAttestation, + ) -> Result<(), spawned_concurrency::error::ActorError> { + Ok(()) + } + fn publish_aggregated_attestation( + &self, + _attestation: ethlambda_types::attestation::SignedAggregatedAttestation, + ) -> Result<(), spawned_concurrency::error::ActorError> { + Ok(()) + } + fn fetch_block( + &self, + request: FetchRequest, + ) -> Result<(), spawned_concurrency::error::ActorError> { + self.fetches.lock().unwrap().push(request); + Ok(()) + } + fn check_data_column_sidecars( + &self, + sidecars: Vec, + ) -> Result<(), spawned_concurrency::error::ActorError> { + self.checks.lock().unwrap().push(sidecars); + Ok(()) + } + } + + /// `beacon_server(store)` with a [`RecordingP2P`] wired in as its p2p ref. + fn beacon_server_recording(store: Store) -> (BlockChainServer, Arc) { + let p2p = Arc::new(RecordingP2P::default()); + let mut server = beacon_server(store); + server.p2p = Some(p2p.clone()); + (server, p2p) + } + + /// A beacon store whose clock reads slot 10, so a sidecar at slot 10 is + /// neither future nor finalized. + fn beacon_store_at_slot_10() -> Store { + let mut store = beacon_store(GENESIS_TIME, 0); + store + .set_time_ms(seconds_to_milliseconds( + GENESIS_TIME + 10 * Config::mainnet().seconds_per_slot, + )) + .unwrap(); + store + } + + #[tokio::test] + async fn a_checked_sidecar_is_stored_as_it_is() { + // Nothing on this path judges the sidecar (that is the p2p layer's + // job), so storing it is the whole contract. `keep_data_column` + // rather than `on_checked_data_columns`, since this placeholder + // sidecar would fail the debug-build re-check. + let mut server = beacon_server(beacon_store_at_slot_10()); + let sidecar = sidecar_at(10, H256::repeat_byte(9)); + let block_root = sidecar.signed_block_header.message.hash_tree_root(); + + server.keep_data_column(sidecar).await; + + assert_eq!( + server + .store + .data_column_indices_for(10, &block_root) + .expect("DB read should succeed"), + vec![0] + ); + } + + /// The debug-build safety net: a sidecar the p2p layer forwarded as + /// checked, but that the chain checks would not have kept, stops the + /// actor rather than reaching the store. Release builds skip the re-check + /// and store it. + #[cfg(debug_assertions)] + #[tokio::test] + #[should_panic(expected = "the chain checks refuse")] + async fn a_sidecar_the_p2p_layer_never_checked_fails_the_debug_recheck() { + let mut server = beacon_server(beacon_store_at_slot_10()); + // Its parent has no state, so the chain checks would park it, never + // keep it. + let sidecar = sidecar_at(10, H256::repeat_byte(9)); + + server.on_checked_data_columns(vec![sidecar]).await; + } + + #[test] + fn a_data_column_sidecar_naming_a_parent_with_no_state_is_parked_not_stored() { + // `beacon_store` writes no anchor state (see its own doc comment), so + // any parent root at all is unknown here, including the anchor's own. + let (mut server, p2p) = beacon_server_recording(beacon_store_at_slot_10()); + let parent_root = H256::repeat_byte(9); + let sidecar = sidecar_at(10, parent_root); + let block_root = sidecar.signed_block_header.message.hash_tree_root(); + + server.park_data_columns(vec![sidecar]); + + // Not stored: it has not been checked, so it has not been accepted. + assert_eq!( + server + .store + .data_column_indices_for(10, &block_root) + .unwrap(), + Vec::::new() + ); + // But kept, under the parent it is waiting on. Dropping it here is + // what deadlocks a follower running the availability gate: a held + // block writes no post-state, so every sidecar of every child of it + // lands in exactly this branch. + assert_eq!( + server + .sidecars_awaiting_parent + .get(&parent_root) + .map(HashSet::len), + Some(1) + ); + assert!(p2p.checks.lock().unwrap().is_empty()); + } + + #[test] + fn a_sidecar_whose_parent_imported_meanwhile_goes_back_for_checks_not_into_the_queue() { + // The race the p2p layer's checks open: they found no parent state, + // then the parent imported and drained its (still empty) queue before + // this message arrived. Parking it now would strand it until + // finality, since that parent never drains again. + let (mut server, p2p) = beacon_server_recording(beacon_store_at_slot_10()); + let parent_root = H256::repeat_byte(9); + server + .store + .insert_state(parent_root, bare_state()) + .expect("insert"); + let sidecar = sidecar_at(10, parent_root); + + server.park_data_columns(vec![sidecar.clone()]); + + assert!(server.sidecars_awaiting_parent.is_empty()); + assert_eq!(*p2p.checks.lock().unwrap(), vec![vec![sidecar]]); + } + + #[test] + fn a_parked_sidecar_goes_back_for_checks_once_its_parent_gains_a_post_state() { + // The deadlock this closes, in miniature: while the parent has no + // post-state every sidecar under it parks, and if parking were the end + // of the story the queue would only ever grow. What breaks the cycle + // is that gaining a post-state releases them, to the p2p layer's + // checks, since this actor no longer judges a sidecar itself. + let (mut server, p2p) = beacon_server_recording(beacon_store_at_slot_10()); + let parent_root = H256::repeat_byte(9); + let sidecar = sidecar_at(10, parent_root); + let block_root = sidecar.signed_block_header.message.hash_tree_root(); + + server.park_data_columns(vec![sidecar.clone()]); + assert!(server.sidecars_awaiting_parent.contains_key(&parent_root)); + + server.drain_sidecars_awaiting_parent(parent_root); + + // Gone from the queue and from `PendingDataColumns`, and handed to + // the checks exactly as it was parked. + assert!(!server.sidecars_awaiting_parent.contains_key(&parent_root)); + assert!( + server + .store + .take_pending_data_column_sidecar(10, &block_root, 0) + .expect("DB read should succeed") + .is_none() + ); + assert_eq!(*p2p.checks.lock().unwrap(), vec![vec![sidecar]]); + } + + #[test] + fn a_parked_sidecar_holds_its_bytes_on_disk_and_not_in_the_queue() { + // The queue's size is chosen by whoever is gossiping, so what it holds + // per entry is the thing that has to stay small: a key, not a cell per + // blob. + let mut server = beacon_server(beacon_store_at_slot_10()); + let parent_root = H256::repeat_byte(9); + let sidecar = sidecar_at(10, parent_root); + let block_root = sidecar.signed_block_header.message.hash_tree_root(); + + server.park_data_columns(vec![sidecar]); + + assert_eq!( + server.sidecars_awaiting_parent.get(&parent_root), + Some(&HashSet::from([ParkedColumn { + slot: 10, + block_root, + index: 0, + }])) + ); + assert!( + server + .store + .take_pending_data_column_sidecar(10, &block_root, 0) + .expect("DB read should succeed") + .is_some(), + "the sidecar's bytes belong in PendingDataColumns" + ); + } + + #[test] + fn a_parked_sidecar_does_not_satisfy_the_availability_gate() { + // Why the parked rows get a table of their own. Nothing has judged a + // parked sidecar's inclusion proof, its KZG batch or its proposer + // signature, so a peer that could get one counted as custodied would + // be able to release a held block with a column it invented. + let mut server = beacon_server(beacon_store_at_slot_10()); + let sidecar = sidecar_at(10, H256::repeat_byte(9)); + let block_root = sidecar.signed_block_header.message.hash_tree_root(); + + server.park_data_columns(vec![sidecar]); + + assert_eq!( + server + .store + .data_column_indices_for(10, &block_root) + .expect("DB read should succeed"), + Vec::::new(), + "an unverified sidecar must be invisible to data_column_indices_for" + ); + } + + #[test] + fn a_sidecar_parked_twice_takes_one_slot_in_the_queue() { + // The by-root and by-range fetch paths skip gossip's seen cache + // entirely, so nothing between them and this actor dedups a + // re-delivery while the parent is still stateless; it is ordinary. A + // second entry would leave a key with no row behind it once the + // first replay took it. + let mut server = beacon_server(beacon_store_at_slot_10()); + let parent_root = H256::repeat_byte(9); + + server.park_data_columns(vec![sidecar_at(10, parent_root)]); + server.park_data_columns(vec![sidecar_at(10, parent_root)]); + + assert_eq!( + server + .sidecars_awaiting_parent + .get(&parent_root) + .map(HashSet::len), + Some(1) + ); + } + + #[test] + fn parked_sidecars_are_dropped_once_finality_passes_their_slot() { + // Populated directly rather than through `park_data_columns`: the + // chain checks refuse a sidecar at or below the finalized slot before + // it could ever be parked, so the only way to observe the sweep is to + // park one behind their back. The finalized slot is fixed at init, so + // the store carries it rather than the test moving it. + let mut server = beacon_server(beacon_store(GENESIS_TIME, 10)); + let superseded = H256::repeat_byte(1); + let still_wanted = H256::repeat_byte(2); + let parked_at = |slot: u64| ParkedColumn { + slot, + block_root: H256::repeat_byte(9), + index: 0, + }; + server + .sidecars_awaiting_parent + .insert(superseded, HashSet::from([parked_at(10)])); + server + .sidecars_awaiting_parent + .insert(still_wanted, HashSet::from([parked_at(20)])); + + server.evict_sidecars_awaiting_parent_at_or_below_finality(); + + // A parent root that never arrives would otherwise pin its children's + // sidecars for this node's whole uptime; one still above finality is + // a parent that may yet show up. + assert!(!server.sidecars_awaiting_parent.contains_key(&superseded)); + assert!(server.sidecars_awaiting_parent.contains_key(&still_wanted)); + } + + // ----------------------------------------------------------------- + // data_availability_for / hold_block_for_columns / + // release_block_if_columns_complete + // + // Neither the KZG proofs nor the signature are checked by the function + // under test in any of these, so `fulu_block_with_commitments` and + // `sidecar_for` build structurally minimal values: present or absent in + // the store is all that matters here. + // ----------------------------------------------------------------- + + /// The columns this node is pretending to custody in these three tests. + const CUSTODY: [u64; 2] = [0, 1]; + + /// A fulu block at `slot`, naming `parent_root`, whose body carries + /// `commitment_count` blob commitments; everything else is a + /// structurally minimal placeholder. Neither the KZG proofs nor the + /// signature are checked by `data_availability_for`, the function every + /// caller of this helper is exercising, so nothing here needs to be real: + /// only structurally present, so `message_hash_tree_root` and `to_ssz` + /// succeed. + fn fulu_block(parent_root: H256, slot: u64, commitment_count: usize) -> SignedBeaconBlock { + let payload = deneb::ExecutionPayload { + parent_hash: Default::default(), + fee_recipient: Default::default(), + state_root: H256::ZERO, + receipts_root: H256::ZERO, + logs_bloom: vec![0u8; preset::BYTES_PER_LOGS_BLOOM] + .try_into() + .expect("exactly the preset length"), + prev_randao: H256::ZERO, + block_number: 0, + gas_limit: 0, + gas_used: 0, + timestamp: 0, + extra_data: Default::default(), + base_fee_per_gas: Default::default(), + block_hash: Default::default(), + transactions: Default::default(), + withdrawals: Default::default(), + blob_gas_used: 0, + excess_blob_gas: 0, + }; + let body = electra::BeaconBlockBody { + randao_reveal: Default::default(), + eth1_data: Default::default(), + graffiti: H256::ZERO, + proposer_slashings: Default::default(), + attester_slashings: Default::default(), + attestations: Default::default(), + deposits: Default::default(), + voluntary_exits: Default::default(), + sync_aggregate: Default::default(), + execution_payload: payload, + bls_to_execution_changes: Default::default(), + blob_kzg_commitments: vec![Default::default(); commitment_count] + .try_into() + .expect("commitment_count stays well within MAX_BLOB_COMMITMENTS_PER_BLOCK here"), + execution_requests: electra::ExecutionRequests { + deposits: Default::default(), + withdrawals: Default::default(), + consolidations: Default::default(), + }, + }; + SignedBeaconBlock::Fulu(electra::SignedBeaconBlock { + message: electra::BeaconBlock { + slot, + proposer_index: 0, + parent_root, + state_root: H256::ZERO, + body, + }, + signature: Default::default(), + }) + } + + /// A fulu block whose body carries `commitment_count` blob commitments, + /// off a zero parent root, at one past `store`'s finalized slot — + /// matching what would actually reach `data_availability_for` in + /// `process_block` off a zero-rooted anchor. See [`fulu_block`] for what + /// the rest of the block looks like. + fn fulu_block_with_commitments(store: &Store, commitment_count: usize) -> SignedBeaconBlock { + let slot = store + .latest_finalized() + .expect("finalized checkpoint exists") + .slot + + 1; + fulu_block(H256::ZERO, slot, commitment_count) + } + + /// A sidecar naming `block`'s own header at `index`; every other field is + /// its type's default, the same minimalism `sidecar_at` uses above. + fn sidecar_for(block: &SignedBeaconBlock, index: u64) -> fulu::DataColumnSidecar { + fulu::DataColumnSidecar { + index, + column: Default::default(), + kzg_commitments: Default::default(), + kzg_proofs: Default::default(), + signed_block_header: shared::SignedBeaconBlockHeader { + message: shared::BeaconBlockHeader { + slot: block.slot(), + proposer_index: block.proposer_index(), + parent_root: block.parent_root(), + state_root: block.state_root(), + body_root: H256::ZERO, + }, + signature: Default::default(), + }, + kzg_commitments_inclusion_proof: vec![ + H256::ZERO; + preset::KZG_COMMITMENTS_INCLUSION_PROOF_DEPTH + ] + .try_into() + .expect("exactly the required depth"), + } + } + + #[test] + fn a_block_with_no_commitments_needs_no_columns() { + let store = beacon_store(0, 0); + let block = fulu_block_with_commitments(&store, 0); + assert!(matches!( + data_availability_for(&store, &block, &CUSTODY).unwrap(), + fork_choice::DataAvailability::NotRequired + )); + } + + #[test] + fn a_block_missing_one_custody_column_is_not_available() { + let store = beacon_store(0, 0); + let block = fulu_block_with_commitments(&store, 2); + let root = block.message_hash_tree_root(); + store + .put_data_column_sidecar(block.slot(), &root, 0, vec![1]) + .unwrap(); + // Column 1 is custodied and absent, so the question cannot be answered + // yet. `None` must not collapse into an empty Columns list: that list + // is vacuously available and would import a block nobody has data for. + assert!(data_availability_for(&store, &block, &CUSTODY).is_none()); + } + + #[test] + fn a_block_with_every_custody_column_is_available() { + let store = beacon_store(0, 0); + let block = fulu_block_with_commitments(&store, 2); + let root = block.message_hash_tree_root(); + for index in CUSTODY { + let sidecar = sidecar_for(&block, index); + store + .put_data_column_sidecar(block.slot(), &root, index, sidecar.to_ssz()) + .unwrap(); + } + assert!(matches!( + data_availability_for(&store, &block, &CUSTODY).unwrap(), + fork_choice::DataAvailability::Columns(sidecars) if sidecars.len() == 2 + )); + } + + #[test] + fn an_empty_custody_set_means_nothing_to_wait_for() { + // The replay harness is the one legitimate caller with no custody set: + // it supplies every block itself and has no columns to wait on. The + // invariant that a real node custodies a non-empty set is enforced + // where a node is configured, not re-derived per block. + // + // With no column to require, `custody_columns_present` is vacuously + // true and the collected evidence is `Columns(vec![])` rather than + // `NotRequired`: the commitments are still there, so this is "nothing + // outstanding", not "no check needed", but the two are equally + // admissible to `is_data_available_columns`. + let store = beacon_store(0, 0); + let block = fulu_block_with_commitments(&store, 1); + + let evidence = data_availability_for(&store, &block, &[]); + + assert!( + matches!( + evidence, + Some(fork_choice::DataAvailability::Columns(sidecars)) if sidecars.is_empty() + ), + "an empty custody set has nothing outstanding, so the block is admissible" + ); + } + + #[test] + fn holding_a_block_persists_it_and_records_its_root() { + let store = beacon_store(GENESIS_TIME, 0); + let mut server = beacon_server(store); + server.custody_columns = CUSTODY.to_vec(); + let block = fulu_block_with_commitments(&server.store, 2); + let root = block.message_hash_tree_root(); + let slot = block.slot(); + + server.hold_block_for_columns(block, slot, ImportTimings::default()); + + assert_eq!(server.blocks_awaiting_columns.get(&root), Some(&slot)); + assert!(server.store.get_signed_block(&root).unwrap().is_some()); + } + + #[tokio::test] + async fn releasing_before_every_custody_column_arrives_is_a_no_op() { + let store = beacon_store(GENESIS_TIME, 0); + let mut server = beacon_server(store); + server.custody_columns = CUSTODY.to_vec(); + let block = fulu_block_with_commitments(&server.store, 2); + let root = block.message_hash_tree_root(); + let slot = block.slot(); + server.hold_block_for_columns(block, slot, ImportTimings::default()); + + // Only one of the two custody columns has arrived. + server + .store + .put_data_column_sidecar(slot, &root, 0, vec![1]) + .unwrap(); + + server.release_block_if_columns_complete(root).await; + + assert!(server.blocks_awaiting_columns.contains_key(&root)); + } + + #[tokio::test] + async fn releasing_once_every_custody_column_arrives_clears_the_hold() { + let store = beacon_store(GENESIS_TIME, 0); + let mut server = beacon_server(store); + server.custody_columns = CUSTODY.to_vec(); + let block = fulu_block_with_commitments(&server.store, 2); + let root = block.message_hash_tree_root(); + let slot = block.slot(); + server.hold_block_for_columns(block.clone(), slot, ImportTimings::default()); + + for index in CUSTODY { + let sidecar = sidecar_for(&block, index); + server + .store + .put_data_column_sidecar(slot, &root, index, sidecar.to_ssz()) + .unwrap(); + } + + server.release_block_if_columns_complete(root).await; + + assert!(!server.blocks_awaiting_columns.contains_key(&root)); + } + + // ----------------------------------------------------------------- + // End-to-end: a held block's fan-out must not livelock the cascade + // + // Regression coverage for the defect a bare `Ok(())` from `process_block` + // used to hide: `process_or_pend_block` treated a hold exactly like an + // import, called `collect_pending_children` on it, and a child block + // naming the still-held block as parent turned that into an unbounded + // cycle on `run_import_cascade`'s own synchronous queue (re-fetch the + // held block from `BlockHeaders` → re-hold it → re-collect the same + // child → repeat), with no yield point, spinning the actor at full CPU + // and starving every tick and gossip message behind it. + // + // This test drives the real entry point (`on_block`, what + // `Handler` calls) end to end rather than the lower-level + // methods the tests above call directly, so it is the one that actually + // exercises `process_or_pend_block`'s handling of `process_block`'s + // return value — nothing else in this file does. + // + // It does not assert that either block ends up with a post-state. + // `fork_choice::on_block`'s state transition needs a real BLS-signed + // RANDAO reveal against a real proposer, a real sync-committee + // aggregate, and a KZG batch over the sidecars this test stores — none + // of which a structurally-minimal block or a placeholder sidecar can + // satisfy, and building ones that could means reproducing the + // spec-fixture machinery `ethlambda-state-transition`'s own test suite + // uses, in a crate this task does not own. What this gate owns, and what + // this test asserts instead, is that a held block's fan-out is bounded: + // the cascade always terminates, the held block is never double-counted, + // and the release path clears the hold regardless of what the resulting + // import attempt does with it. + // ----------------------------------------------------------------- + + #[tokio::test] + async fn a_childs_fan_out_does_not_livelock_a_held_parent() { + // Fulu at genesis: the gate under test only applies inside the + // availability window, which starts at the fulu fork, and this + // fixture's blocks live at single-digit slots. + let mut store = beacon_store_fulu_at_genesis(GENESIS_TIME, 0); + // `get_ancestor`'s finalized-chain walk (inside `fork_choice::on_block`, + // reached once the parent's columns are present) needs a block at the + // zero root to walk from, the same reason + // `the_finalized_root_itself_is_on_the_finalized_chain` needs one. + store + .insert_signed_block(H256::ZERO, bare_block(0, H256::repeat_byte(0xcc))) + .expect("insert"); + store + .insert_state(H256::ZERO, bare_state()) + .expect("insert"); + store + .set_time_ms(seconds_to_milliseconds( + GENESIS_TIME + 5 * Config::mainnet().seconds_per_slot, + )) + .unwrap(); + let mut server = beacon_server(store); + server.custody_columns = CUSTODY.to_vec(); + + let parent = fulu_block_with_commitments(&server.store, 2); + let parent_root = parent.message_hash_tree_root(); + + // First arrival: no columns yet, so the parent must be held, not + // imported. + server + .on_block(parent.clone(), ImportTimings::default()) + .await; + assert!( + !server.store.has_state(&parent_root).unwrap(), + "a held block must not have a post-state" + ); + assert!(server.blocks_awaiting_columns.contains_key(&parent_root)); + + // A child names the still-held block as its parent, in a later, + // separate `on_block` call — exactly the fan-out the defect this + // guards against needs. Under the bug this reproduces, this call + // never returns. + let child = fulu_block(parent_root, parent.slot() + 1, 0); + let child_root = child.message_hash_tree_root(); + server.on_block(child, ImportTimings::default()).await; + + // Reaching this line at all is most of the proof: the cascade + // terminated. What it terminated *into* matters too — the parent + // still held (not re-held into some duplicated bookkeeping) and the + // child parked behind it exactly once, the same shape a genuinely + // missing parent leaves. + assert!(!server.store.has_state(&parent_root).unwrap()); + assert!(server.blocks_awaiting_columns.contains_key(&parent_root)); + assert_eq!( + server.pending_blocks.get(&parent_root), + Some(&HashSet::from([child_root])) + ); + + // The parent's custody columns land and it releases for real. + for index in CUSTODY { + let sidecar = sidecar_for(&parent, index); + server + .store + .put_data_column_sidecar(parent.slot(), &parent_root, index, sidecar.to_ssz()) + .unwrap(); + } + server.release_block_if_columns_complete(parent_root).await; + + // The hold clears unconditionally, before the resulting re-import + // attempt runs (see `release_block_if_columns_complete`'s own + // ordering) — so this holds whether or not that attempt itself + // succeeds, and it does not hang either way. + assert!(!server.blocks_awaiting_columns.contains_key(&parent_root)); + } + + #[tokio::test] + async fn the_descendants_of_a_held_block_wait_for_it_without_re_importing_it() { + // A range batch hands the actor a whole chain at once. When its first + // block is held for columns, every later block names a held ancestor, + // and each used to re-queue that ancestor for another import that + // could only hold it again: 68 re-holds of one block on a mainnet + // follower's first batch after a checkpoint sync. + let mut store = beacon_store_fulu_at_genesis(GENESIS_TIME, 0); + // The held block's parent, as in the test above: a known state is what + // lets it reach the availability gate at all. + store + .insert_signed_block(H256::ZERO, bare_block(0, H256::repeat_byte(0xcc))) + .expect("insert"); + store + .insert_state(H256::ZERO, bare_state()) + .expect("insert"); + store + .set_time_ms(seconds_to_milliseconds( + GENESIS_TIME + 5 * Config::mainnet().seconds_per_slot, + )) + .unwrap(); + let mut server = beacon_server(store); + server.custody_columns = CUSTODY.to_vec(); + + let held = fulu_block_with_commitments(&server.store, 2); + let held_root = held.message_hash_tree_root(); + server + .on_block(held.clone(), ImportTimings::default()) + .await; + assert!(server.blocks_awaiting_columns.contains_key(&held_root)); + + let child = fulu_block(held_root, held.slot() + 1, 0); + let child_root = child.message_hash_tree_root(); + let grandchild = fulu_block(child_root, held.slot() + 2, 0); + let grandchild_root = grandchild.message_hash_tree_root(); + + for block in [child, grandchild] { + let mut queue = VecDeque::new(); + let outcome = server + .process_or_pend_block(block, ImportTimings::default(), &mut queue) + .await; + assert_eq!(outcome, None, "a block with a held ancestor pends"); + assert!( + queue.is_empty(), + "the held ancestor must not be queued for another import" + ); + } + + // Each waits on its own parent, so the held block's release cascades + // down the chain one import at a time. + assert!(server.blocks_awaiting_columns.contains_key(&held_root)); + assert_eq!( + server.pending_blocks.get(&held_root), + Some(&HashSet::from([child_root])) + ); + assert_eq!( + server.pending_blocks.get(&child_root), + Some(&HashSet::from([grandchild_root])) + ); + } + + // ----------------------------------------------------------------- + // The test above proves the cascade cannot livelock; it does not prove + // the fix's whole point, that a released parent actually unblocks what + // was waiting on it. It cannot: `fork_choice::on_block` requires a real + // BLS-signed RANDAO reveal and proposer signature, which a + // structurally-fake block never has, so its own release attempt takes + // the `Err` arm and never reaches `collect_pending_children`. + // + // Building a block that clears real verification needs a genuinely + // signed fulu state: a validator with a real BLS keypair whose + // effective balance and activation make it the deterministic proposer, + // a RANDAO reveal and proposer signature computed over that state's own + // domains, a `state_root` matching the actual resulting post-state, and + // a sync aggregate set to the zero-participant identity-point + // convention rather than left at its all-zero default. `crate::beacon::bls` + // cannot even produce half of that today: it wraps `blst`'s + // verification functions only (`verify`, `aggregate_verify`, ...), with + // no `sign`, and `fulu::BeaconState` alone carries sync committees, + // deposit/exit/consolidation churn queues, and a participation registry + // beyond what a hand-built state can fake its way past. That is exactly + // the spec-fixture machinery this crate does not own; `ethlambda-blockchain` + // depends on `ethlambda-test-fixtures` only to *deserialize* downloaded + // leanSpec vectors, not to synthesize new ones, and no such vector + // targets this implementation-specific regression. + // + // What the test below instead exercises is the nearest reachable case: + // `process_or_pend_block`'s *other* early return, the "beacon block + // already in the store" branch just above the `process_block` call, for + // a root whose post-state exists by the time `on_block` re-delivers it + // (the same shape a redelivery racing an independent import would + // leave). That branch calls `collect_pending_children` directly without + // ever constructing an `ImportOutcome` — a different line than the + // fix's own `Ok(ImportOutcome::Imported)` arm, but the same promise: a + // root with a post-state must not leave its parked children behind. It + // is real production code, not a fake, and seeding the state this way + // is indistinguishable to it from that state having arrived by any + // other route. + // + // It is not a regression guard for the `Held`/`Imported` distinction + // itself: reverting that distinction alone would not make this test + // fail, since the parent here never reaches `process_block` to begin + // with. `_ if !is_new` — `process_block`'s own no-crypto route to + // `Ok(ImportOutcome::Imported)` — is unreachable from `on_block` for the + // same reason: `process_or_pend_block`'s guard intercepts a root whose + // state already exists before `process_block` is ever called, so + // `process_block` only ever sees `is_new == true` through this caller. + // ----------------------------------------------------------------- + + #[tokio::test] + async fn releasing_a_parent_whose_post_state_already_exists_drains_its_parked_child() { + let store = beacon_store(GENESIS_TIME, 0); + let mut server = beacon_server(store); + server.custody_columns = CUSTODY.to_vec(); + + let parent = fulu_block_with_commitments(&server.store, 2); + let parent_root = parent.message_hash_tree_root(); + let slot = parent.slot(); + server.hold_block_for_columns(parent.clone(), slot, ImportTimings::default()); + + // A child parked behind the still-held parent, seeded directly in + // the same shape `process_or_pend_block`'s "parent missing" branch + // leaves one in: readable back by root, and recorded in both + // pending maps. `a_childs_fan_out_does_not_livelock_a_held_parent` + // above already proves a child arriving that way lands here; this + // test starts from that end state to isolate the release side alone. + let child = fulu_block(parent_root, slot + 1, 0); + let child_root = child.message_hash_tree_root(); + server + .store + .insert_pending_block(child_root, child) + .expect("insert"); + server + .pending_blocks + .insert(parent_root, HashSet::from([child_root])); + server.pending_block_parents.insert(child_root, parent_root); + + // Every custody column arrives. + for index in CUSTODY { + let sidecar = sidecar_for(&parent, index); + server + .store + .put_data_column_sidecar(slot, &parent_root, index, sidecar.to_ssz()) + .unwrap(); + } + + // The parent's post-state exists before release runs, standing in + // for whatever independent path put it there; `bare_state` needs no + // parent of its own to diff against (see its own doc), so this + // writes a plain snapshot under `parent_root` with no further setup. + server + .store + .insert_state(parent_root, bare_state()) + .expect("insert"); + + server.release_block_if_columns_complete(parent_root).await; + + // `collect_pending_children` ran: the child is gone from both + // pending maps, whatever its own (real, crypto-checked) re-import + // attempt then did with it. + assert!(!server.pending_blocks.contains_key(&parent_root)); + assert!(!server.pending_block_parents.contains_key(&child_root)); + } + + #[tokio::test] + async fn a_tick_releases_a_held_block_whose_columns_landed_without_waking_it() { + let store = beacon_store(GENESIS_TIME, 0); + let mut server = beacon_server(store); + server.custody_columns = CUSTODY.to_vec(); + + let block = fulu_block_with_commitments(&server.store, 2); + let block_root = block.message_hash_tree_root(); + let slot = block.slot(); + server.hold_block_for_columns(block, slot, ImportTimings::default()); + + // Every custody column is written straight to the store, the way a + // fetched sidecar that never reached `release_block_if_columns_complete` + // would leave it: the hold is satisfied and nothing knows. + for index in CUSTODY { + let sidecar = sidecar_for( + &server.store.get_signed_block(&block_root).unwrap().unwrap(), + index, + ); + server + .store + .put_data_column_sidecar(slot, &block_root, index, sidecar.to_ssz()) + .unwrap(); + } + // Same stand-in as `releasing_a_parent_whose_post_state_already_exists_ + // drains_its_parked_child`: a post-state under this root sends the + // re-import down `process_or_pend_block`'s already-in-store branch + // rather than through crypto a hand-built block cannot pass. + server + .store + .insert_state(block_root, bare_state()) + .expect("insert"); + + server.redrive_held_blocks().await; + + assert!( + !server.blocks_awaiting_columns.contains_key(&block_root), + "a block whose columns are all present must not stay held" + ); + } + + /// A release removes the hold *before* re-importing, which is what makes + /// the re-entry terminate. But the re-import can then fail for a reason + /// that has nothing to do with columns, and until it put the hold back + /// that left the block tracked by nothing at all: neither + /// `redrive_held_blocks` nor `evict_held_blocks_at_or_below_finality` + /// could still see it, so it and every descendant waited for a restart. + /// + /// Driven through the real engine client against a closed port, since an + /// unreachable execution client is exactly the production shape of this: + /// `beacon_engine::ask` spends its retry ladder and `process_block` + /// answers `Held` with nothing recorded. + #[tokio::test] + async fn a_release_that_gets_no_engine_verdict_puts_the_block_back_on_hold() { + let store = beacon_store(GENESIS_TIME, 0); + let mut server = beacon_server(store); + server.custody_columns = CUSTODY.to_vec(); + // Port 1 has nothing listening, so every attempt is refused at once + // rather than waiting out `ENGINE_TIMEOUT`. + server.engine = Some( + EngineClient::new( + "http://127.0.0.1:1".to_string(), + ethlambda_engine::JwtSecret::new([0u8; 32]), + ) + .expect("client builds"), + ); + + let block = fulu_block_with_commitments(&server.store, 2); + let block_root = block.message_hash_tree_root(); + let parent_root = block.parent_root(); + let slot = block.slot(); + server.hold_block_for_columns(block, slot, ImportTimings::default()); + + // The parent's post-state, so the re-import reaches `process_block` + // rather than parking the block on a missing parent. + server + .store + .insert_state(parent_root, bare_state()) + .expect("insert"); + + for index in CUSTODY { + let sidecar = sidecar_for( + &server.store.get_signed_block(&block_root).unwrap().unwrap(), + index, + ); + server + .store + .put_data_column_sidecar(slot, &block_root, index, sidecar.to_ssz()) + .unwrap(); + } + + server.release_block_if_columns_complete(block_root).await; + + assert_eq!( + server.blocks_awaiting_columns.get(&block_root), + Some(&slot), + "a re-import that produced no post-state must leave the block \ + somewhere the per-slot redrive can still reach it" + ); + assert!( + !server + .store + .has_state(&block_root) + .expect("DB read should succeed"), + "the premise: the engine never answered, so nothing imported" + ); + } + + #[tokio::test] + async fn a_tick_leaves_a_block_held_while_a_column_is_still_missing() { + let store = beacon_store(GENESIS_TIME, 0); + let mut server = beacon_server(store); + server.custody_columns = CUSTODY.to_vec(); + + let block = fulu_block_with_commitments(&server.store, 2); + let block_root = block.message_hash_tree_root(); + let slot = block.slot(); + server.hold_block_for_columns(block, slot, ImportTimings::default()); + + // All but one column. The re-drive re-asks for the last one; what it + // must not do is decide the block is available without it. + for index in &CUSTODY[..CUSTODY.len() - 1] { + let sidecar = sidecar_for( + &server.store.get_signed_block(&block_root).unwrap().unwrap(), + *index, + ); + server + .store + .put_data_column_sidecar(slot, &block_root, *index, sidecar.to_ssz()) + .unwrap(); + } + + server.redrive_held_blocks().await; + + assert!( + server.blocks_awaiting_columns.contains_key(&block_root), + "one missing column is still a missing column" + ); + } + + #[tokio::test] + async fn a_new_hold_is_left_to_gossip_and_the_tick_asks_for_what_is_still_missing() { + let store = beacon_store(GENESIS_TIME, 0); + let (mut server, p2p) = beacon_server_recording(store); + server.custody_columns = CUSTODY.to_vec(); + + let block = fulu_block_with_commitments(&server.store, 2); + let block_root = block.message_hash_tree_root(); + let slot = block.slot(); + server.hold_block_for_columns(block, slot, ImportTimings::default()); + + assert!( + p2p.fetches.lock().unwrap().is_empty(), + "a new hold must not ask peers for columns gossip is still delivering" + ); + + // Gossip delivers all but the last column before the tick. + let (last, delivered) = CUSTODY.split_last().expect("CUSTODY is not empty"); + for index in delivered { + let sidecar = sidecar_for( + &server.store.get_signed_block(&block_root).unwrap().unwrap(), + *index, + ); + server + .store + .put_data_column_sidecar(slot, &block_root, *index, sidecar.to_ssz()) + .unwrap(); + } + + server.redrive_held_blocks().await; + + let fetches = p2p.fetches.lock().unwrap(); + let [request] = fetches.as_slice() else { + panic!( + "the tick must ask exactly once, got {} requests", + fetches.len() + ); + }; + assert_eq!(request.block_root, block_root); + assert!(!request.needs_block, "the held block is already in the DB"); + assert_eq!( + request.columns, + vec![*last], + "only the column gossip did not deliver" + ); + } + + /// The counterpart above: a block that is already older than the current + /// slot when it is held gets no gossip window left to race, so this is + /// where it gets its first ask rather than the next redrive. + #[tokio::test] + async fn holding_a_block_older_than_the_current_slot_asks_for_its_columns_at_once() { + let store = beacon_store_at_slot_10(); + let config = store.config(); + let current_slot = fork_choice::get_current_slot(&store, &config); + let (mut server, p2p) = beacon_server_recording(store); + server.custody_columns = CUSTODY.to_vec(); + + // One past the store's finalized slot, the shape a range-synced + // block arrives in: well behind `current_slot`. + let block = fulu_block_with_commitments(&server.store, 2); + let block_root = block.message_hash_tree_root(); + + server.hold_block_for_columns(block, current_slot, ImportTimings::default()); + + let fetches = p2p.fetches.lock().unwrap(); + let [request] = fetches.as_slice() else { + panic!( + "an old block must be asked for at hold time, got {} requests", + fetches.len() + ); + }; + assert_eq!(request.block_root, block_root); + assert!(!request.needs_block, "the held block is already in the DB"); + assert_eq!( + request.columns, + CUSTODY.to_vec(), + "neither custody column has arrived yet" + ); + } } diff --git a/crates/blockchain/src/metrics.rs b/crates/blockchain/src/metrics.rs index 8b3de83cb..3a40d0d7f 100644 --- a/crates/blockchain/src/metrics.rs +++ b/crates/blockchain/src/metrics.rs @@ -41,6 +41,45 @@ pub const BLOCK_PROPOSAL_ATTESTATION_BUILD_PHASES: &[&str] = /// `wrap_proposer` (singleton single-message aggregate over that signature), /// `merge_type2` (merge of every single-message aggregate into the block's /// multi-message aggregate). +/// Per-block section labels for `lean_block_import_phase_seconds`, in the +/// order a block crosses them. Every one of these is produced by +/// `import_timing::ImportTimings::rows`, which a test in that module asserts. +pub const BLOCK_IMPORT_PHASES: &[&str] = &[ + "decode", + "queue", + "defer", + "admit", + "guards", + "preamble", + "parent_wait", + "cascade_wait", + "da_check", + "columns_wait", + "engine", + "verify_struct", + "verify_crypto", + "stf", + "db_write", + "fc_head", + "block_atts", +]; + +/// The label for a whole completed import. +/// +/// Written only when the block actually imported: a held block has no total, +/// because its import has not finished. That is what keeps a two-slot hold +/// out of the import-cost percentiles without an `outcome` label to filter on. +pub const BLOCK_IMPORT_TOTAL_PHASE: &str = "total"; + +/// Section labels charged once per arrival rather than once per block, on the +/// same histogram as [`BLOCK_IMPORT_PHASES`]. +/// +/// One metric rather than two: the query that matters is `rate(..._sum[5m])`, +/// seconds spent per second, which does not divide by an event count and so +/// does not care that these are counted per arrival. `arrival` is the whole +/// handler call, `cascade` the block drain inside it. +pub const BLOCK_ARRIVAL_PHASES: &[&str] = &["arrival", "cascade", "prune", "get_head", "fcu"]; + pub const BLOCK_PROPOSAL_SEAL_PHASES: &[&str] = &["sign_proposer", "wrap_proposer", "merge_type2"]; /// Where a gossip message landed relative to the interval it was due in. @@ -353,6 +392,22 @@ static LEAN_ATTESTATION_VALIDATION_TIME_SECONDS: std::sync::LazyLock .unwrap() }); +/// Buckets run to a whole mainnet slot because that is the question this +/// answers: a head computation that costs seconds is one the chain actor +/// cannot afford between blocks, and it took a live node pinned at 100% CPU +/// with a frozen head to notice, because nothing measured it. +static LEAN_BEACON_HEAD_COMPUTE_TIME_SECONDS: std::sync::LazyLock = + std::sync::LazyLock::new(|| { + register_histogram!( + "lean_beacon_head_compute_time_seconds", + "Duration of one beacon fork-choice head computation", + vec![ + 0.001, 0.005, 0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.0, 4.0, 12.0 + ] + ) + .unwrap() + }); + static LEAN_PQ_SIG_ATTESTATION_SIGNING_TIME_SECONDS: std::sync::LazyLock = std::sync::LazyLock::new(|| { register_histogram!( @@ -546,6 +601,52 @@ static LEAN_BLOCK_PROPOSAL_ATTESTATION_BUILD_PHASE_SECONDS: std::sync::LazyLock< .unwrap() }); +// --- Block import (the sections `import_timing` marks off) --- + +static LEAN_BLOCK_IMPORT_PHASE_SECONDS: std::sync::LazyLock = + std::sync::LazyLock::new(|| { + register_histogram_vec!( + "lean_block_import_phase_seconds", + "Time one section of a block's journey from the wire to a post-state took. `phase` \ + is one of [`BLOCK_IMPORT_PHASES`]: the per-block sections, `total` for a whole \ + completed import, and the per-arrival sections an arrival is charged once for \ + however many blocks its cascade imported. A section that did not run writes \ + nothing, so a lean node never reports the beacon-only phases and vice versa.", + &["phase", "source"], + // One bucket set spans the whole range deliberately: `guards` is + // tens of microseconds, `parent_wait` is tens of seconds, and + // splitting them into two metrics would mean choosing which + // sections may ever be compared against which. + // + // Above half a second the edges step by 1.5x and 1.33x rather than + // doubling. That is where a mainnet block's `stf`, `block_atts`, + // `queue` and `total` all sit, and with doubling edges a 16% + // drop in the mean `stf` left its percentiles interpolated inside + // the same two buckets. The ladder also puts an edge on one + // mainnet slot. It stops at 32 s, so a longer section (in + // practice a `parent_wait`) lands in `+Inf`. + vec![ + 0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 0.75, 1.0, 1.5, 2.0, 3.0, 4.0, + 6.0, 8.0, 12.0, 16.0, 32.0, + ] + ) + .unwrap() + }); + +static LEAN_BLOCK_IMPORT_CASCADE_BLOCKS: std::sync::LazyLock = + std::sync::LazyLock::new(|| { + register_histogram!( + "lean_block_import_cascade_blocks", + "Blocks one arrival put through the import path. Attempts, not imports: a block \ + that ends the pass held or pended counted here all the same, because the question \ + is how much work the arrival caused. Above one means it unblocked children waiting \ + on it, which is when the per-arrival sections are amortised and a late sibling's \ + `cascade_wait` is not the network's fault.", + vec![1.0, 2.0, 3.0, 5.0, 8.0, 16.0, 32.0, 64.0, 128.0] + ) + .unwrap() + }); + static LEAN_BLOCK_PROPOSAL_ATTESTATION_BUILDS_TOTAL: std::sync::LazyLock = std::sync::LazyLock::new(|| { register_int_counter!( @@ -824,6 +925,39 @@ static LEAN_AGGREGATOR_SKIPPED_TOTAL: std::sync::LazyLock = .unwrap() }); +// --- Data Column Sidecars --- +// +// The sidecar checks run in the p2p layer, so their counters live there +// (`lean_data_columns_rejected_total`) and in `ethlambda-state-transition` +// (`lean_data_column_kzg_verify_seconds`). These are the chain actor's own. + +static LEAN_DATA_COLUMNS_STORED_TOTAL: std::sync::LazyLock = + std::sync::LazyLock::new(|| { + register_int_counter!( + "lean_data_columns_stored_total", + "Data column sidecars verified and written to the store" + ) + .unwrap() + }); + +static LEAN_BLOCKS_HELD_FOR_COLUMNS: std::sync::LazyLock = + std::sync::LazyLock::new(|| { + register_int_gauge!( + "lean_blocks_held_for_columns", + "Blocks held from fork choice pending their custody columns" + ) + .unwrap() + }); + +static LEAN_SIDECARS_AWAITING_PARENT: std::sync::LazyLock = + std::sync::LazyLock::new(|| { + register_int_gauge!( + "lean_sidecars_awaiting_parent", + "Data column sidecars parked until their block's parent has a post-state" + ) + .unwrap() + }); + // --- Initialization --- /// Register all metrics with the Prometheus registry so they appear in `/metrics` from startup. @@ -879,6 +1013,7 @@ pub fn init() { // Histograms std::sync::LazyLock::force(&LEAN_FORK_CHOICE_BLOCK_PROCESSING_TIME_SECONDS); std::sync::LazyLock::force(&LEAN_ATTESTATION_VALIDATION_TIME_SECONDS); + std::sync::LazyLock::force(&LEAN_BEACON_HEAD_COMPUTE_TIME_SECONDS); std::sync::LazyLock::force(&LEAN_PQ_SIG_ATTESTATION_SIGNING_TIME_SECONDS); std::sync::LazyLock::force(&LEAN_ATTESTATIONS_PRODUCTION_TIME_SECONDS); std::sync::LazyLock::force(&LEAN_PQ_SIG_ATTESTATION_VERIFICATION_TIME_SECONDS); @@ -902,6 +1037,12 @@ pub fn init() { std::sync::LazyLock::force(&LEAN_BLOCK_PROPOSAL_CHILD_PAYLOADS_CONSUMED_TOTAL); std::sync::LazyLock::force(&LEAN_BLOCK_PROPOSAL_ATTESTATION_DATA_SELECTED); std::sync::LazyLock::force(&LEAN_BLOCK_PROPOSAL_AGGREGATES_SELECTED); + // Block import timing. The label combinations are left to appear as + // blocks arrive: seeding every phase against every source would publish + // over fifty series a lean node can never write to, since a third of the + // phases are beacon-only and a third of the sources are too. + std::sync::LazyLock::force(&LEAN_BLOCK_IMPORT_PHASE_SECONDS); + std::sync::LazyLock::force(&LEAN_BLOCK_IMPORT_CASCADE_BLOCKS); // Gossip arrival timing std::sync::LazyLock::force(&LEAN_GOSSIP_BLOCK_ARRIVAL_DELAY_SECONDS); std::sync::LazyLock::force(&LEAN_GOSSIP_ATTESTATION_ARRIVAL_DELAY_SECONDS); @@ -929,6 +1070,9 @@ pub fn init() { for &reason in AGGREGATOR_SKIP_REASONS { LEAN_AGGREGATOR_SKIPPED_TOTAL.with_label_values(&[reason]); } + // Data column sidecars. + std::sync::LazyLock::force(&LEAN_DATA_COLUMNS_STORED_TOTAL); + LEAN_BLOCKS_HELD_FOR_COLUMNS.set(0); } // --- Public API --- @@ -1006,6 +1150,13 @@ pub fn time_attestation_validation() -> TimingGuard { TimingGuard::new(&LEAN_ATTESTATION_VALIDATION_TIME_SECONDS) } +/// Start timing a beacon fork-choice head computation. Records duration when +/// the guard is dropped, including on the error path: a head computation that +/// fails still spent the time, and the failing one was the expensive one. +pub fn time_beacon_head_compute() -> TimingGuard { + TimingGuard::new(&LEAN_BEACON_HEAD_COMPUTE_TIME_SECONDS) +} + /// Increment the PQ aggregated signatures counter. pub fn inc_pq_sig_aggregated_signatures() { LEAN_PQ_SIG_AGGREGATED_SIGNATURES_TOTAL.inc(); @@ -1214,6 +1365,23 @@ pub fn observe_block_proposal_phase(phase: &str, elapsed: Duration) { .observe(elapsed.as_secs_f64()); } +/// Observe one section of a block's import or of the arrival that carried it. +/// +/// `phase` must be one of [`BLOCK_IMPORT_PHASES`], [`BLOCK_ARRIVAL_PHASES`] or +/// [`BLOCK_IMPORT_TOTAL_PHASE`]. `source` is `gossip` or `sync`; a block this +/// node built itself is not observed at all, since it crossed no wire and its +/// arrival sections would be zeroes that drag every percentile down. +pub fn observe_block_import_phase(phase: &str, source: &str, elapsed: Duration) { + LEAN_BLOCK_IMPORT_PHASE_SECONDS + .with_label_values(&[phase, source]) + .observe(elapsed.as_secs_f64()); +} + +/// Observe how many blocks one arrival put through the import path. +pub fn observe_block_import_cascade_blocks(blocks: usize) { + LEAN_BLOCK_IMPORT_CASCADE_BLOCKS.observe(blocks as f64); +} + /// Increment the completed block-proposal attestation selection runs counter. pub fn inc_block_proposal_attestation_builds() { LEAN_BLOCK_PROPOSAL_ATTESTATION_BUILDS_TOTAL.inc(); @@ -1243,3 +1411,192 @@ pub fn set_node_sync_status(status: SyncStatus) { .set(i64::from(*label == active)); } } + +/// Increment the sidecars written to the store. +pub fn inc_data_column_stored() { + LEAN_DATA_COLUMNS_STORED_TOTAL.inc(); +} + +/// Mirror `blocks_awaiting_columns.len()`: called on both insertion and +/// removal so the gauge is always exact rather than incremented and +/// decremented independently of the map it reports on. +pub fn set_blocks_held_for_columns(count: u64) { + LEAN_BLOCKS_HELD_FOR_COLUMNS.set(count as i64); +} + +/// Sidecars currently parked against a parent root with no post-state. +/// +/// Reads as the queue depth of the recovery path the availability gate depends +/// on: a follower keeping up sits at zero, a brief non-zero is a late parent, +/// and a value that climbs and does not come back down means parents are not +/// arriving at all. +/// +/// Nothing caps the queue, so this is also the only warning that a peer is +/// parking sidecars under parents it never intends to supply: each one holds a +/// `Table::PendingDataColumns` row until finality passes its slot. Worth an +/// alert at a level an honest late parent never reaches. +pub fn set_sidecars_awaiting_parent(count: u64) { + LEAN_SIDECARS_AWAITING_PARENT.set(count as i64); +} + +/// Blocks not imported because no execution client verdict was obtained. +/// +/// A non-zero rate here means the follower is dropping blocks it cannot ask +/// about, and its head will park behind the first one. Nothing re-drives a +/// given-up call, so this is the metric that says the follower has stopped +/// following rather than merely slowed down. +pub fn inc_engine_no_verdict() { + static LEAN_ENGINE_NO_VERDICT_TOTAL: std::sync::LazyLock = + std::sync::LazyLock::new(|| { + register_int_counter!( + "lean_engine_no_verdict_total", + "Blocks not imported because the execution client gave no verdict" + ) + .unwrap() + }); + LEAN_ENGINE_NO_VERDICT_TOTAL.inc(); +} + +/// Blocks not imported because the execution client has not validated them and +/// they do not qualify for an optimistic import. +/// +/// Distinct from [`inc_engine_no_verdict`]: there the execution client never +/// answered, here it answered `SYNCING` or `ACCEPTED` for a block that +/// `is_optimistic_candidate_block` refuses. A sustained rate means the +/// execution client is behind and the blocks reaching this node are too recent +/// to import on age alone. +pub fn inc_engine_not_optimistic_candidate() { + static LEAN_ENGINE_NOT_OPTIMISTIC_CANDIDATE_TOTAL: std::sync::LazyLock = + std::sync::LazyLock::new(|| { + register_int_counter!( + "lean_engine_not_optimistic_candidate_total", + "Blocks not imported because they are not optimistic candidates" + ) + .unwrap() + }); + LEAN_ENGINE_NOT_OPTIMISTIC_CANDIDATE_TOTAL.inc(); +} + +// --------------------------------------------------------------------------- +// Beacon aggregate gossip +// --------------------------------------------------------------------------- +// +// Deferring the per-aggregate amortizations (one `block_index()` scan and one +// `EpochCommittees` build per aggregate, rather than one per batch) is only +// safe while their cost is visible. These are what make it visible: without +// them the symptom is an unexplained head lag, which is exactly the situation +// the block-import timing report was added to answer for blocks. + +/// Buckets for one aggregate's journey, in seconds. +/// +/// Reaching the top of this range means a single aggregate costs more than a +/// mainnet slot's worth of the actor's time at this arrival rate, which is the +/// threshold the deferred amortization exists for. +fn beacon_aggregate_duration_buckets() -> Vec { + vec![ + 0.0005, 0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, + ] +} + +/// How long the chain actor spent on one aggregate, from the moment it was +/// taken off the mailbox to the moment fork choice had it. +/// +/// Covers `apply_verified_aggregate` end to end. `ethlambda-p2p`'s gossip +/// validation already resolved the committees and all three signatures before +/// handing the aggregate over, so what this measures now is just +/// `validate_on_attestation_indexed` and recording the vote against an +/// already-built `block_index()`; a slow observation here points at the store +/// itself, not at cryptography. Observed for aggregates that were actually +/// processed, applied or not; one dropped by the applied-bits gate never +/// reaches this. +pub fn observe_beacon_aggregate_processing(duration: Duration) { + static LEAN_BEACON_AGGREGATE_PROCESSING_SECONDS: std::sync::LazyLock = + std::sync::LazyLock::new(|| { + register_histogram!( + "lean_beacon_aggregate_processing_seconds", + "Time the chain actor spent applying one gossip aggregate to fork choice", + beacon_aggregate_duration_buckets() + ) + .unwrap() + }); + LEAN_BEACON_AGGREGATE_PROCESSING_SECONDS.observe(duration.as_secs_f64()); +} + +/// How long an aggregate waited between the p2p actor handing it over and the +/// chain actor picking it up. +/// +/// The mailbox hop, and the failure mode this whole path introduces: roughly a +/// thousand aggregates a slot queueing behind block imports arrive too late to +/// move the head while every per-aggregate timing still looks healthy. Invisible +/// from inside the chain actor, which is why the instant rides on the message. +pub fn observe_beacon_aggregate_mailbox_wait(duration: Duration) { + static LEAN_BEACON_AGGREGATE_MAILBOX_WAIT_SECONDS: std::sync::LazyLock = + std::sync::LazyLock::new(|| { + register_histogram!( + "lean_beacon_aggregate_mailbox_wait_seconds", + "Time a gossip aggregate spent in the chain actor's mailbox", + beacon_aggregate_duration_buckets() + ) + .unwrap() + }); + LEAN_BEACON_AGGREGATE_MAILBOX_WAIT_SECONDS.observe(duration.as_secs_f64()); +} + +/// Count one aggregate's outcome. +/// +/// `applied` is the one that moved a vote. Everything else names why it did +/// not: `known_subset` is the applied-bits gate, `queue_full` is the deferral +/// queue's cap, and `invalid` is `apply_verified_aggregate` refusing a +/// gossip-accepted aggregate, most often because its target has since been +/// superseded by finality or its own slot has not passed yet. +pub fn inc_beacon_aggregate_outcome(outcome: &str) { + static LEAN_BEACON_AGGREGATE_TOTAL: std::sync::LazyLock = + std::sync::LazyLock::new(|| { + register_int_counter_vec!( + "lean_beacon_aggregate_total", + "Gossip aggregates by outcome", + &["outcome"] + ) + .unwrap() + }); + LEAN_BEACON_AGGREGATE_TOTAL + .with_label_values(&[outcome]) + .inc(); +} + +/// How many aggregates are held waiting for their own slot to pass. +/// +/// A steady value near the queue's cap means aggregates are arriving faster +/// than the once-per-slot drain clears them, which is the backlog the cap +/// turns into a drop rather than into unbounded memory. +pub fn update_beacon_aggregates_deferred(count: usize) { + static LEAN_BEACON_AGGREGATES_DEFERRED: std::sync::LazyLock = + std::sync::LazyLock::new(|| { + register_int_gauge!( + "lean_beacon_aggregates_deferred", + "Gossip aggregates held until their own slot has passed" + ) + .unwrap() + }); + LEAN_BEACON_AGGREGATES_DEFERRED.set(count as i64); +} + +/// How long one aggregate took from coming off the wire to being applied to +/// fork choice. +/// +/// Observed only for aggregates applied on arrival, never for ones released +/// from the deferral queue: those wait a deliberate slot for their own slot to +/// pass, and folding that hold in would report the design as latency. The +/// held path's own health is [`update_beacon_aggregates_deferred`]. +pub fn observe_beacon_aggregate_end_to_end(duration: Duration) { + static LEAN_BEACON_AGGREGATE_END_TO_END_SECONDS: std::sync::LazyLock = + std::sync::LazyLock::new(|| { + register_histogram!( + "lean_beacon_aggregate_end_to_end_seconds", + "Time from a gossip aggregate arriving on the wire to it reaching fork choice", + beacon_aggregate_duration_buckets() + ) + .unwrap() + }); + LEAN_BEACON_AGGREGATE_END_TO_END_SECONDS.observe(duration.as_secs_f64()); +} diff --git a/crates/blockchain/src/reaggregate.rs b/crates/blockchain/src/reaggregate.rs index 43e473112..a566fe977 100644 --- a/crates/blockchain/src/reaggregate.rs +++ b/crates/blockchain/src/reaggregate.rs @@ -73,7 +73,7 @@ pub fn reaggregate_from_block( ); return Vec::new(); }; - let validators = &parent_state.validators; + let validators = &parent_state.expect_lean().validators; let num_validators = validators.len() as u64; // The claims the merged proof carries: one per body attestation in order, diff --git a/crates/blockchain/src/spec_test_runner.rs b/crates/blockchain/src/spec_test_runner.rs index cc2bebc21..3d903b630 100644 --- a/crates/blockchain/src/spec_test_runner.rs +++ b/crates/blockchain/src/spec_test_runner.rs @@ -111,7 +111,7 @@ pub fn apply_fork_choice_step( ) -> Result<(), StepError> { match step.step_type.as_str() { "tick" => { - let config = *store.config(); + let config = store.config().time_grid(); let timestamp_ms = match (step.time, step.interval) { (Some(time_s), _) => time_s * 1000, (None, Some(interval)) => { @@ -134,7 +134,7 @@ pub fn apply_fork_choice_step( let signed_block = block_data.to_blank_signed_block(); if step.tick_to_slot { let block_time_ms = store.config().genesis_time_ms() - + signed_block.message.slot * store.config().milliseconds_per_slot; + + signed_block.message.slot * store.config().slot_duration_ms; store::on_tick(store, block_time_ms, true); } store::on_block_without_verification(store, signed_block)?; diff --git a/crates/blockchain/src/store.rs b/crates/blockchain/src/store.rs index 011d33ee8..dcbe49915 100644 --- a/crates/blockchain/src/store.rs +++ b/crates/blockchain/src/store.rs @@ -10,18 +10,20 @@ use ethlambda_types::{ Attestation, AttestationData, HashedAttestationData, SignedAggregatedAttestation, SignedAttestation, validator_indices, }, + beacon::containers::{BeaconState, SignedBeaconBlock}, block::{Block, BlockHeader, SignedBlock, SingleMessageAggregate}, checkpoint::Checkpoint, primitives::{H256, HashTreeRoot as _}, state::{HISTORICAL_ROOTS_LIMIT, State}, }; -use tracing::{info, trace, warn}; +use tracing::{debug, info, trace, warn}; use crate::{ GOSSIP_DISPARITY_INTERVALS, INTERVALS_PER_SLOT, MAX_ATTESTATIONS_DATA, SlotInterval, block_builder::{ HEAD_VOTE_WINDOW_BLOCKS, PostBlockCheckpoints, ProposalInputs, ProposerConfig, build_block, }, + import_timing::{StoreTimings, VerifyTimings}, metrics, }; @@ -90,7 +92,7 @@ pub fn update_head(store: &mut Store) -> HeadUpdate { let finalized = store .get_state(&new_head) .expect("head state exists") - .map(|state| state.latest_finalized) + .map(|state| state.expect_lean().latest_finalized) .filter(|finalized| { store .get_block_header(&finalized.root) @@ -348,10 +350,11 @@ fn validate_attestation_data(store: &Store, data: &AttestationData) -> Result<() // The bound is in intervals, not slots: a whole-slot margin would let an // adversary pre-publish next-slot aggregates ahead of any honest validator. let attestation_start_interval = data.slot.saturating_mul(INTERVALS_PER_SLOT); - if attestation_start_interval > store.time().unwrap() + GOSSIP_DISPARITY_INTERVALS { + let store_intervals = store.intervals_since_genesis(); + if attestation_start_interval > store_intervals + GOSSIP_DISPARITY_INTERVALS { return Err(StoreError::AttestationTooFarInFuture { attestation_slot: data.slot, - store_time: store.time().unwrap(), + store_time: store_intervals, }); } @@ -360,36 +363,57 @@ fn validate_attestation_data(store: &Store, data: &AttestationData) -> Result<() /// Process a tick event. /// -/// `store.time()` represents interval-count-since-genesis: each increment is one -/// interval, a fifth of the configured slot. Slot and interval-within-slot are -/// derived as: -/// slot = store.time() / INTERVALS_PER_SLOT -/// interval = store.time() % INTERVALS_PER_SLOT +/// Walks the store clock forward one interval at a time, running that +/// interval's duty at each step, and stops at the last interval boundary at or +/// before `timestamp_ms`. The clock itself is `Store::time_ms`, the single row +/// both chains share; this function reads and writes it on the interval grid, +/// which `Store::intervals_since_genesis` derives. Slot and +/// interval-within-slot are: +/// slot = store.intervals_since_genesis() / INTERVALS_PER_SLOT +/// interval = store.intervals_since_genesis() % INTERVALS_PER_SLOT +/// +/// The clock lands on a boundary rather than on `timestamp_ms` itself because +/// the boundary is what was actually processed: the leftover milliseconds +/// carry no duty that has run. Nothing needs finer than that from the store, +/// since the actor schedules against its own wall-clock reading. pub fn on_tick(store: &mut Store, timestamp_ms: u64, has_proposal: bool) { // Convert UNIX timestamp (ms) to interval count since genesis - let time_delta_ms = timestamp_ms.saturating_sub(store.config().genesis_time_ms()); - let time = time_delta_ms / store.config().milliseconds_per_interval(); + let genesis_time_ms = store.config().genesis_time_ms(); + let milliseconds_per_interval = store.config().milliseconds_per_interval(); + let time_delta_ms = timestamp_ms.saturating_sub(genesis_time_ms); + let time = time_delta_ms / milliseconds_per_interval; + + // The clock, set to the start of interval `intervals`. Every write below + // goes through this, so the row can only ever hold a boundary. + let set_interval = |store: &mut Store, intervals: u64| { + store + .set_time_ms(genesis_time_ms + intervals * milliseconds_per_interval) + .expect("set_time_ms should succeed"); + }; // If we're more than a slot behind, fast-forward to a slot before. // Operations are idempotent, so this should be fine. - if time.saturating_sub(store.time().unwrap()) > INTERVALS_PER_SLOT { - store - .set_time(time - INTERVALS_PER_SLOT) - .expect("set_time should succeed"); + // + // The one place the clock moves backwards, and deliberately: it is what + // makes the loop below replay the last slot. A `timestamp_ms` that simply + // precedes the store's own clock (the fork-choice fixtures' `tick_to_slot` + // ticks to a block's slot start, which can) leaves it alone instead, since + // neither this branch nor the loop fires. + if time.saturating_sub(store.intervals_since_genesis()) > INTERVALS_PER_SLOT { + set_interval(store, time - INTERVALS_PER_SLOT); } - while store.time().unwrap() < time { - store - .set_time(store.time().unwrap() + 1) - .expect("set_time should succeed"); + while store.intervals_since_genesis() < time { + let intervals = store.intervals_since_genesis() + 1; + set_interval(store, intervals); - let slot = store.current_slot(); - let interval = SlotInterval::from_intervals_since_genesis(store.time().unwrap()); + let slot = intervals / INTERVALS_PER_SLOT; + let interval = SlotInterval::from_intervals_since_genesis(intervals); trace!(%slot, ?interval, "processing tick"); // has_proposal is only signaled for the final tick (matching Python spec behavior) - let is_final_tick = store.time().unwrap() == time; + let is_final_tick = intervals == time; let should_signal_proposal = has_proposal && is_final_tick; // NOTE: here we assume on_tick never skips intervals. @@ -450,6 +474,7 @@ pub fn on_gossip_attestation( .get_state(&target.root) .expect("target state exists") .ok_or(StoreError::MissingTargetState(target.root))?; + let target_state = target_state.expect_lean(); if validator_id >= target_state.validators.len() as u64 { return Err(StoreError::ValidatorNotInState { validator_index: validator_id, @@ -537,7 +562,7 @@ fn on_gossip_aggregated_attestation_core( .get_state(&aggregated.data.target.root) .expect("target state exists") .ok_or(StoreError::MissingTargetState(aggregated.data.target.root))?; - let validators = &target_state.validators; + let validators = &target_state.expect_lean().validators; let num_validators = validators.len() as u64; let participant_indices: Vec = aggregated.proof.participant_indices().collect(); @@ -604,7 +629,7 @@ fn on_gossip_aggregated_attestation_core( /// /// This is the safe default: it always verifies cryptographic signatures /// and stores them for future block building. Use this for all production paths. -pub fn on_block(store: &mut Store, signed_block: SignedBlock) -> Result<(), StoreError> { +pub fn on_block(store: &mut Store, signed_block: SignedBlock) -> Result { on_block_core(store, signed_block, true) } @@ -615,7 +640,7 @@ pub fn on_block(store: &mut Store, signed_block: SignedBlock) -> Result<(), Stor pub fn on_block_without_verification( store: &mut Store, signed_block: SignedBlock, -) -> Result<(), StoreError> { +) -> Result { on_block_core(store, signed_block, false) } @@ -627,9 +652,13 @@ fn on_block_core( store: &mut Store, signed_block: SignedBlock, verify: bool, -) -> Result<(), StoreError> { +) -> Result { let timing = metrics::time_fork_choice_block_processing(); let block_start = std::time::Instant::now(); + let mut timings = StoreTimings { + guards_start: Some(block_start), + ..StoreTimings::default() + }; let block = &signed_block.message; let block_root = block.hash_tree_root(); @@ -641,7 +670,10 @@ fn on_block_core( .expect("DB read should succeed") { timing.discard(); - return Ok(()); + // Nothing ran, so nothing but the guard boundary is marked: the report + // this feeds prints only the rows whose timings are present. + timings.guards_end = Some(std::time::Instant::now()); + return Ok(timings); } // Verify parent state is available @@ -654,6 +686,7 @@ fn on_block_core( parent_root: block.parent_root, slot, })?; + let parent_state = parent_state.expect_lean(); // Bound the block's slot before the state transition runs (leanSpec #1182). // @@ -672,7 +705,12 @@ fn on_block_core( // Horizon is the current slot plus one whole slot of margin, so an intended // early block still imports (mirrors the attestation future-slot guard, but // with a whole-slot rather than one-interval margin). - let current_slot = store.current_slot(); + // + // From the interval clock, so that it mirrors that guard rather than merely + // resembling it: `on_tick` rewinds the interval clock by a slot to replay + // one after a gap, and reading the shared UNIX-second clock here instead + // would quietly widen the horizon by that slot. + let current_slot = store.intervals_since_genesis() / INTERVALS_PER_SLOT; if slot > current_slot + 1 { return Err(StoreError::BlockTooFarInFuture { block_slot: slot, @@ -700,19 +738,28 @@ fn on_block_core( } let sig_verification_start = std::time::Instant::now(); + timings.guards_end = Some(sig_verification_start); if verify { // Validate cryptographic signatures - verify_block_signatures(&parent_state, &signed_block)?; + let verified = verify_block_signatures(parent_state, &signed_block)?; + timings.verify_structural_start = verified.structural_start; + timings.verify_structural_end = verified.structural_end; + timings.verify_crypto_start = verified.crypto_start; + timings.verify_crypto_end = verified.crypto_end; } let sig_verification = sig_verification_start.elapsed(); let block = signed_block.message.clone(); - // Execute state transition function to compute post-block state + // Execute state transition function to compute post-block state. Clones + // through the cache's `Arc` since the transition mutates in place and the + // store's own cached parent state must be left untouched. let state_transition_start = std::time::Instant::now(); - let mut post_state = parent_state; + timings.stf_start = Some(state_transition_start); + let mut post_state = parent_state.clone(); ethlambda_state_transition::state_transition(&mut post_state, &block)?; let state_transition = state_transition_start.elapsed(); + timings.stf_end = Some(std::time::Instant::now()); // Cache the state root in the latest block header let state_root = block.state_root; @@ -734,13 +781,18 @@ fn on_block_core( .expect("update_checkpoints should succeed"); } - // Store signed block and state + // Store signed block, and hand the post-state to the storage crate's + // background writer. `insert_state` only enqueues; the state's own + // encode/diff/commit cost is measured on that thread instead, as + // `lean_state_write_seconds`, not here. + timings.db_write_start = Some(std::time::Instant::now()); store - .insert_signed_block(block_root, signed_block.clone()) + .insert_signed_block(block_root, SignedBeaconBlock::Lean(signed_block.clone())) .expect("DB insert should succeed"); store - .insert_state(block_root, post_state) + .insert_state(block_root, BeaconState::Lean(post_state)) .expect("DB insert should succeed"); + timings.db_write_end = Some(std::time::Instant::now()); // Block-included attestations are intentionally not counted here. // `lean_attestations_valid_total` tracks the gossip validation pipeline @@ -749,10 +801,18 @@ fn on_block_core( // `lean_state_transition_attestations_processed_total` instead. // Update forkchoice head based on new block and attestations + timings.fc_head_start = Some(std::time::Instant::now()); update_head(store); + timings.fc_head_end = Some(std::time::Instant::now()); let block_total = block_start.elapsed(); - info!( + // Every number here is a row of the import tree `BlockImportReport` prints, + // which additionally separates the writes and the head update this log + // folded into `block_total`. Kept at debug so the two do not say the same + // thing twice at info on every imported block, and so the paths with no + // report to print (the spec-test runner, the corpus builder) still have + // something to turn on. + debug!( %slot, %block_root, %state_root, @@ -761,7 +821,7 @@ fn on_block_core( ?block_total, "Processed new block" ); - Ok(()) + Ok(timings) } /// Calculate target checkpoint for validator attestations. @@ -886,11 +946,12 @@ pub fn get_attestation_target_with_checkpoints( /// with `UnknownSourceBlock`). pub fn produce_attestation_data(store: &Store, slot: u64) -> AttestationData { let head_root = store.head().unwrap(); - let mut source = store + let head_state = store .get_state(&head_root) .expect("head state exists") - .unwrap() - .latest_justified; + .unwrap(); + let head_state = head_state.expect_lean(); + let mut source = head_state.latest_justified; // Replace the placeholder genesis root with the real (head) one. This only // fires on the genesis head, whose state still holds the zero-root @@ -924,7 +985,7 @@ pub fn produce_attestation_data(store: &Store, slot: u64) -> AttestationData { /// before returning the canonical head. fn get_proposal_head(store: &mut Store, slot: u64) -> H256 { // Calculate time corresponding to this slot - let config = *store.config(); + let config = store.config().time_grid(); let slot_time_ms = config.genesis_time_ms() + SlotInterval::BlockPublication.to_ms_since_genesis(slot, &config); @@ -956,6 +1017,7 @@ pub fn produce_block_with_signatures( parent_root: head_root, slot, })?; + let head_state = head_state.expect_lean(); // Validate proposer authorization for this slot let num_validators = head_state.validators.len() as u64; @@ -991,14 +1053,7 @@ pub fn produce_block_with_signatures( let (block, signatures, post_checkpoints) = { let _timing = metrics::time_block_building_payload_aggregation(); - build_block( - &head_state, - slot, - validator_index, - head_root, - inputs, - config, - )? + build_block(head_state, slot, validator_index, head_root, inputs, config)? }; // leanSpec #595: ideally the produced block should not lag the store's @@ -1185,8 +1240,12 @@ pub enum StoreError { pub fn verify_block_signatures( state: &State, signed_block: &SignedBlock, -) -> Result<(), StoreError> { +) -> Result { let total_start = std::time::Instant::now(); + let mut timings = VerifyTimings { + structural_start: Some(total_start), + ..VerifyTimings::default() + }; let block = &signed_block.message; let attestations = &block.body.attestations; @@ -1217,6 +1276,7 @@ pub fn verify_block_signatures( let block_root = block.hash_tree_root(); let structural_elapsed = total_start.elapsed(); + timings.structural_end = Some(std::time::Instant::now()); // Rederive the claims the merged proof must carry from the block body: one // `(message, slot, pubkeys)` per attestation, then the proposer's. Nothing @@ -1267,12 +1327,18 @@ pub fn verify_block_signatures( let merged_bytes = signed_block.proof.proof_bytes(); let crypto_start = std::time::Instant::now(); + timings.crypto_start = Some(crypto_start); ethlambda_crypto::verify_type_2_signature(merged_bytes, &components) .map_err(StoreError::BlockProofVerificationFailed)?; let crypto_elapsed = crypto_start.elapsed(); + timings.crypto_end = Some(std::time::Instant::now()); let total_elapsed = total_start.elapsed(); - info!( + // At debug for the same reason as `on_block_core`'s own timing log: on the + // import path the tree already carries both of these spans as rows. The one + // caller that is not the import path, the Hive driver's `verify_signatures` + // endpoint, prints no tree and reads the returned timings instead. + debug!( slot = block.slot, attestation_count = attestations.len(), ?structural_elapsed, @@ -1281,7 +1347,7 @@ pub fn verify_block_signatures( "Block multi-message aggregate proof verified" ); - Ok(()) + Ok(timings) } /// Check if a head change represents a reorg, returning the depth if so. @@ -1370,8 +1436,8 @@ mod tests { bits } - /// The store clock counts intervals, so it has to advance once per - /// configured interval rather than once per hardcoded 800 ms. + /// The interval clock counts intervals, so it has to advance once per + /// configured interval rather than once per hardcoded fraction of a second. #[test] fn on_tick_advances_one_interval_per_configured_interval() { use ethlambda_storage::backend::InMemoryBackend; @@ -1385,20 +1451,83 @@ mod tests { let mut store = Store::from_anchor_state(backend, genesis_state, MILLISECONDS_PER_SLOT); let genesis_ms = GENESIS_TIME * 1_000; - // One interval in: still short of the second boundary at 1600 ms. + // One interval in: still short of the interval boundary. on_tick(&mut store, genesis_ms + 1_599, false); - assert_eq!(store.time().unwrap(), 0); + assert_eq!(store.intervals_since_genesis(), 0); on_tick(&mut store, genesis_ms + 1_600, false); - assert_eq!(store.time().unwrap(), 1); + assert_eq!(store.intervals_since_genesis(), 1); assert_eq!(store.current_slot(), 0); // A whole slot in: five intervals, so the slot rolls over. on_tick(&mut store, genesis_ms + MILLISECONDS_PER_SLOT, false); - assert_eq!(store.time().unwrap(), INTERVALS_PER_SLOT); + assert_eq!(store.intervals_since_genesis(), INTERVALS_PER_SLOT); assert_eq!(store.current_slot(), 1); } + /// One tick walks the clock interval by interval, and every reading derived + /// from it must agree about where it landed. + #[test] + fn on_tick_leaves_every_derived_clock_on_the_interval_it_processed() { + use ethlambda_storage::backend::InMemoryBackend; + use std::sync::Arc; + + const GENESIS_TIME: u64 = 1_770_407_233; + const MILLISECONDS_PER_SLOT: u64 = 8_000; + + let backend = Arc::new(InMemoryBackend::new()); + let genesis_state = State::from_genesis(GENESIS_TIME, vec![]); + let mut store = Store::from_anchor_state(backend, genesis_state, MILLISECONDS_PER_SLOT); + let genesis_ms = GENESIS_TIME * 1_000; + + for slot in 0..4u64 { + for interval in 0..INTERVALS_PER_SLOT { + let ms = genesis_ms + + slot * MILLISECONDS_PER_SLOT + + interval * (MILLISECONDS_PER_SLOT / INTERVALS_PER_SLOT); + on_tick(&mut store, ms, false); + + let intervals = store.intervals_since_genesis(); + assert_eq!(intervals, slot * INTERVALS_PER_SLOT + interval); + assert_eq!( + store.current_slot(), + intervals / INTERVALS_PER_SLOT, + "slot {slot} interval {interval}" + ); + // The row itself holds the boundary that was processed, which + // here is the tick's own timestamp because every one of these + // lands on one. + assert_eq!(store.time_ms().expect("time"), ms); + } + } + } + + /// `tick_to_slot` in the fork-choice fixtures ticks to a block's slot + /// start, which can precede a tick already applied. The clock may not walk + /// backwards when it does. (The deliberate replay rewind is a different + /// case: it needs the target to be more than a slot *ahead*.) + #[test] + fn on_tick_never_rewinds_the_clock_for_an_earlier_timestamp() { + use ethlambda_storage::backend::InMemoryBackend; + use std::sync::Arc; + + const GENESIS_TIME: u64 = 1_000; + const MILLISECONDS_PER_SLOT: u64 = 8_000; + + let backend = Arc::new(InMemoryBackend::new()); + let genesis_state = State::from_genesis(GENESIS_TIME, vec![]); + let mut store = Store::from_anchor_state(backend, genesis_state, MILLISECONDS_PER_SLOT); + let genesis_ms = GENESIS_TIME * 1_000; + + on_tick(&mut store, genesis_ms + 3 * MILLISECONDS_PER_SLOT, false); + let advanced_time = store.time_ms().expect("time"); + let advanced_intervals = store.intervals_since_genesis(); + + on_tick(&mut store, genesis_ms + MILLISECONDS_PER_SLOT, false); + assert_eq!(store.time_ms().expect("time"), advanced_time); + assert_eq!(store.intervals_since_genesis(), advanced_intervals); + } + #[test] fn on_block_rejects_duplicate_attestation_data() { use ethlambda_storage::backend::InMemoryBackend; @@ -1496,10 +1625,21 @@ mod tests { proof: make_signed_block_proof(0, vec![]), }; store - .insert_signed_block(root, signed_block) + .insert_signed_block(root, SignedBeaconBlock::Lean(signed_block)) .expect("insert test block should succeed"); } + /// Put `store`'s clock at the start of interval `intervals` since genesis. + /// + /// These tests are about the interval grid, but the store keeps one + /// millisecond row, so this is the conversion, written once and read off + /// the store's own configuration rather than a repeated literal. + fn set_interval_clock(store: &mut Store, intervals: u64) { + let config = store.config(); + let ms = config.genesis_time_ms() + intervals * config.milliseconds_per_interval(); + store.set_time_ms(ms).expect("set_time_ms should succeed"); + } + fn new_test_store() -> Store { use ethlambda_storage::backend::InMemoryBackend; use std::sync::Arc; @@ -1533,6 +1673,7 @@ mod tests { // `latest_block_header.parent_root`, and `get_state(b)` then returns it // from the cache. let genesis_state = store.get_state(&genesis).expect("genesis state").unwrap(); + let genesis_state = genesis_state.expect_lean(); let mut head_state = genesis_state.clone(); head_state.slot = genesis_state.slot + 1; head_state.latest_justified = head_justified; @@ -1541,7 +1682,7 @@ mod tests { hbh.push(genesis); head_state.historical_block_hashes = hbh.try_into().expect("within limit"); store - .insert_state(b, head_state) + .insert_state(b, BeaconState::Lean(head_state)) .expect("insert head state should succeed"); // Store's global justified latched onto a higher, off-head checkpoint, @@ -1553,9 +1694,7 @@ mod tests { store .update_checkpoints(ForkCheckpoints::new(b, Some(off_head_justified), None)) .expect("update_checkpoints should succeed"); - store - .set_time(2 * INTERVALS_PER_SLOT) - .expect("set_time should succeed"); + set_interval_clock(&mut store, 2 * INTERVALS_PER_SLOT); let data = produce_attestation_data(&store, 2); @@ -1596,9 +1735,7 @@ mod tests { store .update_checkpoints(ForkCheckpoints::head_only(b3)) .expect("update_checkpoints should succeed"); - store - .set_time(3 * INTERVALS_PER_SLOT) - .expect("set_time should succeed"); + set_interval_clock(&mut store, 3 * INTERVALS_PER_SLOT); let data = AttestationData { slot: 3, @@ -1743,9 +1880,7 @@ mod tests { insert_test_block(&mut store, base, 1, genesis); insert_test_block(&mut store, fork_left, 2, base); insert_test_block(&mut store, fork_right, 3, base); - store - .set_time(3 * INTERVALS_PER_SLOT) - .expect("set_time should succeed"); + set_interval_clock(&mut store, 3 * INTERVALS_PER_SLOT); // source=base, target=fork_left, head=fork_right: target and head share a // parent (base) but neither is an ancestor of the other. @@ -1787,9 +1922,7 @@ mod tests { insert_test_block(&mut store, fork_left, 2, base); insert_test_block(&mut store, fork_right, 3, base); insert_test_block(&mut store, fork_right_head, 4, fork_right); - store - .set_time(4 * INTERVALS_PER_SLOT) - .expect("set_time should succeed"); + set_interval_clock(&mut store, 4 * INTERVALS_PER_SLOT); // source=fork_left (abandoned branch), target=head=fork_right_head: // source precedes target in slot but lies off the target's chain. @@ -1830,9 +1963,7 @@ mod tests { insert_test_block(&mut store, b1, 1, genesis); insert_test_block(&mut store, b2, 2, b1); insert_test_block(&mut store, b3, 3, b2); - store - .set_time(3 * INTERVALS_PER_SLOT) - .expect("set_time should succeed"); + set_interval_clock(&mut store, 3 * INTERVALS_PER_SLOT); // head=b3 at slot 3, but the vote's own slot is 2: it claims to have seen // a head that did not yet exist when the vote was cast. @@ -1872,9 +2003,7 @@ mod tests { let b2 = H256([2u8; 32]); insert_test_block(&mut store, b1, 1, genesis); insert_test_block(&mut store, b2, 2, b1); - store - .set_time(2 * INTERVALS_PER_SLOT) - .expect("set_time should succeed"); + set_interval_clock(&mut store, 2 * INTERVALS_PER_SLOT); // A crafted gossip vote with a near-`u64::MAX` slot. The head-consistency // check passes (slot >= head.slot), so this exercises the time check. @@ -1905,9 +2034,7 @@ mod tests { let b2 = H256([2u8; 32]); insert_test_block(&mut store, b1, 1, genesis); insert_test_block(&mut store, b2, 2, b1); - store - .set_time(2 * INTERVALS_PER_SLOT) - .expect("set_time should succeed"); + set_interval_clock(&mut store, 2 * INTERVALS_PER_SLOT); let data = AttestationData { slot: 2, @@ -1945,9 +2072,7 @@ mod tests { insert_test_block(&mut store, block_2, 2, block_1); insert_test_block(&mut store, orph_2, 2, block_1); insert_test_block(&mut store, orph_3, 3, orph_2); - store - .set_time(3 * INTERVALS_PER_SLOT) - .expect("set_time should succeed"); + set_interval_clock(&mut store, 3 * INTERVALS_PER_SLOT); // Vote entirely on the orphan branch: source=block_1, target=orph_2, // head=orph_3. Source/target/head form one parent chain, so every @@ -2012,7 +2137,7 @@ mod tests { let backend = Arc::new(InMemoryBackend::new()); let mut store = Store::from_anchor_state(backend, genesis_state, DEFAULT_MILLISECONDS_PER_SLOT); - store.set_time(0).expect("set_time should succeed"); + set_interval_clock(&mut store, 0); // current_slot = 0, so the horizon is slot 1; a slot-2 block overshoots it. let block = Block { @@ -2053,7 +2178,7 @@ mod tests { let backend = Arc::new(InMemoryBackend::new()); let mut store = Store::from_anchor_state(backend, genesis_state, DEFAULT_MILLISECONDS_PER_SLOT); - store.set_time(0).expect("set_time should succeed"); + set_interval_clock(&mut store, 0); // Parent (genesis) sits at slot 0, so a slot one past the limit overshoots. let gap_slot = HISTORICAL_ROOTS_LIMIT as u64 + 1; diff --git a/crates/blockchain/state_transition/Cargo.toml b/crates/blockchain/state_transition/Cargo.toml index dcb916dbd..fc7c84fde 100644 --- a/crates/blockchain/state_transition/Cargo.toml +++ b/crates/blockchain/state_transition/Cargo.toml @@ -9,23 +9,108 @@ repository.workspace = true rust-version.workspace = true version.workspace = true +[features] +# Compile the Beacon Chain half against the minimal preset instead of mainnet. +# +# Container shapes are compile-time constants (SSZ list and vector bounds), so +# the preset cannot be selected at runtime. Mainnet is the implicit default; +# enabling this feature switches every beacon constant to the minimal preset. +# The beacon spec-test target builds the crate twice, once per preset, and each +# build walks only its own fixture tree. Lean's own state transition reads none +# of these constants, so the feature cannot change lean behavior. +preset-minimal = ["ethlambda-types/preset-minimal"] + +# Builds the beacon spec-test target. Off by default, and it gates only that +# target: the `beacon` module itself always compiles. What it stands for is the +# fixture tree, a multi-gigabyte download the lean workflow has no use for, and +# `beacon_spec::fixture_root` deliberately panics rather than reporting a green +# suite that found nothing to run. So `cargo test --workspace` skips the target +# instead of failing on the missing directory; `make test-beacon` turns it on +# after downloading the fixtures. +beacon-spec-tests = [] + +# Exposes `beacon::helpers::test_state`, the state builder this crate's own +# tests use, to other crates' tests (the Beacon API's duty endpoints need a +# state with a real validator registry). Never enabled outside +# `[dev-dependencies]`. +test-utils = [] + [dependencies] ethlambda-types.workspace = true ethlambda-metrics.workspace = true +ethlambda-storage.workspace = true thiserror.workspace = true tracing.workspace = true +lru.workspace = true + +# -- The `beacon` module's own dependencies --------------------------------- + +hex.workspace = true + +# For `beacon::genesis`, which fills in an `SszList` of validators. The beacon +# containers and their derives live in `ethlambda-types`, so the derive macros +# and the merkleizer are reached through them rather than named here; the +# spec-test harness declares its own for the containers it defines. +libssz-types.workspace = true + +# For `SszEncode::to_ssz` on a bare `SszList`, which `get_execution_requests_list` +# needs: the beacon containers' own `to_ssz` inherent methods cover whole states +# and blocks, not one list field of a body. +libssz.workspace = true + +# For `beacon::hash::hash`, that module's one SHA-256 entry point. The workspace +# pins the `asm` feature; see the root manifest for what it buys and why +# merkleization cares. `libssz-merkle` depends on `sha2` too and asks for no +# features of its own, and Cargo unifies features across a version, so its copy +# gets the same. +sha2.workspace = true + +# BLS12-381. Assembly-optimized, and the canonical consensus-layer backend. +blst = "0.3.16" + +# For `beacon::bls`'s `aggregate_verify` and `fast_aggregate_verify`, which +# validate an aggregate's public keys (a subgroup check per signer) in +# parallel: a mainnet Electra aggregate can carry thousands of signers behind +# one pairing check, and the beacon state transition otherwise runs that loop +# on a single actor thread while the rest of the host's cores sit idle. +# `crates/blockchain` already depends on this for its own worker, so this +# only adds it to a crate that did not need it before. +rayon.workspace = true + +# KZG, including the EIP-7594 cell and column proofs that fulu needs. Same +# version and feature set ethrex uses, plus the embedded mainnet trusted setup. +c-kzg = { version = "2.1.8", features = ["ethereum_kzg_settings"] } + +# Reduction modulo the BLS field order, for the two Fiat-Shamir challenge +# functions that c-kzg does not export. +num-bigint = "0.4.6" [dev-dependencies] ethlambda-test-fixtures.workspace = true serde.workspace = true serde_json.workspace = true +serde_yaml_ng.workspace = true hex.workspace = true libssz-types.workspace = true +# The beacon spec-test harness defines its own containers for the fixture-only +# shapes (`meta.yaml` payloads, the rewards suite's delta files), so it needs the +# encoder, the derives and the merkleizer the derives expand into. +libssz.workspace = true +libssz-derive.workspace = true +libssz-merkle.workspace = true +snap = "1.1.1" + datatest-stable = "0.3.3" +# Supplies the beacon spec-test binary's harness. A fixture case is only known +# once the fixture tree is walked, which `#[test]` cannot express, so the tests +# are built at run time instead. See `tests/beacon_spec_tests.rs` for the full +# reasoning. +libtest-mimic = "0.8.2" + [[test]] name = "stf_spectests" path = "tests/stf_spectests.rs" @@ -34,3 +119,9 @@ harness = false # leanVM key format and fail to decode. Run on demand with # `cargo test --profile release-fast --test `. test = false + +[[test]] +name = "beacon_spec_tests" +path = "tests/beacon_spec_tests.rs" +harness = false +required-features = ["beacon-spec-tests"] diff --git a/crates/blockchain/state_transition/src/beacon/aggregate.rs b/crates/blockchain/state_transition/src/beacon/aggregate.rs new file mode 100644 index 000000000..5ea264e87 --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/aggregate.rs @@ -0,0 +1,99 @@ +//! What is left of `beacon_aggregate_and_proof`'s validation, now that its +//! gossip conditions have moved to +//! `ethlambda_state_transition::beacon::gossip::aggregate` (validated in +//! `ethlambda-p2p`, off the chain actor) and +//! [`super::fork_choice::apply_verified_aggregate`] only applies what gossip +//! already accepted. +//! +//! Two pieces the actor's own applied-bits gate +//! (`ethlambda_blockchain::beacon_aggregates::AggregateGossip`) still needs: +//! [`is_non_strict_superset`], the same "does one committee's coverage already +//! include this aggregate's bits" test the spec's `Seen` uses, and +//! [`MAX_AGGREGATES_PER_SLOT`], which sizes that gate's deferral queue. Neither +//! reads a state or a store, so neither had a reason to move with the rest. + +use crate::beacon::constants; +use crate::beacon::preset; + +/// Whether `seen` already covers every bit `candidate` sets. +/// +/// The specification's `is_non_strict_superset`, which is what makes a +/// committee's sixteen aggregators cost one verification rather than sixteen: +/// once the union of what has been seen for one `AttestationData` covers a +/// later aggregate, that aggregate can add no vote and is dropped before any +/// pairing. +/// +/// A `candidate` longer than `seen` is not covered, whatever the overlap says: +/// the extra bits are positions `seen` has no opinion on. +pub fn is_non_strict_superset(seen: &[bool], candidate: &[bool]) -> bool { + if candidate.len() > seen.len() { + return false; + } + candidate + .iter() + .zip(seen.iter()) + .all(|(wanted, have)| !*wanted || *have) +} + +/// How many aggregates one slot can carry at most, which is what sizes the +/// actor's per-slot expectations and the deferral queue's bound. +/// +/// `MAX_COMMITTEES_PER_SLOT` committees each selecting +/// [`constants::TARGET_AGGREGATORS_PER_COMMITTEE`] aggregators. An upper +/// bound rather than a forecast: a chain with fewer active validators has +/// fewer committees per slot, and selection is probabilistic around the +/// target rather than exact. +pub const MAX_AGGREGATES_PER_SLOT: u64 = + preset::MAX_COMMITTEES_PER_SLOT as u64 * constants::TARGET_AGGREGATORS_PER_COMMITTEE; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_superset_covers_what_it_contains() { + // Everything the candidate wants is already held. + assert!(is_non_strict_superset( + &[true, true, true], + &[true, false, true] + )); + // Equal sets are non-strict supersets of each other. + assert!(is_non_strict_superset( + &[true, false, true], + &[true, false, true] + )); + // The empty candidate adds nothing to anything. + assert!(is_non_strict_superset(&[false, false], &[false, false])); + } + + #[test] + fn a_candidate_with_a_new_bit_is_not_covered() { + // Position 1 is new, so this aggregate carries a vote the seen union + // does not have and must not be dropped. + assert!(!is_non_strict_superset( + &[true, false, true], + &[true, true, false] + )); + } + + /// A longer candidate is never covered, however much of its prefix + /// overlaps: the positions past the end are ones the seen union has no + /// opinion about, and treating them as covered would drop real votes. + #[test] + fn a_longer_candidate_is_never_covered() { + assert!(!is_non_strict_superset(&[true], &[true, true])); + assert!(!is_non_strict_superset(&[true, true], &[true, true, false])); + } + + /// Pinned per preset rather than against the definition, which would + /// restate the arithmetic instead of checking it. This bound is what sizes + /// the actor's deferral queue, so the number each preset actually produces + /// is worth having written down. + #[test] + fn the_per_slot_bound_is_committees_times_aggregators() { + #[cfg(not(feature = "preset-minimal"))] + assert_eq!(MAX_AGGREGATES_PER_SLOT, 1024); + #[cfg(feature = "preset-minimal")] + assert_eq!(MAX_AGGREGATES_PER_SLOT, 64); + } +} diff --git a/crates/blockchain/state_transition/src/beacon/attestation_pool.rs b/crates/blockchain/state_transition/src/beacon/attestation_pool.rs new file mode 100644 index 000000000..32fa51cb6 --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/attestation_pool.rs @@ -0,0 +1,294 @@ +//! Unaggregated attestations, held until an aggregator asks for them. +//! +//! What `GET /eth/v2/validator/aggregate_attestation` answers from: phase0's +//! `validator.md` ("Aggregation selection" onward) has an aggregator collect +//! the attestations its committee gossiped for the slot and combine every one +//! sharing its own `AttestationData` into a single `Attestation`. +//! +//! Only attestations that already passed gossip validation go in (the Beacon +//! API's pool endpoint, or `beacon_attestation_{subnet_id}`'s verdict), since +//! that is what makes combining their signatures safe: one bad share would +//! make the whole aggregate fail verification for every peer that receives it. +//! The caller also supplies the attester's committee position and the +//! committee's length, which it has from the same validation. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use ethlambda_types::{ + beacon::{ + containers::{ + electra::{AggregationBits, Attestation, CommitteeBits, SingleAttestation}, + shared::AttestationData, + }, + preset, + primitives::{BlsSignature, CommitteeIndex, Root, Slot}, + }, + primitives::HashTreeRoot as _, +}; + +use super::bls; + +/// The pool, shared between whatever fills it (the Beacon API's pool +/// endpoints, the attestation subnet handler, accepted gossip aggregates) +/// and what reads it (the aggregate endpoint, block +/// production). There must be exactly one per node: a second instance hides +/// its writers' entries from the other's readers. +pub type SharedAttestationPool = Arc>; + +/// One committee's votes on one `AttestationData`. +#[derive(Debug)] +struct Votes { + data: AttestationData, + /// One slot per committee member, by committee position. + signatures: Vec>, +} + +#[derive(Debug, Default)] +pub struct AttestationPool { + /// Keyed by the data's root and the committee, which is exactly what an + /// aggregate request names. + votes: HashMap<(Root, CommitteeIndex), Votes>, + /// Single-committee aggregates this node has validated, the best-covered + /// per data and committee: the ones its validator clients published + /// through it, and gossip aggregates from other nodes once P2P has + /// verified all three of their signatures. Block production packs from + /// these as well as from `votes`. + aggregates: HashMap<(Root, CommitteeIndex), Attestation>, +} + +impl AttestationPool { + /// Record a validated attestation, and drop everything more than an epoch + /// older than it: an aggregate is requested in the slot its votes were + /// cast, so older votes can no longer be asked for. + /// + /// A second attestation from the same committee position keeps the first, + /// the same way gossip keeps the first valid attestation per validator. + pub fn insert( + &mut self, + attestation: &SingleAttestation, + committee_position: usize, + committee_len: usize, + ) { + self.prune_before(attestation.data.slot); + + let key = ( + attestation.data.hash_tree_root(), + attestation.committee_index, + ); + let votes = self.votes.entry(key).or_insert_with(|| Votes { + data: attestation.data, + signatures: vec![None; committee_len], + }); + if let Some(position @ None) = votes.signatures.get_mut(committee_position) { + *position = Some(attestation.signature); + } + } + + /// Record a validated single-committee aggregate, keeping whichever of it + /// and the one already held for its data and committee covers more + /// members. An aggregate naming other than exactly one committee is + /// ignored: this pool's unit is one committee. + pub fn insert_aggregate(&mut self, aggregate: Attestation) { + let Some(committee_index) = single_committee(&aggregate) else { + return; + }; + self.prune_before(aggregate.data.slot); + let key = (aggregate.data.hash_tree_root(), committee_index); + let better = self + .aggregates + .get(&key) + .is_none_or(|held| set_bits(&aggregate) > set_bits(held)); + if better { + self.aggregates.insert(key, aggregate); + } + } + + /// The best single-committee aggregate held for every data and + /// committee, for block production to pack: whichever of the aggregate + /// built from the pooled votes and a recorded aggregate covers more. + pub fn block_candidates(&self) -> Vec { + let keys: std::collections::HashSet<_> = self + .votes + .keys() + .chain(self.aggregates.keys()) + .copied() + .collect(); + keys.into_iter() + .filter_map(|(data_root, committee_index)| { + let from_votes = self + .votes + .get(&(data_root, committee_index)) + .and_then(|votes| self.aggregate(data_root, votes.data.slot, committee_index)); + let recorded = self.aggregates.get(&(data_root, committee_index)).cloned(); + match (from_votes, recorded) { + (Some(a), Some(b)) => Some(if set_bits(&a) >= set_bits(&b) { a } else { b }), + (a, b) => a.or(b), + } + }) + .collect() + } + + /// Drop everything more than an epoch older than `slot`. + /// + /// Inserts call this themselves; P2P also calls it once per slot, so a + /// pool nothing is inserted into does not keep stale entries. + pub fn prune_before(&mut self, slot: Slot) { + self.votes + .retain(|_, votes| votes.data.slot + preset::SLOTS_PER_EPOCH > slot); + self.aggregates + .retain(|_, aggregate| aggregate.data.slot + preset::SLOTS_PER_EPOCH > slot); + } + + /// Every vote held for `data_root` from `committee_index`'s committee, + /// aggregated into electra's `Attestation`: one bit per committee member, + /// the one committee named in `committee_bits`, and the BLS aggregate of + /// the members' signatures. + /// + /// `None` if nothing is held for that data and committee, or if the data + /// is not for `slot`, which the endpoint also names. + pub fn aggregate( + &self, + data_root: Root, + slot: Slot, + committee_index: CommitteeIndex, + ) -> Option { + let votes = self.votes.get(&(data_root, committee_index))?; + if votes.data.slot != slot { + return None; + } + + let mut aggregation_bits = AggregationBits::with_length(votes.signatures.len()).ok()?; + let mut signatures = Vec::new(); + for (position, signature) in votes.signatures.iter().enumerate() { + if let Some(signature) = signature { + aggregation_bits.set(position, true).ok()?; + signatures.push(*signature); + } + } + let mut committee_bits = CommitteeBits::default(); + committee_bits.set(committee_index as usize, true).ok()?; + + Some(Attestation { + aggregation_bits, + data: votes.data, + signature: bls::aggregate(&signatures).ok()?, + committee_bits, + }) + } +} + +/// The one committee a single-committee attestation names, `None` if it names +/// none or several. +pub(crate) fn single_committee(attestation: &Attestation) -> Option { + let mut named = (0..preset::MAX_COMMITTEES_PER_SLOT) + .filter(|&index| attestation.committee_bits.get(index).unwrap_or(false)); + let first = named.next()?; + named.next().is_none().then_some(first as CommitteeIndex) +} + +fn set_bits(attestation: &Attestation) -> usize { + (0..attestation.aggregation_bits.len()) + .filter(|&i| attestation.aggregation_bits.get(i).unwrap_or(false)) + .count() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::beacon::helpers::test_state::sign_for; + use ethlambda_types::beacon::containers::shared::Checkpoint; + + fn data(slot: Slot) -> AttestationData { + AttestationData { + slot, + index: 0, + beacon_block_root: Root::repeat_byte(1), + source: Checkpoint::default(), + target: Checkpoint { + epoch: 0, + root: Root::repeat_byte(2), + }, + } + } + + fn vote(slot: Slot, committee_index: u64, validator: u64) -> SingleAttestation { + let data = data(slot); + SingleAttestation { + committee_index, + attester_index: validator, + signature: sign_for(validator as usize, data.hash_tree_root()), + data, + } + } + + #[test] + fn the_aggregate_carries_every_member_seen_and_their_combined_signature() { + let mut pool = AttestationPool::default(); + let (first, second) = (vote(5, 2, 10), vote(5, 2, 11)); + pool.insert(&first, 0, 4); + pool.insert(&second, 3, 4); + + let aggregate = pool.aggregate(data(5).hash_tree_root(), 5, 2).unwrap(); + let bits: Vec = (0..4) + .map(|i| aggregate.aggregation_bits.get(i).unwrap()) + .collect(); + assert_eq!(bits, [true, false, false, true]); + assert!(aggregate.committee_bits.get(2).unwrap()); + assert!(!aggregate.committee_bits.get(0).unwrap()); + let expected = bls::aggregate(&[first.signature, second.signature]).unwrap(); + assert_eq!(aggregate.signature, expected); + } + + #[test] + fn a_repeated_position_keeps_the_first_signature() { + let mut pool = AttestationPool::default(); + let first = vote(5, 0, 10); + let mut repeat = vote(5, 0, 10); + repeat.signature = vote(5, 0, 11).signature; + pool.insert(&first, 1, 2); + pool.insert(&repeat, 1, 2); + + let aggregate = pool.aggregate(data(5).hash_tree_root(), 5, 0).unwrap(); + assert_eq!( + aggregate.signature, + bls::aggregate(&[first.signature]).unwrap() + ); + } + + #[test] + fn nothing_is_answered_for_another_committee_slot_or_data() { + let mut pool = AttestationPool::default(); + pool.insert(&vote(5, 0, 10), 0, 2); + assert!(pool.aggregate(data(5).hash_tree_root(), 5, 1).is_none()); + assert!(pool.aggregate(data(5).hash_tree_root(), 6, 0).is_none()); + assert!(pool.aggregate(data(6).hash_tree_root(), 5, 0).is_none()); + } + + #[test] + fn block_candidates_take_the_better_of_votes_and_a_recorded_aggregate() { + let mut pool = AttestationPool::default(); + pool.insert(&vote(5, 0, 10), 0, 3); + // A recorded aggregate for the same committee covering two members + // beats the one pooled vote. + let mut recorded = pool.aggregate(data(5).hash_tree_root(), 5, 0).unwrap(); + recorded.aggregation_bits.set(1, true).unwrap(); + pool.insert_aggregate(recorded.clone()); + // A second committee has only votes. + pool.insert(&vote(5, 1, 11), 0, 2); + + let mut candidates = pool.block_candidates(); + candidates.sort_by_key(single_committee); + assert_eq!(candidates.len(), 2); + assert_eq!(candidates[0], recorded); + assert_eq!(single_committee(&candidates[1]), Some(1)); + } + + #[test] + fn votes_an_epoch_old_are_dropped() { + let mut pool = AttestationPool::default(); + pool.insert(&vote(5, 0, 10), 0, 2); + pool.insert(&vote(5 + preset::SLOTS_PER_EPOCH, 0, 11), 0, 2); + assert!(pool.aggregate(data(5).hash_tree_root(), 5, 0).is_none()); + } +} diff --git a/crates/blockchain/state_transition/src/beacon/block_production.rs b/crates/blockchain/state_transition/src/beacon/block_production.rs new file mode 100644 index 000000000..0c412cb42 --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/block_production.rs @@ -0,0 +1,644 @@ +//! Assembling an unsigned beacon block for a proposer, per phase0's +//! `validator.md` ("Block proposal") as each later fork's `validator.md` +//! extends it. +//! +//! Electra and fulu only: electra is the earliest fork a validator client +//! served here submits attestations for (`SingleAttestation`), and fulu reuses +//! electra's block containers unchanged. +//! +//! The pieces the execution client supplies (the payload, its blob commitments +//! and its request list) arrive already built; this module decides everything +//! the consensus layer decides, and computes the state root by running the +//! block through `process_block` on a copy of the state, the way +//! `validator.md` describes (`compute_new_state_root`). + +use ethlambda_types::beacon::{ + constants, + containers::{ + BeaconState, SignedBeaconBlock, + altair::SyncAggregate, + capella::Withdrawal, + deneb::ExecutionPayload, + electra::{ + self, AggregationBits, Attestation, BeaconBlock, BeaconBlockBody, CommitteeBits, + ConsolidationRequest, DepositRequest, ExecutionRequests, WithdrawalRequest, + }, + }, + preset, + primitives::{ + BlsSignature, Bytes32, ExecutionBlockHash, HashTreeRoot as _, KzgCommitment, Root, Slot, + }, + signing::compute_epoch_at_slot, +}; +use libssz::SszDecode as _; +use libssz_types::SszList; + +use super::attestation_pool::single_committee; +use super::bls; +use super::config::Config; +use super::error::{Error, Result, verify}; +use super::helpers::accessors::{ + CommitteeCache, get_beacon_proposer_index, get_block_root, get_current_epoch, + get_previous_epoch, get_randao_mix, +}; +use super::helpers::electra::{ + get_attesting_indices, get_indexed_attestation, is_valid_indexed_attestation, +}; +use super::stf::{self, ExecutionEngine}; + +/// `state` advanced through empty slots to `slot`, as a block for `slot` is +/// applied to it. A state already at `slot` is returned as it is. +pub fn advance_to_slot(state: &BeaconState, slot: Slot, config: &Config) -> Result { + let mut advanced = state.clone(); + if advanced.slot() < slot { + stf::process_slots(&mut advanced, slot, config)?; + } + Ok(advanced) +} + +/// What the execution client needs to build the payload for the slot +/// `state` has been advanced to (`PayloadAttributesV3`, less the fee +/// recipient and the parent beacon block root, which the caller knows). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PayloadInputs { + pub timestamp: u64, + pub prev_randao: Bytes32, + pub withdrawals: Vec, + /// The execution block the payload must extend. + pub parent_hash: ExecutionBlockHash, +} + +/// The payload inputs for `state`'s slot: the conditions +/// `process_execution_payload` and `process_withdrawals` will hold the payload +/// to, computed from the same state. +pub fn payload_inputs(state: &BeaconState, config: &Config) -> Result { + let parent_hash = match state { + BeaconState::Electra(state) => state.latest_execution_payload_header.block_hash, + BeaconState::Fulu(state) => state.latest_execution_payload_header.block_hash, + _ => return Err(Error::SpecAssert("block production is electra and later")), + }; + Ok(PayloadInputs { + timestamp: stf::bellatrix::compute_timestamp_at_slot(state, state.slot(), config), + prev_randao: get_randao_mix(state, get_current_epoch(state)), + withdrawals: stf::electra::get_expected_withdrawals(state)?.0, + parent_hash, + }) +} + +/// A sync aggregate no member contributed to. Valid, it only forfeits the +/// rewards: `process_sync_aggregate` requires the empty signature to be the +/// G2 point at infinity rather than zero bytes. +pub fn empty_sync_aggregate() -> SyncAggregate { + SyncAggregate { + sync_committee_bits: Default::default(), + sync_committee_signature: BlsSignature(bls::G2_POINT_AT_INFINITY), + } +} + +/// The inverse of `get_execution_requests_list`: the execution client's +/// EIP-7685 request list back into the block body's `ExecutionRequests`. +/// +/// Each element is a request-type byte followed by the SSZ of that type's +/// list. The Engine API requires the types strictly ascending and no element +/// empty, which is checked rather than trusted, since a list this node +/// accepted would go into a block its peers then reject. +pub fn parse_execution_requests(list: &[Vec]) -> Result { + let mut requests = ExecutionRequests::default(); + let mut previous_type: Option = None; + for element in list { + let (&request_type, data) = element + .split_first() + .ok_or(Error::SpecAssert("an execution request carries its type"))?; + verify(!data.is_empty(), "an execution request is not empty")?; + verify( + previous_type.is_none_or(|previous| request_type > previous), + "execution request types are strictly ascending", + )?; + previous_type = Some(request_type); + let malformed = |_| Error::SpecAssert("an execution request list decodes as SSZ"); + match request_type { + constants::DEPOSIT_REQUEST_TYPE => { + requests.deposits = + SszList::::from_ssz_bytes(data).map_err(malformed)?; + } + constants::WITHDRAWAL_REQUEST_TYPE => { + requests.withdrawals = + SszList::::from_ssz_bytes(data).map_err(malformed)?; + } + constants::CONSOLIDATION_REQUEST_TYPE => { + requests.consolidations = + SszList::::from_ssz_bytes(data).map_err(malformed)?; + } + _ => return Err(Error::SpecAssert("a known execution request type")), + } + } + Ok(requests) +} + +/// Pick the attestations for a block at `state`'s slot from `candidates`, +/// each covering a single committee. +/// +/// Keeps only what `process_attestation` accepts at this slot: included at +/// least `MIN_ATTESTATION_INCLUSION_DELAY` after its own slot, targeting the +/// current or previous epoch, and sourced from the justified checkpoint that +/// target implies. The target root must also be this state's block root at +/// the start of the target epoch. `process_attestation` does not check that, +/// but an attestation for another branch was aggregated against that branch's +/// committees, and its bits may name different validators here. +/// +/// Of those, only candidates with at least one attester the state has not yet +/// credited for that epoch: an attestation already on chain earns nothing +/// again, and with room for only `MAX_ATTESTATIONS_ELECTRA` per block, +/// re-including it would crowd out ones that still count. Candidates voting on +/// the same `AttestationData` are merged into one electra `Attestation` +/// spanning their committees (EIP-7549's on-chain aggregation), ordered by how +/// many new attesters they bring, then newest first. +/// +/// Each merged attestation's aggregate signature is then checked against +/// `state` in that order, and one that fails is dropped and the next taken in +/// its place. One invalid attestation fails `process_block` for the whole +/// block, and a candidate from gossip was verified against its own target +/// state, not this one. That is one signature check per packed attestation, +/// plus one per dropped one. +pub fn pack_attestations(state: &BeaconState, candidates: Vec) -> Vec { + let current_epoch = get_current_epoch(state); + let previous_epoch = get_previous_epoch(state); + let includable = |attestation: &Attestation| { + let data = &attestation.data; + let target_epoch = data.target.epoch; + let expected_source = if target_epoch == current_epoch { + state.current_justified_checkpoint() + } else { + state.previous_justified_checkpoint() + }; + data.slot + preset::MIN_ATTESTATION_INCLUSION_DELAY <= state.slot() + && (target_epoch == current_epoch || target_epoch == previous_epoch) + && target_epoch == compute_epoch_at_slot(data.slot) + && data.source == expected_source + && get_block_root(state, target_epoch).is_ok_and(|root| root == data.target.root) + }; + + // How many of an attestation's attesters the state has not yet credited + // for its target epoch: a participation byte of zero is a validator no + // attestation for that epoch has counted yet. + let Ok((previous_participation, current_participation, _)) = state.altair_validator_lists() + else { + return Vec::new(); + }; + let committees = CommitteeCache::default(); + let new_attesters = |attestation: &Attestation| -> usize { + let participation = if attestation.data.target.epoch == current_epoch { + current_participation + } else { + previous_participation + }; + get_attesting_indices(state, attestation, &committees) + .map(|indices| { + indices + .iter() + .filter(|&&index| participation.get(index as usize) == Some(&0)) + .count() + }) + .unwrap_or(0) + }; + + // Grouped by data, each group's committees in ascending order, which is + // the order `get_attesting_indices` walks `committee_bits` in, with the + // group's total of new attesters. + let mut groups: std::collections::BTreeMap)> = + Default::default(); + for attestation in candidates.into_iter().filter(includable) { + let Some(committee) = single_committee(&attestation) else { + continue; + }; + let new = new_attesters(&attestation); + if new == 0 { + continue; + } + let (total, group) = groups.entry(attestation.data.hash_tree_root()).or_default(); + if group.iter().all(|(index, _)| *index != committee) { + *total += new; + group.push((committee, attestation)); + } + } + + let mut merged: Vec<(usize, Attestation)> = groups + .into_values() + .filter_map(|(new, mut group)| { + group.sort_by_key(|(committee, _)| *committee); + merge_committees(group).map(|attestation| (new, attestation)) + }) + .collect(); + merged.sort_by_key(|(new, attestation)| { + ( + std::cmp::Reverse(*new), + std::cmp::Reverse(attestation.data.slot), + ) + }); + merged + .into_iter() + .map(|(_, attestation)| attestation) + .filter(|attestation| { + get_indexed_attestation(state, attestation, &committees) + .is_ok_and(|indexed| is_valid_indexed_attestation(state, &indexed)) + }) + .take(preset::MAX_ATTESTATIONS_ELECTRA) + .collect() +} + +/// One attestation over every committee in `group`, which shares one +/// `AttestationData`: their aggregation bits concatenated in committee order, +/// and their signatures aggregated. +fn merge_committees(group: Vec<(u64, Attestation)>) -> Option { + let data = group.first()?.1.data; + let total: usize = group.iter().map(|(_, a)| a.aggregation_bits.len()).sum(); + let mut aggregation_bits = AggregationBits::with_length(total).ok()?; + let mut committee_bits = CommitteeBits::default(); + let mut signatures = Vec::with_capacity(group.len()); + let mut offset = 0; + for (committee, attestation) in &group { + committee_bits.set(*committee as usize, true).ok()?; + for position in 0..attestation.aggregation_bits.len() { + if attestation.aggregation_bits.get(position).unwrap_or(false) { + aggregation_bits.set(offset + position, true).ok()?; + } + } + offset += attestation.aggregation_bits.len(); + signatures.push(attestation.signature); + } + Some(Attestation { + aggregation_bits, + data, + signature: bls::aggregate(&signatures).ok()?, + committee_bits, + }) +} + +/// What a block body carries beyond what this module derives from the state. +#[derive(Debug, Clone)] +pub struct BlockInputs { + pub randao_reveal: BlsSignature, + pub graffiti: Bytes32, + pub attestations: Vec, + pub execution_payload: ExecutionPayload, + pub blob_kzg_commitments: Vec, + pub execution_requests: ExecutionRequests, +} + +/// The unsigned block for the slot `state` has been advanced to, with its +/// state root computed. +/// +/// The body votes the state's own `eth1_data` and carries no deposits (the +/// deposit contract's log has been replaced by EIP-6110's requests), no +/// slashings, exits or credential changes (this node pools none), and an empty +/// sync aggregate. The block is run through `process_block` on a copy of +/// `state` with an execution engine that accepts the payload, which is the +/// node's own execution client's payload; that run is also what rejects a body +/// the network would, before anything is signed. +pub fn assemble_block( + state: &BeaconState, + inputs: BlockInputs, + config: &Config, +) -> Result { + let body = BeaconBlockBody { + randao_reveal: inputs.randao_reveal, + eth1_data: state.eth1_data().clone(), + graffiti: inputs.graffiti, + attestations: inputs + .attestations + .try_into() + .map_err(|_| Error::SpecAssert("len(attestations) <= MAX_ATTESTATIONS_ELECTRA"))?, + sync_aggregate: empty_sync_aggregate(), + execution_payload: inputs.execution_payload, + blob_kzg_commitments: inputs + .blob_kzg_commitments + .try_into() + .map_err(|_| Error::SpecAssert("len(blob_kzg_commitments) within bound"))?, + execution_requests: inputs.execution_requests, + ..BeaconBlockBody::empty() + }; + let mut block = BeaconBlock { + slot: state.slot(), + proposer_index: get_beacon_proposer_index(state)?, + // `process_slots` filled the header's state root on the way out of + // the parent's slot, so this is the parent block's root. + parent_root: state.latest_block_header().hash_tree_root(), + state_root: Root::ZERO, + body, + }; + + let signed = electra::SignedBeaconBlock { + message: block.clone(), + signature: BlsSignature::default(), + }; + let wrapped = match state { + BeaconState::Electra(_) => SignedBeaconBlock::Electra(signed), + BeaconState::Fulu(_) => SignedBeaconBlock::Fulu(signed), + _ => return Err(Error::SpecAssert("block production is electra and later")), + }; + let mut post = state.clone(); + stf::block::process_block( + &mut post, + &wrapped, + config, + &ExecutionEngine::valid(), + &CommitteeCache::default(), + )?; + block.state_root = post.hash_tree_root(); + Ok(block) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::beacon::ForkName; + use crate::beacon::helpers::accessors::{get_beacon_committee, get_domain}; + use crate::beacon::helpers::fulu::initialize_proposer_lookahead; + use crate::beacon::helpers::misc::compute_signing_root; + use crate::beacon::helpers::test_state::{sign_for, with_signing_validators_at}; + use ethlambda_types::beacon::containers::shared::{AttestationData, Checkpoint}; + + /// A fulu state one epoch in, its lookahead and sync committee filled from + /// its real registry (the builder leaves both as placeholders), advanced one + /// slot so a block can be built on it. + fn state_to_build_on() -> BeaconState { + state_to_build_on_with(64) + } + + /// [`state_to_build_on`] with `validators` in the registry. Mainnet's + /// preset splits a slot into more than one committee only from + /// `2 * SLOTS_PER_EPOCH * TARGET_COMMITTEE_SIZE` active validators. + fn state_to_build_on_with(validators: usize) -> BeaconState { + let mut state = with_signing_validators_at(ForkName::Fulu, validators); + let lookahead = initialize_proposer_lookahead(&state).unwrap(); + let sync_committee = + crate::beacon::helpers::altair::get_next_sync_committee(&state).unwrap(); + let BeaconState::Fulu(inner) = &mut state else { + unreachable!("built as fulu") + }; + inner.proposer_lookahead = lookahead.try_into().unwrap(); + inner.current_sync_committee = sync_committee.clone(); + inner.next_sync_committee = sync_committee; + let slot = state.slot() + 1; + advance_to_slot(&state, slot, &Config::mainnet()).unwrap() + } + + fn payload_for(state: &BeaconState) -> ExecutionPayload { + let inputs = payload_inputs(state, &Config::mainnet()).unwrap(); + ExecutionPayload { + parent_hash: inputs.parent_hash, + prev_randao: inputs.prev_randao, + timestamp: inputs.timestamp, + withdrawals: inputs.withdrawals.try_into().unwrap(), + ..BeaconBlockBody::empty().execution_payload + } + } + + fn randao_reveal(state: &BeaconState) -> BlsSignature { + let proposer = get_beacon_proposer_index(state).unwrap(); + let epoch = get_current_epoch(state); + let domain = get_domain(state, constants::DOMAIN_RANDAO, Some(epoch)); + sign_for( + proposer as usize, + compute_signing_root(epoch.hash_tree_root(), domain), + ) + } + + #[test] + fn an_assembled_block_passes_process_block_and_names_its_post_state() { + let state = state_to_build_on(); + let inputs = BlockInputs { + randao_reveal: randao_reveal(&state), + graffiti: Bytes32::repeat_byte(7), + attestations: Vec::new(), + execution_payload: payload_for(&state), + blob_kzg_commitments: Vec::new(), + execution_requests: ExecutionRequests::default(), + }; + let block = assemble_block(&state, inputs, &Config::mainnet()).unwrap(); + + assert_eq!(block.slot, state.slot()); + assert_eq!( + block.proposer_index, + get_beacon_proposer_index(&state).unwrap() + ); + // Applying it again, the way a peer importing it would, lands on the + // state root it claims. + let mut post = state.clone(); + let signed = SignedBeaconBlock::Fulu(electra::SignedBeaconBlock { + message: block.clone(), + signature: BlsSignature::default(), + }); + stf::block::process_block( + &mut post, + &signed, + &Config::mainnet(), + &ExecutionEngine::valid(), + &CommitteeCache::default(), + ) + .unwrap(); + assert_eq!(block.state_root, post.hash_tree_root()); + } + + #[test] + fn a_payload_on_the_wrong_parent_is_refused_before_signing() { + let state = state_to_build_on(); + let mut payload = payload_for(&state); + payload.parent_hash = ExecutionBlockHash::repeat_byte(9); + let inputs = BlockInputs { + randao_reveal: randao_reveal(&state), + graffiti: Bytes32::ZERO, + attestations: Vec::new(), + execution_payload: payload, + blob_kzg_commitments: Vec::new(), + execution_requests: ExecutionRequests::default(), + }; + assert!(assemble_block(&state, inputs, &Config::mainnet()).is_err()); + } + + /// An attestation at `slot` voting for this state's own chain: sourced from + /// its current justified checkpoint, targeting its current epoch at the + /// state's block root there. + fn attestation_data(state: &BeaconState, slot: Slot) -> AttestationData { + let epoch = get_current_epoch(state); + AttestationData { + slot, + index: 0, + beacon_block_root: Root::repeat_byte(1), + source: state.current_justified_checkpoint(), + target: Checkpoint { + epoch, + root: get_block_root(state, epoch).unwrap(), + }, + } + } + + /// A single-committee aggregate over `data` from `committee`, with the + /// members at `positions` set and signed by exactly those members, so it + /// verifies against `state`. + fn committee_aggregate( + state: &BeaconState, + data: AttestationData, + committee: u64, + positions: &[usize], + ) -> Attestation { + let members = get_beacon_committee(state, data.slot, committee).unwrap(); + let domain = get_domain( + state, + constants::DOMAIN_BEACON_ATTESTER, + Some(data.target.epoch), + ); + let signing_root = compute_signing_root(data.hash_tree_root(), domain); + let mut aggregation_bits = AggregationBits::with_length(members.len()).unwrap(); + let signatures: Vec<_> = positions + .iter() + .map(|&position| { + aggregation_bits.set(position, true).unwrap(); + sign_for(members[position] as usize, signing_root) + }) + .collect(); + let mut committee_bits = CommitteeBits::default(); + committee_bits.set(committee as usize, true).unwrap(); + Attestation { + aggregation_bits, + data, + signature: bls::aggregate(&signatures).unwrap(), + committee_bits, + } + } + + #[test] + fn committees_voting_alike_are_merged_and_the_too_recent_left_out() { + // Enough validators for at least two committees a slot: exactly two + // under mainnet's preset, more under minimal's smaller committees. The + // test only uses committees 0 and 1. + let state = state_to_build_on_with(8192); + let committees_per_slot = crate::beacon::helpers::accessors::get_committee_count_per_slot( + &state, + get_current_epoch(&state), + ); + assert!(committees_per_slot >= 2, "got {committees_per_slot}"); + let data = attestation_data(&state, state.slot() - 1); + let too_recent = AttestationData { + slot: state.slot(), + ..data + }; + let first = committee_aggregate(&state, data, 1, &[0]); + let second = committee_aggregate(&state, data, 0, &[1, 2]); + let committee_0_len = second.aggregation_bits.len(); + let packed = pack_attestations( + &state, + vec![ + first.clone(), + second.clone(), + committee_aggregate(&state, too_recent, 0, &[0]), + ], + ); + + assert_eq!(packed.len(), 1); + let merged = &packed[0]; + // Committee 0's bits, then committee 1's. + let set: Vec = (0..merged.aggregation_bits.len()) + .filter(|&i| merged.aggregation_bits.get(i).unwrap()) + .collect(); + assert_eq!(set, [1, 2, committee_0_len]); + assert!(merged.committee_bits.get(0).unwrap() && merged.committee_bits.get(1).unwrap()); + assert_eq!( + merged.signature, + bls::aggregate(&[second.signature, first.signature]).unwrap() + ); + } + + #[test] + fn an_attestation_already_credited_on_chain_is_not_packed_again() { + let mut state = state_to_build_on(); + let data = attestation_data(&state, state.slot() - 1); + let committee_len = get_beacon_committee(&state, data.slot, 0).unwrap().len(); + let everyone: Vec = (0..committee_len).collect(); + let candidate = committee_aggregate(&state, data, 0, &everyone); + assert_eq!(pack_attestations(&state, vec![candidate.clone()]).len(), 1); + + // Every validator credited for the current epoch, as if the same + // votes had already been included. + let BeaconState::Fulu(inner) = &mut state else { + unreachable!("built as fulu") + }; + for flags in inner.current_epoch_participation.iter_mut() { + *flags = 0b111; + } + assert!(pack_attestations(&state, vec![candidate]).is_empty()); + } + + /// A vote for another branch was aggregated against that branch's + /// committees, so its bits cannot be trusted to name the same validators + /// on this one, however valid it was where it came from. + #[test] + fn an_attestation_targeting_another_branch_is_not_packed() { + let state = state_to_build_on(); + let mut data = attestation_data(&state, state.slot() - 1); + data.target.root = Root::repeat_byte(2); + let candidate = committee_aggregate(&state, data, 0, &[0]); + assert!(pack_attestations(&state, vec![candidate]).is_empty()); + } + + /// One attestation whose signature does not verify against this state + /// would fail the whole block, so it is dropped and the rest are packed. + #[test] + fn an_attestation_that_does_not_verify_is_dropped_and_the_rest_packed() { + let state = state_to_build_on(); + let valid = + committee_aggregate(&state, attestation_data(&state, state.slot() - 1), 0, &[0]); + + // Different data, so the two are not merged, signed by the wrong + // member of the committee. + let mut forged_data = attestation_data(&state, state.slot() - 1); + forged_data.beacon_block_root = Root::repeat_byte(3); + let mut forged = committee_aggregate(&state, forged_data, 0, &[0]); + forged.signature = committee_aggregate(&state, forged_data, 0, &[1]).signature; + + let packed = pack_attestations(&state, vec![forged, valid.clone()]); + assert_eq!(packed, vec![valid]); + } + + #[test] + fn execution_requests_round_trip_through_the_request_list() { + let requests = ExecutionRequests { + withdrawals: vec![WithdrawalRequest { + source_address: Default::default(), + validator_pubkey: Default::default(), + amount: 5, + }] + .try_into() + .unwrap(), + ..Default::default() + }; + let list = stf::electra::get_execution_requests_list(&requests); + assert_eq!(parse_execution_requests(&list).unwrap(), requests); + assert_eq!( + parse_execution_requests(&[]).unwrap(), + ExecutionRequests::default() + ); + } + + #[test] + fn a_malformed_request_list_is_refused() { + // Out of order. + let withdrawal = vec![constants::WITHDRAWAL_REQUEST_TYPE, 1]; + let deposit = vec![constants::DEPOSIT_REQUEST_TYPE, 1]; + assert!(parse_execution_requests(&[withdrawal, deposit]).is_err()); + // Empty data, and an unknown type. + assert!(parse_execution_requests(&[vec![constants::DEPOSIT_REQUEST_TYPE]]).is_err()); + assert!(parse_execution_requests(&[vec![0x7f, 0]]).is_err()); + } + + #[test] + fn the_empty_sync_aggregate_signs_with_the_point_at_infinity() { + let aggregate = empty_sync_aggregate(); + assert_eq!(aggregate.sync_committee_signature.0[0], 0xc0); + assert!( + aggregate.sync_committee_signature.0[1..] + .iter() + .all(|b| *b == 0) + ); + } +} diff --git a/crates/blockchain/state_transition/src/beacon/bls.rs b/crates/blockchain/state_transition/src/beacon/bls.rs new file mode 100644 index 000000000..bf10923b9 --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/bls.rs @@ -0,0 +1,810 @@ +//! The specification's `bls` module: `bls.Verify`, `bls.Aggregate`, and friends. +//! +//! The state transition never touches `blst` directly; it calls the functions +//! here, named exactly as the spec names them (`specs/phase0/beacon-chain.md`'s +//! "BLS signatures" section, extended by `specs/altair/bls.md`), so a reader can +//! match a call site against the spec line by line. `blst` is the only backend: +//! there is no trait to abstract over another one, since the consensus layer has +//! settled on `blst` as the reference implementation and a second backend would +//! only be dead code here. +//! +//! # Why every function treats its inputs as unvalidated +//! +//! [`crate::beacon::primitives::BlsPubkey`] is deliberately *not* validated on +//! construction: deposit processing has to be able to hold a public key that +//! never validates, because a deposit with a bad key is still a real message +//! that changes the state (it is simply never able to sign anything). Nothing +//! upstream of this module guarantees a `BlsPubkey` or `BlsSignature` is a valid, +//! subgroup-correct curve point, so every function below derives that from the +//! raw bytes rather than assuming it from the type. That costs an extra point +//! check per input compared to a backend that validates once at deserialization +//! time and trusts a typed wrapper afterward (the approach Lighthouse's `blst` +//! backend takes, and the approach `blst`'s own `fast_aggregate_verify` helper +//! assumes when it hardcodes its public-key validation flag to skip the check). +//! Here, skipping it would mean a garbage or adversarial byte string could reach +//! a pairing check unchecked. +//! +//! # Why public keys are validated once per byte string, not once per call +//! +//! For a public key, that check is also the expensive half of a signature +//! check: decompressing a G1 point and proving it is in the prime-order +//! subgroup costs far more per key than adding it into an aggregate, and an +//! attestation aggregate carries a whole committee of keys. Re-deriving every +//! key on every call made a mainnet aggregate's verification almost entirely +//! key validation, and because every active validator's key signs again each +//! epoch, almost all of it was repeated work. +//! +//! So [`PublicKey::key_validate`]'s answer is memoized, keyed by the exact +//! compressed bytes it was asked about (see [`PubkeyCache`]). That keeps the +//! guarantee above intact rather than trading it away: `key_validate` is a +//! pure function of those bytes, so a hit hands back precisely the point a +//! fresh call would have produced, and a byte string only ever enters the +//! cache by passing it. Keying by the bytes rather than by validator index is +//! what makes that true without further argument: an index names whatever key +//! the state at hand says it does, which two forks need not agree on, while a +//! byte string names one point everywhere. A key that fails is never cached, +//! so an invalid key costs a full check every time, exactly as before, and +//! cannot grow the cache. +//! +//! # `bool` versus `Result` +//! +//! The verification functions ([`verify`], [`aggregate_verify`], +//! [`fast_aggregate_verify`], [`eth_fast_aggregate_verify`], [`key_validate`]) +//! return `bool`, matching the spec's own signatures (`bls.Verify(...) -> bool` +//! and so on): they are predicates, and a `false` covers every way a claim can +//! fail to hold, whether the signature does not match, the public key does not +//! decode, or a point is not subgroup-correct. The specification does not +//! distinguish "the key was gibberish" from "the key was valid but the +//! signature was wrong": both mean the check did not pass, so collapsing them +//! into one boolean is what lets a caller use the result directly as a gate +//! (`if !bls::verify(...) { return }`) instead of first deciding which `Err` +//! variants count as "reject" and which count as a bug worth propagating. +//! +//! The aggregation functions ([`aggregate`], [`eth_aggregate_pubkeys`]) return +//! [`crate::beacon::Result`] instead, because there is no boolean predicate to collapse +//! to: aggregation either produces a point or it structurally cannot (an empty +//! input, or an element that is not itself a valid point), and that is a +//! different kind of failure than "verification did not pass". Keeping it a +//! `Result` keeps that distinction visible at the call site instead of forcing +//! an aggregation failure to masquerade as a rejected signature. +//! +//! # Why a cache miss is validated in parallel, and a hit is not +//! +//! [`aggregate_verify`] and [`fast_aggregate_verify`] each resolve `pubkeys` +//! to points before the pairing check runs; a single Electra block can carry +//! up to `MAX_ATTESTATIONS_ELECTRA` aggregates, each covering up to a whole +//! committee, so a cold cache (the first blocks after a start) turns that into +//! thousands of independent point checks per block import. The beacon state +//! transition runs on one actor thread (see `BlockChain` in +//! `crates/blockchain/src/lib.rs`), so on a multi-core host every one of those +//! checks but the one currently running would leave a core idle. Validating one +//! key never reads or writes anything another key's validation touches, so +//! nothing depends on which order they run in or finish in: the misses go +//! through `par_iter().map(...).collect::>>()`, which spreads the +//! checks across rayon's global thread pool and still collapses to `None` the +//! moment any key fails, without skipping or weakening [`key_validate`] for a +//! single key. Each validated point is written back to its own index, which +//! `aggregate_verify` relies on to keep `points[i]` paired with `messages[i]`. +//! +//! A hit is a hash-map read, far cheaper than handing work to another thread, +//! so hits resolve on the calling thread. That matters beyond the saving +//! itself: gossip validation runs many signature checks at once on blocking +//! threads, and if every one of them queued its keys on rayon's single global +//! pool, a burst of aggregates would stall there even with the keys already +//! validated. + +use std::collections::HashMap; +use std::sync::{LazyLock, RwLock}; + +use blst::BLST_ERROR; +use blst::min_pk::{AggregatePublicKey, AggregateSignature, PublicKey, Signature}; +use rayon::prelude::*; + +use crate::beacon::error::Error; +use crate::beacon::primitives::{ + BLS_PUBKEY_SIZE, BLS_SIGNATURE_SIZE, BlsPubkey, BlsSignature, Root, +}; + +/// The ciphersuite the specification pins BLS signatures to: the IETF BLS +/// draft's proof-of-possession scheme over BLS12-381's G2, using SHA-256 in +/// the XMD hash-to-curve construction. +/// +/// This is the domain separation tag threaded through every hash-to-curve call +/// in this module. Two signatures produced under different DSTs never verify +/// against each other, which is exactly the point: it is what lets the same +/// keys be reused for other purposes (or other chains) without cross-protocol +/// signature reuse. +pub const DST: &[u8] = b"BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_"; + +/// The minimum number of public keys rayon may put in one sequential chunk +/// when [`aggregate_verify`] and [`fast_aggregate_verify`] validate the +/// signers [`PubkeyCache`] has not seen yet in parallel. +/// +/// A devnet or a spec-fixture aggregate can be as small as a single signer, +/// and splitting a handful of keys across worker threads would spend more +/// time on task dispatch than [`PublicKey::key_validate`] itself takes to +/// decompress and subgroup-check one key. `with_min_len` keeps that decision +/// inside rayon's own splitting logic rather than a hand-rolled length check +/// before choosing serial or parallel: sixteen is comfortably above the +/// single-digit-signer inputs a fixture or a small devnet produces, so those +/// stay on the calling thread, while a mainnet aggregate of hundreds to +/// thousands of signers still splits into far more chunks than there are +/// cores to run them on. +const KEY_VALIDATE_MIN_PAR_LEN: usize = 16; + +/// How many shards [`PubkeyCache`] splits its map across. +/// +/// Every signature check reads the cache once per signer, from whichever +/// thread runs it: the chain actor, rayon's workers, and every blocking thread +/// gossip validation has running at once. One lock would put every one of +/// those reads on the same cache line; a shard per lock spreads them out. +const PUBKEY_CACHE_SHARDS: usize = 64; + +/// The most validated public keys [`VALIDATED_PUBKEYS`] holds, across all of +/// its shards. +/// +/// Far above any real validator registry: the bound is not there to evict +/// anything a registry needs, but so that inputs which are not a registry +/// (deposits carrying arbitrary keys, or a test run over thousands of +/// generated ones) cannot grow the cache without limit. A key arriving past +/// the bound is still validated, just on every call, as it was before the +/// cache existed. +const MAX_CACHED_PUBKEYS: usize = 1 << 22; + +/// Every public key that has passed [`PublicKey::key_validate`], by its +/// compressed bytes, for the whole process. +/// +/// Process-wide rather than owned by a `Store`, because nothing about the +/// answer is per chain or per state: it is a pure function of the bytes (see +/// the module documentation), so one copy serves the state transition, fork +/// choice and gossip validation alike. Lean code never calls into this module, +/// so a lean run never allocates it. +static VALIDATED_PUBKEYS: LazyLock = + LazyLock::new(|| PubkeyCache::new(MAX_CACHED_PUBKEYS)); + +/// Validated, decompressed public keys, keyed by their compressed encoding. +/// +/// A bounded, append-only memo of [`PublicKey::key_validate`]: an entry is +/// only ever added for a key that passed, never changed, and never removed, +/// since its answer can never change. See the module documentation for why +/// that is sound. +struct PubkeyCache { + shards: [RwLock>; PUBKEY_CACHE_SHARDS], + /// The most entries one shard takes, so the whole cache stays within the + /// capacity it was built with. + shard_capacity: usize, +} + +impl PubkeyCache { + fn new(capacity: usize) -> Self { + Self { + shards: std::array::from_fn(|_| RwLock::new(HashMap::new())), + shard_capacity: capacity.div_ceil(PUBKEY_CACHE_SHARDS), + } + } + + /// The shard `pubkey` lives in, chosen by the last byte of its encoding: + /// the low byte of the point's x-coordinate, so it spreads keys evenly + /// without hashing them twice. The flag bits sit in the first byte. + fn shard(&self, pubkey: &BlsPubkey) -> &RwLock> { + let byte = usize::from(pubkey.0[BLS_PUBKEY_SIZE - 1]); + &self.shards[byte % PUBKEY_CACHE_SHARDS] + } + + /// The validated point for `pubkey`, if it has passed before. + fn get(&self, pubkey: &BlsPubkey) -> Option { + self.shard(pubkey).read().unwrap().get(pubkey).copied() + } + + /// Records that `pubkey` validated to `point`, unless its shard is full. + fn insert(&self, pubkey: BlsPubkey, point: PublicKey) { + let mut shard = self.shard(&pubkey).write().unwrap(); + if shard.len() < self.shard_capacity && shard.insert(pubkey, point).is_none() { + crate::metrics::inc_pubkey_cache_entries(); + } + } + + /// The number of keys held, across every shard. + #[cfg(test)] + fn len(&self) -> usize { + self.shards + .iter() + .map(|shard| shard.read().unwrap().len()) + .sum() + } +} + +/// `pubkey` as a validated point, or `None` if it is not a valid, +/// subgroup-correct, non-identity key: [`PublicKey::key_validate`], through +/// [`VALIDATED_PUBKEYS`]. +fn validated_pubkey(pubkey: &BlsPubkey) -> Option { + if let Some(point) = VALIDATED_PUBKEYS.get(pubkey) { + crate::metrics::inc_pubkey_cache_lookups(1, 0); + return Some(point); + } + crate::metrics::inc_pubkey_cache_lookups(0, 1); + let point = PublicKey::key_validate(pubkey.as_ref()).ok()?; + VALIDATED_PUBKEYS.insert(*pubkey, point); + Some(point) +} + +/// Every key in `pubkeys` as a validated point, in order, or `None` if any +/// one of them fails to validate. +/// +/// Hits resolve on the calling thread and only the misses go to rayon; see +/// the module documentation for why. Each miss that validates is added to +/// [`VALIDATED_PUBKEYS`] before returning. +fn validated_pubkeys(pubkeys: &[BlsPubkey]) -> Option> { + let mut points: Vec> = pubkeys + .iter() + .map(|pubkey| VALIDATED_PUBKEYS.get(pubkey)) + .collect(); + let misses: Vec = points + .iter() + .enumerate() + .filter_map(|(index, point)| point.is_none().then_some(index)) + .collect(); + let hits = pubkeys.len() - misses.len(); + crate::metrics::inc_pubkey_cache_lookups(hits as u64, misses.len() as u64); + + if !misses.is_empty() { + let fresh: Vec = misses + .par_iter() + .with_min_len(KEY_VALIDATE_MIN_PAR_LEN) + .map(|&index| PublicKey::key_validate(pubkeys[index].as_ref()).ok()) + .collect::>()?; + for (&index, point) in misses.iter().zip(fresh) { + VALIDATED_PUBKEYS.insert(pubkeys[index], point); + points[index] = Some(point); + } + } + points.into_iter().collect() +} + +/// Builds `specs/altair/bls.md`'s `G2_POINT_AT_INFINITY` constant: the +/// compressed encoding of the identity element of G2, which is the +/// specification's sentinel value for "no one signed anything". +/// +/// A `const fn` rather than a byte literal so the encoding rule (the +/// compression flag and infinity flag bits set, every other bit zero) reads as +/// what it is instead of as a string of hex digits to take on faith. +const fn g2_point_at_infinity() -> [u8; BLS_SIGNATURE_SIZE] { + let mut bytes = [0u8; BLS_SIGNATURE_SIZE]; + // The top two bits of the first byte are the compression flag and the + // infinity flag; setting both and leaving every other bit zero is exactly + // the encoding of the point at infinity, compressed. + bytes[0] = 0b1100_0000; + bytes +} + +/// `specs/altair/bls.md`'s `G2_POINT_AT_INFINITY`, the compressed encoding of +/// the identity element of G2. +pub const G2_POINT_AT_INFINITY: [u8; BLS_SIGNATURE_SIZE] = g2_point_at_infinity(); + +/// The specification's `bls.Verify(pubkey, message, signature) -> bool`. +/// +/// Deserializes and fully validates both inputs (subgroup membership for the +/// signature, subgroup membership and non-identity for the public key) before +/// checking the pairing equation, so a `pubkey` that never passed +/// [`key_validate`] and never will (see the module documentation) simply fails +/// here rather than panicking or returning an error: an unparseable or +/// invalid-point key is treated exactly like a valid key over the wrong +/// signature, since the specification never distinguishes the two. +pub fn verify(pubkey: &BlsPubkey, message: Root, signature: &BlsSignature) -> bool { + let Some(pubkey) = validated_pubkey(pubkey) else { + return false; + }; + let Ok(signature) = Signature::sig_validate(signature.as_ref(), false) else { + return false; + }; + let result = signature.verify(false, message.as_slice(), DST, &[], &pubkey, false); + result == BLST_ERROR::BLST_SUCCESS +} + +/// The specification's `bls.Aggregate(signatures) -> BLSSignature`. +/// +/// Fails on an empty `signatures`, matching the spec's `Aggregate` (there is no +/// meaningful signature aggregating zero signatures), and fails if any element +/// does not deserialize to a subgroup-correct point in G2. Checking every +/// element here, rather than only the final sum, matters because elliptic +/// curve addition of two points outside the prime-order subgroup can still +/// land back inside it: an invalid share could otherwise cancel against +/// another invalid share and slip past a check performed only on the result. +pub fn aggregate(signatures: &[BlsSignature]) -> crate::beacon::Result { + crate::beacon::verify(!signatures.is_empty(), "len(signatures) > 0")?; + let encoded: Vec<&[u8]> = signatures + .iter() + .map(|signature| signature.as_ref()) + .collect(); + let aggregated = AggregateSignature::aggregate_serialized(&encoded, true) + .map_err(|_| Error::InvalidSignature("aggregate: not a valid, subgroup-correct point"))?; + Ok(BlsSignature(aggregated.to_signature().to_bytes())) +} + +/// The specification's +/// `bls.AggregateVerify(pubkeys, messages, signature) -> bool`. +/// +/// `pubkeys` and `messages` are matched up positionally, one message per +/// signer; an empty `pubkeys`, or a length mismatch between the two, fails +/// immediately rather than vacuously succeeding. As with [`verify`], every +/// public key and the signature are independently deserialized and validated +/// (subgroup membership, non-identity for the keys) before the pairing check +/// runs, so an invalid key or signature simply fails this predicate. Keys go +/// through [`PubkeyCache`], and the ones it has not seen are validated in +/// parallel; see the module documentation for why both are safe and why +/// `points` still lines up with `messages` afterward. +pub fn aggregate_verify( + pubkeys: &[BlsPubkey], + messages: &[Root], + signature: &BlsSignature, +) -> bool { + if pubkeys.is_empty() || pubkeys.len() != messages.len() { + return false; + } + let Ok(signature) = Signature::sig_validate(signature.as_ref(), false) else { + return false; + }; + let Some(points) = validated_pubkeys(pubkeys) else { + return false; + }; + let point_refs: Vec<&PublicKey> = points.iter().collect(); + let message_refs: Vec<&[u8]> = messages.iter().map(Root::as_slice).collect(); + let result = signature.aggregate_verify(false, &message_refs, DST, &point_refs, false); + result == BLST_ERROR::BLST_SUCCESS +} + +/// The specification's +/// `bls.FastAggregateVerify(pubkeys, message, signature) -> bool`. +/// +/// The same check as [`aggregate_verify`] specialized to one shared `message`, +/// which is the shape every attestation aggregate takes. An empty `pubkeys` +/// always fails here: this function has no notion of "no one signed, and that +/// is fine", unlike its eth2-specific wrapper [`eth_fast_aggregate_verify`], +/// which is exactly why that wrapper exists. This is the hot path for a real +/// Electra block's attestations, where a single aggregate can carry +/// thousands of signers behind one shared message; keys go through +/// [`PubkeyCache`], see the module documentation for why that is safe. +pub fn fast_aggregate_verify( + pubkeys: &[BlsPubkey], + message: Root, + signature: &BlsSignature, +) -> bool { + if pubkeys.is_empty() { + return false; + } + let Ok(signature) = Signature::sig_validate(signature.as_ref(), false) else { + return false; + }; + let Some(points) = validated_pubkeys(pubkeys) else { + return false; + }; + let point_refs: Vec<&PublicKey> = points.iter().collect(); + let result = signature.fast_aggregate_verify(false, message.as_slice(), DST, &point_refs); + result == BLST_ERROR::BLST_SUCCESS +} + +/// `specs/altair/bls.md`'s `eth_aggregate_pubkeys(pubkeys) -> BLSPubkey`. +/// +/// Follows the spec's own pseudocode: `assert len(pubkeys) > 0`, then +/// `assert all(bls.KeyValidate(pubkey) for pubkey in pubkeys)` before summing. +/// The `KeyValidate` step is not optional the way it might look from the name: +/// without it, an all-zero or otherwise invalid `pubkey` would silently +/// contribute nothing (or something unintended) to the sum instead of failing +/// the aggregation outright, which is why this returns [`crate::beacon::Result`] rather +/// than substituting a default. +pub fn eth_aggregate_pubkeys(pubkeys: &[BlsPubkey]) -> crate::beacon::Result { + crate::beacon::verify(!pubkeys.is_empty(), "len(pubkeys) > 0")?; + let points = validated_pubkeys(pubkeys).ok_or(Error::SpecAssert( + "all(bls.KeyValidate(pubkey) for pubkey in pubkeys)", + ))?; + let point_refs: Vec<&PublicKey> = points.iter().collect(); + // Every point above already passed `key_validate`, so the sum skips + // re-checking them. + let aggregated = AggregatePublicKey::aggregate(&point_refs, false) + .map_err(|_| Error::SpecAssert("len(pubkeys) > 0"))?; + Ok(BlsPubkey(aggregated.to_public_key().to_bytes())) +} + +/// `specs/altair/bls.md`'s +/// `eth_fast_aggregate_verify(pubkeys, message, signature) -> bool`. +/// +/// Identical to [`fast_aggregate_verify`] except for one case: an empty +/// `pubkeys` returns `true` exactly when `signature` is +/// [`G2_POINT_AT_INFINITY`], and `false` for every other signature in that +/// case. This carve-out exists because an +/// empty-committee attestation aggregate is a legitimate value on chain (no +/// validators were assigned, or none of them attested), and its signature is +/// the identity element by convention rather than "no signature was +/// provided"; [`fast_aggregate_verify`] itself has no such case, since the +/// underlying IETF ciphersuite it wraps was never given one. +pub fn eth_fast_aggregate_verify( + pubkeys: &[BlsPubkey], + message: Root, + signature: &BlsSignature, +) -> bool { + if pubkeys.is_empty() { + return signature.as_ref() == G2_POINT_AT_INFINITY; + } + fast_aggregate_verify(pubkeys, message, signature) +} + +/// The specification's `bls.KeyValidate(pubkey) -> bool`. +/// +/// A `pubkey` passes when it deserializes to a point on the curve, that point +/// is in the correct prime-order subgroup, and it is not the identity element. +/// Exposed standalone because the spec calls `KeyValidate` directly in more +/// than one place (deposit processing, [`eth_aggregate_pubkeys`]'s own +/// assertion), not only as a step inside a signature check. +pub fn key_validate(pubkey: &BlsPubkey) -> bool { + validated_pubkey(pubkey).is_some() +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::path::{Path, PathBuf}; + + use serde::Deserialize; + + use super::*; + + /// The root the two BLS handlers this module tests live under. + /// + /// BLS test vectors are configuration-independent (they do not touch any + /// preset constant), so they live under `general` rather than under a + /// preset name; see `crates/blockchain/state_transition/tests/beacon_spec/mod.rs` for the layout the + /// rest of the crate's spec tests share. This module keeps its own tiny, + /// local copy of just enough of that layout to run these two suites, + /// rather than depending on that harness. + fn handler_root(handler: &str) -> PathBuf { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../../consensus-spec-tests/tests/general/altair/bls") + .join(handler); + assert!( + root.is_dir(), + "BLS spec fixtures are missing from {}; run `make consensus-spec-tests`", + root.display() + ); + root + } + + /// Every case's `data.yaml` under a handler: one directory level for the + /// handler's suite (named `bls` in every release seen so far), one for the + /// case itself. + fn fixture_cases(handler: &str) -> Vec { + let mut cases = Vec::new(); + for suite in fs::read_dir(handler_root(handler)).unwrap() { + let suite_path = suite.unwrap().path(); + if !suite_path.is_dir() { + continue; + } + for case in fs::read_dir(&suite_path).unwrap() { + let case_path = case.unwrap().path(); + let data = case_path.join("data.yaml"); + if data.is_file() { + cases.push(data); + } + } + } + cases + } + + /// Decodes a `0x`-prefixed hex string into a fixed-size array, panicking + /// with the offending file's path on any mismatch. A malformed fixture is a + /// bug in the fixture release, not a condition the functions under test + /// need to handle, so this does not return a `Result`. + fn parse_hex(path: &Path, value: &str) -> [u8; N] { + let digits = value.strip_prefix("0x").unwrap_or(value); + let bytes = hex::decode(digits) + .unwrap_or_else(|err| panic!("{}: invalid hex: {err}", path.display())); + bytes.try_into().unwrap_or_else(|bytes: Vec| { + panic!( + "{}: expected {N} bytes, got {}", + path.display(), + bytes.len() + ) + }) + } + + #[derive(Deserialize)] + struct EthAggregatePubkeysCase { + input: Vec, + output: Option, + } + + #[test] + #[cfg_attr( + not(feature = "beacon-spec-tests"), + ignore = "needs the consensus-spec fixture tree; run `make consensus-spec-tests`" + )] + fn eth_aggregate_pubkeys_matches_spec_fixtures() { + let mut executed = 0; + for path in fixture_cases("eth_aggregate_pubkeys") { + let text = + fs::read_to_string(&path).unwrap_or_else(|err| panic!("{}: {err}", path.display())); + let case: EthAggregatePubkeysCase = serde_yaml_ng::from_str(&text) + .unwrap_or_else(|err| panic!("{}: {err}", path.display())); + + let pubkeys: Vec = case + .input + .iter() + .map(|hex| BlsPubkey(parse_hex(&path, hex))) + .collect(); + let result = eth_aggregate_pubkeys(&pubkeys); + + match case.output { + Some(expected_hex) => { + let expected = BlsPubkey(parse_hex(&path, &expected_hex)); + let actual = result + .unwrap_or_else(|err| panic!("{}: expected Ok, got {err}", path.display())); + assert_eq!(actual.0, expected.0, "{}", path.display()); + } + None => { + assert!( + result.is_err(), + "{}: expected an error, got {result:?}", + path.display() + ); + } + } + executed += 1; + } + println!("eth_aggregate_pubkeys: {executed} cases executed"); + assert!(executed > 0, "no eth_aggregate_pubkeys cases were executed"); + } + + #[derive(Deserialize)] + struct EthFastAggregateVerifyInput { + pubkeys: Vec, + message: String, + signature: String, + } + + #[derive(Deserialize)] + struct EthFastAggregateVerifyCase { + input: EthFastAggregateVerifyInput, + output: bool, + } + + /// Parses one `eth_fast_aggregate_verify` case's `data.yaml` into the + /// crate's own BLS types. + fn parse_fast_aggregate_verify_case(path: &Path) -> (Vec, Root, BlsSignature, bool) { + let text = + fs::read_to_string(path).unwrap_or_else(|err| panic!("{}: {err}", path.display())); + let case: EthFastAggregateVerifyCase = serde_yaml_ng::from_str(&text) + .unwrap_or_else(|err| panic!("{}: {err}", path.display())); + + let pubkeys: Vec = case + .input + .pubkeys + .iter() + .map(|hex| BlsPubkey(parse_hex(path, hex))) + .collect(); + let message = crate::beacon::primitives::H256(parse_hex(path, &case.input.message)); + let signature = BlsSignature(parse_hex(path, &case.input.signature)); + (pubkeys, message, signature, case.output) + } + + #[test] + #[cfg_attr( + not(feature = "beacon-spec-tests"), + ignore = "needs the consensus-spec fixture tree; run `make consensus-spec-tests`" + )] + fn eth_fast_aggregate_verify_matches_spec_fixtures() { + let mut executed = 0; + for path in fixture_cases("eth_fast_aggregate_verify") { + let (pubkeys, message, signature, expected) = parse_fast_aggregate_verify_case(&path); + let actual = eth_fast_aggregate_verify(&pubkeys, message, &signature); + assert_eq!(actual, expected, "{}", path.display()); + executed += 1; + } + println!("eth_fast_aggregate_verify: {executed} cases executed"); + assert!( + executed > 0, + "no eth_fast_aggregate_verify cases were executed" + ); + } + + #[test] + #[cfg_attr( + not(feature = "beacon-spec-tests"), + ignore = "needs the consensus-spec fixture tree; run `make consensus-spec-tests`" + )] + fn verify_accepts_a_known_good_vector_from_the_fixtures() { + // `eth_fast_aggregate_verify_valid_0` has exactly one signer. A + // FastAggregateVerify over a single signer is mathematically the same + // check as a plain Verify, so this fixture vector doubles as a + // known-good input for `verify` without this module needing its own + // signing function to produce one. + let path = handler_root("eth_fast_aggregate_verify") + .join("bls") + .join("eth_fast_aggregate_verify_valid_0") + .join("data.yaml"); + let (pubkeys, message, signature, expected) = parse_fast_aggregate_verify_case(&path); + assert_eq!(pubkeys.len(), 1, "fixture assumption: a single signer"); + assert!(expected, "fixture assumption: a valid signature"); + + assert!(verify(&pubkeys[0], message, &signature)); + } + + #[test] + fn key_validate_rejects_the_all_zero_pubkey() { + assert!(!key_validate(&BlsPubkey::default())); + } + + /// A fresh keypair's public key, distinct per `seed` and distinct from + /// [`build_aggregate`]'s signers (whose seeds start at one). + fn fresh_pubkey(seed: u64) -> BlsPubkey { + let mut ikm = [0xa5u8; 32]; + ikm[..8].copy_from_slice(&seed.to_le_bytes()); + let secret = blst::min_pk::SecretKey::key_gen(&ikm, &[]) + .expect("32 bytes of input material is enough for key generation"); + BlsPubkey(secret.sk_to_pk().to_bytes()) + } + + /// The compressed encoding of G1's identity element: a well-formed point, + /// so it decodes, but one `key_validate` must refuse. + fn identity_pubkey() -> BlsPubkey { + let mut bytes = [0u8; BLS_PUBKEY_SIZE]; + bytes[0] = 0b1100_0000; + BlsPubkey(bytes) + } + + /// Resolves `pubkey` straight through `blst`, with no cache involved. + fn uncached(pubkey: &BlsPubkey) -> Option<[u8; BLS_PUBKEY_SIZE]> { + PublicKey::key_validate(pubkey.as_ref()) + .ok() + .map(|point| point.to_bytes()) + } + + #[test] + fn a_warm_cache_verifies_exactly_as_a_cold_one() { + let (pubkeys, message, signature) = build_aggregate(24); + assert!(fast_aggregate_verify(&pubkeys, message, &signature)); + for pubkey in &pubkeys { + let cached = VALIDATED_PUBKEYS.get(pubkey).map(|point| point.to_bytes()); + assert_eq!( + cached, + uncached(pubkey), + "a cached point must be key_validate's own" + ); + } + + // Every key is a hit now; the answers must not change with that. + assert!(fast_aggregate_verify(&pubkeys, message, &signature)); + let other_message = crate::beacon::primitives::H256([8u8; 32]); + assert!(!fast_aggregate_verify(&pubkeys, other_message, &signature)); + assert!(!fast_aggregate_verify(&pubkeys[1..], message, &signature)); + } + + #[test] + fn an_invalid_key_is_never_cached_and_always_refused() { + let (mut pubkeys, message, signature) = build_aggregate(2); + for invalid in [BlsPubkey::default(), identity_pubkey()] { + assert_eq!( + uncached(&invalid), + None, + "fixture assumption: an invalid key" + ); + pubkeys.push(invalid); + for _ in 0..2 { + assert!(!key_validate(&invalid)); + assert!(!verify(&invalid, message, &signature)); + assert!(!fast_aggregate_verify(&pubkeys, message, &signature)); + assert!(eth_aggregate_pubkeys(&pubkeys).is_err()); + assert!(VALIDATED_PUBKEYS.get(&invalid).is_none()); + } + pubkeys.pop(); + } + } + + #[test] + fn mixed_hits_and_misses_keep_their_positions() { + let warm = [fresh_pubkey(1), fresh_pubkey(2)]; + assert!(warm.iter().all(key_validate)); + // Misses between hits, in a run long enough to go through rayon. + let mut pubkeys = vec![warm[0]]; + pubkeys.extend((10..10 + 2 * KEY_VALIDATE_MIN_PAR_LEN as u64).map(fresh_pubkey)); + pubkeys.push(warm[1]); + + let points = validated_pubkeys(&pubkeys).expect("every key above is valid"); + assert_eq!(points.len(), pubkeys.len()); + for (pubkey, point) in pubkeys.iter().zip(&points) { + assert_eq!(Some(point.to_bytes()), uncached(pubkey)); + } + } + + #[test] + fn eth_aggregate_pubkeys_matches_blst_s_own_sum() { + let pubkeys: Vec = (20..25).map(fresh_pubkey).collect(); + let encoded: Vec<&[u8]> = pubkeys.iter().map(|pubkey| pubkey.as_ref()).collect(); + let expected = AggregatePublicKey::aggregate_serialized(&encoded, true) + .expect("every key above is valid") + .to_public_key() + .to_bytes(); + // Once cold, once warm. + for _ in 0..2 { + assert_eq!(eth_aggregate_pubkeys(&pubkeys).unwrap().0, expected); + } + } + + #[test] + fn the_cache_stops_growing_at_its_capacity() { + // One entry per shard. + let cache = PubkeyCache::new(PUBKEY_CACHE_SHARDS); + let pubkeys: Vec = (100..100 + 4 * PUBKEY_CACHE_SHARDS as u64) + .map(fresh_pubkey) + .collect(); + for pubkey in &pubkeys { + let point = PublicKey::key_validate(pubkey.as_ref()).unwrap(); + cache.insert(*pubkey, point); + // Inserting the same key again must not take a second slot. + cache.insert(*pubkey, point); + } + assert!(cache.len() <= PUBKEY_CACHE_SHARDS); + let held = pubkeys + .iter() + .filter(|pubkey| cache.get(pubkey).is_some()) + .count(); + assert_eq!(held, cache.len()); + } + + /// Builds a realistic attestation aggregate of `count` independent + /// signers over one shared message: each signer gets its own + /// `key_gen`-derived keypair and signs [`message`](Root) under this + /// module's own [`DST`], the same DST [`fast_aggregate_verify`] checks + /// against, and the resulting signatures are folded together with this + /// module's own [`aggregate`], the same call a real caller makes to + /// produce one. This mirrors the shape of an Electra attestation + /// aggregate, where every attester signs identical attestation data. + fn build_aggregate(count: usize) -> (Vec, Root, BlsSignature) { + let message = crate::beacon::primitives::H256([7u8; 32]); + let mut pubkeys = Vec::with_capacity(count); + let mut signatures = Vec::with_capacity(count); + for index in 0..count { + // `key_gen` requires at least 32 bytes of input key material; + // seeding it with the signer's index keeps every key distinct + // and the whole aggregate reproducible run to run. + let mut ikm = [0u8; 32]; + ikm[..8].copy_from_slice(&(index as u64 + 1).to_le_bytes()); + let secret = blst::min_pk::SecretKey::key_gen(&ikm, &[]) + .expect("32 bytes of input material is enough for key generation"); + pubkeys.push(BlsPubkey(secret.sk_to_pk().to_bytes())); + let signature = secret.sign(message.as_slice(), DST, &[]); + signatures.push(BlsSignature(signature.to_bytes())); + } + let aggregated = aggregate(&signatures) + .expect("every signature above comes from a fresh, valid keypair"); + (pubkeys, message, aggregated) + } + + /// Wall-clock timing for [`fast_aggregate_verify`] at the scale a real + /// Electra attestation aggregate reaches: hundreds to thousands of + /// attesters behind one shared message. Prints, for each size, the time + /// with every key a cache miss (validated in parallel) and then with every + /// key a hit, so the two paths (or two machines) can be compared by hand; + /// the `assert!`s also make this a correctness check, not only a + /// stopwatch, since a bug that dropped or misaligned a key on either path + /// would make the aggregate fail to verify. + /// + /// `#[ignore]`d for the same reason the crate's other slow crypto tests + /// are: generating and signing thousands of real BLS keypairs, twice, + /// dominates the run time and has no place in a default `cargo test`. + #[test] + #[ignore = "slow: generates and signs thousands of real BLS keypairs"] + fn fast_aggregate_verify_key_validation_timing() { + for count in [512usize, 2048] { + let (pubkeys, message, signature) = build_aggregate(count); + for pass in ["cold", "warm"] { + let start = std::time::Instant::now(); + let result = fast_aggregate_verify(&pubkeys, message, &signature); + let elapsed = start.elapsed(); + println!("fast_aggregate_verify, {count} signers, {pass} cache: {elapsed:?}"); + assert!( + result, + "a correctly-aggregated signature over {count} signers must verify ({pass})" + ); + } + } + } +} diff --git a/crates/blockchain/state_transition/src/beacon/das.rs b/crates/blockchain/state_transition/src/beacon/das.rs new file mode 100644 index 000000000..a0c46a542 --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/das.rs @@ -0,0 +1,260 @@ +//! Data availability sampling: which columns this node owes the network. +//! +//! `das-core.md` splits the extended data matrix's columns into custody +//! groups and assigns each node a set of them as a public function of its node +//! id, so any peer can compute what any other peer should be able to serve +//! without asking it. This module is that function and nothing else: the +//! sidecar verifiers live with their fork-choice siblings in +//! [`super::fork_choice`], and the matrix helpers `compute_matrix` and +//! `recover_matrix` are deliberately absent, since both exist for +//! reconstruction and reconstruction needs half the matrix while this node +//! holds [`constants::CUSTODY_REQUIREMENT`]'s worth of it. +//! +//! Two properties of [`get_custody_groups`] are load-bearing and both are +//! covered by fixtures. The selection is an *extension*, not a reshuffle: a +//! node that raises its custody count keeps every group it already had, which +//! is what lets a peer guess a node's custody from the default when its real +//! count is unknown. And the walk wraps at `UINT256_MAX` rather than +//! overflowing: any `current_id` the walk drives up to the maximum reaches +//! the wrap, and the all-ones node id is simply the one input that reaches it +//! on the very first step. + +use crate::beacon::constants; +use crate::beacon::error::{Result, verify}; +use crate::beacon::hash::hash; +use crate::beacon::helpers::math::bytes_to_uint64; +use crate::beacon::preset; +use crate::beacon::primitives::ColumnIndex; + +/// The index of a custody group: `das-core.md`'s `CustodyIndex`. +/// +/// The specification lists this as a custom type alongside +/// [`crate::beacon::primitives::ColumnIndex`], but only this module's own +/// functions need it, so it is defined here instead of in +/// `crate::beacon::primitives`, the same reasoning +/// [`crate::beacon::containers::fulu::RowIndex`] gives for staying beside its +/// only consumers. +pub type CustodyIndex = u64; + +/// How many custody groups a node samples each slot given what it custodies. +/// +/// `das-core.md`, "Custody sampling": a node samples the larger of the floor +/// and its own custody, so a node at the minimum still samples +/// [`constants::SAMPLES_PER_SLOT`] groups and its custody set is a subset of +/// what it samples. +pub fn sampling_size(custody_group_count: u64) -> u64 { + custody_group_count.max(constants::SAMPLES_PER_SLOT) +} + +/// The custody groups `node_id` is assigned, sorted ascending. +/// +/// `node_id` is the discv5 node id, 32 bytes big endian, which is how a peer +/// reads it off an ENR. The walk hashes the *little endian* encoding of that +/// number, since the specification's `uint_to_bytes` is SSZ's, and SSZ +/// integers are little endian; hashing the big-endian bytes produces a +/// plausible-looking set that agrees with no other client. +/// +/// The walk increments `node_id` after each hash and wraps at `UINT256_MAX` +/// rather than overflowing (see [`increment_wrapping`]). The all-ones node id +/// reaches that wrap on its very first increment, which is why it is the +/// fixture input that exercises it; any other starting id reaches the same +/// wrap eventually, just later. +pub fn get_custody_groups( + node_id: [u8; 32], + custody_group_count: u64, +) -> Result> { + verify( + custody_group_count <= constants::NUMBER_OF_CUSTODY_GROUPS, + "custody_group_count <= NUMBER_OF_CUSTODY_GROUPS", + )?; + + // Skip the walk when everything is custodied: it would take an unbounded + // number of iterations to collect the last few groups by chance. + if custody_group_count == constants::NUMBER_OF_CUSTODY_GROUPS { + return Ok((0..constants::NUMBER_OF_CUSTODY_GROUPS).collect()); + } + + let mut current_id = node_id; + let mut groups: Vec = Vec::with_capacity(custody_group_count as usize); + while (groups.len() as u64) < custody_group_count { + let mut little_endian = current_id; + little_endian.reverse(); + let digest = hash(&little_endian); + let group = bytes_to_uint64(&digest.0[0..8]) % constants::NUMBER_OF_CUSTODY_GROUPS; + if !groups.contains(&group) { + groups.push(group); + } + increment_wrapping(&mut current_id); + } + + groups.sort_unstable(); + Ok(groups) +} + +/// The columns belonging to `custody_group`: an interleave, not a contiguous +/// block. A group holds `custody_group`, then +/// `custody_group + NUMBER_OF_CUSTODY_GROUPS`, and so on, striding across the +/// whole column range rather than owning a run of adjacent columns. +/// +/// Relies on `preset::NUMBER_OF_COLUMNS` being an exact multiple of +/// [`constants::NUMBER_OF_CUSTODY_GROUPS`] so that stride divides evenly and +/// every column lands in exactly one group; see that constant's own doc for +/// why the two are equal today, and +/// `number_of_columns_is_a_multiple_of_custody_groups` in +/// `crate::beacon::constants`'s tests for where the invariant is checked. +pub fn compute_columns_for_custody_group(custody_group: CustodyIndex) -> Result> { + verify( + custody_group < constants::NUMBER_OF_CUSTODY_GROUPS, + "custody_group < NUMBER_OF_CUSTODY_GROUPS", + )?; + let columns_per_group = preset::NUMBER_OF_COLUMNS as u64 / constants::NUMBER_OF_CUSTODY_GROUPS; + Ok((0..columns_per_group) + .map(|index| constants::NUMBER_OF_CUSTODY_GROUPS * index + custody_group) + .collect()) +} + +/// Every column `node_id` custodies at `custody_group_count`, sorted ascending. +/// +/// The union of the two helpers above, which is what every caller in this +/// repository actually wants: the subnet subscription, the availability check +/// and the by-root fetch all reason about columns, never about groups. +pub fn custody_columns(node_id: [u8; 32], custody_group_count: u64) -> Result> { + let mut columns = Vec::new(); + for group in get_custody_groups(node_id, custody_group_count)? { + columns.extend(compute_columns_for_custody_group(group)?); + } + columns.sort_unstable(); + Ok(columns) +} + +/// Adds one to a big-endian 256-bit integer, wrapping to all-zero rather than +/// overflowing past the all-ones maximum. +/// +/// Carries from the last byte (the least significant one, since `value` is +/// big-endian) toward the first, stopping as soon as a byte absorbs the carry +/// without itself overflowing. +fn increment_wrapping(value: &mut [u8; 32]) { + for byte in value.iter_mut().rev() { + let (next, carried) = byte.overflowing_add(1); + *byte = next; + if !carried { + return; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A node id with every byte set, which is `UINT256_MAX`. Exercises + /// `get_custody_groups`'s two early-return branches below, both of which + /// return before the walk begins; [`increment_wrapping`]'s own carry + /// propagation, which only the walk reaches, is exercised directly by the + /// tests further down instead. + const MAX_NODE_ID: [u8; 32] = [0xff; 32]; + + #[test] + fn a_full_custody_count_returns_every_group_without_hashing() { + let groups = get_custody_groups(MAX_NODE_ID, constants::NUMBER_OF_CUSTODY_GROUPS).unwrap(); + assert_eq!( + groups, + (0..constants::NUMBER_OF_CUSTODY_GROUPS).collect::>() + ); + } + + #[test] + fn asking_for_more_groups_than_exist_is_refused() { + assert!(get_custody_groups(MAX_NODE_ID, constants::NUMBER_OF_CUSTODY_GROUPS + 1).is_err()); + } + + #[test] + fn wraps_at_the_maximum_id() { + let mut id = MAX_NODE_ID; + increment_wrapping(&mut id); + assert_eq!(id, [0u8; 32]); + } + + #[test] + fn carries_across_multiple_bytes() { + let mut id = [0u8; 32]; + id[30] = 0xff; + id[31] = 0xff; + increment_wrapping(&mut id); + assert_eq!(id[29], 1); + assert_eq!(id[30], 0); + assert_eq!(id[31], 0); + } + + #[test] + fn get_custody_groups_walks_through_the_wrap() { + assert!(get_custody_groups(MAX_NODE_ID, 8).is_ok()); + } + + #[test] + fn groups_are_sorted_and_unique() { + let groups = get_custody_groups([7; 32], 8).unwrap(); + assert_eq!(groups.len(), 8); + let mut sorted = groups.clone(); + sorted.sort_unstable(); + sorted.dedup(); + assert_eq!(groups, sorted); + } + + #[test] + fn a_larger_count_extends_the_same_selection() { + // das-core: "Increasing the custody_size parameter for a given node_id + // extends the returned list (rather than being an entirely new + // shuffle)". A node raising its custody must not have to re-backfill + // what it already held. + let small = get_custody_groups([3; 32], 4).unwrap(); + let large = get_custody_groups([3; 32], 8).unwrap(); + for group in small { + assert!( + large.contains(&group), + "group {group} was dropped by a larger count" + ); + } + } + + #[test] + fn a_group_maps_to_its_own_column_when_the_counts_are_equal() { + for group in [0, 1, 55, constants::NUMBER_OF_CUSTODY_GROUPS - 1] { + assert_eq!( + compute_columns_for_custody_group(group).unwrap(), + vec![group] + ); + } + } + + #[test] + fn a_group_outside_the_range_is_refused() { + assert!(compute_columns_for_custody_group(constants::NUMBER_OF_CUSTODY_GROUPS).is_err()); + } + + #[test] + fn the_sampling_size_is_the_floor_for_a_minimal_custodian() { + assert_eq!( + sampling_size(constants::CUSTODY_REQUIREMENT), + constants::SAMPLES_PER_SLOT + ); + assert_eq!( + sampling_size(constants::NUMBER_OF_CUSTODY_GROUPS), + constants::NUMBER_OF_CUSTODY_GROUPS + ); + } + + #[test] + fn custody_columns_are_sorted_and_match_the_groups() { + let node_id = [9; 32]; + let groups = get_custody_groups(node_id, 8).unwrap(); + let columns = custody_columns(node_id, 8).unwrap(); + assert_eq!(columns.len(), groups.len()); + let mut sorted = columns.clone(); + sorted.sort_unstable(); + assert_eq!(columns, sorted); + for group in groups { + assert!(columns.contains(&group)); + } + } +} diff --git a/crates/blockchain/state_transition/src/beacon/fork_choice.rs b/crates/blockchain/state_transition/src/beacon/fork_choice.rs new file mode 100644 index 000000000..5560782b8 --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/fork_choice.rs @@ -0,0 +1,3424 @@ +//! The fork choice store: LMD GHOST, with FFG-derived justification and +//! finalization gating which branches are even eligible to be head. +//! +//! Implements `specs/phase0/fork-choice.md`, which is also every later fork's +//! fork choice through at least altair: none of them change anything here. +//! `Store` therefore accepts a block from any fork this module implements, +//! even though the algorithm applied to it is unconditionally phase0's. +//! `Store` tracks the block tree, attester votes, and the checkpoints fork +//! choice reasons about; the four handlers at the bottom of this file +//! ([`on_tick`], [`on_block`], [`on_attestation`], [`on_attester_slashing`]) +//! are the only ones the specification lists as sole ways to change it, +//! matching its own framing: "Invalid calls to handlers must not modify +//! `store`." Every other function in this file takes `&Store`, with one +//! exception: [`get_head`] takes `&mut Store` too, since it records the head +//! it just computed; see its own documentation for why that write belongs +//! there rather than in a caller. +//! +//! # Units: one seconds-granularity clock, read out in milliseconds at the edges +//! +//! This module's entry points speak the specification's unit: both [`on_tick`] +//! and [`on_tick_per_slot`] take a `time: u64` in seconds, exactly like +//! `BeaconState.genesis_time`. The store underneath keeps one clock in +//! milliseconds, +//! [`Store::time_ms`](ethlambda_storage::Store::time_ms), so those two convert +//! on the way in and nothing else in this module reads the row directly: +//! `get_slots_since_genesis` and `get_current_slot` reduce to +//! [`Store::current_slot`](ethlambda_storage::Store::current_slot), and the +//! handlers that need to place a moment *within* the current slot against the +//! basis-point deadlines (`get_attestation_due_ms` and friends) read +//! [`Store::ms_since_genesis`](ethlambda_storage::Store::ms_since_genesis). +//! +//! The lean chain shares that row and those derivations, and reads it on a +//! third grid of its own, `Store::intervals_since_genesis`, which nothing here +//! touches. +//! +//! Milliseconds only appear where a handler needs to place a moment *within* +//! the current slot against the basis-point deadlines +//! (`get_attestation_due_ms` and friends, fractions of +//! `Config::slot_duration_ms`): [`seconds_to_milliseconds`] converts the +//! coarse seconds-since-genesis value at exactly that point, and nowhere else. +//! So this is not two clocks running at different rates; it is one +//! seconds-resolution clock with a millisecond-resolution read-out computed on +//! demand, purely for comparing against the sub-slot deadlines. +//! +//! # Why `Store::block_index` never needs to be a `BTreeMap` +//! +//! The one place this file iterates every block the store holds is +//! `filter_block_tree`'s scan of [`Store::block_index`](ethlambda_storage::Store::block_index) +//! for a block's children, and [`get_head`]'s equivalent scan of the tree +//! `filter_block_tree` already filtered down. Both immediately reduce that +//! scan to a single winner via an explicit, fully-ordered sort key +//! (`(weight, root)`, with `root` breaking ties the same way Python compares +//! two `bytes` values, since [`Root`]'s `Ord` compares its bytes in the same +//! order). Two distinct blocks never share a root, so that key never actually +//! ties, and the winner is the same regardless of which order the underlying +//! map happened to yield its entries in. Nothing else in this file examines a +//! map's keys as a whole, so `block_index` never needs an order of its own. +//! +//! # Signed blocks, not the specification's unsigned ones +//! +//! The specification's `store.blocks: Dict[Root, BeaconBlock]` holds the +//! unsigned message. +//! [`Store::insert_signed_block`](ethlambda_storage::Store::insert_signed_block)/ +//! [`Store::get_signed_block`](ethlambda_storage::Store::get_signed_block) +//! hold [`SignedBeaconBlock`] instead: it is what every caller already has in +//! hand (a fixture case, a gossiped block, a `BlocksByRoot` response), the +//! extra signature is small next to a full body, and storage keys on the +//! *unsigned* message's root ([`SignedBeaconBlock::message_hash_tree_root`]), +//! so nothing about lookup or ancestry changes. [`get_forkchoice_store`]'s +//! `anchor_block` is signed for the same reason, even though a trusted +//! anchor's own signature is never actually checked. +//! +//! Holding the fork-generic enum here, rather than a concrete per-fork +//! struct, is what lets [`on_block`] accept a block from any fork this module +//! implements: every place in this file that reads a field off a block in hand +//! goes through the enum's shared accessors (`slot()`, `parent_root()`, and +//! so on) rather than a phase0-specific field. A block already *stored* is +//! read through [`Store::block_entry`](ethlambda_storage::Store::block_entry) +//! instead, which answers the only two fields this file ever wants of one +//! without decoding its body at all. +//! +//! # `on_block`'s execution engine +//! +//! [`stf::state_transition`] takes an [`stf::ExecutionEngine`] from bellatrix +//! on, for the one call a real client would route to its execution layer. +//! [`on_block`] derives that engine from the [`PayloadValidity`] its caller +//! hands it: an `INVALIDATED` verdict makes `verify_and_notify_new_payload` +//! answer false and the transition fail from inside +//! `process_execution_payload`, which is where the specification puts that +//! failure; every other verdict answers true. +//! +//! No released `fork_choice` fixture, at any fork or preset, ships an +//! `execution.yaml` or an `on_payload_info` step, so that whole suite passes +//! [`PayloadValidity::NotRequired`] and behaves exactly as it did before the +//! verdict existed. The suite that does exercise a standing registry keyed by +//! execution block hash is `sync/optimistic`, which keeps it on the store +//! rather than in a parameter, because it is updated over the course of a case +//! rather than fixed at construction time. +//! +//! # [`Attestation`] and [`AttesterSlashing`]: a second fork-generic enum +//! +//! [`phase0::Attestation`] and [`phase0::AttesterSlashing`] keep the same +//! shape from phase0 through deneb, so [`on_attestation`] and +//! [`on_attester_slashing`] could stay phase0-typed through every fork this +//! crate implemented before electra. EIP-7549 breaks that: electra widens +//! `aggregation_bits` from one committee's worth to a whole slot's and adds +//! `committee_bits`, so [`electra::Attestation`] (and, following from it, +//! [`electra::IndexedAttestation`] and [`electra::AttesterSlashing`]) is a +//! different concrete type, not just a wider bound on the same one. +//! +//! [`Attestation`] and [`AttesterSlashing`] mirror [`SignedBeaconBlock`]'s own +//! answer to that problem: an enum over the two shapes, with two variants +//! rather than one per fork for the same reason `SignedBeaconBlock` has only +//! seven, not one per fork through fulu. But fork choice reads far less out +//! of an attestation than a block: `data.slot`, `data.target`, +//! `data.beacon_block_root`, and the attesting indices, per the module +//! documentation above. `AttestationData` (`crate::beacon::containers::shared`) is +//! already fork-invariant, so every function below except the two enums' +//! own methods reads it directly rather than matching on a fork tag it does +//! not need: [`validate_on_attestation`] and [`update_latest_messages`] take +//! `AttestationData` and a resolved `&[ValidatorIndex]`, not an +//! [`Attestation`]. The one place a fork's own shape actually matters is +//! resolving an [`Attestation`] into the attesters it names and checking +//! their aggregate signature, which needs the fork-specific +//! `get_indexed_attestation`/`is_valid_indexed_attestation` pair +//! ([`crate::beacon::helpers::attestation`] for phase0, [`crate::beacon::helpers::electra`] +//! for electra); [`Attestation::verified_attesting_indices`] and +//! [`AttesterSlashing::verified_attesting_indices`] are where that dispatch +//! happens, once, so [`on_attestation`] and [`on_attester_slashing`] +//! themselves never match on a fork at all. +//! +//! # Bellatrix's merge check, and data availability from deneb on +//! +//! [`on_block`] gains two more fork-conditional steps beyond `phase0/fork- +//! choice.md`, both because a later fork's own `fork-choice.md` modifies +//! `on_block` directly rather than leaving it to state transition: +//! +//! - Bellatrix requires a transitioning block's parent execution payload to +//! sit on a valid terminal PoW block ([`validate_merge_block`]), checked +//! against [`get_pow_block`] rather than a real execution client, the +//! same way [`stf::ExecutionEngine`] stands in for one elsewhere in this +//! crate. Capella's own `fork-choice.md` removes this check outright +//! ("deletion of the verification of merge transition block conditions"), +//! so it applies to bellatrix alone. +//! - Deneb, electra, and fulu each require `is_data_available` to hold +//! before a block with blob commitments is even considered +//! ([`is_data_available_blobs`] for deneb/electra's blob-and-proof shape, +//! [`is_data_available_columns`] for fulu's column-sidecar shape). Both +//! read `retrieve_blobs_and_proofs`/`retrieve_column_sidecars`'s answer out +//! of [`DataAvailability`], a parameter on [`on_block`] rather than a +//! `Store` field: unlike a PoW block, this evidence is scoped to the one +//! block being considered right now, not a registry looked up by hash +//! later. Both helpers are also "implementation and context dependent" in +//! the specification's own words, exactly the class of thing +//! [`stf::ExecutionEngine`] already collapses to whatever the fixture +//! suites supply directly. + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use ethlambda_storage::{CacheKey, ForkCheckpoints, StorageBackend}; +use ethlambda_types::ShortRoot; +use tracing::{error, warn}; + +use crate::beacon::config::Config; +use crate::beacon::constants; +use crate::beacon::containers::{AttestationData, BeaconState, Checkpoint, SignedBeaconBlock}; +use crate::beacon::containers::{bellatrix, deneb, electra, fulu, phase0}; +use crate::beacon::error::{Error, Result, verify}; +use crate::beacon::helpers::accessors::{ + CommitteeCache, get_active_validator_indices, get_beacon_proposer_index, get_current_epoch, + get_total_active_balance, +}; +use crate::beacon::helpers::attestation as phase0_attestation; +use crate::beacon::helpers::electra as electra_helpers; +use crate::beacon::helpers::misc::{ + compute_epoch_at_slot, compute_start_slot_at_epoch, is_valid_merkle_branch, +}; +use crate::beacon::helpers::predicates::{is_active_validator, is_slashable_attestation_data}; +use crate::beacon::kzg; +use crate::beacon::lean_boundary::lean_block_unreachable; +use crate::beacon::preset; +use crate::beacon::primitives::{ + Epoch, ExecutionBlockHash, Gwei, HashTreeRoot as _, KzgCommitment, KzgProof, Root, Slot, + ValidatorIndex, +}; +use crate::beacon::stf; + +// --------------------------------------------------------------------------- +// LatestMessage, PowBlock, PayloadStatusV1 +// --------------------------------------------------------------------------- + +// All of these live in `ethlambda-types` rather than here, because +// `ethlambda-storage` persists them and cannot depend on this crate. +// Re-exported at the paths they had when they were defined here, so [`Store`] +// and its callers are unchanged. +pub use ethlambda_types::beacon::fork_choice::{ + LatestMessage, PayloadStatusEnum, PayloadStatusV1, PowBlock, +}; + +// --------------------------------------------------------------------------- +// Attestation, AttesterSlashing +// --------------------------------------------------------------------------- + +/// An attestation, in whichever fork's shape it currently has. See the module +/// documentation for why this exists and what it lets the rest of this file +/// stay generic over. +/// +/// Two variants, not one per fork: every fork through deneb shares +/// [`phase0::Attestation`] outright, and fulu shares [`electra::Attestation`] +/// the same way [`SignedBeaconBlock::Fulu`] shares +/// [`electra::SignedBeaconBlock`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Attestation { + Phase0(phase0::Attestation), + Electra(electra::Attestation), +} + +impl Attestation { + /// The fork-invariant half of an attestation: everything + /// [`validate_on_attestation`] and [`update_latest_messages`] need, which + /// is why neither of them takes an [`Attestation`] at all. + pub fn data(&self) -> AttestationData { + match self { + Attestation::Phase0(attestation) => attestation.data, + Attestation::Electra(attestation) => attestation.data, + } + } + + /// The attesters this attestation names, once its aggregate signature and + /// index ordering have both been checked against `state`. + /// + /// The one place this enum's two shapes actually matter: building the + /// indexed form and checking it needs the fork-specific + /// `get_indexed_attestation`/`is_valid_indexed_attestation` pair, so this + /// dispatches once here rather than leaving that match to every caller. + pub fn verified_attesting_indices( + &self, + state: &BeaconState, + committees: &CommitteeCache, + ) -> Result> { + self.indices(state, true, committees) + } + + /// The attesters this attestation names, taking `state`'s word for the + /// committees and checking nothing else. + /// + /// Only sound for an attestation that has already been through + /// `process_block`, which is why [`on_block_attestation`] is its only + /// caller: `process_attestation` runs the same `get_indexed_attestation` + /// and the same `is_valid_indexed_attestation` the verifying sibling above + /// does, so for a block's own attestations that verdict is already in hand + /// and re-reaching it is the expensive part of the import. + pub fn attesting_indices( + &self, + state: &BeaconState, + committees: &CommitteeCache, + ) -> Result> { + self.indices(state, false, committees) + } + + /// The body both accessors above share: the one place this enum's two + /// shapes actually matter, since building the indexed form and checking it + /// needs the fork-specific + /// `get_indexed_attestation`/`is_valid_indexed_attestation` pair. Kept as + /// one dispatch so a new attestation shape cannot be added to the + /// verifying path and forgotten on the other. + fn indices( + &self, + state: &BeaconState, + verify_signature: bool, + committees: &CommitteeCache, + ) -> Result> { + match self { + Attestation::Phase0(attestation) => { + let indexed = + phase0_attestation::get_indexed_attestation(state, attestation, committees)?; + if verify_signature { + verify( + phase0_attestation::is_valid_indexed_attestation(state, &indexed), + "is_valid_indexed_attestation(target_state, indexed_attestation)", + )?; + } + Ok(indexed.attesting_indices.into_inner()) + } + Attestation::Electra(attestation) => { + let indexed = + electra_helpers::get_indexed_attestation(state, attestation, committees)?; + if verify_signature { + verify( + electra_helpers::is_valid_indexed_attestation(state, &indexed), + "is_valid_indexed_attestation(target_state, indexed_attestation)", + )?; + } + Ok(indexed.attesting_indices.into_inner()) + } + } + } +} + +/// Evidence that a set of validators made two conflicting attestations, in +/// whichever fork's shape it currently has. See [`Attestation`] for why this +/// has the same two variants and no more. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AttesterSlashing { + Phase0(phase0::AttesterSlashing), + Electra(electra::AttesterSlashing), +} + +impl AttesterSlashing { + /// The fork-invariant half of both attestations `self` accuses of + /// equivocating: what [`on_attester_slashing`] needs to check + /// [`is_slashable_attestation_data`] before it looks at either half's + /// attesters at all. + pub fn data(&self) -> (AttestationData, AttestationData) { + match self { + AttesterSlashing::Phase0(slashing) => { + (slashing.attestation_1.data, slashing.attestation_2.data) + } + AttesterSlashing::Electra(slashing) => { + (slashing.attestation_1.data, slashing.attestation_2.data) + } + } + } + + /// The attesting indices of both halves, once each has been checked as + /// an individually valid indexed attestation against `state`. See + /// [`Attestation::verified_attesting_indices`] for why this is where the + /// fork-specific dispatch happens. + pub fn verified_attesting_indices( + &self, + state: &BeaconState, + ) -> Result<(Vec, Vec)> { + match self { + AttesterSlashing::Phase0(slashing) => { + verify( + phase0_attestation::is_valid_indexed_attestation( + state, + &slashing.attestation_1, + ), + "is_valid_indexed_attestation(state, attestation_1)", + )?; + verify( + phase0_attestation::is_valid_indexed_attestation( + state, + &slashing.attestation_2, + ), + "is_valid_indexed_attestation(state, attestation_2)", + )?; + Ok(( + slashing + .attestation_1 + .attesting_indices + .iter() + .copied() + .collect(), + slashing + .attestation_2 + .attesting_indices + .iter() + .copied() + .collect(), + )) + } + AttesterSlashing::Electra(slashing) => { + verify( + electra_helpers::is_valid_indexed_attestation(state, &slashing.attestation_1), + "is_valid_indexed_attestation(state, attestation_1)", + )?; + verify( + electra_helpers::is_valid_indexed_attestation(state, &slashing.attestation_2), + "is_valid_indexed_attestation(state, attestation_2)", + )?; + Ok(( + slashing + .attestation_1 + .attesting_indices + .iter() + .copied() + .collect(), + slashing + .attestation_2 + .attesting_indices + .iter() + .copied() + .collect(), + )) + } + } + } +} + +/// The attestations and attester slashings carried in `block`'s body, each +/// wrapped in the fork-generic shape [`on_block_attestation`] and +/// [`on_attester_slashing`] take. +/// +/// Lives here, beside the two enums it builds, because the fork-to-shape +/// mapping is theirs: phase0 through deneb share +/// [`phase0::Attestation`]/[`phase0::AttesterSlashing`], electra and fulu the +/// `electra` pair. Both consumers of a block's own operations, the chain actor +/// and the `fork_choice` fixture runner, read it from here, so a new fork +/// reshaping `body.attestations` cannot be handled in one and forgotten in the +/// other. +pub fn block_operations(block: &SignedBeaconBlock) -> (Vec, Vec) { + match block { + SignedBeaconBlock::Electra(block) => ( + block + .message + .body + .attestations + .iter() + .cloned() + .map(Attestation::Electra) + .collect(), + block + .message + .body + .attester_slashings + .iter() + .cloned() + .map(AttesterSlashing::Electra) + .collect(), + ), + SignedBeaconBlock::Fulu(block) => ( + block + .message + .body + .attestations + .iter() + .cloned() + .map(Attestation::Electra) + .collect(), + block + .message + .body + .attester_slashings + .iter() + .cloned() + .map(AttesterSlashing::Electra) + .collect(), + ), + SignedBeaconBlock::Phase0(block) => phase0_operations( + block.message.body.attestations.iter(), + block.message.body.attester_slashings.iter(), + ), + SignedBeaconBlock::Altair(block) => phase0_operations( + block.message.body.attestations.iter(), + block.message.body.attester_slashings.iter(), + ), + SignedBeaconBlock::Bellatrix(block) => phase0_operations( + block.message.body.attestations.iter(), + block.message.body.attester_slashings.iter(), + ), + SignedBeaconBlock::Capella(block) => phase0_operations( + block.message.body.attestations.iter(), + block.message.body.attester_slashings.iter(), + ), + SignedBeaconBlock::Deneb(block) => phase0_operations( + block.message.body.attestations.iter(), + block.message.body.attester_slashings.iter(), + ), + SignedBeaconBlock::Lean(_) => lean_block_unreachable("fork_choice::block_operations"), + } +} + +/// Phase0 through deneb share one attestation and slashing shape, so their +/// five arms above share one body. +/// +/// Takes iterators rather than the lists themselves: each fork's body names +/// its own `SszList` bound, so a parameter typed on the list would need one +/// generic per bound, and `.iter()` erases exactly that difference. +fn phase0_operations<'a>( + attestations: impl Iterator, + slashings: impl Iterator, +) -> (Vec, Vec) { + ( + attestations.cloned().map(Attestation::Phase0).collect(), + slashings.cloned().map(AttesterSlashing::Phase0).collect(), + ) +} + +// --------------------------------------------------------------------------- +// DataAvailability +// --------------------------------------------------------------------------- + +/// What [`on_block`] needs from `retrieve_blobs_and_proofs` (deneb, electra) +/// or `retrieve_column_sidecars` (fulu) to decide `is_data_available`. +/// +/// Both are "implementation and context dependent" in the specification's +/// own words, exactly like [`stf::ExecutionEngine`]'s execution-payload +/// validity call; this collapses the same way, to whatever the fixture +/// suites supply directly for the one block being considered right now. A +/// `Store` field would be the wrong shape for that: unlike [`PowBlock`], +/// this evidence is never looked up again by some other hash later, so +/// [`on_block`] takes it as a parameter instead. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DataAvailability { + /// The block carries no blob commitments, or predates deneb: nothing for + /// [`on_block`]'s data-availability check to do. + NotRequired, + /// Deneb and electra's shape: every blob and its proof, in + /// `block.body.blob_kzg_commitments`'s own order. A length mismatch + /// against the block's own commitments is not checked here; it is + /// exactly what [`is_data_available_blobs`] rejects. + Blobs { + blobs: Vec, + proofs: Vec, + }, + /// Fulu's shape: the column sidecars sampled for this block. + Columns(Vec), +} + +/// What an execution client said about the payload of the block being imported +/// right now. +/// +/// A parameter on [`on_block`] rather than a `Store` field, for the same reason +/// [`DataAvailability`] is one: it is evidence about this one block, not a +/// registry looked up by some other hash later. The fixture-seeded registry +/// keyed by execution block hash is the other half of that split and does live +/// on the store, next to [`PowBlock`]. +/// +/// [`Validated`](Self::Validated) and [`NotRequired`](Self::NotRequired) both +/// let a block in, and the difference is what gets recorded, not whether the +/// import succeeds: a `NotRequired` block was never the subject of a question, +/// so answering it "valid" would be a claim nobody made. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PayloadValidity { + /// Nothing to ask: the block predates bellatrix and carries no payload, or + /// no execution client is configured. Today's behaviour before any engine + /// existed, and what every `fork_choice` fixture case still gets. + NotRequired, + /// `VALID`. The block and every ancestor leave `optimistic_roots`. + Validated, + /// `SYNCING` or `ACCEPTED`, `optimistic-sync.md`'s `NOT_VALIDATED` alias. + /// The block is imported and joins `optimistic_roots`. + Optimistic, + /// `INVALID` or `INVALID_BLOCK_HASH`, its `INVALIDATED` alias. The import + /// fails, and `latest_valid_hash` decides how much of the branch dies with + /// it. `None` is the specification's `null`. + Invalidated { + latest_valid_hash: Option, + }, +} + +/// Reads an execution client's status as the verdict [`on_block`] takes. +/// +/// One function for both sources of a status: the real client's JSON-RPC answer +/// and the fixture runner's seeded registry. Keeping the mapping here rather +/// than at each call site is what lets the `sync/optimistic` suite prove the +/// production reading of `optimistic-sync.md`'s two aliases rather than a +/// test's own copy of it. +pub fn payload_validity(status: &PayloadStatusV1) -> PayloadValidity { + if status.status.is_invalidated() { + return PayloadValidity::Invalidated { + latest_valid_hash: status.latest_valid_hash, + }; + } + if status.status.is_not_validated() { + return PayloadValidity::Optimistic; + } + PayloadValidity::Validated +} + +/// The block an `INVALID` verdict actually condemns, per `optimistic-sync.md`'s +/// `latestValidHash` table. +/// +/// | `latest_valid_hash` | result | +/// |---|---| +/// | an execution hash found on this chain | the child of the block carrying it | +/// | all zeroes | the deepest indexed ancestor carrying a payload | +/// | `None`, or a hash not on this chain | `block_root` itself | +/// +/// Walked up the rejected block's own ancestry rather than looked up in an index +/// over every block, because the specification scopes it that way: "the *child* +/// of a block with `body.execution_payload.block_hash == latestValidHash` **in +/// the chain containing the block with payload in question**". Two branches can +/// share a parent whose payload is the last valid one, and only the branch that +/// was rejected may die. +/// +/// `parent_root` is a parameter rather than read out of `index`, because +/// `block_root` is not in `index`: an `INVALID` verdict arrives before the block +/// is imported, so the store has no row for it. That also makes the `None` and +/// unfindable cases self-enforcing: they answer `block_root`, and +/// [`invalidate_subtree`] on an unindexed root removes nothing, which is exactly +/// "only the block in question dies" for a block that never joined the tree. +/// +/// The unfindable case is the specification's own instruction, not a +/// convenience: "When `latestValidHash` is a meaningful execution block hash but +/// consensus engine cannot find a block satisfying +/// `body.execution_payload.block_hash == latestValidHash`, consensus engine +/// SHOULD behave the same as if `latestValidHash` was `null`." A +/// checkpoint-synced follower meets this whenever the named block is below its +/// anchor. +pub fn resolve_invalid_block( + store: &Store, + index: &HashMap, + block_root: Root, + parent_root: Root, + latest_valid_hash: Option, +) -> Root { + let Some(latest_valid_hash) = latest_valid_hash else { + return block_root; + }; + + // All zeroes: every payload-carrying block on this chain is condemned, so + // the answer is the earliest ancestor this store still indexes that carries + // one. The walk moves toward genesis, so each step reaches a *shallower* + // block, and the last one it can reach is the whole branch's root. + if latest_valid_hash.is_zero() { + let mut earliest_execution_block = block_root; + let mut cursor = parent_root; + while store.beacon_el_block_hash(cursor).is_some() { + earliest_execution_block = cursor; + let Some((_slot, parent)) = index.get(&cursor).copied() else { + break; + }; + cursor = parent; + } + return earliest_execution_block; + } + + // Walk up from the parent, carrying the block we came from. The first + // ancestor whose own payload hash matches is the last valid block, so the + // child we arrived from is the first invalid one. + let mut child = block_root; + let mut cursor = parent_root; + loop { + if store.beacon_el_block_hash(cursor) == Some(latest_valid_hash) { + return child; + } + let Some((_slot, parent)) = index.get(&cursor).copied() else { + // Ran off the top of what this store indexes without finding it. + return block_root; + }; + child = cursor; + cursor = parent; + } +} + +/// Removes `invalid_root` and every descendant from fork choice, returning how +/// many blocks were removed. +/// +/// Removes nothing, and returns `0`, for a root at or below finality: see the +/// finality floor in the body for why that verdict is refused rather than +/// obeyed. +/// +/// `optimistic-sync.md`: "a block deemed `INVALIDATED` at any point MUST NOT be +/// included in the canonical chain and the weights from those `INVALIDATED` +/// blocks MUST NOT be applied to any `VALID` or `NOT_VALIDATED` ancestors." +/// Deleting the `LiveChain` rows satisfies both halves at once, because +/// `Store::block_index` is the only source fork choice reads: +/// [`compute_weights`] folds a subtree total only into parents it finds in that +/// index and gates the proposer-boost walk on the same membership, so a vote +/// naming a removed root seeds an entry that is never folded anywhere; +/// [`filter_block_tree`] and [`get_head`] are index-derived too. +/// +/// One index scan builds the whole child map rather than rescanning per level: +/// the descendants of one root are a tiny fraction of the tree, but finding +/// them at all means knowing every block's parent. +/// +/// # A dangling vote this can create +/// +/// Unlike `Store::promote_beacon_anchor`, which only ever prunes below a +/// finality horizon, this removes rows from the *live* window, so a validator +/// whose freshest vote named a branch the execution layer has since rejected +/// keeps pointing at a root no longer in the index, until it attests again. +/// +/// [`compute_weights`] already drops such a vote rather than raising, so +/// [`get_head`] is unaffected. [`get_weight`] deliberately does not: it is the +/// specification's own version, and +/// `tests::a_vote_for_a_pruned_block_weighs_nothing_instead_of_failing` pins +/// that divergence on purpose. Nothing on this node's paths calls it today +/// ([`get_proposer_head`] and [`should_override_forkchoice_update`] have no +/// callers outside this file), but wiring up a beacon proposer duty would make +/// it reachable, and it should get `compute_weights`' treatment first. +pub fn invalidate_subtree(store: &mut Store, invalid_root: Root) -> usize { + let index = store.block_index(); + + // A condemned root at or below finality means the execution client and this + // node disagree about finalized history, which is an operator emergency, not + // something to resolve by emptying fork choice. Obeying it would delete every + // `LiveChain` row from the finalized block upward, after which [`get_head`] + // fails its "block_root in store.blocks" check on every call and the node + // only logs that it cannot compute a head until its database is rebuilt. + // + // Reachable without any disagreement about a *specific* block: EIP-3675 lets + // an execution client answer `INVALID` with `latestValidHash = 0x00..0`, + // meaning every payload on this chain is invalid, and + // [`resolve_invalid_block`]'s zero branch then walks to the earliest ancestor + // whose hash this store still caches, which the cache's own finality bound + // keeps down to the finalized block. + let finalized = store.beacon_finalized_checkpoint(); + let finalized_slot = compute_start_slot_at_epoch(finalized.epoch); + let at_or_below_finality = index + .get(&invalid_root) + .is_some_and(|(slot, _parent)| *slot <= finalized_slot); + if invalid_root == finalized.root || at_or_below_finality { + error!( + condemned = %ShortRoot(&invalid_root.0), + finalized_slot, + finalized_root = %ShortRoot(&finalized.root.0), + "The execution client condemned a finalized block; refusing to invalidate. \ + The execution and consensus layers disagree about finalized history and \ + this node needs operator attention" + ); + return 0; + } + + let mut children: HashMap> = HashMap::new(); + for (&root, &(_slot, parent_root)) in &index { + children.entry(parent_root).or_default().push(root); + } + + let mut doomed: Vec<(Slot, Root)> = Vec::new(); + let mut stack = vec![invalid_root]; + while let Some(root) = stack.pop() { + let Some(&(slot, _parent_root)) = index.get(&root) else { + continue; + }; + doomed.push((slot, root)); + if let Some(kids) = children.get(&root) { + stack.extend(kids.iter().copied()); + } + } + + if doomed.is_empty() { + return 0; + } + + for (_slot, root) in &doomed { + // No longer merely unvalidated: it is refused. `optimistic_roots` holds + // only blocks still awaiting an answer. + store.remove_beacon_optimistic_root(*root); + } + store.delete_live_chain_entries(&doomed); + doomed.len() +} + +/// Clears `root` and every optimistic ancestor from `optimistic_roots`. +/// +/// `optimistic-sync.md`: "when a block transitions from `NOT_VALIDATED` to +/// `VALID`, all *ancestors* of the block MUST also transition". One walk up the +/// index clears the whole prefix, stopping at the first ancestor that is not +/// optimistic, because everything above it was already cleared when that one +/// was. +/// +/// Reached from two places, which is why it is a function rather than an inline +/// walk: [`on_block`], when `engine_newPayloadV4` answers `VALID`, and the +/// actor's `forkchoiceUpdated` handler, which is how a block imported on +/// `SYNCING` eventually becomes validated. +pub fn mark_validated(store: &mut Store, root: Root) { + store.remove_beacon_optimistic_root(root); + + // With a healthy execution client nothing is optimistic, and the walk below + // would exit on its own first iteration. Ask that before paying for + // `block_index`, which is an uncached prefix scan of the whole `LiveChain` + // table (never pruned on beacon) plus a map build, on a path that runs once + // per imported block and once per `forkchoiceUpdated`. + if !store.has_beacon_optimistic_roots() { + return; + } + + let index = store.block_index(); + let mut cursor = root; + while let Some((_slot, parent)) = index.get(&cursor).copied() { + if !store.is_beacon_optimistic(parent) { + break; + } + store.remove_beacon_optimistic_root(parent); + cursor = parent; + } +} + +/// Whether a block may be imported before its payload has been validated. +/// +/// `optimistic-sync.md`'s function of the same name. Two ways to qualify: +/// +/// 1. The parent already has execution enabled. Any descendant of a merge block +/// is fair game, since the poisoning attack the horizon guards against needs +/// a *transition* block with a junk parent hash. +/// 2. The block is at least `safe_slots` behind the wall clock, so an honest +/// chain has had time to justify around any poison. +/// +/// Reads `is_execution_block(parent)` off the cached execution hash rather than +/// decoding the parent block: the cache is populated at import for exactly the +/// blocks that have one, so its absence is the answer. +/// +/// `safe_slots` is a parameter rather than the constant read directly, because +/// the specification requires the value to be operator-configurable. +pub fn is_optimistic_candidate_block( + store: &Store, + current_slot: Slot, + block_slot: Slot, + parent_root: Root, + safe_slots: u64, +) -> bool { + if store.beacon_el_block_hash(parent_root).is_some() { + return true; + } + block_slot.saturating_add(safe_slots) <= current_slot +} + +// --------------------------------------------------------------------------- +// Store +// --------------------------------------------------------------------------- + +/// The fork choice store: the DB-backed store the lean chain already runs on, +/// rather than a struct defined in this file. +/// +/// Every field the specification's own `Store` names has a home here: +/// checkpoints and the clock live in `Metadata`, blocks in the block tables, +/// unrealized justifications in their own table, and the per-slot/per-epoch +/// scratch (`proposer_boost_root`, `block_timeliness`, `equivocating_indices`, +/// `latest_messages`, `pow_blocks`) in an in-memory struct cheap enough to +/// rebuild after a restart rather than worth persisting. See +/// [`ethlambda_storage::Store`]'s own documentation for the full +/// field-by-field accounting; this module reads and writes it exclusively +/// through its public accessors. +/// +/// The specification's `checkpoint_states` cache lives in +/// [`ethlambda_storage::Store::state_cache`], the same bounded LRU that +/// memoizes plain block states: [`checkpoint_state`] is what keys it by +/// [`CacheKey::CheckpointState`] and fills it on a miss; see its own +/// documentation. +pub use ethlambda_storage::Store; + +/// The state advanced to the first slot of `checkpoint`'s epoch. +/// +/// Checks the store's bounded state cache first, keyed by +/// [`CacheKey::CheckpointState`] (epoch and root both, since a checkpoint's +/// root is the last block at or before its boundary slot and so can serve +/// more than one epoch). A hit returns the same `Arc` with no reconstruction +/// and no `stf::process_slots` replay. A miss derives the value and records +/// it before returning; a miss is never an error, which is what makes the +/// cache a pure speed trade with no correctness stake. Nothing here may +/// become a consensus input: a decision that changed with cache residency +/// would be a bug, not a tuning choice. +/// +/// Takes `&Store`, not `&mut Store`: it reads the checkpoint's state via +/// [`Store::get_state`](ethlambda_storage::Store::get_state), which is itself +/// `&self`, and advances only a local `BeaconState` clone through +/// `stf::process_slots` on a miss. Storage's state-cache accessors are +/// `&self` by design, using interior mutability, which is what lets this +/// read-only helper record a derived value on a miss without widening to +/// `&mut Store`. +/// +/// Public so the Beacon API can take attestation data's source checkpoint +/// from the same cached, advanced state fork choice uses. +pub fn checkpoint_state( + store: &Store, + checkpoint: &Checkpoint, + config: &Config, +) -> Result> { + let key = CacheKey::CheckpointState { + epoch: checkpoint.epoch, + root: checkpoint.root, + }; + if let Some(state) = store.cached_state(key) { + return Ok(state); + } + + let state = store + .get_state(&checkpoint.root) + .expect("get") + .ok_or(Error::SpecAssert("checkpoint.root in store.block_states"))?; + + let target_slot = compute_start_slot_at_epoch(checkpoint.epoch); + let state = if state.slot() < target_slot { + let mut advanced = (*state).clone(); + stf::process_slots(&mut advanced, target_slot, config)?; + Arc::new(advanced) + } else { + state + }; + + store.cache_state(key, state.clone()); + Ok(state) +} + +// --------------------------------------------------------------------------- +// get_forkchoice_store +// --------------------------------------------------------------------------- + +/// Builds the initial store from a trusted anchor state and block. +/// +/// "Trusted" means fork choice will never roll back past this point: a full +/// client anchors at genesis, and a checkpoint-syncing client anchors at +/// whatever finalized state and block it fetched instead. +pub fn get_forkchoice_store( + backend: Arc, + mut anchor_state: BeaconState, + anchor_block: SignedBeaconBlock, + config: &Config, +) -> Result { + // The specification's `BeaconState` and `BeaconBlock` are already one + // fork's own types, so a mismatch between them cannot even be expressed + // there; here both are enums, so this module has to enforce the invariant + // by hand, the same way `stf::state_transition` does for every later + // block. + verify( + anchor_block.fork_name() == anchor_state.fork_name(), + "anchor_block's fork matches anchor_state's", + )?; + + let anchor_root = anchor_block.message_hash_tree_root(); + + // The specification asserts `anchor_block.state_root == + // hash_tree_root(anchor_state)`, which holds only when the anchor state is + // the block's own post-state. A checkpoint-synced anchor is not: the + // Beacon API's finalized state is the state at + // `finalized_checkpoint.epoch.start_slot()`, and when that slot was empty + // the state has been advanced past its own `latest_block_header`. + // + // Lighthouse makes the same deviation, validating the header instead: + // `beacon_node/beacon_chain/src/builder.rs`, `weak_subjectivity_state`. + // The header root still pins the pair, since it names exactly one block. + // + // While the state is inside its block's own slot the header's `state_root` + // is this state's own root, which the specification leaves zero; + // substituting it is what `get_latest_block_root` does upstream. Cached + // back into the state too, the way `on_block` does for every later block + // and `Store::init_store` does for a lean anchor. Computed from the state + // with the field cleared rather than trusting what a provider sent: an + // anchor arriving with it populated is not a shape the specification + // produces, and the value would land unchecked in `state_roots` a slot + // later. + let mut header = anchor_state.latest_block_header().clone(); + if anchor_state.slot() == header.slot { + anchor_state.latest_block_header_mut().state_root = Root::ZERO; + let anchor_state_root = anchor_state.hash_tree_root(); + anchor_state.latest_block_header_mut().state_root = anchor_state_root; + header.state_root = anchor_state_root; + } + verify( + header.hash_tree_root() == anchor_root, + "hash_tree_root(anchor_state.latest_block_header) == hash_tree_root(anchor_block.message)", + )?; + + let anchor_epoch = get_current_epoch(&anchor_state); + let justified_checkpoint = Checkpoint { + epoch: anchor_epoch, + root: anchor_root, + }; + // The specification gives finality the same starting value as + // justification: a trusted anchor is finalized by fiat, not by having + // actually gone through the FFG rules. + let finalized_checkpoint = justified_checkpoint; + + // `SECONDS_PER_SLOT * anchor_state.slot` is arithmetic over values that + // ultimately come from an externally supplied anchor, so this fails + // loudly on overflow rather than silently wrapping the store's clock. + let time = config + .seconds_per_slot + .checked_mul(anchor_state.slot()) + .and_then(|slot_seconds| slot_seconds.checked_add(anchor_state.genesis_time())) + .ok_or(Error::ArithmeticOverflow( + "anchor_state.genesis_time + SECONDS_PER_SLOT * anchor_state.slot", + ))?; + + // The anchor is the store's first head and its justified and finalized + // checkpoint at once, so `init_beacon` seeds all three rows, the same way + // `init_store` does on a lean directory. That is what lets + // `update_checkpoints` below read a head to move *from*. + // `anchor_state.slot()`, not the checkpoint's: the stored checkpoint is + // epoch-denominated, so an anchor taken mid-epoch would record its epoch's + // start slot and advertise a floor below anything this directory holds. + let mut store = Store::init_beacon( + backend, + anchor_state.genesis_time(), + config.clone(), + anchor_root, + Store::beacon_checkpoint_as_stored(justified_checkpoint), + anchor_state.slot(), + ); + // The store's row is milliseconds; `time` above is the specification's + // seconds, computed with its own overflow check just as the specification + // writes it. + store + .set_time_ms(seconds_to_milliseconds(time)) + .expect("set time"); + + store.set_beacon_unrealized_checkpoints(Some(justified_checkpoint), Some(finalized_checkpoint)); + + // The specification stores the anchor state under both `block_states` and + // `checkpoint_states` (`copy(anchor_state)` in each), which used to be + // this file's only outright whole-`BeaconState` clone. `checkpoint_state` + // now derives that second copy on demand instead of caching it, so the + // anchor is written once. + store + .insert_signed_block(anchor_root, anchor_block) + .expect("insert"); + store + .insert_state(anchor_root, anchor_state) + .expect("insert"); + store.set_unrealized_justification(anchor_root, justified_checkpoint); + + Ok(store) +} + +// --------------------------------------------------------------------------- +// Time and slot helpers +// --------------------------------------------------------------------------- + +/// How many whole slots have elapsed since genesis, as of `store.time`. +/// +/// Through [`Store::current_slot`](ethlambda_storage::Store::current_slot), +/// the slot derivation both chains share, rather than a second copy of the +/// arithmetic here. Two reasons beyond not repeating it. The genesis time this +/// must measure from is the store's own, written at bootstrap off the anchor +/// state, and not necessarily the `genesis_time` of the `config` value a +/// caller happens to be holding; reading one field from each was a way for the +/// two to disagree. And a store's clock never reads earlier than its own +/// genesis, so the saturation that guarded against it belongs with the field +/// it guards. +pub fn get_slots_since_genesis(store: &Store, _config: &Config) -> u64 { + store.current_slot() +} + +/// The slot `store.time` currently falls in. +pub fn get_current_slot(store: &Store, config: &Config) -> Slot { + constants::GENESIS_SLOT + get_slots_since_genesis(store, config) +} + +/// The epoch `store.time` currently falls in. +pub fn get_current_store_epoch(store: &Store, config: &Config) -> Epoch { + compute_epoch_at_slot(get_current_slot(store, config)) +} + +/// How many slots into its epoch `slot` is, `0` for the epoch's first slot. +pub fn compute_slots_since_epoch_start(slot: Slot) -> Slot { + slot - compute_start_slot_at_epoch(compute_epoch_at_slot(slot)) +} + +/// The ancestor of `root` at `slot`: the block on `root`'s chain whose own +/// slot is at or before `slot`, found by walking parent links. +/// +/// The specification defines this recursively; implemented here as a loop +/// instead, so that a long unfinalized suffix cannot risk a stack overflow. +/// An unknown `root` is exactly the "unhandled exception" case the +/// specification calls out as invalid (`store.blocks[root]` would raise +/// `KeyError` in the reference implementation), so it becomes a `SpecAssert` +/// here rather than a panic. +/// +/// Takes `index` (`root -> (slot, parent_root)`, [`Store::block_index`]'s own +/// shape) rather than `&Store`: a caller in a per-validator loop +/// ([`get_weight`]) walks this once per active validator, so a point lookup +/// per hop here would multiply a scan the specification already writes as +/// naive by a backend round trip. Every caller builds `index` once, outside +/// its own loop, and threads it down. +pub fn get_ancestor(index: &HashMap, root: Root, slot: Slot) -> Result { + let mut root = root; + loop { + let &(block_slot, parent_root) = index + .get(&root) + .ok_or(Error::SpecAssert("root in store.blocks"))?; + if block_slot > slot { + root = parent_root; + } else { + return Ok(root); + } + } +} + +// --------------------------------------------------------------------------- +// Committee-relative weight helpers +// --------------------------------------------------------------------------- + +/// A committee's share of `state`'s total active balance, scaled by +/// `committee_percent` out of one hundred. +/// +/// `committee_percent` is a plain percentage, not basis points: unlike the +/// `*_due_bps` configuration values (fractions of [`Config::slot_duration_ms`] +/// out of [`constants::BASIS_POINTS`]), the specification writes this +/// divisor as a bare `100` with no name of its own, since +/// [`Config::proposer_score_boost`] and the two `Config::reorg_*_threshold` +/// values it is called with are themselves already expressed on a 0-100 +/// scale. +pub fn calculate_committee_fraction(state: &BeaconState, committee_percent: u64) -> Result { + let committee_weight = get_total_active_balance(state)? / preset::SLOTS_PER_EPOCH; + Ok(committee_weight.saturating_mul(committee_percent) / 100) +} + +/// The checkpoint block for `epoch`, on `root`'s chain: the ancestor of `root` +/// at that epoch's first slot. See [`get_ancestor`] for why this takes the +/// block index rather than `&Store`. +pub fn get_checkpoint_block( + index: &HashMap, + root: Root, + epoch: Epoch, +) -> Result { + get_ancestor(index, root, compute_start_slot_at_epoch(epoch)) +} + +/// The extra weight a timely, uncontested block gets over its competitors, +/// scaled to deter a "balancing" attack that splits votes right at a slot +/// boundary. +/// +/// See [`calculate_committee_fraction`] for why this divides by a bare +/// `100` rather than [`constants::BASIS_POINTS`]. +pub fn get_proposer_score(store: &Store, config: &Config) -> Result { + let justified_checkpoint = store.beacon_justified_checkpoint(); + let justified_state = checkpoint_state(store, &justified_checkpoint, config)?; + let committee_weight = get_total_active_balance(&justified_state)? / preset::SLOTS_PER_EPOCH; + Ok(committee_weight.saturating_mul(config.proposer_score_boost) / 100) +} + +/// The LMD GHOST weight of `root`: the effective balance of every +/// non-equivocating, active, unslashed validator whose latest vote descends +/// through `root`, plus the proposer boost if it applies. +/// +/// Takes `index` rather than building it, the way [`filter_block_tree`] does, +/// and reuses it for every [`get_ancestor`] call this makes: one per active +/// validator, plus one for the proposer boost. See [`get_ancestor`]'s +/// documentation for why that matters. +/// +/// The specification's own per-root definition, kept as written. [`get_head`] +/// calls [`compute_weights`] instead, which produces the same numbers for the +/// whole tree at once; this is what that is tested against. +pub fn get_weight( + store: &Store, + index: &HashMap, + root: Root, + config: &Config, +) -> Result { + let justified_checkpoint = store.beacon_justified_checkpoint(); + let state = checkpoint_state(store, &justified_checkpoint, config)?; + let current_epoch = get_current_epoch(&state); + let block_slot = index + .get(&root) + .ok_or(Error::SpecAssert("root in store.blocks"))? + .0; + + let mut attestation_score: Gwei = 0; + for validator_index in get_active_validator_indices(&state, current_epoch) { + let validator = state.validator(validator_index)?; + if validator.slashed || store.is_equivocating(validator_index) { + continue; + } + let Some(message) = store.latest_message(validator_index) else { + continue; + }; + if get_ancestor(index, message.root, block_slot)? == root { + attestation_score = attestation_score.saturating_add(validator.effective_balance); + } + } + + let proposer_boost_root = store.proposer_boost_root(); + if proposer_boost_root.is_zero() { + return Ok(attestation_score); + } + + let mut proposer_score: Gwei = 0; + if get_ancestor(index, proposer_boost_root, block_slot)? == root { + proposer_score = get_proposer_score(store, config)?; + } + Ok(attestation_score.saturating_add(proposer_score)) +} + +/// Every indexed block's LMD GHOST weight, in one pass over the votes. +/// +/// [`get_weight`] is the specification's definition and is per-root, so a head +/// descent that calls it once per candidate re-walks every validator's vote at +/// every step: on mainnet that is a two-million-entry registry scan and a +/// parent walk per voter, repeated for each of the tens of blocks between the +/// justified checkpoint and the head. Measured on a live mainnet follower at +/// 2.36M validators, that walk was 62% of the whole process's CPU, more than +/// half of it inside `SipHash` on the block index's own keys, and imports ran +/// at 14 s against 12 s slots so the follower lost ground every slot. +/// +/// The same numbers fall out of one bottom-up accumulation, because a vote +/// counts for a root exactly when the voted block descends from it: sum each +/// vote at its own block, then fold every block's total into its parent, +/// walking blocks from the highest slot down so a child is complete before its +/// parent reads it. A parent link always points at a strictly earlier slot, so +/// that order is a valid topological one. That is one index lookup per voter +/// rather than one per voter per level, over a map small enough to stay in +/// cache, and no registry scan at all. +/// +/// A vote for a block no longer in `index` is dropped rather than raising. +/// `Store::promote_beacon_anchor` prunes the block index below the oldest kept +/// finalized anchor, and a validator whose freshest recorded vote is for a +/// block down there keeps that vote until it attests again. Such a vote cannot +/// distinguish between candidates above the justified checkpoint (all of them +/// descend from the finalized block it voted below), so it weighs nothing, and +/// the alternative is what a live node actually hit: one stale voter aborting +/// the whole head computation with `SpecAssert("root in store.blocks")` and +/// pinning the head for as long as it stayed stale. +pub fn compute_weights( + store: &Store, + index: &HashMap, + config: &Config, +) -> Result> { + let justified_checkpoint = store.beacon_justified_checkpoint(); + let state = checkpoint_state(store, &justified_checkpoint, config)?; + let current_epoch = get_current_epoch(&state); + + // Keyed on the voted block itself; the fold below turns these into subtree + // totals in place. + let mut weights: HashMap = HashMap::new(); + // Equivocators are filtered by the store itself: see + // `for_each_non_equivocating_latest_message` for why asking it per voter + // from in here would deadlock. + store.for_each_non_equivocating_latest_message(|validator_index, message| { + // Not `get_active_validator_indices`: that allocates the whole active + // set (~2 million entries on mainnet) to answer a membership question, + // and an index past this state's registry is a validator that did not + // exist yet at the justified checkpoint, which is a skip rather than an + // error. + let Ok(validator) = state.validator(validator_index) else { + return; + }; + if validator.slashed || !is_active_validator(validator, current_epoch) { + return; + } + let entry = weights.entry(message.root).or_default(); + *entry = entry.saturating_add(validator.effective_balance); + }); + + // Highest slot first: see above for why that is a topological order. + let mut blocks: Vec<(Root, Slot, Root)> = index + .iter() + .map(|(root, (slot, parent_root))| (*root, *slot, *parent_root)) + .collect(); + blocks.sort_unstable_by(|left, right| right.1.cmp(&left.1).then(right.0.cmp(&left.0))); + + for (root, _slot, parent_root) in &blocks { + let subtree_weight = weights.get(root).copied().unwrap_or_default(); + if subtree_weight == 0 { + continue; + } + // Only into a parent that is still indexed: the anchor's own parent is + // below the retained window, and there is nothing there to weigh. + if index.contains_key(parent_root) { + let entry = weights.entry(*parent_root).or_default(); + *entry = entry.saturating_add(subtree_weight); + } + } + + let boost_root = store.proposer_boost_root(); + if !boost_root.is_zero() && index.contains_key(&boost_root) { + // The specification gives the boost to every root the boosted block + // descends from, which is every block on its ancestor walk. Ends at the + // justified checkpoint: `get_head` never descends below it, and below + // it the walk would leave the retained window. + let justified_slot = index + .get(&justified_checkpoint.root) + .map_or(0, |(slot, _)| *slot); + let proposer_score = get_proposer_score(store, config)?; + let mut cursor = boost_root; + while let Some((slot, parent_root)) = index.get(&cursor).copied() { + let entry = weights.entry(cursor).or_default(); + *entry = entry.saturating_add(proposer_score); + if slot <= justified_slot { + break; + } + cursor = parent_root; + } + } + + Ok(weights) +} + +/// The checkpoint a block would cast as its FFG source if it were canonical +/// head right now. +/// +/// A block from a strictly earlier epoch than the store's current one has its +/// vote "pulled up" to the unrealized justification [`compute_pulled_up_tip`] +/// computed for it, rather than to whatever its own post-state's +/// `current_justified_checkpoint` happened to be at the time it was +/// processed; a block from the current epoch has no unrealized value to pull +/// up to yet, so its own post-state's checkpoint is used directly. +pub fn get_voting_source( + store: &Store, + index: &HashMap, + block_root: Root, + config: &Config, +) -> Result { + let (block_slot, _) = *index + .get(&block_root) + .ok_or(Error::SpecAssert("block_root in store.blocks"))?; + let current_epoch = get_current_store_epoch(store, config); + let block_epoch = compute_epoch_at_slot(block_slot); + + if current_epoch > block_epoch { + store + .unrealized_justification(&block_root) + .ok_or(Error::SpecAssert( + "block_root in store.unrealized_justifications", + )) + } else { + let head_state = store + .get_state(&block_root) + .expect("get") + .ok_or(Error::SpecAssert("block_root in store.block_states"))?; + Ok(head_state.current_justified_checkpoint()) + } +} + +// --------------------------------------------------------------------------- +// Filtering the block tree +// --------------------------------------------------------------------------- + +/// Walks `block_root`'s subtree, adding every block on a viable branch to +/// `blocks`, and reporting whether `block_root` itself sits on one. +/// +/// *Note*: external callers must pass `store.justified_checkpoint.root` for +/// `block_root`; only the recursive calls below pass anything else. +/// +/// Recursive, following the specification directly rather than an explicit +/// stack: the subtree walked here is the unfinalized suffix since the +/// justified checkpoint, which stays shallow in ordinary operation. +/// +/// Takes `index`, [`Store::block_index`] built once by +/// [`get_filtered_block_tree`] and threaded through every recursive call, +/// rather than re-scanning the store's blocks at each node: the children scan +/// below is exactly the whole-map iteration the module documentation says +/// this file never needs a `BTreeMap` for, and it runs once per node visited, +/// not once per node per DB round trip. `blocks`' value is `index`'s own +/// `(slot, parent_root)` shape rather than a whole [`SignedBeaconBlock`], +/// since [`get_head`], the only reader of this function's output, never needs +/// more than that. +pub fn filter_block_tree( + store: &Store, + index: &HashMap, + block_root: Root, + blocks: &mut HashMap, + config: &Config, +) -> Result { + let entry = *index + .get(&block_root) + .ok_or(Error::SpecAssert("block_root in store.blocks"))?; + + let children: Vec = index + .iter() + .filter(|&(_, &(_, parent_root))| parent_root == block_root) + .map(|(&root, _)| root) + .collect(); + + // If any children branches contain expected finalized/justified + // checkpoints, add to filtered block-tree and signal viability to parent. + if !children.is_empty() { + let mut any_viable = false; + for child in children { + if filter_block_tree(store, index, child, blocks, config)? { + any_viable = true; + } + } + if any_viable { + blocks.insert(block_root, entry); + return Ok(true); + } + return Ok(false); + } + + let current_epoch = get_current_store_epoch(store, config); + let voting_source = get_voting_source(store, index, block_root, config)?; + + // The voting source should be either at the same height as the store's + // justified checkpoint or not more than two epochs ago. + let justified_checkpoint = store.beacon_justified_checkpoint(); + let correct_justified = justified_checkpoint.epoch == constants::GENESIS_EPOCH + || voting_source.epoch == justified_checkpoint.epoch + || voting_source.epoch.saturating_add(2) >= current_epoch; + + let finalized_checkpoint = store.beacon_finalized_checkpoint(); + let finalized_checkpoint_block = + get_checkpoint_block(index, block_root, finalized_checkpoint.epoch)?; + + let correct_finalized = finalized_checkpoint.epoch == constants::GENESIS_EPOCH + || finalized_checkpoint.root == finalized_checkpoint_block; + + // If expected finalized/justified, add to viable block-tree and signal + // viability to parent. + if correct_justified && correct_finalized { + blocks.insert(block_root, entry); + return Ok(true); + } + + Ok(false) +} + +/// The filtered block tree: every block, from the justified checkpoint down, +/// whose leaf state's justified/finalized info agrees with `store`'s own. +pub fn get_filtered_block_tree( + store: &Store, + index: &HashMap, + config: &Config, +) -> Result> { + let base = store.beacon_justified_checkpoint().root; + let mut blocks = HashMap::new(); + filter_block_tree(store, index, base, &mut blocks, config)?; + Ok(blocks) +} + +/// The LMD GHOST head: starting from the justified checkpoint, repeatedly +/// step to the child with the greatest weight until a leaf is reached. +/// +/// The children scan in the loop below reads `blocks`, [`get_filtered_block_tree`]'s +/// already-filtered, already in-memory result, not [`Store::block_index`] +/// itself: it is the specification's own second whole-`Dict` scan the module +/// documentation calls out, but it never costs a further backend round trip. +/// +/// Takes `&mut Store`, unlike most functions in this file: it records the head +/// it just found through +/// [`Store::update_checkpoints`](ethlambda_storage::Store::update_checkpoints), +/// the head-and-checkpoint writer both chains share, so a restarted node has +/// something to answer from immediately rather than replaying this whole walk +/// on its first tick. That writer also keeps the canonical `BlockRoots` index +/// in step with the branch fork choice just picked. +/// +/// Written unconditionally on every call, not only when the head changes: a +/// value written once and then left alone is a second source of truth a bug +/// can let drift, and the write is one small metadata row plus an index diff +/// that is empty whenever the head did not move, set against a whole weighted +/// tree walk. +pub fn get_head(store: &mut Store, config: &Config) -> Result { + // One scan for the whole walk: the filtered tree and the weight table are + // both built from it, instead of each rescanning the live chain for itself. + let index = store.block_index(); + let blocks = get_filtered_block_tree(store, &index, config)?; + // Every candidate's weight at once: see `compute_weights` for why the + // specification's per-root `get_weight` is not what the descent calls. + let weights = compute_weights(store, &index, config)?; + let mut head = store.beacon_justified_checkpoint().root; + loop { + let children: Vec = blocks + .iter() + .filter(|&(_, &(_, parent_root))| parent_root == head) + .map(|(&root, _)| root) + .collect(); + if children.is_empty() { + break; + } + + // Sort by latest attesting balance with ties broken lexicographically, + // favoring the higher root: pairing the weight with the root itself as + // the sort key gives exactly that, and `Root`'s derived `Ord` compares + // its bytes in order, matching Python's default comparison of a + // `bytes` root. + let mut ranked = Vec::with_capacity(children.len()); + for root in children { + ranked.push((weights.get(&root).copied().unwrap_or_default(), root)); + } + head = ranked + .into_iter() + .max() + .expect("children is non-empty, checked above") + .1; + } + + store + .update_checkpoints(ForkCheckpoints::head_only(head)) + .expect("record beacon head"); + + Ok(head) +} + +// --------------------------------------------------------------------------- +// Checkpoint bookkeeping +// --------------------------------------------------------------------------- + +/// Advances `store`'s justified and finalized checkpoints to `justified` and +/// `finalized`, if each is more recent than what is already recorded. +/// +/// Justification and finalization only ever move forward: a lower-epoch +/// checkpoint arriving later (as can happen while replaying blocks out of +/// order) must not roll a more advanced view back. +pub fn update_checkpoints(store: &mut Store, justified: Checkpoint, finalized: Checkpoint) { + // Through the same `Store::update_checkpoints` lean advances: an epoch is + // stored as its own start slot, so the two chains' checkpoints share one + // row and one writer. The head is passed through unchanged, since this + // moves only the checkpoints; `get_head` is what moves the head. + let justified = + (justified.epoch > store.beacon_justified_checkpoint().epoch).then_some(justified); + let finalized = + (finalized.epoch > store.beacon_finalized_checkpoint().epoch).then_some(finalized); + if justified.is_none() && finalized.is_none() { + return; + } + let head = store.head().expect("head block exists"); + let checkpoints = ForkCheckpoints::new( + head, + justified.map(Store::beacon_checkpoint_as_stored), + finalized.map(Store::beacon_checkpoint_as_stored), + ); + store + .update_checkpoints(checkpoints) + .expect("update beacon checkpoints"); +} + +/// The unrealized-checkpoint counterpart to [`update_checkpoints`]. +pub fn update_unrealized_checkpoints( + store: &mut Store, + unrealized_justified: Checkpoint, + unrealized_finalized: Checkpoint, +) { + let justified = (unrealized_justified.epoch + > store.beacon_unrealized_justified_checkpoint().epoch) + .then_some(unrealized_justified); + let finalized = (unrealized_finalized.epoch + > store.beacon_unrealized_finalized_checkpoint().epoch) + .then_some(unrealized_finalized); + store.set_beacon_unrealized_checkpoints(justified, finalized); +} + +// --------------------------------------------------------------------------- +// Millisecond time helpers +// --------------------------------------------------------------------------- +// +// See the module documentation for how these relate to `Store.time`, which +// stays in seconds throughout. + +/// Converts `seconds` to milliseconds, saturating at [`constants::UINT64_MAX`] +/// instead of wrapping. +pub fn seconds_to_milliseconds(seconds: u64) -> u64 { + seconds.checked_mul(1000).unwrap_or(constants::UINT64_MAX) +} + +/// The duration, in milliseconds, that `basis_points` out of +/// [`constants::BASIS_POINTS`] of a slot spans. +pub fn get_slot_component_duration_ms(basis_points: u64, config: &Config) -> u64 { + basis_points.saturating_mul(config.slot_duration_ms) / constants::BASIS_POINTS +} + +/// How far into a slot, in milliseconds, an attestation is due. +/// +/// `epoch` is accepted, matching the specification's signature, but not read: +/// the deadline is a fixed fraction of the slot in every epoch this module +/// implements. +pub fn get_attestation_due_ms(_epoch: Epoch, config: &Config) -> u64 { + get_slot_component_duration_ms(config.attestation_due_bps, config) +} + +/// How far into a slot, in milliseconds, a proposer must stop attempting a +/// late-block reorg. See [`get_attestation_due_ms`] for why `epoch` is unused. +pub fn get_proposer_reorg_cutoff_ms(_epoch: Epoch, config: &Config) -> u64 { + get_slot_component_duration_ms(config.proposer_reorg_cutoff_bps, config) +} + +/// How far into a slot, in milliseconds, an aggregate attestation is due. See +/// [`get_attestation_due_ms`] for why `epoch` is unused. +pub fn get_aggregate_due_ms(_epoch: Epoch, config: &Config) -> u64 { + get_slot_component_duration_ms(config.aggregate_due_bps, config) +} + +// --------------------------------------------------------------------------- +// Proposer head and reorg helpers +// --------------------------------------------------------------------------- +// +// The specification marks implementing these as optional, but a proposer +// that skips them simply always builds on `get_head`'s result rather than +// ever reorging out a late block; this module implements them so a validator +// client built on it can make that choice instead of having it made for it. + +/// Whether `head_root`'s block arrived after the attestation deadline of the +/// slot it was imported in. +pub fn is_head_late(store: &Store, head_root: Root) -> Result { + let timely = store + .block_timeliness(&head_root) + .ok_or(Error::SpecAssert("head_root in store.block_timeliness"))?; + Ok(!timely) +} + +/// Whether `slot` is not the first slot of its epoch, i.e. the proposer +/// shuffling in effect for it cannot change from reorging one slot. +pub fn is_shuffling_stable(slot: Slot) -> bool { + !slot.is_multiple_of(preset::SLOTS_PER_EPOCH) +} + +/// Whether `head_root` and `parent_root` would cast the same FFG vote if +/// either were head, so that reorging one for the other costs nothing on the +/// justification side. +pub fn is_ffg_competitive(store: &Store, head_root: Root, parent_root: Root) -> Result { + let head = store + .unrealized_justification(&head_root) + .ok_or(Error::SpecAssert( + "head_root in store.unrealized_justifications", + ))?; + let parent = store + .unrealized_justification(&parent_root) + .ok_or(Error::SpecAssert( + "parent_root in store.unrealized_justifications", + ))?; + Ok(head == parent) +} + +/// Whether the chain has finalized recently enough that a reorg is still +/// worth risking: reorgs are a liveness optimization, and this bounds how +/// much finality progress they may put at stake to pursue it. +pub fn is_finalization_ok(store: &Store, slot: Slot, config: &Config) -> bool { + let epochs_since_finalization = + compute_epoch_at_slot(slot).saturating_sub(store.beacon_finalized_checkpoint().epoch); + epochs_since_finalization <= config.reorg_max_epochs_since_finalization +} + +/// Whether `store.time` is early enough in the current slot that a proposer +/// building now still counts as on time. +pub fn is_proposing_on_time(store: &Store, config: &Config) -> bool { + let time_into_slot_ms = store.ms_since_genesis() % config.slot_duration_ms; + let epoch = get_current_store_epoch(store, config); + time_into_slot_ms <= get_proposer_reorg_cutoff_ms(epoch, config) +} + +/// Whether `head_root` has few enough votes to be overpowered by the +/// proposer's own boost, i.e. reorging it out would not be fighting an +/// already-decisive lead. +pub fn is_head_weak(store: &Store, head_root: Root, config: &Config) -> Result { + let justified_checkpoint = store.beacon_justified_checkpoint(); + let justified_state = checkpoint_state(store, &justified_checkpoint, config)?; + let reorg_threshold = + calculate_committee_fraction(&justified_state, config.reorg_head_weight_threshold)?; + Ok(get_weight(store, &store.block_index(), head_root, config)? < reorg_threshold) +} + +/// Whether `parent_root` already has enough votes of its own that the missing +/// votes are assigned to it rather than being hoarded elsewhere. +pub fn is_parent_strong(store: &Store, parent_root: Root, config: &Config) -> Result { + let justified_checkpoint = store.beacon_justified_checkpoint(); + let justified_state = checkpoint_state(store, &justified_checkpoint, config)?; + let parent_threshold = + calculate_committee_fraction(&justified_state, config.reorg_parent_weight_threshold)?; + Ok(get_weight(store, &store.block_index(), parent_root, config)? > parent_threshold) +} + +/// The block a proposer at `slot` should build on: `head_root`'s parent +/// instead of `head_root` itself, if every reorg condition holds, and +/// `head_root` otherwise. +/// +/// *Note*: the ordering of conditions here is the specification's suggested +/// order, not a requirement; an implementation may reorder or short-circuit +/// for performance. +pub fn get_proposer_head( + store: &Store, + head_root: Root, + slot: Slot, + config: &Config, +) -> Result { + let (head_slot, parent_root) = store + .block_entry(&head_root) + .ok_or(Error::SpecAssert("head_root in store.blocks"))?; + let (parent_slot, _) = store + .block_entry(&parent_root) + .ok_or(Error::SpecAssert("parent_root in store.blocks"))?; + + // Only re-org the head block if it arrived later than the attestation + // deadline. + let head_late = is_head_late(store, head_root)?; + // Do not re-org on an epoch boundary where the proposer shuffling could + // change. + let shuffling_stable = is_shuffling_stable(slot); + // Ensure that the FFG information of the new head will be competitive + // with the current head. + let ffg_competitive = is_ffg_competitive(store, head_root, parent_root)?; + // Do not re-org if the chain is not finalizing with acceptable frequency. + let finalization_ok = is_finalization_ok(store, slot, config); + // Only re-org if we are proposing on-time. + let proposing_on_time = is_proposing_on_time(store, config); + + // Only re-org a single slot at most. + let parent_slot_ok = parent_slot.checked_add(1) == Some(head_slot); + let current_time_ok = head_slot.checked_add(1) == Some(slot); + let single_slot_reorg = parent_slot_ok && current_time_ok; + + // Check that the head has few enough votes to be overpowered by our + // proposer boost. + verify( + store.proposer_boost_root() != head_root, + "store.proposer_boost_root != head_root", + )?; + let head_weak = is_head_weak(store, head_root, config)?; + + // Check that the missing votes are assigned to the parent and not being + // hoarded. + let parent_strong = is_parent_strong(store, parent_root, config)?; + + if head_late + && shuffling_stable + && ffg_competitive + && finalization_ok + && proposing_on_time + && single_slot_reorg + && head_weak + && parent_strong + { + // We can re-org the current head by building upon its parent block. + Ok(parent_root) + } else { + Ok(head_root) + } +} + +/// Whether a proposer confident it will build the next block should ask its +/// execution engine to build on `head_root`'s parent instead of `head_root` +/// itself, suppressing the `notify_forkchoice_updated` call bellatrix's +/// `ExecutionEngine` protocol would otherwise make right away. +/// +/// `validator_is_connected` stands in for the specification's own +/// `validator_is_connected(validator_index: ValidatorIndex) -> bool`, "a +/// function that indicates whether the validator ... is connected to the +/// node (e.g. has sent an unexpired proposer preparation message)" +/// (`specs/bellatrix/fork-choice.md`). Every real answer is +/// implementation-specific, so a caller supplies its own policy here rather +/// than this module guessing at one; the fixture suites that exercise this +/// supply a fixed answer directly, the same way [`stf::ExecutionEngine`] +/// stands in for a real execution client elsewhere in this module. +/// +/// Shares [`get_proposer_head`]'s own reorg conditions +/// (`is_head_late`/`is_shuffling_stable`/`is_ffg_competitive`/`is_finalization_ok`), +/// but evaluated against `proposal_slot` (`head_root`'s slot plus one) +/// rather than the caller's own current slot: this asks about the block a +/// confident proposer is *about* to build, one slot ahead of `head_root`, +/// not about reorging a block already received. +pub fn should_override_forkchoice_update( + store: &Store, + head_root: Root, + validator_is_connected: impl Fn(ValidatorIndex) -> bool, + config: &Config, +) -> Result { + let (head_slot, parent_root) = store + .block_entry(&head_root) + .ok_or(Error::SpecAssert("head_root in store.blocks"))?; + let (parent_slot, _) = store + .block_entry(&parent_root) + .ok_or(Error::SpecAssert("parent_root in store.blocks"))?; + let current_slot = get_current_slot(store, config); + let proposal_slot = head_slot.saturating_add(1); + + // Only re-org the head block if it arrived later than the attestation + // deadline. + let head_late = is_head_late(store, head_root)?; + // Shuffling stable. + let shuffling_stable = is_shuffling_stable(proposal_slot); + // FFG information of the new head block will be competitive with the + // current head. + let ffg_competitive = is_ffg_competitive(store, head_root, parent_root)?; + // Do not re-org if the chain is not finalizing with acceptable frequency. + let finalization_ok = is_finalization_ok(store, proposal_slot, config); + + // Only suppress the fork choice update if we are confident that we will + // propose the next block. `get_state` hands back a shared `Arc`, so this + // clones out of it before advancing: matching the specification's own + // `.copy()`, advancing to `proposal_slot` is only how this samples the + // proposer that slot would draw, not a change the store's own cached + // entry for `parent_root` should keep. + let parent_state = store + .get_state(&parent_root) + .expect("get") + .ok_or(Error::SpecAssert("parent_root in store.block_states"))?; + let mut parent_state_advanced = (*parent_state).clone(); + stf::process_slots(&mut parent_state_advanced, proposal_slot, config)?; + let proposer_index = get_beacon_proposer_index(&parent_state_advanced)?; + let proposing_reorg_slot = validator_is_connected(proposer_index); + + // Single slot re-org. + let parent_slot_ok = parent_slot.checked_add(1) == Some(head_slot); + let proposing_on_time = is_proposing_on_time(store, config); + // Note that this condition is different from `get_proposer_head`. + let current_time_ok = + head_slot == current_slot || (proposal_slot == current_slot && proposing_on_time); + let single_slot_reorg = parent_slot_ok && current_time_ok; + + // Check the head weight only if the attestations from the head slot have + // already been applied; before then, both conditions default to true + // rather than judging the head on attestations that have not arrived + // yet. + let (head_weak, parent_strong) = if current_slot > head_slot { + ( + is_head_weak(store, head_root, config)?, + is_parent_strong(store, parent_root, config)?, + ) + } else { + (true, true) + }; + + Ok(head_late + && shuffling_stable + && ffg_competitive + && finalization_ok + && proposing_reorg_slot + && single_slot_reorg + && head_weak + && parent_strong) +} + +// --------------------------------------------------------------------------- +// Merge transition helpers (bellatrix) +// --------------------------------------------------------------------------- + +/// Looks up a PoW block by hash, matching the specification's own +/// `get_pow_block`. See [`PowBlock`]'s documentation for why this reads +/// [`Store::beacon_pow_block`] rather than calling out to a real execution +/// client. +pub fn get_pow_block(store: &Store, hash: Root) -> Option { + store.beacon_pow_block(hash) +} + +/// Records `pow_block` so later [`get_pow_block`] lookups by its own hash can +/// find it. Not one of the four handlers at the bottom of this file: there is +/// no validity condition to check first, since this only ever adds data a +/// fixture suite's `on_merge_block` step already trusts. +pub fn insert_pow_block(store: &mut Store, pow_block: PowBlock) { + store.insert_beacon_pow_block(pow_block); +} + +/// Whether `block` is the one PoW block where this chain's proof-of-work +/// history ends and its proof-of-stake history begins: its own total +/// difficulty has crossed [`Config::terminal_total_difficulty`], but its +/// parent's had not yet. +pub fn is_valid_terminal_pow_block(block: &PowBlock, parent: &PowBlock, config: &Config) -> bool { + let is_total_difficulty_reached = block.total_difficulty >= config.terminal_total_difficulty; + let is_parent_total_difficulty_valid = + parent.total_difficulty < config.terminal_total_difficulty; + is_total_difficulty_reached && is_parent_total_difficulty_valid +} + +/// Checks that a bellatrix block's parent execution payload really does sit +/// on a valid terminal PoW block, the one condition [`on_block`] adds for +/// bellatrix and never again afterward: capella's own `fork-choice.md` drops +/// it outright ("deletion of the verification of merge transition block +/// conditions"). +/// +/// [`Config::terminal_block_hash`] is an emergency override that, if ever +/// set, replaces the PoW-chain lookup with a direct hash comparison; every +/// network that shipped the Merge left it unset, so the common path is the +/// `get_pow_block` chain below. +pub fn validate_merge_block( + store: &Store, + block: &bellatrix::BeaconBlock, + config: &Config, +) -> Result<()> { + let parent_hash = block.body.execution_payload.parent_hash; + + if !config.terminal_block_hash.is_zero() { + verify( + compute_epoch_at_slot(block.slot) >= config.terminal_block_hash_activation_epoch, + "compute_epoch_at_slot(block.slot) >= TERMINAL_BLOCK_HASH_ACTIVATION_EPOCH", + )?; + verify( + parent_hash == config.terminal_block_hash, + "block.body.execution_payload.parent_hash == TERMINAL_BLOCK_HASH", + )?; + return Ok(()); + } + + let pow_block = get_pow_block(store, parent_hash).ok_or(Error::SpecAssert( + "get_pow_block(block.body.execution_payload.parent_hash) is not None", + ))?; + let pow_parent = get_pow_block(store, pow_block.parent_hash).ok_or(Error::SpecAssert( + "get_pow_block(pow_block.parent_hash) is not None", + ))?; + verify( + is_valid_terminal_pow_block(&pow_block, &pow_parent, config), + "is_valid_terminal_pow_block(pow_block, pow_parent)", + ) +} + +// --------------------------------------------------------------------------- +// Data availability helpers (deneb, electra, fulu) +// --------------------------------------------------------------------------- + +/// The specification's `is_data_available` for deneb and electra +/// (`specs/deneb/fork-choice.md`): every commitment the block claims must +/// come with a blob and a proof that verify against it. +/// +/// `retrieve_blobs_and_proofs` is "implementation and context dependent" +/// there; [`DataAvailability::Blobs`] is what a caller supplies in its place. +/// A length mismatch between `commitments` and the evidence's own blobs or +/// proofs is not checked separately here: `kzg::verify_blob_kzg_proof_batch` +/// already rejects it, which is exactly what deneb's own +/// `invalid_wrong_blobs_length`/`invalid_wrong_proofs_length` fixture cases +/// exercise. +pub fn is_data_available_blobs( + commitments: &[KzgCommitment], + evidence: &DataAvailability, +) -> Result { + let (blobs, proofs) = match evidence { + DataAvailability::Blobs { blobs, proofs } => (blobs.as_slice(), proofs.as_slice()), + _ => (&[][..], &[][..]), + }; + let blob_slices: Vec<&[u8]> = blobs.iter().map(|blob| &blob[..]).collect(); + kzg::verify_blob_kzg_proof_batch(&blob_slices, commitments, proofs) +} + +/// The specification's `verify_data_column_sidecar` +/// (`specs/fulu/p2p-interface.md`): the structural checks a column sidecar +/// must pass before its KZG proofs are even worth checking. +pub fn verify_data_column_sidecar(sidecar: &fulu::DataColumnSidecar, config: &Config) -> bool { + // The sidecar index must be within the valid range. + if sidecar.index as usize >= preset::NUMBER_OF_COLUMNS { + return false; + } + // A sidecar for zero blobs is invalid. + if sidecar.kzg_commitments.is_empty() { + return false; + } + // Check that the sidecar respects the blob limit. + let epoch = compute_epoch_at_slot(sidecar.signed_block_header.message.slot); + if sidecar.kzg_commitments.len() as u64 > config.max_blobs_per_block(epoch) { + return false; + } + // The column length must be equal to the number of commitments/proofs. + sidecar.column.len() == sidecar.kzg_commitments.len() + && sidecar.column.len() == sidecar.kzg_proofs.len() +} + +/// The specification's `verify_data_column_sidecar_kzg_proofs` +/// (`specs/fulu/p2p-interface.md`): batch-verifies every cell in `sidecar`'s +/// column against its own commitment and proof. Every cell shares +/// `sidecar.index` as its cell index, since a column names one cell position +/// across every blob in the block. +pub fn verify_data_column_sidecar_kzg_proofs(sidecar: &fulu::DataColumnSidecar) -> Result { + let cell_indices = vec![sidecar.index; sidecar.column.len()]; + let mut cells = Vec::with_capacity(sidecar.column.len()); + for cell in sidecar.column.iter() { + cells.push( + c_kzg::Cell::from_bytes(&cell[..]) + .map_err(|_| Error::SpecAssert("len(cell) == BYTES_PER_CELL"))?, + ); + } + kzg::verify_cell_kzg_proof_batch( + &sidecar.kzg_commitments, + &cell_indices, + &cells, + &sidecar.kzg_proofs, + ) +} + +/// Where `blob_kzg_commitments` sits among `BeaconBlockBody`'s fields, as an +/// index into the leaves of the body's merkle tree. +/// +/// Fulu's body's field count rounds up to two to the +/// `KZG_COMMITMENTS_INCLUSION_PROOF_DEPTH`th power leaves, and this is a +/// position within them rather than a generalized index; the fixture suite +/// states the generalized index, which is this plus the leaf offset. Nothing +/// derives it from the container, because nothing here can: SSZ field order is +/// declaration order, and a field added to the body would move this silently. +/// `the_commitments_subtree_index_is_the_bodys_own_position` only pins this +/// constant against the fixture's stated generalized index, so it is a typo +/// guard, not a schema-change guard: it never touches `BeaconBlockBody`. What +/// actually catches a field shifting this position is the `merkle_proof` +/// fixtures' end-to-end check, which decodes a real `BeaconBlockBody`, +/// recomputes its `hash_tree_root()`, and drives it through +/// `verify_data_column_sidecar_inclusion_proof`. +pub const BLOB_KZG_COMMITMENTS_SUBTREE_INDEX: u64 = 11; + +/// The specification's `verify_data_column_sidecar_inclusion_proof` +/// (`specs/fulu/p2p-interface.md`): the commitments a sidecar carries are the +/// ones the block it names actually committed to. +/// +/// The third of the sidecar checks, and the one that ties a sidecar to a +/// block. Without it a peer could pair a valid column with any block header it +/// liked, and the KZG check would still pass, since that only compares cells +/// against the commitments in the same sidecar. +/// +/// Every sidecar of one block proves the same list against the same body root, +/// so a caller checking many sidecars of one block may cache the result on +/// `(kzg_commitments, kzg_commitments_inclusion_proof, signed_block_header)`; +/// the specification says as much. Nothing caches it yet. +pub fn verify_data_column_sidecar_inclusion_proof(sidecar: &fulu::DataColumnSidecar) -> bool { + is_valid_merkle_branch( + sidecar.kzg_commitments.hash_tree_root(), + &sidecar.kzg_commitments_inclusion_proof, + preset::KZG_COMMITMENTS_INCLUSION_PROOF_DEPTH as u64, + BLOB_KZG_COMMITMENTS_SUBTREE_INDEX, + sidecar.signed_block_header.message.body_root, + ) +} + +/// The specification's `is_data_available` for fulu +/// (`specs/fulu/fork-choice.md`): every column sidecar sampled for this +/// block must be individually valid. +/// +/// Unlike deneb's version, this takes no commitments of its own: fulu's +/// `is_data_available` does not either, since sampling checks each sidecar +/// against the commitment list it itself carries +/// ([`verify_data_column_sidecar`]) rather than the caller cross-checking a +/// separate list. An evidence value with no sidecars is vacuously +/// available, matching the specification's `all(... for ... in +/// column_sidecars)` over an empty sequence; a caller simulating "not all +/// required columns have been sampled" must reject the block itself rather +/// than relying on this to do it, since nothing about an empty list is +/// distinguishable here from "this block needed no sampling at all". +pub fn is_data_available_columns(evidence: &DataAvailability, config: &Config) -> Result { + let DataAvailability::Columns(sidecars) = evidence else { + return Ok(true); + }; + for sidecar in sidecars { + if !(verify_data_column_sidecar(sidecar, config) + && verify_data_column_sidecar_kzg_proofs(sidecar)?) + { + return Ok(false); + } + } + Ok(true) +} + +// --------------------------------------------------------------------------- +// Pull-up tip helper +// --------------------------------------------------------------------------- + +/// Eagerly computes what `block_root`'s post-state's justification and +/// finality *would* become at the next epoch boundary, without waiting for an +/// actual block at that boundary to realize it on-chain. +/// +/// This is what lets [`get_voting_source`] treat a block from a prior epoch as +/// already having the checkpoint its own chain is clearly heading towards, +/// rather than being stuck with whatever its post-state's +/// `current_justified_checkpoint` was at the moment it was imported. +pub fn compute_pulled_up_tip( + store: &mut Store, + block_root: Root, + block_slot: Slot, + config: &Config, +) -> Result<()> { + // `get_state` hands back a shared `Arc`, so this clones out of it, + // matching the specification's own `.copy()`: the clone advances to the + // next epoch boundary as a throwaway, and the store's own cached entry + // for `block_root` must be left exactly as the block itself produced it. + let state = store + .get_state(&block_root) + .expect("get") + .ok_or(Error::SpecAssert("block_root in store.block_states"))?; + let mut state = (*state).clone(); + + // Through the fork-dispatching wrapper rather than phase0's version + // directly. Altair rewrote this step to read participation flags instead of + // replaying stored attestations, so calling phase0's against an altair or + // later state fails outright, which is what made every `fork_choice` case + // that crosses an epoch boundary fail. + stf::epoch::process_justification_and_finalization(&mut state, config)?; + + let current_justified = state.current_justified_checkpoint(); + let finalized = state.finalized_checkpoint(); + + store.set_unrealized_justification(block_root, current_justified); + update_unrealized_checkpoints(store, current_justified, finalized); + + // If the block is from a prior epoch, apply the realized values. `block_slot` + // is passed in rather than read back: the only caller is `on_block`, which + // has the block itself in hand, so reading it here would decode a whole + // stored block to recover a field the caller already had. + let block_epoch = compute_epoch_at_slot(block_slot); + let current_epoch = get_current_store_epoch(store, config); + if block_epoch < current_epoch { + update_checkpoints(store, current_justified, finalized); + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// on_tick helpers +// --------------------------------------------------------------------------- + +/// Advances `store` to `time`, one slot boundary at a time from where it was. +/// +/// `on_tick` is what actually calls this in a loop to catch up more than one +/// slot at once; called directly, `time` must already be at most one slot +/// ahead of `store`'s current slot for the "new slot" resets below to fire at +/// the right boundary. +pub fn on_tick_per_slot(store: &mut Store, time: u64, config: &Config) { + let previous_slot = get_current_slot(store, config); + + // `time` is the specification's seconds; the store's row is milliseconds. + store + .set_time_ms(seconds_to_milliseconds(time)) + .expect("set time"); + + let current_slot = get_current_slot(store, config); + + // If this is a new slot, reset store.proposer_boost_root. + if current_slot > previous_slot { + store.set_proposer_boost_root(Root::ZERO); + } + + // If a new epoch, pull-up justification and finalization from previous + // epoch. + if current_slot > previous_slot && compute_slots_since_epoch_start(current_slot) == 0 { + let unrealized_justified = store.beacon_unrealized_justified_checkpoint(); + let unrealized_finalized = store.beacon_unrealized_finalized_checkpoint(); + update_checkpoints(store, unrealized_justified, unrealized_finalized); + } +} + +// --------------------------------------------------------------------------- +// on_attestation helpers +// --------------------------------------------------------------------------- + +/// Rejects an attestation whose target is not the current or previous epoch, +/// relative to `store`'s own clock. +/// +/// Only checked for attestations arriving directly (not inside a block): +/// a block-borne attestation may target an epoch that has since passed, since +/// the block itself is being processed after the fact. +/// +/// Takes `data` directly rather than an [`Attestation`]: this and +/// [`validate_on_attestation`] read nothing from an attestation besides its +/// fork-invariant `data`, so neither needs to know which of +/// [`Attestation`]'s two shapes the caller actually has. See the module +/// documentation. +pub fn validate_target_epoch_against_current_time( + store: &Store, + data: AttestationData, + config: &Config, +) -> Result<()> { + let target = data.target; + let current_epoch = get_current_store_epoch(store, config); + // Use GENESIS_EPOCH for previous when genesis to avoid underflow. + let previous_epoch = if current_epoch > constants::GENESIS_EPOCH { + current_epoch - 1 + } else { + constants::GENESIS_EPOCH + }; + verify( + target.epoch == current_epoch || target.epoch == previous_epoch, + "target.epoch in [current_epoch, previous_epoch]", + ) +} + +/// Every check `on_attestation` requires before it may look up or update +/// anything in `store`. See [`validate_target_epoch_against_current_time`] +/// for why this takes `data` rather than an [`Attestation`]. +pub fn validate_on_attestation( + store: &Store, + data: AttestationData, + is_from_block: bool, + config: &Config, +) -> Result<()> { + validate_on_attestation_indexed(store, data, is_from_block, config, &store.block_index()) +} + +/// [`validate_on_attestation`] against an already-built block index. +/// +/// Takes `index` (`root -> (slot, parent_root)`, [`Store::block_index`]'s own +/// shape) for the reason [`filter_block_tree`] does: a caller validating every +/// attestation carried in one block ([`on_block_attestation`]) would otherwise +/// re-scan `Table::LiveChain` once per attestation, and that table grows one +/// row per imported block on a chain whose blocks are never pruned from it. +fn validate_on_attestation_indexed( + store: &Store, + data: AttestationData, + is_from_block: bool, + config: &Config, + index: &HashMap, +) -> Result<()> { + let target = data.target; + + // If the given attestation is not from a beacon block message, we have to + // check the target epoch scope. + if !is_from_block { + validate_target_epoch_against_current_time(store, data, config)?; + } + + // Check that the epoch number and slot number are matching. + verify( + target.epoch == compute_epoch_at_slot(data.slot), + "target.epoch == compute_epoch_at_slot(attestation.data.slot)", + )?; + + // Attestation target must be for a known block. If target block is + // unknown, delay consideration until block is found. + verify(store.has_block(&target.root), "target.root in store.blocks")?; + + // Attestations must be for a known block. If block is unknown, delay + // consideration until the block is found. + let (head_block_slot, _) = *index.get(&data.beacon_block_root).ok_or(Error::SpecAssert( + "attestation.data.beacon_block_root in store.blocks", + ))?; + // Attestations must not be for blocks in the future. If not, the + // attestation should not be considered. + verify( + head_block_slot <= data.slot, + "store.blocks[attestation.data.beacon_block_root].slot <= attestation.data.slot", + )?; + + // LMD vote must be consistent with FFG vote target. + let checkpoint_block = get_checkpoint_block(index, data.beacon_block_root, target.epoch)?; + verify( + target.root == checkpoint_block, + "target.root == get_checkpoint_block(store, attestation.data.beacon_block_root, target.epoch)", + )?; + + // Attestations can only affect the fork choice of subsequent slots. Delay + // consideration in the fork choice until their slot is in the past. + verify( + get_current_slot(store, config) >= data.slot.saturating_add(1), + "get_current_slot(store) >= attestation.data.slot + 1", + )?; + + Ok(()) +} + +/// Records an attestation as each attester's latest message, for every +/// attesting index that is not a known equivocator. +/// +/// An attester's latest message only ever moves to a later target epoch: an +/// attestation for an epoch already superseded by that attester's own later +/// vote is simply not the freshest thing known about them anymore. +/// +/// Takes `attesting_indices` and `data` rather than an [`Attestation`]: by +/// the time [`on_attestation`] calls this, [`Attestation::verified_attesting_indices`] +/// has already resolved the one fork-specific fact this needed out of it. +pub fn update_latest_messages( + store: &mut Store, + attesting_indices: &[ValidatorIndex], + data: AttestationData, +) { + let target = data.target; + let beacon_block_root = data.beacon_block_root; + + for &index in attesting_indices { + if store.is_equivocating(index) { + continue; + } + let should_update = match store.latest_message(index) { + None => true, + Some(existing) => target.epoch > existing.epoch, + }; + if should_update { + store.set_latest_message( + index, + LatestMessage { + epoch: target.epoch, + root: beacon_block_root, + }, + ); + } + } +} + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- +// +// These four are the only functions in this file the specification itself +// lists as the sole ways to change `store`; each validates before it mutates +// anything, so a rejected call leaves `store` exactly as it found it, matching +// its requirement that "invalid calls to handlers must not modify store". +// [`get_head`], above, is the one non-handler that also takes `&mut Store`: +// it records the head it just computed, which is not a validity-gated +// mutation a rejected call would need rolled back, just a derived value kept +// in sync with every call. + +/// Advances `store` to `time` (Unix seconds), running [`on_tick_per_slot`] +/// once per slot boundary crossed so that none of them are skipped even if +/// `time` jumps forward by more than one slot since the last call. +pub fn on_tick(store: &mut Store, time: u64, config: &Config) { + let genesis_time = store.config().genesis_time; + let tick_slot = time.saturating_sub(genesis_time) / config.seconds_per_slot; + while get_current_slot(store, config) < tick_slot { + let next_slot = get_current_slot(store, config).saturating_add(1); + let previous_time = + genesis_time.saturating_add(next_slot.saturating_mul(config.seconds_per_slot)); + on_tick_per_slot(store, previous_time, config); + } + on_tick_per_slot(store, time, config); +} + +/// Validates and applies `signed_block`, adding it and its resulting +/// post-state to `store`. +/// +/// Takes `signed_block` by value rather than by reference (a departure from +/// the specification's own signature, which makes no such distinction in +/// Python): every fork's block carries its whole body, and taking ownership +/// lets it move directly into +/// [`Store::insert_signed_block`](ethlambda_storage::Store::insert_signed_block) +/// on success instead of being cloned there. A caller that still needs its +/// own copy afterward clones before calling, same as the store's own state +/// entries do explicitly inside this function. +/// +/// `blob_evidence` is this module's own addition, beyond the specification's +/// two-argument `on_block(store, signed_block)`: see [`DataAvailability`]'s +/// documentation for why deneb, electra, and fulu's data-availability check +/// needs one. A pre-deneb block, or one with no blob commitments, never +/// reads it; [`DataAvailability::NotRequired`] is the right value to pass in +/// that case. +/// +/// `payload_validity` is this module's second such addition: what an execution +/// client said about this block's payload, or [`PayloadValidity::NotRequired`] +/// when there was nothing to ask. See its own documentation, and the module +/// documentation's "`on_block`'s execution engine", for how it reaches +/// [`stf::state_transition`] and what it records. +pub fn on_block( + store: &mut Store, + signed_block: SignedBeaconBlock, + config: &Config, + blob_evidence: &DataAvailability, + payload_validity: &PayloadValidity, + committees: &CommitteeCache, +) -> Result<()> { + let block_root = signed_block.message_hash_tree_root(); + let parent_root = signed_block.parent_root(); + + // Parent block must be known. `get_state` hands back a shared `Arc`, which + // both checks the parent is known and gives the value to clone the copy + // `state_transition` below mutates from: `state_transition` must not be + // able to corrupt the parent's own cached post-state if this block turns + // out to be invalid partway through applying it, and it can't, since this + // is already an independent clone rather than a borrow of the store's own + // cached entry. + let parent_state = store + .get_state(&parent_root) + .expect("get") + .ok_or(Error::SpecAssert("block.parent_root in store.block_states"))?; + let mut state = (*parent_state).clone(); + + // Blocks cannot be in the future. If they are, their consideration must + // be delayed until they are in the past. + verify( + get_current_slot(store, config) >= signed_block.slot(), + "get_current_slot(store) >= block.slot", + )?; + + // Check that block is later than the finalized epoch slot (optimization + // to reduce calls to get_ancestor). + let finalized_checkpoint = store.beacon_finalized_checkpoint(); + let finalized_slot = compute_start_slot_at_epoch(finalized_checkpoint.epoch); + verify( + signed_block.slot() > finalized_slot, + "block.slot > finalized_slot", + )?; + // Check block is a descendant of the finalized block at the checkpoint + // finalized slot. A single-call index: see `get_ancestor`'s documentation + // for why a per-hop lookup would be the wrong trade, which does not apply + // to this one walk. + let index = store.block_index(); + let finalized_checkpoint_block = + get_checkpoint_block(&index, parent_root, finalized_checkpoint.epoch)?; + verify( + finalized_checkpoint.root == finalized_checkpoint_block, + "store.finalized_checkpoint.root == finalized_checkpoint_block", + )?; + + // [New in Deneb/Electra] Check if blob data is available. [New in Fulu] + // The same check, over column sidecars instead of blobs. Both run before + // `state_transition`, matching the specification's own ordering: an + // unavailable block is not even worth transitioning. + match &signed_block { + SignedBeaconBlock::Deneb(block) => { + verify( + is_data_available_blobs(&block.message.body.blob_kzg_commitments, blob_evidence)?, + "is_data_available(hash_tree_root(block), block.body.blob_kzg_commitments)", + )?; + } + SignedBeaconBlock::Electra(block) => { + verify( + is_data_available_blobs(&block.message.body.blob_kzg_commitments, blob_evidence)?, + "is_data_available(hash_tree_root(block), block.body.blob_kzg_commitments)", + )?; + } + SignedBeaconBlock::Fulu(_) => { + verify( + is_data_available_columns(blob_evidence, config)?, + "is_data_available(hash_tree_root(block))", + )?; + } + _ => {} + } + + // Check the block is valid and compute the post-state. The engine's answer + // is read the way the specification reads it: an `INVALIDATED` verdict makes + // `verify_and_notify_new_payload` return false, and everything else makes it + // return true. Running the transition even when the verdict is already + // `Invalidated` is deliberate. It costs a merkleization on a path that + // should never run, and it buys the failure arriving from inside + // `process_execution_payload`, which is where the specification puts it and + // where a reviewer checks this code against it. + let engine = match payload_validity { + PayloadValidity::Invalidated { .. } => stf::ExecutionEngine::invalid(), + PayloadValidity::NotRequired | PayloadValidity::Validated | PayloadValidity::Optimistic => { + stf::ExecutionEngine::valid() + } + }; + let transition = + stf::state_transition(&mut state, &signed_block, true, config, &engine, committees); + + // `optimistic-sync.md`: a block deemed `INVALIDATED` MUST NOT be included + // in the canonical chain. That is stated here, on the verdict, rather than + // left to `transition` having failed, because the transition only fails for + // forks whose `process_execution_payload` consults the `ExecutionEngine` at + // all: bellatrix gates that step on `is_execution_enabled`, and phase0 and + // altair have no such step. A condemned block on one of those would + // otherwise transition cleanly and be imported with nothing recorded. + // + // The transition still runs above, and its own error is still what this + // returns when there is one. That is what keeps the failure arriving from + // inside `process_execution_payload`, where the specification puts it and + // where a reviewer checks this code against it, and what keeps the + // `sync/optimistic` fixture exercising that path rather than this guard. + // + // The invalidation must land even though the import fails. The + // `sync/optimistic` fixture's last step carries `valid: false` for the + // rejected block while still requiring its whole branch to disappear, so it + // cannot be deferred to a success path that never runs. + if let PayloadValidity::Invalidated { latest_valid_hash } = payload_validity { + let index = store.block_index(); + // `block_root` is not in `index`: this block never imported, which is + // why `resolve_invalid_block` takes `parent_root` separately and starts + // the walk there. The `None` and unfindable cases answer `block_root`, + // and invalidating an unindexed root removes nothing, which is exactly + // "only the block in question dies" for a block that never joined the + // tree. + let condemned = + resolve_invalid_block(store, &index, block_root, parent_root, *latest_valid_hash); + let removed = invalidate_subtree(store, condemned); + warn!( + block_root = %ShortRoot(&block_root.0), + condemned = %ShortRoot(&condemned.0), + removed, + "Execution layer rejected a payload; invalidated its branch" + ); + return Err(transition.err().unwrap_or(Error::SpecAssert( + "the execution layer rejected this block's payload", + ))); + } + + transition?; + + // Cache the state root in the latest block header. Sound because the + // `true` above means `state_transition` checked it against the root it + // computed; see `BeaconState::compute_state_root`. + state.latest_block_header_mut().state_root = signed_block.state_root(); + + // [New in Bellatrix] Check the merge transition block conditions. + // Capella's own `fork-choice.md` removes this check outright, so it + // applies to bellatrix alone. Re-reads the store's own entry for + // `parent_root` rather than `state`: that entry is still exactly the + // parent's own post-state, since `state_transition` above mutated the + // independent copy this function made of it, not the store's own. + if let SignedBeaconBlock::Bellatrix(block) = &signed_block { + let pre_state = store + .get_state(&parent_root) + .expect("get") + .expect("checked above"); + if stf::bellatrix::is_merge_transition_block( + &pre_state, + &block.message.body.execution_payload, + )? { + validate_merge_block(store, &block.message, config)?; + } + } + + // Read the post-state's checkpoints out before `state` moves into the + // store: unlike a map entry, an owned value can't be re-borrowed once + // moved, and copying two `Checkpoint`s out is cheaper than reading the + // whole state back from storage afterward. + let current_justified = state.current_justified_checkpoint(); + let finalized = state.finalized_checkpoint(); + + // Add new block to the store, and the new state for this block to the + // store. `block_slot` is copied out first since `signed_block` moves next. + let block_slot = signed_block.slot(); + let signed_block_el_hash = signed_block.execution_block_hash(); + store + .insert_signed_block(block_root, signed_block) + .expect("insert"); + store.insert_state(block_root, state).expect("insert"); + + // Cache this block's own execution hash for `forkchoiceUpdated` and for the + // `latestValidHash` walk, and record whether the execution layer has + // actually vouched for it yet. + // + // A zero hash is not cached, because the presence of an entry is what + // [`is_optimistic_candidate_block`] reads as the specification's + // `is_execution_block`, and that predicate is "the payload is not the + // fork's own empty one", not "the container has a payload field". A + // pre-merge bellatrix block carries a payload field whose every byte is + // zero (see `stf::bellatrix::default_execution_payload`), and caching that + // would make its children look like descendants of a merge block and skip + // the age horizon that exists precisely to guard the merge transition. + // Testing the block hash alone is enough: it is a keccak digest in a real + // payload and zero in the empty one. + if let Some(el_block_hash) = signed_block_el_hash + && !el_block_hash.is_zero() + { + store.insert_beacon_el_block_hash(block_root, block_slot, el_block_hash); + } + match payload_validity { + PayloadValidity::Optimistic => { + store.insert_beacon_optimistic_root(block_root, block_slot); + } + PayloadValidity::Validated => mark_validated(store, block_root), + PayloadValidity::NotRequired | PayloadValidity::Invalidated { .. } => {} + } + + // Add block timeliness to the store. + let time_into_slot_ms = store.ms_since_genesis() % config.slot_duration_ms; + let epoch = get_current_store_epoch(store, config); + let attestation_threshold_ms = get_attestation_due_ms(epoch, config); + let is_before_attesting_interval = time_into_slot_ms < attestation_threshold_ms; + let is_timely = get_current_slot(store, config) == block_slot && is_before_attesting_interval; + store.set_block_timeliness(block_root, is_timely); + + // Add proposer score boost if the block is timely and not conflicting + // with an existing block. + let is_first_block = store.proposer_boost_root().is_zero(); + if is_timely && is_first_block { + store.set_proposer_boost_root(block_root); + } + + // Update checkpoints in store if necessary. + update_checkpoints(store, current_justified, finalized); + + // Eagerly compute unrealized justification and finality. + compute_pulled_up_tip(store, block_root, block_slot, config)?; + + Ok(()) +} + +/// Validates `attestation` and, if valid, records it as each attester's +/// latest message. +/// +/// `is_from_block` marks an attestation carried inside a block rather than +/// received directly over gossip: [`validate_on_attestation`] skips the +/// current/previous-epoch target check for those, since a block can carry an +/// attestation for an epoch that has since passed. +pub fn on_attestation( + store: &mut Store, + attestation: &Attestation, + is_from_block: bool, + config: &Config, + committees: &CommitteeCache, +) -> Result<()> { + let data = attestation.data(); + validate_on_attestation(store, data, is_from_block, config)?; + + // The state at the `target` to fully validate attestation against. + // `checkpoint_state` hands back an owned value now, so there is no borrow + // of `store` left to release before `update_latest_messages` needs it + // mutably below, unlike when this cached state lived behind a reference + // into `store` itself. + let target_state = checkpoint_state(store, &data.target, config)?; + let attesting_indices = attestation.verified_attesting_indices(&target_state, committees)?; + + // Update latest messages for attesting indices. + update_latest_messages(store, &attesting_indices, data); + + Ok(()) +} + +/// [`on_attestation`] for an attestation carried inside a block, given the +/// post-state of the block that carried it. +/// +/// Has the same effect on `store` as `on_attestation(store, attestation, true, +/// config)`, and reaches it without materializing the target checkpoint's +/// state. Two things make that equivalent rather than merely cheaper: +/// +/// * The aggregate signature does not need checking again. `process_block` +/// ran `process_attestation` over this exact attestation on the way to +/// producing `block_state`, and that runs the same +/// `is_valid_indexed_attestation` the verifying path here would. A block +/// whose attestation failed it never became a block; one that is in the +/// store carries a verdict this node reached itself. +/// * `block_state` names the same committees the target checkpoint's state +/// would. `get_beacon_committee` for a slot in epoch `E` reads the active +/// validator set at `E` and the seed at `E`, and that seed is the randao mix +/// from `E - MIN_SEED_LOOKAHEAD - 1`, fixed before `E` began. The target +/// checkpoint is an ancestor of this block by +/// [`validate_on_attestation`]'s own LMD/FFG consistency check, so both +/// states share that history. It is the same equivalence `process_attestation` +/// relies on when it validates a previous-epoch attestation against the +/// current state. +/// +/// What it buys: a target checkpoint's root is the last block at or before +/// the boundary *on the attester's branch*, so an attester whose view lagged +/// names a mid-epoch block. Once that block's post-state falls out of the +/// recency cache, [`checkpoint_state`] rebuilds a ~350MB state by replaying +/// every block since the last pinned boundary, and the attestation is not +/// even the reason the import is happening. Observed on a mainnet follower: +/// one import at 78.9s against a 3.6s steady state, on a 23-block replay for +/// a target 16 blocks behind the head. +/// `index` is [`Store::block_index`], built once for the whole block rather +/// than per attestation: it is a full `Table::LiveChain` scan, and that table +/// carries a row for every block this node ever imported. +pub fn on_block_attestation( + store: &mut Store, + attestation: &Attestation, + block_state: &BeaconState, + config: &Config, + index: &HashMap, + committees: &CommitteeCache, +) -> Result<()> { + let data = attestation.data(); + validate_on_attestation_indexed(store, data, true, config, index)?; + + let attesting_indices = attestation.attesting_indices(block_state, committees)?; + update_latest_messages(store, &attesting_indices, data); + + Ok(()) +} + +/// Apply an aggregate that reached the chain actor after +/// `ethlambda-p2p`'s beacon gossip validation already accepted it. +/// +/// Every condition `beacon_aggregate_and_proof`'s own gossip rules add over a +/// plain attestation, the committee lookups, `is_aggregator`, committee +/// membership, and all three BLS checks, ran once in +/// `ethlambda_state_transition::beacon::gossip::aggregate` before this was +/// called, on the state that attestation's own target checkpoint names. This +/// function must not repeat any of it: doing so would be the reviewed defect +/// this replaced, committees rebuilt and signatures re-verified once per +/// aggregate on the chain actor's single thread. +/// +/// What is left is exactly [`on_attestation`]'s own validity check and its +/// bookkeeping, since neither is gossip's to answer: [`validate_on_attestation_indexed`] +/// catches a target this node has since finalized past or a vote whose own +/// slot has not passed yet, both of which can change between p2p's verdict +/// and the chain actor picking the aggregate up, and [`update_latest_messages`] +/// records it against `attesting_indices`, resolved by the caller's gossip +/// validation rather than recomputed here. +/// +/// `is_from_block` is fixed at `false`, matching [`on_attestation`]'s call for +/// this topic: an aggregate here is by definition not carried in a block, so +/// the current-or-previous-epoch target check applies. +/// +/// `index` is [`Store::block_index`], taken as a parameter rather than built +/// here so a caller applying several aggregates at once (the chain actor's +/// deferral queue, drained once per tick) pays for the full `Table::LiveChain` +/// scan once for the whole drain rather than once per aggregate. +pub fn apply_verified_aggregate( + store: &mut Store, + data: AttestationData, + attesting_indices: &[ValidatorIndex], + config: &Config, + index: &HashMap, +) -> Result<()> { + validate_on_attestation_indexed(store, data, false, config, index)?; + update_latest_messages(store, attesting_indices, data); + Ok(()) +} + +/// Records every validator common to both halves of `attester_slashing` as +/// equivocating, once both halves are confirmed to actually be slashable and +/// individually valid. +/// +/// *Note*: the specification calls for maintaining the equivocation set from +/// at least the latest finalized checkpoint onward while syncing, which this +/// function does not enforce on its own; a caller replaying history is +/// responsible for calling this for every attester slashing it encounters +/// rather than only recent ones. +pub fn on_attester_slashing(store: &mut Store, attester_slashing: &AttesterSlashing) -> Result<()> { + let (data_1, data_2) = attester_slashing.data(); + + verify( + is_slashable_attestation_data(&data_1, &data_2), + "is_slashable_attestation_data(attestation_1.data, attestation_2.data)", + )?; + + // `get_state` already hands back an owned value, so there is no borrow of + // `store` left to release before `insert_equivocating_index` needs it + // mutably below. + let justified_root = store.beacon_justified_checkpoint().root; + let state = store + .get_state(&justified_root) + .expect("get") + .ok_or(Error::SpecAssert( + "store.justified_checkpoint.root in store.block_states", + ))?; + let (indices_1, indices_2) = attester_slashing.verified_attesting_indices(&state)?; + + let indices_1: HashSet = indices_1.into_iter().collect(); + for index in indices_2 { + if indices_1.contains(&index) { + store.insert_equivocating_index(index); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use ethlambda_storage::backend::InMemoryBackend; + + use super::*; + use crate::beacon::containers::BeaconBlockHeader; + use crate::beacon::helpers::test_state; + + /// A store backed by a fresh in-memory backend, with every checkpoint at + /// its default (genesis) value and no anchor block or state written. + /// Tests populate only what the function under test actually reads. + fn empty_store() -> Store { + store_anchored_at(Root::ZERO) + } + + /// A fresh store whose head and both realized checkpoints name `root` in + /// the genesis epoch. + /// + /// Seeded at bootstrap rather than written afterwards, because the writer + /// that moves a checkpoint moves the head with it and diffs the + /// `BlockRoots` index across the two, so a store cannot be pointed at a + /// root whose block it does not hold yet. + fn store_anchored_at(root: Root) -> Store { + let backend = Arc::new(InMemoryBackend::new()); + let anchor = Checkpoint { + epoch: constants::GENESIS_EPOCH, + root, + }; + Store::init_beacon( + backend, + 0, + Config::active(), + root, + Store::beacon_checkpoint_as_stored(anchor), + 0, + ) + } + + /// A signed block with an empty body and a zero signature, for tests that + /// only care about `slot` and `parent_root`. Phase0-shaped since nothing + /// under test here reads anything fork-specific. + fn block(slot: Slot, parent_root: Root) -> SignedBeaconBlock { + SignedBeaconBlock::Phase0(phase0::SignedBeaconBlock { + message: phase0::BeaconBlock { + slot, + proposer_index: 0, + parent_root, + state_root: Root::ZERO, + body: phase0::BeaconBlockBody { + randao_reveal: Default::default(), + eth1_data: Default::default(), + graffiti: Root::ZERO, + proposer_slashings: Default::default(), + attester_slashings: Default::default(), + attestations: Default::default(), + deposits: Default::default(), + voluntary_exits: Default::default(), + }, + }, + signature: Default::default(), + }) + } + + /// An exact anchor pair: `state` is `block`'s own post-state. + /// + /// Built as a fixed point, the way the state transition produces one. The + /// state's `latest_block_header` names the block with a zero `state_root`, + /// which is how the specification leaves it inside the block's own slot; + /// the state's root is then computed against that, and written back into + /// the block. `BeaconBlock` and `BeaconBlockHeader` merkleize identically, + /// five fields with the body's root standing in for the body, so the + /// header root and the block root agree once the zero is substituted. + fn anchor_pair() -> (BeaconState, SignedBeaconBlock) { + anchor_pair_with(4) + } + + /// [`anchor_pair`] over a registry of `count` validators, for the weight + /// tests, which name a voter per validator index. + fn anchor_pair_with(count: usize) -> (BeaconState, SignedBeaconBlock) { + let mut state = test_state::with_validators(count); + let parent_root = state.latest_block_header().parent_root; + let mut signed = block(state.slot(), parent_root); + + let SignedBeaconBlock::Phase0(inner) = &signed else { + unreachable!("`block` builds a phase0 signed block"); + }; + *state.latest_block_header_mut() = BeaconBlockHeader { + slot: inner.message.slot, + proposer_index: inner.message.proposer_index, + parent_root, + state_root: Root::ZERO, + body_root: inner.message.body.hash_tree_root(), + }; + + let state_root = state.hash_tree_root(); + let SignedBeaconBlock::Phase0(inner) = &mut signed else { + unreachable!("`block` builds a phase0 signed block"); + }; + inner.message.state_root = state_root; + + (state, signed) + } + + /// Advance a state one empty slot by hand, the way `process_slot` does: + /// fill in the header's `state_root`, then move the slot on. The state is + /// then past its own anchor block, which is the shape a checkpoint-synced + /// anchor arrives in when the finalized epoch boundary was empty. + fn advance_one_empty_slot(state: &mut BeaconState) { + let root = state.hash_tree_root(); + state.latest_block_header_mut().state_root = root; + *state.slot_mut() += 1; + } + + /// A block index from `(root, slot, parent_root)` triples, the shape + /// `Store::block_index` hands fork choice. + fn index(entries: &[(Root, Slot, Root)]) -> HashMap { + entries + .iter() + .map(|&(root, slot, parent_root)| (root, (slot, parent_root))) + .collect() + } + + /// A store anchored on `count` validators, plus the anchor's own root and + /// slot. + /// + /// The anchor is what `checkpoint_state` resolves the justified checkpoint + /// to, which is the one thing both weight functions need from a real store. + fn anchored_store(count: usize) -> (Store, Root, Slot) { + let (anchor_state, anchor_block) = anchor_pair_with(count); + let anchor_slot = anchor_state.slot(); + let anchor_root = anchor_block.message_hash_tree_root(); + let store = get_forkchoice_store( + Arc::new(InMemoryBackend::new()), + anchor_state, + anchor_block, + &Config::active(), + ) + .expect("the pair matches"); + (store, anchor_root, anchor_slot) + } + + /// `compute_weights` is the specification's `get_weight` for every root at + /// once, so the two have to agree root by root: over a fork, over voters + /// spread across both branches, and with the proposer boost applied. + #[test] + fn the_single_pass_weights_match_the_specifications_per_root_weight() { + let config = Config::active(); + let (mut store, anchor_root, anchor_slot) = anchored_store(8); + + // anchor -> a -> {b, c}: a fork whose two leaves split the vote, so a + // wrong fold shows up as a leaf carrying its sibling's balance. + let a_root = Root::repeat_byte(0xa1); + let b_root = Root::repeat_byte(0xb2); + let c_root = Root::repeat_byte(0xc3); + let index = index(&[ + (anchor_root, anchor_slot, Root::ZERO), + (a_root, anchor_slot + 1, anchor_root), + (b_root, anchor_slot + 2, a_root), + (c_root, anchor_slot + 2, a_root), + ]); + + // Three voters on `b`, one on `c`, one on the anchor itself (a vote + // that counts for no candidate above it), and one equivocator whose + // vote must not count at all. + for (validator_index, root) in [(0, b_root), (1, b_root), (2, b_root), (3, c_root)] { + store.set_latest_message(validator_index, LatestMessage { epoch: 0, root }); + } + store.set_latest_message( + 4, + LatestMessage { + epoch: 0, + root: anchor_root, + }, + ); + store.set_latest_message( + 5, + LatestMessage { + epoch: 0, + root: b_root, + }, + ); + store.insert_equivocating_index(5); + store.set_proposer_boost_root(b_root); + + let weights = compute_weights(&store, &index, &config).expect("the anchor state is there"); + + for root in [anchor_root, a_root, b_root, c_root] { + assert_eq!( + weights.get(&root).copied().unwrap_or_default(), + get_weight(&store, &index, root, &config).expect("every root is indexed"), + "the two weights disagree at {root}" + ); + } + assert!( + weights[&b_root] > weights[&c_root], + "three voters and the boost must outweigh one voter" + ); + } + + /// The failure a live mainnet follower hit: `promote_beacon_anchor` prunes + /// the block index below the oldest kept anchor, and any validator whose + /// freshest vote was for a block down there kept pointing at it. The + /// specification's `get_weight` raises on that vote and takes the whole + /// head computation with it; the head froze for as long as one stale voter + /// stayed stale. + #[test] + fn a_vote_for_a_pruned_block_weighs_nothing_instead_of_failing() { + let config = Config::active(); + let (mut store, anchor_root, anchor_slot) = anchored_store(8); + let a_root = Root::repeat_byte(0xa1); + let index = index(&[ + (anchor_root, anchor_slot, Root::ZERO), + (a_root, anchor_slot + 1, anchor_root), + ]); + + store.set_latest_message( + 0, + LatestMessage { + epoch: 0, + root: a_root, + }, + ); + store.set_latest_message( + 1, + LatestMessage { + epoch: 0, + // Below the anchor, so no longer indexed. + root: Root::repeat_byte(0xde), + }, + ); + + assert!( + get_weight(&store, &index, a_root, &config).is_err(), + "the specification's own version is what raises here; this test \ + exists because that took the whole head computation with it" + ); + + let weights = compute_weights(&store, &index, &config).expect("a pruned vote is not fatal"); + + let one_validator_balance = weights[&a_root]; + assert!(one_validator_balance > 0, "the live vote still counts"); + assert_eq!( + weights[&anchor_root], one_validator_balance, + "and it counts exactly once, for a_root and everything it descends from" + ); + } + + #[test] + fn get_ancestor_walks_past_an_empty_slot_gap() { + let genesis_root = Root::repeat_byte(1); + let a_root = Root::repeat_byte(2); + let b_root = Root::repeat_byte(3); + + let mut index = HashMap::new(); + index.insert(genesis_root, (0, Root::ZERO)); + index.insert(a_root, (1, genesis_root)); + // Slot 2 is empty: b's parent is a, two slots later. + index.insert(b_root, (3, a_root)); + + // At b's own slot, b is its own ancestor. + assert_eq!(get_ancestor(&index, b_root, 3).unwrap(), b_root); + // Querying the empty slot, or a's own slot, must land on a rather + // than on b, since b's slot is strictly after both. + assert_eq!(get_ancestor(&index, b_root, 2).unwrap(), a_root); + assert_eq!(get_ancestor(&index, b_root, 1).unwrap(), a_root); + // Querying before a's slot must walk one hop further, to genesis. + assert_eq!(get_ancestor(&index, b_root, 0).unwrap(), genesis_root); + } + + #[test] + fn get_ancestor_rejects_an_unknown_root() { + let index = HashMap::new(); + // The specification's own KeyError-on-unknown-root is exactly the + // "unhandled exception" case it calls invalid, so this must be an + // error rather than a panic. + assert!(get_ancestor(&index, Root::repeat_byte(9), 0).is_err()); + } + + #[test] + fn compute_slots_since_epoch_start_counts_from_the_epoch_boundary() { + let epoch_start = compute_start_slot_at_epoch(3); + assert_eq!(compute_slots_since_epoch_start(epoch_start), 0); + assert_eq!(compute_slots_since_epoch_start(epoch_start + 1), 1); + assert_eq!( + compute_slots_since_epoch_start(epoch_start + preset::SLOTS_PER_EPOCH - 1), + preset::SLOTS_PER_EPOCH - 1 + ); + } + + #[test] + fn get_head_breaks_equal_weight_ties_by_higher_root() { + let config = Config::active(); + let genesis_root = Root::repeat_byte(1); + let low_root = Root::repeat_byte(2); + let high_root = Root::repeat_byte(3); + + // Both checkpoints sit at the genesis epoch, which is what makes + // `filter_block_tree` accept any leaf unconditionally (both of its + // "correct_justified"/"correct_finalized" checks have a + // `== GENESIS_EPOCH` escape hatch): the point of this test is the + // weight tie-break in `get_head`, not the filtering rules. + let mut store = store_anchored_at(genesis_root); + + store + .insert_signed_block(genesis_root, block(0, Root::ZERO)) + .unwrap(); + store + .insert_signed_block(low_root, block(1, genesis_root)) + .unwrap(); + store + .insert_signed_block(high_root, block(1, genesis_root)) + .unwrap(); + + // `get_weight` derives the justified checkpoint's state (now that + // there is no cache to seed) from `store.get_state(&genesis_root)`, + // only to enumerate active validators; with no latest messages + // recorded, neither child gets any attesting balance, so both are + // weight zero and the root comparison is all that can decide between + // them. + let state = crate::beacon::helpers::test_state::with_validators(1); + store.insert_state(genesis_root, state.clone()).unwrap(); + // `get_voting_source` needs a post-state for each leaf, since both + // children are in the store's current epoch (its clock is left at + // the default of slot zero) and so take the "not pulled up" branch. + store.insert_state(low_root, state.clone()).unwrap(); + store.insert_state(high_root, state).unwrap(); + + let head = get_head(&mut store, &config).unwrap(); + assert_eq!( + head, high_root, + "a weight tie must be broken by the lexicographically higher root" + ); + } + + #[test] + fn get_voting_source_pulls_up_a_prior_epoch_blocks_vote() { + let config = Config::active(); + let mut store = empty_store(); + // Put the store's clock two epochs ahead of the block below, so + // `get_voting_source` takes the pulled-up branch + // (`current_epoch > block_epoch`) rather than reading the block's own + // post-state directly. + store + .set_time_ms(seconds_to_milliseconds( + config.seconds_per_slot * preset::SLOTS_PER_EPOCH * 2, + )) + .unwrap(); + + let block_root = Root::repeat_byte(5); + store + .insert_signed_block(block_root, block(0, Root::ZERO)) + .unwrap(); + + let unrealized = Checkpoint { + epoch: 1, + root: Root::repeat_byte(6), + }; + let realized = Checkpoint { + epoch: 0, + root: Root::repeat_byte(7), + }; + assert_ne!( + unrealized, realized, + "the test must exercise two different values" + ); + + store.set_unrealized_justification(block_root, unrealized); + + let mut state = crate::beacon::helpers::test_state::with_validators(1); + *state.current_justified_checkpoint_mut() = realized; + store.insert_state(block_root, state).unwrap(); + + let voting_source = + get_voting_source(&store, &store.block_index(), block_root, &config).unwrap(); + assert_eq!( + voting_source, unrealized, + "a block from a prior epoch must vote its pulled-up (unrealized) checkpoint" + ); + } + + #[test] + fn get_head_persists_the_head_it_computed() { + let config = Config::active(); + + // Both checkpoints at the genesis epoch, so `filter_block_tree` + // accepts the genesis leaf unconditionally; the point of this test is + // the persistence side effect, not the filtering rules. + let genesis_root = Root::repeat_byte(1); + let mut store = store_anchored_at(genesis_root); + + store + .insert_signed_block(genesis_root, block(0, Root::ZERO)) + .unwrap(); + let state = crate::beacon::helpers::test_state::with_validators(1); + store.insert_state(genesis_root, state).unwrap(); + + let head = get_head(&mut store, &config).expect("get_head"); + + // Written on every call, so the stored value cannot drift from what a + // fresh computation produces. + let (slot, root) = store.beacon_head().expect("head recorded"); + assert_eq!(root, head); + assert_eq!(slot, store.block_entry(&head).expect("head block").0); + } + + /// The specification's assertion cannot hold for a checkpoint-synced + /// anchor: the finalized state sits at the epoch boundary, so when that + /// slot was empty it has advanced past its own `latest_block_header` and + /// `block.state_root` is no longer the state's root. The header root is + /// what still identifies the pair. + #[test] + fn get_forkchoice_store_accepts_a_state_advanced_past_its_anchor_block() { + let (mut state, block) = anchor_pair(); + advance_one_empty_slot(&mut state); + assert_ne!(block.state_root(), state.hash_tree_root()); + + let store = get_forkchoice_store( + Arc::new(InMemoryBackend::new()), + state, + block, + &Config::active(), + ); + + assert!(store.is_ok(), "{:?}", store.err()); + } + + /// The exact pair, which the specification's own assertion accepts too, + /// must keep working: a full client anchors at genesis this way. + #[test] + fn get_forkchoice_store_accepts_an_exact_anchor_pair() { + let (state, block) = anchor_pair(); + + let store = get_forkchoice_store( + Arc::new(InMemoryBackend::new()), + state, + block, + &Config::active(), + ); + + assert!(store.is_ok(), "{:?}", store.err()); + } + + /// Relaxing the state-root check must not accept any block at all: the + /// header still names exactly one. + #[test] + fn get_forkchoice_store_rejects_a_block_the_state_does_not_name() { + let (state, _) = anchor_pair(); + let unrelated = block(state.slot(), Root::repeat_byte(9)); + + let store = get_forkchoice_store( + Arc::new(InMemoryBackend::new()), + state, + unrelated, + &Config::active(), + ); + + assert!(store.is_err()); + } + + #[test] + fn the_commitments_subtree_index_is_the_bodys_own_position() { + // The generalized index the fixture states, minus the offset of a tree + // with KZG_COMMITMENTS_INCLUSION_PROOF_DEPTH levels. This only keeps + // the constant and the fixture's generalized index from drifting + // apart; it never touches BeaconBlockBody, so a field added there + // that silently shifts the real position would pass this unchanged. + // The merkle_proof fixtures' end-to-end check, which decodes a real + // body, recomputes its hash_tree_root(), and drives it through + // verify_data_column_sidecar_inclusion_proof, is what catches that. + let depth = preset::KZG_COMMITMENTS_INCLUSION_PROOF_DEPTH as u32; + assert_eq!( + BLOB_KZG_COMMITMENTS_SUBTREE_INDEX + 2u64.pow(depth), + 27, + "the fixture's generalized index for blob_kzg_commitments" + ); + } + + #[test] + fn a_valid_status_becomes_a_validated_verdict() { + let status = PayloadStatusV1 { + status: PayloadStatusEnum::Valid, + latest_valid_hash: Some(ExecutionBlockHash::repeat_byte(1)), + validation_error: None, + }; + assert_eq!(payload_validity(&status), PayloadValidity::Validated); + } + + #[test] + fn the_not_validated_statuses_become_an_optimistic_verdict() { + for status in [PayloadStatusEnum::Syncing, PayloadStatusEnum::Accepted] { + let status = PayloadStatusV1 { + status, + latest_valid_hash: None, + validation_error: None, + }; + assert_eq!(payload_validity(&status), PayloadValidity::Optimistic); + } + } + + #[test] + fn the_invalidated_statuses_carry_their_latest_valid_hash_through() { + for status in [ + PayloadStatusEnum::Invalid, + PayloadStatusEnum::InvalidBlockHash, + ] { + let status = PayloadStatusV1 { + status, + latest_valid_hash: Some(ExecutionBlockHash::repeat_byte(9)), + validation_error: Some("invalid".to_string()), + }; + assert_eq!( + payload_validity(&status), + PayloadValidity::Invalidated { + latest_valid_hash: Some(ExecutionBlockHash::repeat_byte(9)), + } + ); + } + } + + const BLOCK_0: Root = Root::repeat_byte(10); + const CHAIN_A0: Root = Root::repeat_byte(11); + const CHAIN_B0: Root = Root::repeat_byte(20); + const CHAIN_B1: Root = Root::repeat_byte(21); + /// The rejected block: never indexed, parented on `CHAIN_B1`. + const CHAIN_B2: Root = Root::repeat_byte(22); + + /// The fixture's own topology, minus the block that gets rejected: + /// + /// ```text + /// block_0 (el 0xb0) -- a0 (el 0xa0) + /// \- b0 (el 0xc0) -- b1 (el 0xc1) + /// ``` + /// + /// The rejected block, `b2`, is deliberately absent: an `INVALID` verdict + /// arrives before its block is ever indexed. + fn store_with_two_branches() -> Store { + let mut store = empty_store(); + // (beacon root, slot, parent root, execution hash) + let rows = [ + ( + BLOCK_0, + 1u64, + Root::ZERO, + ExecutionBlockHash::repeat_byte(0xb0), + ), + (CHAIN_A0, 2, BLOCK_0, ExecutionBlockHash::repeat_byte(0xa0)), + (CHAIN_B0, 2, BLOCK_0, ExecutionBlockHash::repeat_byte(0xc0)), + (CHAIN_B1, 3, CHAIN_B0, ExecutionBlockHash::repeat_byte(0xc1)), + ]; + for (root, slot, parent, el_hash) in rows { + store.insert_live_chain_entry(slot, root, parent); + store.insert_beacon_el_block_hash(root, slot, el_hash); + } + store + } + + #[test] + fn a_named_latest_valid_hash_condemns_the_child_of_the_last_valid_block() { + let store = store_with_two_branches(); + let index = store.block_index(); + + // b2 is rejected and block_0's payload is the last valid one. Walking + // up b2's own chain, the child of block_0 is b0, so b0 and everything + // under it is condemned. + let condemned = resolve_invalid_block( + &store, + &index, + CHAIN_B2, + CHAIN_B1, + Some(ExecutionBlockHash::repeat_byte(0xb0)), + ); + + assert_eq!(condemned, CHAIN_B0); + } + + #[test] + fn the_walk_stays_on_the_rejected_blocks_own_branch() { + let store = store_with_two_branches(); + let index = store.block_index(); + + // a0 is also a child of block_0, and must never be the answer for a + // block on chain b. + let condemned = resolve_invalid_block( + &store, + &index, + CHAIN_B2, + CHAIN_B1, + Some(ExecutionBlockHash::repeat_byte(0xb0)), + ); + + assert_ne!(condemned, CHAIN_A0); + } + + #[test] + fn a_null_latest_valid_hash_condemns_only_the_block_in_question() { + let store = store_with_two_branches(); + let index = store.block_index(); + + let condemned = resolve_invalid_block(&store, &index, CHAIN_B2, CHAIN_B1, None); + + assert_eq!(condemned, CHAIN_B2); + } + + #[test] + fn an_unfindable_latest_valid_hash_behaves_as_null() { + let store = store_with_two_branches(); + let index = store.block_index(); + + let condemned = resolve_invalid_block( + &store, + &index, + CHAIN_B2, + CHAIN_B1, + Some(ExecutionBlockHash::repeat_byte(0xee)), + ); + + assert_eq!(condemned, CHAIN_B2); + } + + #[test] + fn a_zero_latest_valid_hash_condemns_the_whole_execution_branch() { + let store = store_with_two_branches(); + let index = store.block_index(); + + // Every block on this chain carries a payload, so the deepest indexed + // ancestor is block_0 itself. + let condemned = resolve_invalid_block(&store, &index, CHAIN_B2, CHAIN_B1, Some(Root::ZERO)); + + assert_eq!(condemned, BLOCK_0); + } + + #[test] + fn invalidating_a_subtree_removes_it_and_leaves_its_sibling_branch() { + let mut store = store_with_two_branches(); + store.insert_beacon_optimistic_root(CHAIN_B0, 2); + store.insert_beacon_optimistic_root(CHAIN_B1, 3); + + let removed = invalidate_subtree(&mut store, CHAIN_B0); + + assert_eq!(removed, 2); + + let index = store.block_index(); + // Chain b is gone. + assert!(!index.contains_key(&CHAIN_B0)); + assert!(!index.contains_key(&CHAIN_B1)); + // block_0 and chain a survive. + assert!(index.contains_key(&BLOCK_0)); + assert!(index.contains_key(&CHAIN_A0)); + // And the invalidated roots are no longer merely optimistic. + assert!(!store.is_beacon_optimistic(CHAIN_B0)); + assert!(!store.is_beacon_optimistic(CHAIN_B1)); + } + + #[test] + fn invalidating_a_leaf_removes_only_that_leaf() { + let mut store = store_with_two_branches(); + + let removed = invalidate_subtree(&mut store, CHAIN_B1); + + assert_eq!(removed, 1); + let index = store.block_index(); + assert!(!index.contains_key(&CHAIN_B1)); + assert!(index.contains_key(&CHAIN_B0)); + } + + #[test] + fn invalidating_a_root_the_index_never_held_removes_nothing() { + let mut store = store_with_two_branches(); + + // The `None`/unfindable `latestValidHash` case condemns the rejected + // block itself, which never imported and so has no row. + let removed = invalidate_subtree(&mut store, CHAIN_B2); + + assert_eq!(removed, 0); + assert_eq!(store.block_index().len(), 4); + } + + /// The checkpoint root is the last block at *or before* its epoch + /// boundary, so a skipped boundary slot leaves the finalized block above + /// the slot its own checkpoint is stored as. The slot comparison alone + /// would miss it, which is why the floor names the root too. + #[test] + fn invalidating_the_finalized_block_itself_is_refused() { + // Anchored at b0: the finalized checkpoint's root, in the genesis + // epoch, while its block sits at slot 2. + let mut store = store_anchored_at(CHAIN_B0); + store.insert_live_chain_entry(2, CHAIN_B0, BLOCK_0); + store.insert_live_chain_entry(3, CHAIN_B1, CHAIN_B0); + + let removed = invalidate_subtree(&mut store, CHAIN_B0); + + assert_eq!( + removed, 0, + "obeying this would delete every row from finality upward and \ + leave `get_head` nothing to compute a head from" + ); + let index = store.block_index(); + assert!(index.contains_key(&CHAIN_B0)); + assert!(index.contains_key(&CHAIN_B1)); + + // Not a blanket refusal: an unfinalized descendant still goes. + assert_eq!(invalidate_subtree(&mut store, CHAIN_B1), 1); + } + + /// An all-zero `latestValidHash` condemns every payload on the chain, and + /// [`resolve_invalid_block`]'s walk for it stops only where the execution + /// hash cache does, which finality bounds. So the ancestor it answers can + /// be finalized history even when no block was named directly. + #[test] + fn invalidating_a_block_below_the_finalized_slot_is_refused() { + let mut store = store_with_two_branches(); + let finalized = Checkpoint { + epoch: 1, + root: CHAIN_B1, + }; + update_checkpoints(&mut store, finalized, finalized); + + // Every row in the fixture is below the epoch-1 start slot. + let removed = invalidate_subtree(&mut store, CHAIN_B0); + + assert_eq!(removed, 0); + assert_eq!(store.block_index().len(), 4); + } + + #[test] + fn a_child_of_an_execution_block_is_always_an_optimistic_candidate() { + let store = store_with_two_branches(); + + // block_0 carries a payload, so its child may be imported + // optimistically whatever the clock says. + assert!(is_optimistic_candidate_block( + &store, + /* current_slot */ 2, + /* block_slot */ 2, + /* parent_root */ BLOCK_0, + constants::SAFE_SLOTS_TO_IMPORT_OPTIMISTICALLY, + )); + } + + #[test] + fn a_block_on_a_payloadless_parent_needs_the_age_horizon() { + let store = empty_store(); + let parent = Root::repeat_byte(77); + let safe_slots = constants::SAFE_SLOTS_TO_IMPORT_OPTIMISTICALLY; + + // No cached execution hash for the parent: pre-merge, so only age + // qualifies it. + assert!(!is_optimistic_candidate_block( + &store, 100, 90, parent, safe_slots + )); + assert!(is_optimistic_candidate_block( + &store, 300, 90, parent, safe_slots + )); + } +} diff --git a/crates/blockchain/state_transition/src/beacon/genesis.rs b/crates/blockchain/state_transition/src/beacon/genesis.rs new file mode 100644 index 000000000..c48f444ae --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/genesis.rs @@ -0,0 +1,210 @@ +//! Genesis: building the first `BeaconState` from Eth1 deposit history, and +//! deciding when a candidate built that way is allowed to become the real +//! genesis. +//! +//! Two functions, matching the specification's own split. Building a +//! candidate never fails because the chain is not ready yet: every deposit +//! given to it is replayed exactly as the deposit contract received it, so +//! this can only produce *a* state, not necessarily one old enough or with +//! enough validators to actually start a chain. Whether it may start is +//! [`is_valid_genesis_state`]'s job, checked separately (in practice, against +//! every candidate as new Eth1 blocks arrive) so the same construction logic +//! runs whether or not this particular candidate turns out to be the one that +//! finally crosses the threshold. + +use libssz_types::SszList; + +use crate::beacon::config::Config; +use crate::beacon::constants; +use crate::beacon::containers::shared::{BeaconBlockHeader, Deposit, DepositData, Eth1Data, Fork}; +use crate::beacon::containers::{BeaconState, phase0}; +use crate::beacon::error::Result; +use crate::beacon::helpers::accessors::get_active_validator_indices; +use crate::beacon::preset; +use crate::beacon::primitives::{HashTreeRoot as _, Root, ValidatorIndex}; +use crate::beacon::stf::operations::process_deposit; + +/// The deposit data list [`initialize_beacon_state_from_eth1`] rebuilds one +/// leaf larger at every step while replaying Eth1 deposit history, so that +/// each deposit's merkle proof can be checked against the root as it stood +/// right after the deposit before it, exactly as the deposit contract's own +/// incremental tree would have it. +/// +/// Bounded by two to the power of `DEPOSIT_CONTRACT_TREE_DEPTH`, the deposit +/// contract's own tree capacity: this list can never need to hold more leaves +/// than the tree has room for. Not a preset value: the specification gives +/// this bound as an inline formula on this one list rather than as a named +/// preset entry, so it is defined here rather than in `crate::beacon::preset`. +type GenesisDepositDataList = + SszList; + +/// Builds a candidate genesis state from Eth1 deposit history. +/// +/// `deposits` must be every deposit up to and including `eth1_block_hash`, in +/// the order the deposit contract received them: each one is checked against +/// the merkle root of every deposit before it, so an out-of-order or +/// truncated history fails a later deposit's proof rather than silently +/// producing a different, still internally consistent, state. +/// +/// The result is a candidate only. Call [`is_valid_genesis_state`] on it, with +/// the same configuration, to find out whether the chain may actually start +/// from it. +pub fn initialize_beacon_state_from_eth1( + eth1_block_hash: Root, + eth1_timestamp: u64, + deposits: &[Deposit], + config: &Config, +) -> Result { + let fork = Fork { + previous_version: config.genesis_fork_version, + current_version: config.genesis_fork_version, + epoch: constants::GENESIS_EPOCH, + }; + + let empty_body_root = phase0::BeaconBlockBody { + randao_reveal: Default::default(), + eth1_data: Default::default(), + graffiti: Default::default(), + proposer_slashings: Default::default(), + attester_slashings: Default::default(), + attestations: Default::default(), + deposits: Default::default(), + voluntary_exits: Default::default(), + } + .hash_tree_root(); + + let mut state = BeaconState::Phase0(phase0::BeaconState { + // `eth1_timestamp` comes from outside the chain, so it is treated the + // same as any other externally supplied value: saturating rather than + // wrapping past `u64::MAX`. + genesis_time: eth1_timestamp.saturating_add(config.genesis_delay), + genesis_validators_root: Root::ZERO, + slot: constants::GENESIS_SLOT, + fork, + latest_block_header: BeaconBlockHeader { + body_root: empty_body_root, + ..Default::default() + }, + // The rolling root windows start zeroed: nothing has been processed + // yet for `process_slot` to have filled them with. + block_roots: vec![Root::ZERO; preset::SLOTS_PER_HISTORICAL_ROOT] + .try_into() + .expect("the vector is built at its exact length"), + state_roots: vec![Root::ZERO; preset::SLOTS_PER_HISTORICAL_ROOT] + .try_into() + .expect("the vector is built at its exact length"), + historical_roots: Default::default(), + eth1_data: Eth1Data { + deposit_root: Root::ZERO, + deposit_count: deposits.len() as u64, + block_hash: eth1_block_hash, + }, + eth1_data_votes: Default::default(), + eth1_deposit_index: 0, + validators: Default::default(), + balances: Default::default(), + // Seeded with the Eth1 block hash in every position, not only the + // first. This is deliberate: it is what makes the very first epoch's + // shuffling unpredictable before any validator has produced a randao + // reveal of its own. + randao_mixes: vec![eth1_block_hash; preset::EPOCHS_PER_HISTORICAL_VECTOR] + .try_into() + .expect("the vector is built at its exact length"), + slashings: vec![0; preset::EPOCHS_PER_SLASHINGS_VECTOR] + .try_into() + .expect("the vector is built at its exact length"), + previous_epoch_attestations: Default::default(), + current_epoch_attestations: Default::default(), + justification_bits: Default::default(), + previous_justified_checkpoint: Default::default(), + current_justified_checkpoint: Default::default(), + finalized_checkpoint: Default::default(), + }); + + // Replay deposit history, growing the merkle tree one leaf at a time so + // that `process_deposit` checks each deposit's proof against the root as + // it stood right after the deposit before it, not the final root every + // deposit would otherwise be checked against. + let mut deposit_data_list = GenesisDepositDataList::default(); + for deposit in deposits { + deposit_data_list.push(deposit.data.clone())?; + let deposit_root = deposit_data_list.hash_tree_root(); + state.eth1_data_mut().deposit_root = deposit_root; + process_deposit(&mut state, deposit, config)?; + } + + // Activate every validator whose deposits reached the cap. Genesis is the + // one moment effective balance is set directly from the raw balance + // rather than eased toward it over time: there is no previous effective + // balance yet for hysteresis to protect. + for index in 0..state.validators().len() as ValidatorIndex { + let balance = state.balance(index)?; + let effective_balance = (balance - balance % preset::EFFECTIVE_BALANCE_INCREMENT) + .min(preset::MAX_EFFECTIVE_BALANCE); + + let validator = state.validator_mut(index)?; + validator.effective_balance = effective_balance; + if effective_balance == preset::MAX_EFFECTIVE_BALANCE { + validator.activation_eligibility_epoch = constants::GENESIS_EPOCH; + validator.activation_epoch = constants::GENESIS_EPOCH; + } + } + + // Last, not first: everything above can still change which validators + // exist and what their keys are, and this root is what permanently + // separates this chain, and every signature made on it, from any other + // network that happens to run the same fork schedule. + let genesis_validators_root = state.validators().hash_tree_root(); + *state.genesis_validators_root_mut() = genesis_validators_root; + + Ok(state) +} + +/// Whether a candidate genesis state may actually become the chain's genesis. +/// +/// Checked against every candidate as Eth1 deposit history grows; the first +/// one for which this returns `true` is genesis. Both conditions are about +/// the chain being old enough and big enough to be worth starting, not about +/// the candidate being internally well-formed, which +/// [`initialize_beacon_state_from_eth1`] already guarantees by construction. +pub fn is_valid_genesis_state(state: &BeaconState, config: &Config) -> bool { + if state.genesis_time() < config.min_genesis_time { + return false; + } + let active_validator_count = + get_active_validator_indices(state, constants::GENESIS_EPOCH).len() as u64; + if active_validator_count < config.min_genesis_active_validator_count { + return false; + } + true +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn genesis_state_is_invalid_before_the_configured_genesis_time() { + let config = Config::minimal(); + let mut state = crate::beacon::helpers::test_state::with_validators( + config.min_genesis_active_validator_count as usize, + ); + + *state.genesis_time_mut() = config.min_genesis_time - 1; + assert!(!is_valid_genesis_state(&state, &config)); + + *state.genesis_time_mut() = config.min_genesis_time; + assert!(is_valid_genesis_state(&state, &config)); + } + + #[test] + fn genesis_state_is_invalid_with_too_few_active_validators() { + let config = Config::minimal(); + let mut state = crate::beacon::helpers::test_state::with_validators( + config.min_genesis_active_validator_count as usize - 1, + ); + *state.genesis_time_mut() = config.min_genesis_time; + + assert!(!is_valid_genesis_state(&state, &config)); + } +} diff --git a/crates/blockchain/state_transition/src/beacon/gossip/aggregate.rs b/crates/blockchain/state_transition/src/beacon/gossip/aggregate.rs new file mode 100644 index 000000000..260c7e794 --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/gossip/aggregate.rs @@ -0,0 +1,775 @@ +//! `beacon_aggregate_and_proof` gossip validation: electra's +//! `validate_beacon_aggregate_and_proof_gossip` (`specs/electra/p2p-interface.md`), +//! which fulu (what mainnet runs) inherits unchanged. +//! +//! # Deviations from the specification +//! +//! **Which state.** The specification resolves every committee, signature and +//! ancestry question against `store.block_states[get_head(store).root]`; see +//! [`stateful_checks`] for why this reads the aggregate's own vote block's +//! post-state instead. +//! +//! **"Block passes validation" becomes "post-state is cached".** The +//! specification's `block_root not in store.block_states` REJECT assumes a +//! bad-block cache: a block that failed its own validation is known but has +//! no post-state, and so is a block that is merely still importing or was +//! queued. Telling those apart needs remembering which failed, which this +//! node does not do (see `gossip::block`'s and `gossip::column`'s own copies +//! of this same limitation). So a vote block with no cached post-state is +//! [`IgnoreReason::StateUnavailable`] here rather than a REJECT; the spec +//! vector that depends on that distinction +//! (`reject_block_failed_validation`) is skipped, not made to pass. +//! +//! **Signatures before committees.** The specification checks the committee +//! index range, the committee itself, and the aggregator's membership before +//! either signature. This runs the two pubkey-only checks (the selection +//! proof and the aggregator's signature over the whole envelope) first +//! instead: both need nothing but the vote state's validator registry, while +//! every committee question needs [`store.committee_cache()`](Store::committee_cache) +//! to derive (or, worst case, shuffle) the target epoch's committees. A +//! forged aggregate is caught before it can force that work. The spec's own +//! test format states that independent conditions may run in any order +//! without changing a vector's expected result, and every condition +//! reordered here is independent of the ones it moves past. +//! +//! **Ancestry via the vote state's own history, not a `LiveChain` scan.** +//! [`super::ancestor_at`] answers both the target-checkpoint and the +//! finalized-checkpoint ancestry questions from `block_roots`, in place of +//! the specification's `get_checkpoint_block(store, ...)` (which +//! `gossip::block` and `gossip::column` still use, because they only ever +//! have a *parent* state, whose own history does not yet reach the checkpoint +//! in question). See [`stateful_checks`] for why the vote state can always +//! answer this instead. +//! +//! # Seen state +//! +//! [`SeenAggregates`] is this module's copy of the specification's `Seen` +//! fields `aggregator_epochs` and `aggregate_data_roots`. It is written only +//! by [`SeenAggregates::record`], called once per gossip message, on +//! `Accept`; see that method's own documentation for the race it closes. + +use std::num::NonZeroUsize; + +use lru::LruCache; + +use super::{ + IgnoreReason, Outcome, RejectReason, ancestor_at, is_current_or_previous_epoch, is_future_slot, +}; +use crate::beacon::bls; +use crate::beacon::constants::{ + DOMAIN_AGGREGATE_AND_PROOF, DOMAIN_SELECTION_PROOF, TARGET_AGGREGATORS_PER_COMMITTEE, +}; +use crate::beacon::containers::SignedAggregateAndProof; +use crate::beacon::fork_choice::Store; +use crate::beacon::hash::hash; +use crate::beacon::helpers::accessors::{CommitteeCacheExt, get_domain}; +use crate::beacon::helpers::math::bytes_to_uint64; +use crate::beacon::helpers::misc::{ + compute_epoch_at_slot, compute_signing_root, compute_start_slot_at_epoch, +}; +use crate::beacon::primitives::{ + BlsSignature, CommitteeIndex, Epoch, HashTreeRoot as _, Root, ValidatorIndex, +}; +use ethlambda_storage::CacheKey; + +/// One committee's worth of aggregation bits, packed a `u64` at a time. +/// +/// The specification's `Seen.aggregate_data_roots` stores each accepted +/// bitfield as a `Tuple[bool, ...]`; packing here keeps a whole committee's +/// bits (up to `MAX_VALIDATORS_PER_COMMITTEE`, thousands on mainnet) to a +/// handful of words instead of one heap-allocated `bool` per bit. +#[derive(Clone, PartialEq, Eq)] +struct PackedBits(Vec); + +impl PackedBits { + fn from_bits(bits: &[bool]) -> Self { + let mut words = vec![0u64; bits.len().div_ceil(64)]; + for (index, &bit) in bits.iter().enumerate() { + if bit { + words[index / 64] |= 1 << (index % 64); + } + } + Self(words) + } + + /// The specification's `is_non_strict_superset` (`phase0/p2p-interface.md`): + /// every bit `other` sets is also set here. + /// + /// A `other` longer than `self` is never covered, however much the + /// overlapping words agree: the extra bits are positions `self` has no + /// opinion on. In practice the two are always the same length, since both + /// come from aggregates sharing one `(data_root, committee_index)` key + /// and so the same committee, but nothing here relies on that. + fn is_superset_of(&self, other: &PackedBits) -> bool { + if other.0.len() > self.0.len() { + return false; + } + self.0 + .iter() + .zip(other.0.iter()) + .all(|(mine, theirs)| theirs & !mine == 0) + } +} + +/// Accepted aggregates, keyed the way the specification's `Seen` keys them. +/// +/// Bounded by capacity, like [`super::SeenBlocks`], rather than pruned on +/// finality (reviewer feedback on PR #19: a finality-pruned set lets a peer +/// hold the deduplication window open indefinitely just by not finalizing). +/// The two capacities are independent because the two maps hold different +/// keys at a different natural cardinality: `aggregator_epochs` holds one +/// entry per `(epoch, aggregator)` no matter how many committees or slots +/// that epoch has, while `aggregate_data_roots` holds one entry per +/// `(attestation data, committee)` and, under it, one bitfield per +/// aggregator that reached it before the superset covered them. Sizing both +/// is the caller's job (`ethlambda-p2p` defines the constants); see that +/// crate's own reasoning for the numbers. +pub struct SeenAggregates { + aggregator_epochs: LruCache<(Epoch, ValidatorIndex), ()>, + data_roots: LruCache<(Root, CommitteeIndex), Vec>, +} + +impl SeenAggregates { + pub fn new(aggregators: NonZeroUsize, data_roots: NonZeroUsize) -> Self { + Self { + aggregator_epochs: LruCache::new(aggregators), + data_roots: LruCache::new(data_roots), + } + } + + /// Whether an aggregator is already recorded for `target_epoch`. + /// Read-only, so [`super::cheap_checks`]-style callers can use it without + /// touching recency ([`LruCache::contains`] does not). + fn aggregator_seen(&self, target_epoch: Epoch, aggregator_index: ValidatorIndex) -> bool { + self.aggregator_epochs + .contains(&(target_epoch, aggregator_index)) + } + + /// Whether some already-accepted aggregate for `(data_root, + /// committee_index)` is a non-strict superset of `bits`. Read-only, via + /// [`LruCache::peek`], for the same reason as [`Self::aggregator_seen`]. + fn covered(&self, data_root: Root, committee_index: CommitteeIndex, bits: &[bool]) -> bool { + let candidate = PackedBits::from_bits(bits); + self.data_roots + .peek(&(data_root, committee_index)) + .is_some_and(|seen| seen.iter().any(|prior| prior.is_superset_of(&candidate))) + } + + /// [`cheap_checks`]'s verdict for `aggregate`, given its already-parsed + /// `committee_index`: `Ignore(AlreadySeen)` if this aggregator already has + /// an accepted aggregate for the target epoch, `Ignore(CoveredBits)` if + /// an accepted aggregate already covers every bit it sets. + fn verdict( + &self, + aggregate: &SignedAggregateAndProof, + committee_index: CommitteeIndex, + ) -> Result<(), Outcome> { + let data = aggregate.data(); + let bits = aggregate.aggregation_bits(); + // Checked in the specification's own order: the superset rule first, + // then the aggregator/epoch rule. + if self.covered(data.hash_tree_root(), committee_index, &bits) { + return Err(Outcome::Ignore(IgnoreReason::CoveredBits)); + } + if self.aggregator_seen(data.target.epoch, aggregate.aggregator_index()) { + return Err(Outcome::Ignore(IgnoreReason::AlreadySeen)); + } + Ok(()) + } + + /// Record an accepted aggregate. Returns `false`, recording nothing, when + /// its aggregator is already recorded for the target epoch or an accepted + /// aggregate for the same data and committee already covers its bits. + /// + /// That second case is what keeps a race honest: two validation tasks for + /// aggregates that overlap can both pass [`cheap_checks`]' read of this + /// same state before either one's `Accept` reaches `settle`. Whichever + /// settles first calls this and wins; this method re-runs the exact same + /// verdict the settling caller is about to publish, so the second racer's + /// `record` (called from its own, now-stale `Accept`) finds itself + /// already covered and reports `false`. The caller then turns that + /// `Accept` into `Ignore(AlreadySeen)` rather than double-recording (or, + /// worse, propagating two aggregates gossipsub only meant to accept one + /// of). + pub fn record(&mut self, aggregate: &SignedAggregateAndProof) -> bool { + let Some(committee_index) = aggregate.committee_index() else { + return false; + }; + if self.verdict(aggregate, committee_index).is_err() { + return false; + } + + let data = aggregate.data(); + let bits = aggregate.aggregation_bits(); + self.aggregator_epochs + .put((data.target.epoch, aggregate.aggregator_index()), ()); + let packed = PackedBits::from_bits(&bits); + match self + .data_roots + .get_mut(&(data.hash_tree_root(), committee_index)) + { + Some(existing) => existing.push(packed), + None => { + self.data_roots + .put((data.hash_tree_root(), committee_index), vec![packed]); + } + } + true + } +} + +/// The specification's `is_aggregator` (`validator.md`), taking the +/// committee's length rather than a whole committee cache and state: by the +/// time [`stateful_checks`] calls this, it has already paid for the +/// committee slice this needs, so this cannot trigger a second, uncached +/// derivation the way `crate::beacon::aggregate::is_aggregator` (which this +/// replaces) could when handed a fresh cache. +/// +/// The modulo is floored at one, which is what makes every member of a +/// committee smaller than [`TARGET_AGGREGATORS_PER_COMMITTEE`] an aggregator +/// rather than dividing by zero. +fn is_aggregator(committee_len: usize, slot_signature: &BlsSignature) -> bool { + let modulo = (committee_len as u64 / TARGET_AGGREGATORS_PER_COMMITTEE).max(1); + let digest = hash(slot_signature.as_ref()); + bytes_to_uint64(&digest.0[0..8]).is_multiple_of(modulo) +} + +/// `hash_tree_root(aggregate_and_proof)`, the object the aggregator's own +/// (non-selection-proof) signature is over. +fn aggregate_and_proof_root(aggregate: &SignedAggregateAndProof) -> Root { + match aggregate { + SignedAggregateAndProof::Phase0(signed) => signed.message.hash_tree_root(), + SignedAggregateAndProof::Electra(signed) => signed.message.hash_tree_root(), + } +} + +/// The conditions that read only the message, the clock and the seen caches. +/// +/// `Err` carries the verdict; `Ok` sends the aggregate on to +/// [`stateful_checks`]. +pub fn cheap_checks( + seen: &SeenAggregates, + store: &Store, + aggregate: &SignedAggregateAndProof, + now_ms: u64, +) -> Result<(), Outcome> { + let config = store.config(); + let data = aggregate.data(); + + // [New in Electra:EIP7549] [REJECT] `data.index` is zero: the committee + // now lives in `committee_bits` instead. Phase0 has no `committee_bits` + // and carries the real committee in `data.index`, so this only applies + // to the electra shape. + if matches!(aggregate, SignedAggregateAndProof::Electra(_)) && data.index != 0 { + return Err(Outcome::Reject(RejectReason::NonZeroDataIndex)); + } + // [New in Electra:EIP7549] [REJECT] Exactly one committee is named. + // Always `Some` for phase0, whose `data.index` alone names the committee. + let Some(committee_index) = aggregate.committee_index() else { + return Err(Outcome::Reject(RejectReason::CommitteeBits)); + }; + + // [Modified in Electra:EIP7549] [IGNORE] Not a covered superset, and + // [IGNORE] first for this epoch and aggregator. + seen.verdict(aggregate, committee_index)?; + + // [IGNORE] Not from a future slot. + if is_future_slot(&config, data.slot, now_ms) { + return Err(Outcome::Ignore(IgnoreReason::FutureSlot)); + } + // [IGNORE] The current or the previous epoch. + let attestation_epoch = compute_epoch_at_slot(data.slot); + if !is_current_or_previous_epoch(&config, attestation_epoch, now_ms) { + return Err(Outcome::Ignore(IgnoreReason::OutsideEpochWindow)); + } + // [REJECT] The epoch matches its target. + if data.target.epoch != attestation_epoch { + return Err(Outcome::Reject(RejectReason::EpochMismatch)); + } + // [REJECT] Has participants. Needs only the raw bitfield: electra's + // `aggregation_bits` spans exactly the one named committee when + // `committee_bits` names only one, so a set bit here is a real attester + // regardless of which committee it turns out to be. + if aggregate.attester_count() == 0 { + return Err(Outcome::Reject(RejectReason::NoParticipants)); + } + Ok(()) +} + +/// The conditions that need the voted block's state. Runs on a blocking +/// thread. `Ok` is `Accept`, carrying the attesting indices whose aggregate +/// signature verified: they travel to the chain actor, which applies them to +/// fork choice without ever deriving them again. +/// +/// # Which state +/// +/// This resolves every committee, signature and ancestry question against +/// the vote block's own cached post-state +/// (`store.cached_state(CacheKey::BlockState(data.beacon_block_root))`), +/// rather than the specification's head state. Three reasons converge here: +/// +/// - It is the attested chain's own state, so its shuffling is the one the +/// attesters were actually assigned. The head may sit on a branch this +/// aggregate does not vote for at all, in which case the specification's +/// own choice would check against the wrong committee. +/// - It is an `O(1)` cache read, unlike the head state, which the +/// specification re-reads (and this node would have to look up) fresh per +/// message. +/// - Its own `block_roots` answers both ancestry questions +/// ([`super::ancestor_at`]) without a `Store::block_index` / `LiveChain` +/// scan, because a block's post-state always has history back through its +/// own ancestors. +pub fn stateful_checks( + store: &Store, + aggregate: &SignedAggregateAndProof, +) -> Result, Outcome> { + let data = aggregate.data(); + let beacon_block_root = data.beacon_block_root; + + // [IGNORE] The block being voted for has been seen. + if !store.has_block(&beacon_block_root) { + return Err(Outcome::Ignore(IgnoreReason::UnknownBlock)); + } + // See this function's own documentation for why this state, and the + // module documentation for why an uncached state is `IGNORE` rather than + // the specification's `REJECT`. + let Some(state) = store.cached_state(CacheKey::BlockState(beacon_block_root)) else { + return Err(Outcome::Ignore(IgnoreReason::StateUnavailable)); + }; + + let target_epoch = data.target.epoch; + + // Pubkey-only signatures, before any committee derivation; see the + // module documentation for why this order. + let aggregator_index = aggregate.aggregator_index(); + let Ok(aggregator) = state.validator(aggregator_index) else { + return Err(Outcome::Reject(RejectReason::UnknownValidator)); + }; + // [REJECT] The selection proof selects the validator as an aggregator. + let selection_proof = aggregate.selection_proof(); + let selection_domain = get_domain(&state, DOMAIN_SELECTION_PROOF, Some(target_epoch)); + let selection_signing_root = compute_signing_root(data.slot.hash_tree_root(), selection_domain); + if !bls::verify(&aggregator.pubkey, selection_signing_root, &selection_proof) { + return Err(Outcome::Reject(RejectReason::SelectionProof)); + } + // [REJECT] The aggregator's own signature, over the whole envelope. + let aggregator_domain = get_domain(&state, DOMAIN_AGGREGATE_AND_PROOF, Some(target_epoch)); + let aggregator_signing_root = + compute_signing_root(aggregate_and_proof_root(aggregate), aggregator_domain); + if !bls::verify( + &aggregator.pubkey, + aggregator_signing_root, + &aggregate.signature(), + ) { + return Err(Outcome::Reject(RejectReason::AggregatorSignature)); + } + + // Committees for the target epoch, through the shared cache. + let committees = store.committee_cache(); + let epoch_committees = committees.committees(&state, target_epoch); + // `cheap_checks` already rejected `None`. + let committee_index = aggregate + .committee_index() + .expect("cheap_checks rejects a message with no single named committee"); + // [REJECT] The committee index is within range. + if committee_index >= epoch_committees.committees_per_slot() { + return Err(Outcome::Reject(RejectReason::CommitteeIndex)); + } + let Ok(committee) = epoch_committees.committee(data.slot, committee_index) else { + // `committee_index` and `data.slot`'s epoch were both just checked + // against this same `epoch_committees`, so this cannot fail; treated + // as internal rather than unreachable so a future change here fails + // safe instead of panicking. + return Err(Outcome::Ignore(IgnoreReason::Internal)); + }; + // [REJECT] The aggregation bits match the committee's size. + let aggregation_bits = aggregate.aggregation_bits(); + if aggregation_bits.len() != committee.len() { + return Err(Outcome::Reject(RejectReason::BitsLength)); + } + // [REJECT] The selection proof selects this validator as an aggregator. + if !is_aggregator(committee.len(), &selection_proof) { + return Err(Outcome::Reject(RejectReason::NotAggregator)); + } + // [REJECT] The aggregator is a member of the committee. + if !committee.contains(&aggregator_index) { + return Err(Outcome::Reject(RejectReason::NotInCommittee)); + } + + // [REJECT] The aggregate's own signature is valid. Built from the same + // (cached) committees, so this costs no further shuffle. + let attesting_indices = match aggregate { + SignedAggregateAndProof::Phase0(signed) => { + let phase0_attestation = &signed.message.aggregate; + let indexed = crate::beacon::helpers::attestation::get_indexed_attestation( + &state, + phase0_attestation, + &committees, + ) + .map_err(|_| Outcome::Ignore(IgnoreReason::Internal))?; + if !crate::beacon::helpers::attestation::is_valid_indexed_attestation(&state, &indexed) + { + return Err(Outcome::Reject(RejectReason::AggregateSignature)); + } + indexed.attesting_indices.to_vec() + } + SignedAggregateAndProof::Electra(signed) => { + let electra_attestation = &signed.message.aggregate; + let indexed = crate::beacon::helpers::electra::get_indexed_attestation( + &state, + electra_attestation, + &committees, + ) + .map_err(|_| Outcome::Ignore(IgnoreReason::Internal))?; + if !crate::beacon::helpers::electra::is_valid_indexed_attestation(&state, &indexed) { + return Err(Outcome::Reject(RejectReason::AggregateSignature)); + } + indexed.attesting_indices.to_vec() + } + }; + + // Ancestry, via the vote state's own history; see this function's + // documentation for why this state can always answer both questions. + let (target_checkpoint_epoch, target_root) = aggregate.target(); + let target_start_slot = compute_start_slot_at_epoch(target_checkpoint_epoch); + // [REJECT] The target is the vote block's ancestor at the target epoch. + let Some(checkpoint_block) = ancestor_at(&state, beacon_block_root, target_start_slot) else { + return Err(Outcome::Ignore(IgnoreReason::AncestryUnknown)); + }; + if checkpoint_block != target_root { + return Err(Outcome::Reject(RejectReason::TargetNotAncestor)); + } + // [IGNORE] The finalized checkpoint is an ancestor of the vote block. + let finalized = store.beacon_finalized_checkpoint(); + let finalized_start_slot = compute_start_slot_at_epoch(finalized.epoch); + let Some(finalized_block) = ancestor_at(&state, beacon_block_root, finalized_start_slot) else { + return Err(Outcome::Ignore(IgnoreReason::AncestryUnknown)); + }; + if finalized_block != finalized.root { + return Err(Outcome::Ignore(IgnoreReason::FinalizedNotAncestor)); + } + + Ok(attesting_indices) +} + +/// `cheap_checks` then `stateful_checks`, the order the p2p actor runs them +/// in. For callers that have no reason to split them, such as the spec +/// vectors. +pub fn validate( + seen: &SeenAggregates, + store: &Store, + aggregate: &SignedAggregateAndProof, + now_ms: u64, +) -> Result, Outcome> { + cheap_checks(seen, store, aggregate, now_ms)?; + stateful_checks(store, aggregate) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::beacon::containers::electra; + use crate::beacon::gossip::test_support::{seen_aggregates, slot_start_ms, store}; + use crate::beacon::primitives::Slot; + + fn capacity(n: usize) -> NonZeroUsize { + NonZeroUsize::new(n).expect("non-zero") + } + + fn aggregate_with( + target_epoch: Epoch, + aggregator_index: ValidatorIndex, + committee_index: CommitteeIndex, + bits: &[bool], + ) -> SignedAggregateAndProof { + let aggregation_bits: electra::AggregationBits = bits + .to_vec() + .try_into() + .expect("within the committee bound"); + let mut committee_bits = electra::CommitteeBits::default(); + committee_bits + .set(committee_index as usize, true) + .expect("within MAX_COMMITTEES_PER_SLOT"); + SignedAggregateAndProof::Electra(electra::SignedAggregateAndProof { + message: electra::AggregateAndProof { + aggregator_index, + aggregate: electra::Attestation { + aggregation_bits, + data: shared_data(target_epoch), + signature: Default::default(), + committee_bits, + }, + selection_proof: Default::default(), + }, + signature: Default::default(), + }) + } + + fn shared_data(target_epoch: Epoch) -> crate::beacon::containers::shared::AttestationData { + crate::beacon::containers::shared::AttestationData { + slot: compute_start_slot_at_epoch(target_epoch), + index: 0, + beacon_block_root: Root::repeat_byte(1), + source: Default::default(), + target: crate::beacon::containers::shared::Checkpoint { + epoch: target_epoch, + root: Root::repeat_byte(2), + }, + } + } + + // -- PackedBits / superset semantics -------------------------------- + + #[test] + fn a_superset_covers_what_it_contains() { + let seen = PackedBits::from_bits(&[true, true, true]); + assert!(seen.is_superset_of(&PackedBits::from_bits(&[true, false, true]))); + assert!(seen.is_superset_of(&PackedBits::from_bits(&[true, true, true]))); + } + + #[test] + fn a_candidate_with_a_new_bit_is_not_covered() { + let seen = PackedBits::from_bits(&[true, false, true]); + assert!(!seen.is_superset_of(&PackedBits::from_bits(&[true, true, false]))); + } + + #[test] + fn a_longer_candidate_is_never_covered() { + let seen = PackedBits::from_bits(&[true]); + assert!(!seen.is_superset_of(&PackedBits::from_bits(&[true, true]))); + } + + #[test] + fn packing_survives_more_than_one_word() { + // 65 bits spans two `u64` words; the high bit must not be lost. + let mut bits = vec![false; 65]; + bits[64] = true; + let packed = PackedBits::from_bits(&bits); + assert!(packed.is_superset_of(&PackedBits::from_bits(&bits))); + + let mut missing_high_bit = bits.clone(); + missing_high_bit[64] = false; + let short = PackedBits::from_bits(&missing_high_bit); + assert!(short.is_superset_of(&PackedBits::from_bits(&missing_high_bit))); + assert!(!short.is_superset_of(&packed)); + } + + // -- SeenAggregates --------------------------------------------------- + + #[test] + fn the_first_aggregate_for_an_epoch_and_aggregator_is_recorded_once() { + let mut seen = seen_aggregates(); + let first = aggregate_with(3, 7, 0, &[true, false]); + assert!(seen.record(&first)); + // A second aggregate from the same aggregator and epoch, even for + // different data, is already seen. + let second = aggregate_with(3, 7, 0, &[false, true]); + assert!(!seen.record(&second)); + } + + #[test] + fn a_covered_bitfield_is_not_recorded_again() { + let mut seen = seen_aggregates(); + let wide = aggregate_with(3, 1, 0, &[true, true, false]); + assert!(seen.record(&wide)); + // A different aggregator, but every bit it sets is already covered. + let narrow = aggregate_with(3, 2, 0, &[true, false, false]); + assert!(!seen.record(&narrow)); + assert!(seen.covered(shared_data(3).hash_tree_root(), 0, &[true, false, false])); + } + + #[test] + fn a_bit_outside_the_covered_set_is_still_recorded() { + let mut seen = seen_aggregates(); + let first = aggregate_with(3, 1, 0, &[true, false, false]); + assert!(seen.record(&first)); + // Adds a new bit no prior aggregate covered, so it is not dropped. + let second = aggregate_with(3, 2, 0, &[false, true, false]); + assert!(seen.record(&second)); + } + + #[test] + fn the_aggregator_cache_forgets_its_oldest_entry_past_capacity() { + // Distinct, non-overlapping bit patterns throughout: two aggregates + // covered by the same or an overlapping bitfield are dropped by the + // superset rule regardless of the aggregator cache, which is not + // what this test means to exercise. See `a_covered_bitfield_is_not_recorded_again` + // for that rule on its own. + let mut seen = SeenAggregates::new(capacity(2), capacity(8)); + seen.record(&aggregate_with(1, 1, 0, &[true, false, false, false])); + seen.record(&aggregate_with(1, 2, 0, &[false, true, false, false])); + seen.record(&aggregate_with(1, 3, 0, &[false, false, true, false])); + // The first aggregator's entry was the oldest, so it was evicted + // first; the two more recent ones are still recorded. + assert!(!seen.aggregator_seen(1, 1)); + assert!(seen.aggregator_seen(1, 2)); + assert!(seen.aggregator_seen(1, 3)); + // A new bitfield from the now-evicted aggregator: its epoch slot is + // free again. + assert!(seen.record(&aggregate_with(1, 1, 0, &[false, false, false, true]))); + } + + // -- is_aggregator ----------------------------------------------------- + + #[test] + fn every_member_is_an_aggregator_below_the_target_committee_size() { + // A committee too small to divide by `TARGET_AGGREGATORS_PER_COMMITTEE` + // floors the modulo at one, so every signature selects its signer. + let small_committee = (TARGET_AGGREGATORS_PER_COMMITTEE as usize) - 1; + for byte in 0..8u8 { + let signature = BlsSignature::default(); + let _ = byte; // exercise a few, deterministic, signature bytes + assert!(is_aggregator(small_committee, &signature)); + } + } + + #[test] + fn a_larger_committee_does_not_select_every_signature() { + let large_committee = (TARGET_AGGREGATORS_PER_COMMITTEE as usize) * 64; + let mut selected = 0; + for byte in 0u8..=255 { + let mut signature = BlsSignature::default(); + signature.0[0] = byte; + if is_aggregator(large_committee, &signature) { + selected += 1; + } + } + assert!( + selected < 255, + "every signature selected its signer, which the modulo should rule out" + ); + } + + // -- cheap_checks -------------------------------------------------------- + + #[test] + fn electra_rejects_a_nonzero_data_index() { + let store = store(0); + let seen = seen_aggregates(); + let mut aggregate = aggregate_with(0, 1, 0, &[true]); + if let SignedAggregateAndProof::Electra(signed) = &mut aggregate { + signed.message.aggregate.data.index = 1; + } + let now = slot_start_ms(&store, 0); + assert_eq!( + cheap_checks(&seen, &store, &aggregate, now), + Err(Outcome::Reject(RejectReason::NonZeroDataIndex)) + ); + } + + #[test] + fn zero_named_committees_are_rejected() { + let store = store(0); + let seen = seen_aggregates(); + let mut aggregate = aggregate_with(0, 1, 0, &[true]); + if let SignedAggregateAndProof::Electra(signed) = &mut aggregate { + signed.message.aggregate.committee_bits = Default::default(); + } + let now = slot_start_ms(&store, 0); + assert_eq!( + cheap_checks(&seen, &store, &aggregate, now), + Err(Outcome::Reject(RejectReason::CommitteeBits)) + ); + } + + #[test] + fn a_future_slot_is_ignored() { + let store = store(0); + let seen = seen_aggregates(); + let target_epoch = 5; + let aggregate = aggregate_with(target_epoch, 1, 0, &[true]); + let slot = compute_start_slot_at_epoch(target_epoch); + let too_early = slot_start_ms(&store, slot) + - (crate::beacon::constants::MAXIMUM_GOSSIP_CLOCK_DISPARITY + 100); + assert_eq!( + cheap_checks(&seen, &store, &aggregate, too_early), + Err(Outcome::Ignore(IgnoreReason::FutureSlot)) + ); + } + + #[test] + fn an_epoch_far_from_current_is_ignored() { + let store = store(0); + let seen = seen_aggregates(); + // The aggregate names a stale epoch; its own slot is safely in the + // past (not a future slot), but the clock has since moved many + // epochs ahead, so the epoch itself is no longer current or previous. + let stale_epoch = 0; + let aggregate = aggregate_with(stale_epoch, 1, 0, &[true]); + let now_epoch = 10; + let now = slot_start_ms(&store, compute_start_slot_at_epoch(now_epoch)); + assert_eq!( + cheap_checks(&seen, &store, &aggregate, now), + Err(Outcome::Ignore(IgnoreReason::OutsideEpochWindow)) + ); + } + + #[test] + fn an_epoch_mismatched_with_the_slot_is_rejected() { + let store = store(0); + let seen = seen_aggregates(); + let mut aggregate = aggregate_with(0, 1, 0, &[true]); + // `target.epoch` (0) no longer matches `slot`'s own epoch once the + // slot moves into epoch 1. + if let SignedAggregateAndProof::Electra(signed) = &mut aggregate { + signed.message.aggregate.data.slot = preset_slots_per_epoch(); + } + let now = slot_start_ms(&store, preset_slots_per_epoch()); + assert_eq!( + cheap_checks(&seen, &store, &aggregate, now), + Err(Outcome::Reject(RejectReason::EpochMismatch)) + ); + } + + #[test] + fn an_aggregate_with_no_participants_is_rejected() { + let store = store(0); + let seen = seen_aggregates(); + let aggregate = aggregate_with(0, 1, 0, &[false, false]); + let now = slot_start_ms(&store, 0); + assert_eq!( + cheap_checks(&seen, &store, &aggregate, now), + Err(Outcome::Reject(RejectReason::NoParticipants)) + ); + } + + #[test] + fn a_vote_for_an_unseen_block_is_ignored() { + let store = store(0); + let aggregate = aggregate_with(0, 1, 0, &[true]); + assert_eq!( + stateful_checks(&store, &aggregate), + Err(Outcome::Ignore(IgnoreReason::UnknownBlock)) + ); + } + + #[test] + fn a_known_block_with_no_cached_state_is_ignored_not_rejected() { + let mut store = store(0); + let block_root = Root::repeat_byte(1); + store + .insert_pending_block( + block_root, + crate::beacon::containers::SignedBeaconBlock::Fulu(electra::SignedBeaconBlock { + message: electra::BeaconBlock { + slot: 0, + proposer_index: 0, + parent_root: Root::ZERO, + state_root: Root::ZERO, + body: electra::BeaconBlockBody::empty(), + }, + signature: Default::default(), + }), + ) + .expect("insert pending block"); + let aggregate = aggregate_with(0, 1, 0, &[true]); + assert_eq!( + stateful_checks(&store, &aggregate), + Err(Outcome::Ignore(IgnoreReason::StateUnavailable)) + ); + } + + fn preset_slots_per_epoch() -> Slot { + crate::beacon::preset::SLOTS_PER_EPOCH + } +} diff --git a/crates/blockchain/state_transition/src/beacon/gossip/attestation.rs b/crates/blockchain/state_transition/src/beacon/gossip/attestation.rs new file mode 100644 index 000000000..4db9cd8bc --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/gossip/attestation.rs @@ -0,0 +1,419 @@ +//! `beacon_attestation_{subnet_id}` gossip validation: electra's modified +//! `validate_beacon_attestation_gossip` (`specs/electra/p2p-interface.md`), +//! which fulu (what mainnet runs) inherits unchanged. +//! +//! EIP-7549 replaced the wire type for this topic with [`SingleAttestation`]: +//! one attester's vote, with its committee named explicitly +//! (`committee_index`) rather than inferred from the bit position in a +//! committee-scoped bitfield. This module, like the specification's own +//! modified function, only ever sees that shape; a pre-electra, +//! phase0-shaped attestation on this topic is not this module's problem; see +//! the design spec's split for where that answers `Ignore(NoConsumer)` +//! instead. +//! +//! # Deviations from the specification +//! +//! Both are shared with [`super::aggregate`], which explains them in more +//! depth: +//! +//! - **"Block passes validation" becomes "post-state is cached"**: an +//! uncached vote-block state is [`IgnoreReason::StateUnavailable`], not +//! the specification's `REJECT`. The vector this cannot satisfy +//! (`reject_block_failed_validation`) is skipped, not forced to pass. +//! - **The attester's signature is checked before any committee +//! derivation.** The specification checks committee membership first; +//! this checks the pubkey-only signature first instead, so a forged +//! attestation cannot force [`store.committee_cache()`](crate::beacon::fork_choice::Store::committee_cache) +//! to derive a shuffling it did not need to. +//! - **Ancestry through the vote state's own `block_roots`** +//! ([`super::ancestor_at`]), not a `Store::block_index` scan: the vote +//! state's own history already reaches back to both checkpoints in +//! question. +//! +//! # Seen state +//! +//! [`SeenAttestations`] is this module's copy of the specification's +//! `Seen.attestation_validator_epochs`: bounded by capacity like every other +//! seen cache in this crate (see [`super::SeenBlocks`]), rather than pruned +//! on finality. + +use std::num::NonZeroUsize; + +use lru::LruCache; + +use super::{ + IgnoreReason, Outcome, RejectReason, ancestor_at, is_current_or_previous_epoch, is_future_slot, +}; +use crate::beacon::bls; +use crate::beacon::config::Config; +use crate::beacon::constants::DOMAIN_BEACON_ATTESTER; +use crate::beacon::containers::electra::SingleAttestation; +use crate::beacon::fork_choice::Store; +use crate::beacon::helpers::accessors::CommitteeCacheExt; +use crate::beacon::helpers::accessors::get_domain; +use crate::beacon::helpers::misc::{ + compute_epoch_at_slot, compute_signing_root, compute_start_slot_at_epoch, +}; +use crate::beacon::preset; +use crate::beacon::primitives::{CommitteeIndex, Epoch, HashTreeRoot as _, Slot, ValidatorIndex}; +use ethlambda_storage::CacheKey; + +/// Accepted subnet attestations by `(target_epoch, attester_index)`. +/// +/// Bounded by capacity, like [`super::SeenBlocks`], rather than pruned on +/// finality: the capacity is the caller's (`ethlambda-p2p` defines the +/// constant, next to its other seen-cache sizes). +pub struct SeenAttestations(LruCache<(Epoch, ValidatorIndex), ()>); + +impl SeenAttestations { + pub fn new(capacity: NonZeroUsize) -> Self { + Self(LruCache::new(capacity)) + } + + /// Read-only ([`LruCache::contains`] does not touch recency), so + /// [`cheap_checks`] can use it without mutating anything on a message + /// that turns out invalid. + fn contains(&self, target_epoch: Epoch, attester_index: ValidatorIndex) -> bool { + self.0.contains(&(target_epoch, attester_index)) + } + + /// Record an accepted attestation. Returns `false`, recording nothing, + /// when its attester is already recorded for the target epoch: the same + /// race [`super::aggregate::SeenAggregates::record`] documents can land + /// two racing `Accept`s here too, and this settles the second as + /// `Ignore(AlreadySeen)` rather than double-recording. + pub fn record(&mut self, attestation: &SingleAttestation) -> bool { + let key = (attestation.data.target.epoch, attestation.attester_index); + if self.0.contains(&key) { + return false; + } + self.0.put(key, ()); + true + } +} + +/// `validator.md`'s `compute_subnet_for_attestation`, kept here rather than +/// imported from `ethlambda-p2p` (which has its own copy, used for a +/// validator's own gossip publication): the dependency between the two +/// crates only runs one way, `ethlambda-p2p` depends on +/// `ethlambda-state-transition`, so this side cannot reach across to reuse +/// it. +/// +/// `committees_per_slot` is the caller's, since it is a function of the +/// state at the attestation's epoch and this function holds no state. +/// +/// Public for the Beacon API, which computes the subnet of each attestation a +/// validator client submits before publishing it. +pub fn compute_subnet_for_attestation( + committees_per_slot: u64, + slot: Slot, + committee_index: CommitteeIndex, + config: &Config, +) -> u64 { + let slots_since_epoch_start = slot % preset::SLOTS_PER_EPOCH; + let committees_since_epoch_start = committees_per_slot.saturating_mul(slots_since_epoch_start); + committees_since_epoch_start.saturating_add(committee_index) % config.attestation_subnet_count +} + +/// The conditions that read only the message, the clock and the seen cache. +/// +/// `Err` carries the verdict; `Ok` sends the attestation on to +/// [`stateful_checks`]. +pub fn cheap_checks( + seen: &SeenAttestations, + store: &Store, + attestation: &SingleAttestation, + now_ms: u64, +) -> Result<(), Outcome> { + let config = store.config(); + let data = &attestation.data; + let target_epoch = data.target.epoch; + + // [Modified in Electra:EIP7549] [IGNORE] No other valid attestation seen + // for this target epoch and validator. + if seen.contains(target_epoch, attestation.attester_index) { + return Err(Outcome::Ignore(IgnoreReason::AlreadySeen)); + } + // [New in Electra:EIP7549] [REJECT] `data.index` is zero: the committee + // now travels in `committee_index` instead. + if data.index != 0 { + return Err(Outcome::Reject(RejectReason::NonZeroDataIndex)); + } + // [IGNORE] Not from a future slot. + if is_future_slot(&config, data.slot, now_ms) { + return Err(Outcome::Ignore(IgnoreReason::FutureSlot)); + } + // [IGNORE] The current or the previous epoch. + let attestation_epoch = compute_epoch_at_slot(data.slot); + if !is_current_or_previous_epoch(&config, attestation_epoch, now_ms) { + return Err(Outcome::Ignore(IgnoreReason::OutsideEpochWindow)); + } + // [REJECT] The epoch matches its target. + if target_epoch != attestation_epoch { + return Err(Outcome::Reject(RejectReason::EpochMismatch)); + } + Ok(()) +} + +/// The conditions that need the voted block's state. Runs on a blocking +/// thread. See [`super::aggregate::stateful_checks`] for why this reads the +/// vote block's own cached post-state rather than the head's, and why the +/// pubkey-only signature check runs before any committee derivation. +pub fn stateful_checks(store: &Store, attestation: &SingleAttestation, subnet_id: u64) -> Outcome { + let data = &attestation.data; + let beacon_block_root = data.beacon_block_root; + + // [IGNORE] The block being voted for has been seen. + if !store.has_block(&beacon_block_root) { + return Outcome::Ignore(IgnoreReason::UnknownBlock); + } + let Some(state) = store.cached_state(CacheKey::BlockState(beacon_block_root)) else { + return Outcome::Ignore(IgnoreReason::StateUnavailable); + }; + + let target_epoch = data.target.epoch; + + // The pubkey-only signature, before any committee derivation. + let Ok(attester) = state.validator(attestation.attester_index) else { + return Outcome::Reject(RejectReason::UnknownValidator); + }; + let domain = get_domain(&state, DOMAIN_BEACON_ATTESTER, Some(target_epoch)); + let signing_root = compute_signing_root(data.hash_tree_root(), domain); + if !bls::verify(&attester.pubkey, signing_root, &attestation.signature) { + return Outcome::Reject(RejectReason::BadSignature); + } + + // Committees for the target epoch, through the shared cache. + let committees = store.committee_cache(); + let epoch_committees = committees.committees(&state, target_epoch); + // [REJECT] The committee index is within range. + if attestation.committee_index >= epoch_committees.committees_per_slot() { + return Outcome::Reject(RejectReason::CommitteeIndex); + } + // [New in Electra:EIP7549] [REJECT] The correct subnet. + let config = store.config(); + let expected_subnet = compute_subnet_for_attestation( + epoch_committees.committees_per_slot(), + data.slot, + attestation.committee_index, + &config, + ); + if expected_subnet != subnet_id { + return Outcome::Reject(RejectReason::WrongSubnet); + } + let Ok(committee) = epoch_committees.committee(data.slot, attestation.committee_index) else { + // `committee_index` and `data.slot`'s epoch were both just checked + // against this same `epoch_committees`, so this cannot fail. + return Outcome::Ignore(IgnoreReason::Internal); + }; + // [New in Electra:EIP7549] [REJECT] The attester is a member of the + // named committee. + if !committee.contains(&attestation.attester_index) { + return Outcome::Reject(RejectReason::NotInCommittee); + } + + // Ancestry, via the vote state's own history. + let target_start_slot = compute_start_slot_at_epoch(target_epoch); + // [REJECT] The target is the vote block's ancestor at the target epoch. + let Some(checkpoint_block) = ancestor_at(&state, beacon_block_root, target_start_slot) else { + return Outcome::Ignore(IgnoreReason::AncestryUnknown); + }; + if checkpoint_block != data.target.root { + return Outcome::Reject(RejectReason::TargetNotAncestor); + } + // [IGNORE] The finalized checkpoint is an ancestor of the vote block. + let finalized = store.beacon_finalized_checkpoint(); + let finalized_start_slot = compute_start_slot_at_epoch(finalized.epoch); + let Some(finalized_block) = ancestor_at(&state, beacon_block_root, finalized_start_slot) else { + return Outcome::Ignore(IgnoreReason::AncestryUnknown); + }; + if finalized_block != finalized.root { + return Outcome::Ignore(IgnoreReason::FinalizedNotAncestor); + } + + Outcome::Accept +} + +/// `cheap_checks` then `stateful_checks`, for callers with no reason to +/// split them, such as the spec vectors. +pub fn validate( + seen: &SeenAttestations, + store: &Store, + attestation: &SingleAttestation, + subnet_id: u64, + now_ms: u64, +) -> Outcome { + if let Err(outcome) = cheap_checks(seen, store, attestation, now_ms) { + return outcome; + } + stateful_checks(store, attestation, subnet_id) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::beacon::gossip::test_support::{seen_attestations, slot_start_ms, store}; + use crate::beacon::primitives::Root; + + fn capacity(n: usize) -> NonZeroUsize { + NonZeroUsize::new(n).expect("non-zero") + } + + fn attestation_at( + target_epoch: Epoch, + attester_index: ValidatorIndex, + committee_index: CommitteeIndex, + ) -> SingleAttestation { + SingleAttestation { + committee_index, + attester_index, + data: crate::beacon::containers::shared::AttestationData { + slot: compute_start_slot_at_epoch(target_epoch), + index: 0, + beacon_block_root: Root::repeat_byte(1), + source: Default::default(), + target: crate::beacon::containers::shared::Checkpoint { + epoch: target_epoch, + root: Root::repeat_byte(2), + }, + }, + signature: Default::default(), + } + } + + // -- SeenAttestations ---------------------------------------------------- + + #[test] + fn the_first_attestation_for_an_epoch_and_attester_is_recorded_once() { + let mut seen = seen_attestations(); + let first = attestation_at(3, 7, 0); + assert!(!seen.contains(3, 7)); + assert!(seen.record(&first)); + assert!(seen.contains(3, 7)); + // A second attestation from the same attester and epoch, even with + // different data, is already seen. + let second = attestation_at(3, 7, 1); + assert!(!seen.record(&second)); + } + + #[test] + fn a_different_attester_or_epoch_is_recorded_independently() { + let mut seen = seen_attestations(); + assert!(seen.record(&attestation_at(3, 1, 0))); + assert!(seen.record(&attestation_at(3, 2, 0))); + assert!(seen.record(&attestation_at(4, 1, 0))); + } + + #[test] + fn the_cache_forgets_its_oldest_entry_past_capacity() { + let mut seen = SeenAttestations::new(capacity(2)); + seen.record(&attestation_at(1, 1, 0)); + seen.record(&attestation_at(1, 2, 0)); + seen.record(&attestation_at(1, 3, 0)); + assert!(!seen.contains(1, 1)); + assert!(seen.contains(1, 3)); + } + + // -- compute_subnet_for_attestation -------------------------------------- + + #[test] + fn an_attestation_maps_to_its_subnet() { + let config = Config::mainnet(); + assert_eq!(compute_subnet_for_attestation(4, 0, 0, &config), 0); + assert_eq!(compute_subnet_for_attestation(4, 0, 2, &config), 2); + assert_eq!(compute_subnet_for_attestation(4, 1, 0, &config), 4); + assert_eq!(compute_subnet_for_attestation(4, 1, 3, &config), 7); + } + + #[test] + fn the_subnet_mapping_wraps_at_the_subnet_count() { + let config = Config::mainnet(); + let count = config.attestation_subnet_count; + assert_eq!(compute_subnet_for_attestation(count, 1, 0, &config), 0); + } + + // -- cheap_checks -------------------------------------------------------- + + #[test] + fn an_already_seen_attester_and_epoch_is_ignored() { + let store = store(0); + let mut seen = seen_attestations(); + let attestation = attestation_at(0, 1, 0); + seen.record(&attestation); + let now = slot_start_ms(&store, 0); + assert_eq!( + cheap_checks(&seen, &store, &attestation, now), + Err(Outcome::Ignore(IgnoreReason::AlreadySeen)) + ); + } + + #[test] + fn a_nonzero_data_index_is_rejected() { + let store = store(0); + let seen = seen_attestations(); + let mut attestation = attestation_at(0, 1, 0); + attestation.data.index = 1; + let now = slot_start_ms(&store, 0); + assert_eq!( + cheap_checks(&seen, &store, &attestation, now), + Err(Outcome::Reject(RejectReason::NonZeroDataIndex)) + ); + } + + #[test] + fn a_future_slot_is_ignored() { + let store = store(0); + let seen = seen_attestations(); + let target_epoch = 5; + let attestation = attestation_at(target_epoch, 1, 0); + let slot = compute_start_slot_at_epoch(target_epoch); + let too_early = slot_start_ms(&store, slot) + - (crate::beacon::constants::MAXIMUM_GOSSIP_CLOCK_DISPARITY + 100); + assert_eq!( + cheap_checks(&seen, &store, &attestation, too_early), + Err(Outcome::Ignore(IgnoreReason::FutureSlot)) + ); + } + + #[test] + fn an_epoch_far_from_current_is_ignored() { + let store = store(0); + let seen = seen_attestations(); + // The attestation names a stale epoch; its own slot is safely in the + // past (not a future slot), but the clock has since moved many + // epochs ahead, so the epoch itself is no longer current or previous. + let stale_epoch = 0; + let attestation = attestation_at(stale_epoch, 1, 0); + let now_epoch = 10; + let now = slot_start_ms(&store, compute_start_slot_at_epoch(now_epoch)); + assert_eq!( + cheap_checks(&seen, &store, &attestation, now), + Err(Outcome::Ignore(IgnoreReason::OutsideEpochWindow)) + ); + } + + #[test] + fn an_epoch_mismatched_with_the_slot_is_rejected() { + let store = store(0); + let seen = seen_attestations(); + let mut attestation = attestation_at(0, 1, 0); + attestation.data.slot = crate::beacon::preset::SLOTS_PER_EPOCH; + let now = slot_start_ms(&store, crate::beacon::preset::SLOTS_PER_EPOCH); + assert_eq!( + cheap_checks(&seen, &store, &attestation, now), + Err(Outcome::Reject(RejectReason::EpochMismatch)) + ); + } + + // -- stateful_checks ------------------------------------------------- + + #[test] + fn a_vote_for_an_unseen_block_is_ignored() { + let store = store(0); + let attestation = attestation_at(0, 1, 0); + assert_eq!( + stateful_checks(&store, &attestation, 0), + Outcome::Ignore(IgnoreReason::UnknownBlock) + ); + } +} diff --git a/crates/blockchain/state_transition/src/beacon/gossip/block.rs b/crates/blockchain/state_transition/src/beacon/gossip/block.rs new file mode 100644 index 000000000..02360ef2b --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/gossip/block.rs @@ -0,0 +1,448 @@ +//! `beacon_block` gossip validation: fulu's `validate_beacon_block_gossip` +//! (`specs/fulu/p2p-interface.md`), with the deviations the design spec lists. + +use ethlambda_storage::CacheKey; + +use super::{ + IgnoreReason, Outcome, QueueReason, RejectReason, SeenBlocks, finalized_ancestry, + finalized_start_slot, is_future_slot, +}; +use crate::beacon::containers::SignedBeaconBlock; +use crate::beacon::fork_choice::Store; +use crate::beacon::helpers::misc::compute_epoch_at_slot; +use crate::beacon::precheck::{self, PrecheckError, Reference, precheck_block}; +use crate::beacon::primitives::Root; +use crate::beacon::stf::bellatrix::compute_timestamp_at_slot; + +/// The rules that read only the block, the clock and the store's metadata. +/// +/// `Err` carries the verdict; `Ok` sends the block on to [`stateful_checks`]. +pub fn cheap_checks( + seen: &SeenBlocks, + store: &Store, + block: &SignedBeaconBlock, + now_ms: u64, +) -> Result<(), Outcome> { + let config = store.config(); + let slot = block.slot(); + // [IGNORE] The block is not from a future slot. + if is_future_slot(&config, slot, now_ms) { + return Err(Outcome::Ignore(IgnoreReason::FutureSlot)); + } + // [REJECT] The number of blob commitments is within the epoch's limit. + let max_blobs = config.max_blobs_per_block(compute_epoch_at_slot(slot)); + if block.blob_kzg_commitment_count() as u64 > max_blobs { + return Err(Outcome::Reject(RejectReason::TooManyBlobs)); + } + // [IGNORE] The block is from a slot greater than the latest finalized slot. + if slot <= finalized_start_slot(store) { + return Err(Outcome::Ignore(IgnoreReason::Finalized)); + } + // [IGNORE] The block is the first with a valid signature for its slot and + // proposer. + if seen.contains(slot, block.proposer_index()) { + return Err(Outcome::Ignore(IgnoreReason::AlreadySeen)); + } + Ok(()) +} + +/// The rules that need the parent's post-state. Runs on a blocking thread. +/// +/// Reads states only through [`Store::cached_state`]: a miss would otherwise +/// rebuild the state from diffs, which is far too slow for a verdict that +/// gossipsub waits on. A miss queues the block instead. +pub fn stateful_checks(store: &Store, block: &SignedBeaconBlock, block_root: Root) -> Outcome { + let config = store.config(); + let parent_root = block.parent_root(); + let parent_known = store.has_block(&parent_root); + let parent_state = if parent_known { + store.cached_state(CacheKey::BlockState(parent_root)) + } else { + None + }; + // [IGNORE] The parent has been seen and passed validation (MAY queue). + // A parent without a post-state may have failed, which the specification + // rejects; without a bad-block cache this cannot tell failed from not yet + // imported, so it queues. See the design spec's deviations. + let Some(parent_state) = parent_state else { + let reason = if parent_known { + QueueReason::ParentNotReady + } else { + QueueReason::ParentUnknown + }; + return queue_unless_forged(store, block, block_root, reason); + }; + // [REJECT] After its parent, expected proposer (when the parent's + // lookahead can answer), known proposer, valid signature. + if let Err(err) = precheck_block(block, block_root, Reference::Parent(&parent_state), &config) { + return Outcome::Reject(err.into()); + } + // [REJECT] The finalized checkpoint is an ancestor of the block. + if let Err(outcome) = finalized_ancestry(store, parent_root).verdict() { + return outcome; + } + // [REJECT] The execution payload's timestamp is the slot's. + if let Some(timestamp) = block.execution_payload_timestamp() + && timestamp != compute_timestamp_at_slot(&parent_state, block.slot(), &config) + { + return Outcome::Reject(RejectReason::PayloadTimestamp); + } + // [REJECT] Proposed by the expected proposer; when the parent's lookahead + // cannot place the slot, `precheck_block` above already checked the + // signature against the parent's key, so this only queues rather than + // rejecting. + if precheck::fixed_proposer(&parent_state, block.slot()).is_none() { + return Outcome::Queue(QueueReason::ShufflingUnavailable); + } + Outcome::Accept +} + +/// `cheap_checks` then `stateful_checks`, the order the p2p actor runs them in. +/// For callers that have no reason to split them, such as the spec vectors. +pub fn validate( + seen: &SeenBlocks, + store: &Store, + block: &SignedBeaconBlock, + now_ms: u64, +) -> Outcome { + if let Err(outcome) = cheap_checks(seen, store, block, now_ms) { + return outcome; + } + stateful_checks(store, block, block.message_hash_tree_root()) +} + +/// `Queue(reason)`, unless the head state already shows the signature is forged. +/// +/// A block that cannot be judged against its parent yet can still be judged +/// on its signature: validator indices never move, so the head state's key for +/// the proposer is the one the block was signed with. Prysm does the same +/// before queueing. +fn queue_unless_forged( + store: &Store, + block: &SignedBeaconBlock, + block_root: Root, + reason: QueueReason, +) -> Outcome { + let head_state = store + .head() + .ok() + .and_then(|head| store.cached_state(CacheKey::BlockState(head))); + let Some(head_state) = head_state else { + return Outcome::Queue(reason); + }; + match precheck_block( + block, + block_root, + Reference::Recent(&head_state), + &store.config(), + ) { + Err(PrecheckError::BadSignature) => Outcome::Reject(RejectReason::BadSignature), + _ => Outcome::Queue(reason), + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + use crate::beacon::bls; + use crate::beacon::config::Config; + use crate::beacon::constants::{ + DOMAIN_BEACON_PROPOSER, MAXIMUM_GOSSIP_CLOCK_DISPARITY as DISPARITY, + }; + use crate::beacon::containers::{BeaconState, electra}; + use crate::beacon::gossip::test_support::{fulu_parent, seen_blocks, slot_start_ms, store}; + use crate::beacon::gossip::{IgnoreReason, Outcome, RejectReason}; + use crate::beacon::helpers::misc::{ + compute_domain, compute_epoch_at_slot, compute_signing_root, compute_start_slot_at_epoch, + }; + use crate::beacon::helpers::test_state::secret_key_for; + use crate::beacon::preset; + use crate::beacon::primitives::{BlsSignature, KzgCommitment, Root, Slot, ValidatorIndex}; + + fn fulu_block(slot: Slot, proposer: ValidatorIndex, commitments: usize) -> SignedBeaconBlock { + let mut body = electra::BeaconBlockBody::empty(); + body.blob_kzg_commitments = vec![KzgCommitment::default(); commitments] + .try_into() + .expect("within MAX_BLOB_COMMITMENTS_PER_BLOCK"); + SignedBeaconBlock::Fulu(electra::SignedBeaconBlock { + message: electra::BeaconBlock { + slot, + proposer_index: proposer, + parent_root: Root::ZERO, + state_root: Root::ZERO, + body, + }, + signature: Default::default(), + }) + } + + /// [`fulu_parent`], repositioned to `slot` rather than the one + /// `with_validators_at` places it at. + fn fulu_parent_at(proposer: ValidatorIndex, slot: Slot) -> BeaconState { + let mut state = fulu_parent(proposer); + if let BeaconState::Fulu(fulu_state) = &mut state { + fulu_state.slot = slot; + } + state + } + + /// Signs `block` under `config`'s domain for its own slot's epoch, with + /// `signer`'s test key, over `state`'s genesis validators root. Mirrors + /// `column.rs`'s own `signed` helper. + fn sign_block( + mut block: SignedBeaconBlock, + state: &BeaconState, + config: &Config, + signer: ValidatorIndex, + ) -> SignedBeaconBlock { + let root = block.message_hash_tree_root(); + let fork_version = + config.fork_version(config.fork_at_epoch(compute_epoch_at_slot(block.slot()))); + let domain = compute_domain( + DOMAIN_BEACON_PROPOSER, + fork_version, + state.genesis_validators_root(), + ); + let signing_root = compute_signing_root(root, domain); + let signature = + secret_key_for(signer as usize).sign(signing_root.as_slice(), bls::DST, &[]); + let SignedBeaconBlock::Fulu(inner) = &mut block else { + unreachable!("fulu_block builds a fulu block"); + }; + inner.signature = BlsSignature(signature.to_bytes()); + block + } + + #[test] + fn a_block_within_the_clock_disparity_passes() { + let store = store(0); + let now = slot_start_ms(&store, 5) - (DISPARITY - 100); + assert_eq!( + cheap_checks(&seen_blocks(), &store, &fulu_block(5, 1, 0), now), + Ok(()) + ); + } + + #[test] + fn a_block_beyond_the_clock_disparity_is_ignored() { + let store = store(0); + let now = slot_start_ms(&store, 5) - (DISPARITY + 100); + assert_eq!( + cheap_checks(&seen_blocks(), &store, &fulu_block(5, 1, 0), now), + Err(Outcome::Ignore(IgnoreReason::FutureSlot)) + ); + } + + #[test] + fn too_many_blob_commitments_are_rejected() { + let store = store(0); + let max = store.config().max_blobs_per_block(compute_epoch_at_slot(5)) as usize; + let now = slot_start_ms(&store, 5); + assert_eq!( + cheap_checks(&seen_blocks(), &store, &fulu_block(5, 1, max), now), + Ok(()) + ); + assert_eq!( + cheap_checks(&seen_blocks(), &store, &fulu_block(5, 1, max + 1), now), + Err(Outcome::Reject(RejectReason::TooManyBlobs)) + ); + } + + #[test] + fn a_block_at_or_below_the_finalized_epoch_start_is_ignored() { + // Finalized at the start of epoch 1: slot 32 itself is no longer new. + let store = store(32); + let now = slot_start_ms(&store, 40); + assert_eq!( + cheap_checks(&seen_blocks(), &store, &fulu_block(32, 1, 0), now), + Err(Outcome::Ignore(IgnoreReason::Finalized)) + ); + assert_eq!( + cheap_checks(&seen_blocks(), &store, &fulu_block(33, 1, 0), now), + Ok(()) + ); + } + + #[test] + fn a_second_block_for_a_seen_slot_and_proposer_is_ignored() { + let store = store(0); + let now = slot_start_ms(&store, 5); + let mut seen = seen_blocks(); + seen.record(5, 1, Root::repeat_byte(9)); + assert_eq!( + cheap_checks(&seen, &store, &fulu_block(5, 1, 0), now), + Err(Outcome::Ignore(IgnoreReason::AlreadySeen)) + ); + assert_eq!( + cheap_checks(&seen, &store, &fulu_block(5, 2, 0), now), + Ok(()) + ); + } + + #[test] + fn a_block_whose_parent_was_never_seen_is_queued() { + let store = store(0); + let SignedBeaconBlock::Fulu(mut orphan) = fulu_block(5, 1, 0) else { + unreachable!("fulu_block builds a fulu block"); + }; + orphan.message.parent_root = Root::repeat_byte(0x11); + let orphan = SignedBeaconBlock::Fulu(orphan); + // No head state is cached either, so the signature cannot be judged + // and the block is queued as it is. + assert_eq!( + stateful_checks(&store, &orphan, Root::repeat_byte(5)), + Outcome::Queue(QueueReason::ParentUnknown) + ); + } + + #[test] + fn an_unknown_parent_with_a_cached_head_state_is_judged_on_its_signature() { + let store = store(0); + let config = store.config(); + let proposer: ValidatorIndex = 3; + // `store()` sets the head to `Root::ZERO`, the same root + // `queue_unless_forged` reads through `Store::head`. + let head_state = fulu_parent(proposer); + store.cache_state( + CacheKey::BlockState(Root::ZERO), + Arc::new(head_state.clone()), + ); + + let SignedBeaconBlock::Fulu(mut orphan) = fulu_block(5, proposer, 0) else { + unreachable!("fulu_block builds a fulu block"); + }; + orphan.message.parent_root = Root::repeat_byte(0x11); + let orphan = SignedBeaconBlock::Fulu(orphan); + + // The default signature verifies against nobody. + assert_eq!( + stateful_checks(&store, &orphan, orphan.message_hash_tree_root()), + Outcome::Reject(RejectReason::BadSignature) + ); + + // The same block, properly signed by the head state's proposer. + let signed = sign_block(orphan, &head_state, &config, proposer); + assert_eq!( + stateful_checks(&store, &signed, signed.message_hash_tree_root()), + Outcome::Queue(QueueReason::ParentUnknown) + ); + } + + #[test] + fn a_slot_past_the_lookahead_is_queued_only_once_verified() { + let mut store = store(0); + let config = store.config(); + let proposer: ValidatorIndex = 3; + let parent = fulu_parent(proposer); + let parent_root = Root::repeat_byte(0x22); + + // A `BlockHeaders` row and a cached state, plus a `LiveChain` chain + // back to the finalized root, so the finalized-ancestry walk + // succeeds and this test reaches the lookahead gate rather than + // `Queue(ParentNotReady)`. + store + .insert_pending_block(parent_root, fulu_block(parent.slot(), proposer, 0)) + .expect("insert pending parent"); + store.cache_state(CacheKey::BlockState(parent_root), Arc::new(parent.clone())); + store.insert_live_chain_entry(parent.slot(), parent_root, Root::ZERO); + store.insert_live_chain_entry(0, Root::ZERO, Root::ZERO); + + let window_start = compute_start_slot_at_epoch(compute_epoch_at_slot(parent.slot())); + let beyond = window_start + preset::PROPOSER_LOOKAHEAD_LENGTH as Slot; + assert_eq!(precheck::fixed_proposer(&parent, beyond), None); + + let SignedBeaconBlock::Fulu(mut inner) = fulu_block(beyond, proposer, 0) else { + unreachable!("fulu_block builds a fulu block"); + }; + inner.message.parent_root = parent_root; + // Outside the lookahead window the proposer rule is skipped, but the + // payload-timestamp rule still runs before it, so it must match. + inner.message.body.execution_payload.timestamp = + compute_timestamp_at_slot(&parent, beyond, &config); + let unsigned = SignedBeaconBlock::Fulu(inner); + + let good = sign_block(unsigned.clone(), &parent, &config, proposer); + assert_eq!( + stateful_checks(&store, &good, good.message_hash_tree_root()), + Outcome::Queue(QueueReason::ShufflingUnavailable) + ); + + // The default (forged) signature is rejected before the lookahead is + // ever consulted. + assert_eq!( + stateful_checks(&store, &unsigned, unsigned.message_hash_tree_root()), + Outcome::Reject(RejectReason::BadSignature) + ); + } + + #[test] + fn a_slot_the_lookahead_cannot_place_still_fails_on_slot_order() { + // Pins that `precheck_block`'s not-after-parent check runs before the + // lookahead gate: a slot the lookahead cannot place at all must still + // surface the spec's REJECT for a slot not after the parent's, rather + // than `Queue(ShufflingUnavailable)` masking it. + let mut store = store(0); + let config = store.config(); + let proposer: ValidatorIndex = 3; + let parent_slot: Slot = 40; + let parent = fulu_parent_at(proposer, parent_slot); + let parent_root = Root::repeat_byte(0x33); + + // Only a `BlockHeaders` row is needed: `precheck_block`'s + // not-after-parent check returns before the finalized-ancestry walk + // is ever reached, so no `LiveChain` row is set up for it. + store + .insert_pending_block(parent_root, fulu_block(parent_slot, proposer, 0)) + .expect("insert pending parent"); + store.cache_state(CacheKey::BlockState(parent_root), Arc::new(parent.clone())); + + let slot: Slot = 31; + assert!(slot < parent_slot); + assert_eq!(precheck::fixed_proposer(&parent, slot), None); + + let SignedBeaconBlock::Fulu(mut inner) = fulu_block(slot, proposer, 0) else { + unreachable!("fulu_block builds a fulu block"); + }; + inner.message.parent_root = parent_root; + let unsigned = SignedBeaconBlock::Fulu(inner); + let block = sign_block(unsigned, &parent, &config, proposer); + + assert_eq!( + stateful_checks(&store, &block, block.message_hash_tree_root()), + Outcome::Reject(RejectReason::NotAfterParent) + ); + } + + #[test] + fn a_failed_finalized_ancestry_walk_queues_rather_than_rejects() { + let mut store = store(0); + let config = store.config(); + let proposer: ValidatorIndex = 3; + let parent = fulu_parent(proposer); + let parent_root = Root::repeat_byte(0x44); + + // A `BlockHeaders` row and a cached state for the parent, but no + // `LiveChain` row for it: the finalized-ancestry walk cannot finish. + store + .insert_pending_block(parent_root, fulu_block(parent.slot(), proposer, 0)) + .expect("insert pending parent"); + store.cache_state(CacheKey::BlockState(parent_root), Arc::new(parent.clone())); + + // Inside the lookahead window, so `precheck_block` fully verifies the + // proposer and signature before the ancestry walk is ever reached. + let slot = parent.slot() + 1; + let SignedBeaconBlock::Fulu(mut inner) = fulu_block(slot, proposer, 0) else { + unreachable!("fulu_block builds a fulu block"); + }; + inner.message.parent_root = parent_root; + let unsigned = SignedBeaconBlock::Fulu(inner); + let block = sign_block(unsigned, &parent, &config, proposer); + + assert_eq!( + stateful_checks(&store, &block, block.message_hash_tree_root()), + Outcome::Queue(QueueReason::ParentNotReady) + ); + } +} diff --git a/crates/blockchain/state_transition/src/beacon/gossip/column.rs b/crates/blockchain/state_transition/src/beacon/gossip/column.rs new file mode 100644 index 000000000..2d7130abd --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/gossip/column.rs @@ -0,0 +1,936 @@ +//! `data_column_sidecar_{subnet_id}` gossip validation: fulu's +//! `validate_data_column_sidecar_gossip` (`specs/fulu/p2p-interface.md`). +//! +//! Also every check the chain relies on before it keeps a sidecar gossip did +//! not accept ([`chain_checks`]): one fetched over req/resp, one gossip queued +//! or had no free permit for, or a parked one whose parent has since +//! imported. The chain actor stores whatever reaches it without checking it +//! again, so these are the only checks such a sidecar gets. + +use ethlambda_storage::CacheKey; + +use super::{ + IgnoreReason, Outcome, QueueReason, RejectReason, SeenColumns, finalized_ancestry, + finalized_start_slot, is_future_slot, +}; +use crate::beacon::bls; +use crate::beacon::config::Config; +use crate::beacon::constants::{DATA_COLUMN_SIDECAR_SUBNET_COUNT, DOMAIN_BEACON_PROPOSER}; +use crate::beacon::containers::BeaconState; +use crate::beacon::containers::fulu::DataColumnSidecar; +use crate::beacon::fork_choice::{self, Store}; +use crate::beacon::helpers::accessors::get_beacon_proposer_index; +use crate::beacon::helpers::misc::{compute_domain, compute_epoch_at_slot, compute_signing_root}; +use crate::beacon::precheck; +use crate::beacon::primitives::HashTreeRoot as _; +use crate::beacon::primitives::{Slot, ValidatorIndex}; +use crate::beacon::stf; +use crate::metrics; + +/// The rules that read only the sidecar, the clock and the store's metadata. +/// +/// `Err` carries the verdict; `Ok` sends the sidecar on to [`stateful_checks`]. +pub fn cheap_checks( + seen: &SeenColumns, + store: &Store, + sidecar: &DataColumnSidecar, + subnet_id: u64, + now_ms: u64, +) -> Result<(), Outcome> { + let config = store.config(); + // [REJECT] The sidecar is structurally valid. + if !fork_choice::verify_data_column_sidecar(sidecar, &config) { + return Err(Outcome::Reject(RejectReason::Malformed)); + } + // [REJECT] The sidecar is for the correct subnet. + if sidecar.index % DATA_COLUMN_SIDECAR_SUBNET_COUNT != subnet_id { + return Err(Outcome::Reject(RejectReason::WrongSubnet)); + } + let header = &sidecar.signed_block_header.message; + // [IGNORE] The sidecar is not from a future slot. + if is_future_slot(&config, header.slot, now_ms) { + return Err(Outcome::Ignore(IgnoreReason::FutureSlot)); + } + // [IGNORE] The sidecar is from a slot greater than the latest finalized slot. + if header.slot <= finalized_start_slot(store) { + return Err(Outcome::Ignore(IgnoreReason::Finalized)); + } + // [IGNORE] The first valid sidecar for its (slot, proposer, index). + if seen.contains(header.slot, header.proposer_index, sidecar.index) { + return Err(Outcome::Ignore(IgnoreReason::AlreadySeen)); + } + // [IGNORE] Already stored, fetched over req/resp before gossip delivered + // it. Everything past here is the expensive part and would change + // nothing. + if store.has_data_column(header.slot, &header.hash_tree_root(), sidecar.index) { + return Err(Outcome::Ignore(IgnoreReason::AlreadyStored)); + } + Ok(()) +} + +/// The rules that need the parent's post-state. Runs on a blocking thread, +/// and reads states only from the cache, as [`super::block::stateful_checks`] +/// does. The rules themselves are [`judge_against_parent`]'s, a permit held +/// while they run. +pub fn stateful_checks(store: &Store, sidecar: &DataColumnSidecar) -> Outcome { + let header = &sidecar.signed_block_header.message; + let parent_known = store.has_block(&header.parent_root); + let parent_state = if parent_known { + store.cached_state(CacheKey::BlockState(header.parent_root)) + } else { + None + }; + // [IGNORE] The parent has been seen and passed validation (MAY queue). + // Every queued column still reaches the chain actor's + // `PendingDataColumns`, so it gets the same signature-against-the-head- + // state treatment a queued block does; see [`queue_unless_forged`]. + let Some(parent_state) = parent_state else { + let reason = if parent_known { + QueueReason::ParentNotReady + } else { + QueueReason::ParentUnknown + }; + return queue_unless_forged(store, sidecar, reason); + }; + judge_against_parent(store, sidecar, &parent_state, PastLookahead::Queue) +} + +/// What the chain may do with a sidecar [`chain_checks`] judged. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChainVerdict { + /// Passed every rule: the chain stores it as it is. + Keep, + /// Its parent has no post-state yet. The chain parks it and sends it back + /// through [`chain_checks`] once the parent imports. + AwaitParent, + /// Not kept. The outcome says why: an `Ignore` or a `Reject`, or + /// `Queue(ParentNotReady)` when the parent has a post-state but its + /// finalized-ancestry walk cannot finish, which no import will fix. + Drop(Outcome), +} + +/// Every check the chain relies on before it keeps a sidecar gossip did not +/// accept. +/// +/// The gossip rules minus the two that only mean something on a gossip topic +/// (the subnet match and the seen cache), with two differences that both come +/// from running with no gossipsub cache waiting on the answer: +/// +/// - The parent's post-state may come from the database, not only from the +/// state cache: a parent whose state was written out is still a parent. +/// - A slot outside the parent state's proposer lookahead is answered by +/// advancing a clone of that state to it ([`advanced_proposer`]), rather +/// than queued. Nothing would ever come along to answer it later. +/// +/// Runs off both actors. It costs a KZG batch and a BLS verification per +/// sidecar, and those used to run on the chain actor's single thread. +pub fn chain_checks(store: &Store, sidecar: &DataColumnSidecar, now_ms: u64) -> ChainVerdict { + let config = store.config(); + // [REJECT] The sidecar is structurally valid. First, so a peer answering + // a fetch with garbage cannot make this pay for a state read or a KZG + // batch. + if !fork_choice::verify_data_column_sidecar(sidecar, &config) { + return ChainVerdict::Drop(Outcome::Reject(RejectReason::Malformed)); + } + let header = &sidecar.signed_block_header.message; + // [IGNORE] Not from a future slot. Also what bounds `advanced_proposer`'s + // `process_slots` below: a header naming a slot far ahead would otherwise + // make it advance one slot at a time towards that slot. + if is_future_slot(&config, header.slot, now_ms) { + return ChainVerdict::Drop(Outcome::Ignore(IgnoreReason::FutureSlot)); + } + // [IGNORE] From a slot greater than the latest finalized slot. + if header.slot <= finalized_start_slot(store) { + return ChainVerdict::Drop(Outcome::Ignore(IgnoreReason::Finalized)); + } + // [IGNORE] Already stored. Common during a range sync, whose consecutive + // batches ask for overlapping spans of columns, and everything after this + // is the expensive part. + if store.has_data_column(header.slot, &header.hash_tree_root(), sidecar.index) { + return ChainVerdict::Drop(Outcome::Ignore(IgnoreReason::AlreadyStored)); + } + let Ok(Some(parent_state)) = store.get_state(&header.parent_root) else { + // Parked rather than dropped: the specification's "MAY be queued" + // for a sidecar whose parent has not been seen. The head-state + // signature check keeps a forged header from taking a + // `PendingDataColumns` row while it waits. + return match queue_unless_forged(store, sidecar, QueueReason::ParentUnknown) { + Outcome::Queue(_) => ChainVerdict::AwaitParent, + outcome => ChainVerdict::Drop(outcome), + }; + }; + match judge_against_parent(store, sidecar, &parent_state, PastLookahead::Advance) { + Outcome::Accept => ChainVerdict::Keep, + outcome => ChainVerdict::Drop(outcome), + } +} + +/// What [`judge_against_parent`] does when the parent state's proposer +/// lookahead has no answer for the sidecar's slot. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PastLookahead { + /// Queue it, as gossip does: advancing a state clone takes longer than a + /// gossip verdict can wait. + Queue, + /// Advance a clone of the parent state to the slot and ask it, as + /// [`chain_checks`] does. + Advance, +} + +/// The rules that read the parent's post-state, shared by [`stateful_checks`] +/// and [`chain_checks`]. +/// +/// Signature first, lookahead last, mirroring +/// [`super::block::stateful_checks`]'s own order: a forged header naming a +/// real parent is caught by [`header_signature_is_valid`] before this pays +/// for the finalized-ancestry walk (a full `LiveChain` scan) or the KZG +/// proof. +fn judge_against_parent( + store: &Store, + sidecar: &DataColumnSidecar, + parent_state: &BeaconState, + past_lookahead: PastLookahead, +) -> Outcome { + let config = store.config(); + let header = &sidecar.signed_block_header.message; + // [REJECT] The sidecar is from a higher slot than its parent. + if header.slot <= parent_state.slot() { + return Outcome::Reject(RejectReason::NotAfterParent); + } + // [REJECT] Signed by a validator the parent's state knows, over this + // header. + if let Err(outcome) = header_signature_is_valid(sidecar, parent_state, &config) { + return outcome; + } + // [REJECT] Proposed by the proposer the parent's state fixes for the + // slot. Read from its lookahead when that covers the slot; past it, only + // the chain path pays to find out. + let expected = match ( + precheck::fixed_proposer(parent_state, header.slot), + past_lookahead, + ) { + (Some(expected), _) => Some(expected), + (None, PastLookahead::Queue) => None, + (None, PastLookahead::Advance) => { + let Some(expected) = advanced_proposer(parent_state, header.slot, &config) else { + return Outcome::Ignore(IgnoreReason::Internal); + }; + Some(expected) + } + }; + if expected.is_some_and(|expected| header.proposer_index != expected) { + return Outcome::Reject(RejectReason::WrongProposer); + } + // [REJECT] The finalized checkpoint is an ancestor of the sidecar's block. + if let Err(outcome) = finalized_ancestry(store, header.parent_root).verdict() { + return outcome; + } + // [REJECT] The commitments are the ones the block committed to. + if !fork_choice::verify_data_column_sidecar_inclusion_proof(sidecar) { + return Outcome::Reject(RejectReason::InclusionProof); + } + // [REJECT] Every cell matches its commitment and proof. + let kzg = { + let _timing = metrics::time_data_column_kzg_verify(); + fork_choice::verify_data_column_sidecar_kzg_proofs(sidecar) + }; + if !matches!(kzg, Ok(true)) { + return Outcome::Reject(RejectReason::Kzg); + } + // [IGNORE] The sidecar's slot is outside the parent state's proposer + // lookahead window (MAY queue). + if past_lookahead == PastLookahead::Queue + && let Err(outcome) = lookahead_covers(parent_state, header.slot) + { + return outcome; + } + Outcome::Accept +} + +/// The proposer `parent_state`, advanced to `slot`, names for it: the answer +/// [`precheck::fixed_proposer`] has no window for. `None` when the state +/// cannot be advanced or cannot name one. +/// +/// Advances a clone, so the caller's copy (the store's cached `Arc`) is left +/// exactly as it was. Costs a `process_slots` over every slot between the +/// parent and `slot`, including a full state merkleization per empty slot +/// crossed, which is why only [`chain_checks`] calls this, and only for a +/// slot the lookahead does not cover. +fn advanced_proposer( + parent_state: &BeaconState, + slot: Slot, + config: &Config, +) -> Option { + let mut state = parent_state.clone(); + stf::process_slots(&mut state, slot, config).ok()?; + get_beacon_proposer_index(&state).ok() +} + +/// `Queue(reason)`, unless the head state already shows the header's +/// signature is forged. +/// +/// Mirrors [`super::block::queue_unless_forged`]: validator indices never +/// move, so the head state's key for the header's proposer is the one it was +/// signed with, even when that state cannot yet say whether this proposer is +/// the *expected* one for the slot. A column queued here is written to the +/// chain actor's `PendingDataColumns`, so without this check a forged one +/// would sit there rather than being refused up front. +fn queue_unless_forged(store: &Store, sidecar: &DataColumnSidecar, reason: QueueReason) -> Outcome { + let head_state = store + .head() + .ok() + .and_then(|head| store.cached_state(CacheKey::BlockState(head))); + let Some(head_state) = head_state else { + return Outcome::Queue(reason); + }; + match header_signature_is_valid(sidecar, &head_state, &store.config()) { + Err(Outcome::Reject(RejectReason::BadSignature)) => { + Outcome::Reject(RejectReason::BadSignature) + } + _ => Outcome::Queue(reason), + } +} + +/// `cheap_checks` then `stateful_checks`, for callers with no reason to split +/// them, such as the spec vectors. +pub fn validate( + seen: &SeenColumns, + store: &Store, + sidecar: &DataColumnSidecar, + subnet_id: u64, + now_ms: u64, +) -> Outcome { + if let Err(outcome) = cheap_checks(seen, store, sidecar, subnet_id, now_ms) { + return outcome; + } + stateful_checks(store, sidecar) +} + +/// Whether `parent_state`'s proposer lookahead covers `slot` at all. +/// +/// Checked last in [`stateful_checks`], after every REJECT-worthy rule: the +/// proposer-identity and signature questions are both settled earlier, so a +/// sidecar reaching this has already cleared those, and the parent state may +/// simply have no answer yet for a slot this far out, which the spec lets us +/// queue rather than reject. The gossip counterpart of [`advanced_proposer`], +/// which advances a clone of the state with `process_slots` instead and so +/// can answer for a slot outside the lookahead window, at the cost of a state +/// clone this path cannot afford. +fn lookahead_covers(parent_state: &BeaconState, slot: Slot) -> Result<(), Outcome> { + if precheck::fixed_proposer(parent_state, slot).is_none() { + return Err(Outcome::Queue(QueueReason::ShufflingUnavailable)); + } + Ok(()) +} + +/// Whether `sidecar`'s header carries `state`'s key for its `proposer_index`. +/// +/// Checked in [`stateful_checks`] before the proposer-identity check and +/// every later rule, so a forged header is caught before the finalized- +/// ancestry walk or the KZG proof. Also what [`queue_unless_forged`] judges a +/// column on before its parent's own state is even available, against the +/// head state instead of the parent's. Neither caller can answer the +/// *expected*-proposer question the state `state` is judged against here (the +/// head state may predate the proposer's own deposit; the parent's state +/// knows the proposer but not always the slot's shuffling), so this checks +/// only what the signature alone can tell them: an unknown proposer or a bad +/// signature, never `WrongProposer`. +fn header_signature_is_valid( + sidecar: &DataColumnSidecar, + state: &BeaconState, + config: &Config, +) -> Result<(), Outcome> { + let header = &sidecar.signed_block_header.message; + let Ok(proposer) = state.validator(header.proposer_index) else { + return Err(Outcome::Reject(RejectReason::UnknownProposer)); + }; + // The domain comes from the fork schedule at the header's own epoch, not + // from the state's `fork`, for the reason `precheck_block` gives. + let fork_version = + config.fork_version(config.fork_at_epoch(compute_epoch_at_slot(header.slot))); + let domain = compute_domain( + DOMAIN_BEACON_PROPOSER, + fork_version, + state.genesis_validators_root(), + ); + let signing_root = compute_signing_root(header.hash_tree_root(), domain); + if !bls::verify( + &proposer.pubkey, + signing_root, + &sidecar.signed_block_header.signature, + ) { + return Err(Outcome::Reject(RejectReason::BadSignature)); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + use crate::beacon::constants::MAXIMUM_GOSSIP_CLOCK_DISPARITY as DISPARITY; + use crate::beacon::containers::{SignedBeaconBlock, electra, fulu, shared}; + use crate::beacon::gossip::test_support::{fulu_parent, seen_columns, slot_start_ms, store}; + use crate::beacon::gossip::{IgnoreReason, Outcome, QueueReason, RejectReason}; + use crate::beacon::helpers::misc::compute_start_slot_at_epoch; + use crate::beacon::helpers::test_state::secret_key_for; + use crate::beacon::preset; + use crate::beacon::primitives::{ + BlsSignature, KzgCommitment, KzgProof, Root, Slot, ValidatorIndex, + }; + + /// A block that only needs to exist for [`Store::has_block`] to see its + /// root: none of these tests read anything else out of it. + fn parent_block(slot: Slot) -> SignedBeaconBlock { + SignedBeaconBlock::Fulu(electra::SignedBeaconBlock { + message: electra::BeaconBlock { + slot, + proposer_index: 0, + parent_root: Root::ZERO, + state_root: Root::ZERO, + body: electra::BeaconBlockBody::empty(), + }, + signature: Default::default(), + }) + } + + /// Structurally valid (one commitment, one proof, one cell), so every + /// check before the parent lookup can be reached. + fn sidecar(slot: Slot, proposer: ValidatorIndex, index: u64) -> DataColumnSidecar { + let cell: fulu::Cell = libssz_types::SszVector::try_from(vec![0u8; preset::BYTES_PER_CELL]) + .expect("exact cell size"); + DataColumnSidecar { + index, + column: vec![cell].try_into().expect("within the per-block limit"), + kzg_commitments: vec![KzgCommitment::default()] + .try_into() + .expect("within the per-block limit"), + kzg_proofs: vec![KzgProof::default()] + .try_into() + .expect("within the per-block limit"), + signed_block_header: shared::SignedBeaconBlockHeader { + message: shared::BeaconBlockHeader { + slot, + proposer_index: proposer, + parent_root: Root::repeat_byte(0x11), + ..Default::default() + }, + signature: Default::default(), + }, + kzg_commitments_inclusion_proof: vec![ + Root::ZERO; + preset::KZG_COMMITMENTS_INCLUSION_PROOF_DEPTH + ] + .try_into() + .expect("exact depth"), + } + } + + #[test] + fn a_well_formed_sidecar_on_its_subnet_passes() { + let store = store(0); + let subnet = 7 % DATA_COLUMN_SIDECAR_SUBNET_COUNT; + assert_eq!( + cheap_checks( + &seen_columns(), + &store, + &sidecar(5, 1, 7), + subnet, + slot_start_ms(&store, 5) + ), + Ok(()) + ); + } + + #[test] + fn a_sidecar_on_the_wrong_subnet_is_rejected() { + let store = store(0); + let wrong = (7 + 1) % DATA_COLUMN_SIDECAR_SUBNET_COUNT; + assert_eq!( + cheap_checks( + &seen_columns(), + &store, + &sidecar(5, 1, 7), + wrong, + slot_start_ms(&store, 5) + ), + Err(Outcome::Reject(RejectReason::WrongSubnet)) + ); + } + + #[test] + fn a_sidecar_with_no_commitments_is_rejected() { + let store = store(0); + let mut malformed = sidecar(5, 1, 0); + malformed.kzg_commitments = Default::default(); + assert_eq!( + cheap_checks( + &seen_columns(), + &store, + &malformed, + 0, + slot_start_ms(&store, 5) + ), + Err(Outcome::Reject(RejectReason::Malformed)) + ); + } + + #[test] + fn future_finalized_and_seen_sidecars_are_ignored() { + let store = store(32); + let too_early = slot_start_ms(&store, 40) - (DISPARITY + 100); + assert_eq!( + cheap_checks(&seen_columns(), &store, &sidecar(40, 1, 0), 0, too_early), + Err(Outcome::Ignore(IgnoreReason::FutureSlot)) + ); + assert_eq!( + cheap_checks( + &seen_columns(), + &store, + &sidecar(32, 1, 0), + 0, + slot_start_ms(&store, 40) + ), + Err(Outcome::Ignore(IgnoreReason::Finalized)) + ); + let mut seen = seen_columns(); + seen.record(40, 1, 0); + assert_eq!( + cheap_checks( + &seen, + &store, + &sidecar(40, 1, 0), + 0, + slot_start_ms(&store, 40) + ), + Err(Outcome::Ignore(IgnoreReason::AlreadySeen)) + ); + } + + #[test] + fn an_already_stored_sidecar_is_ignored() { + let store = store(0); + let card = sidecar(5, 1, 0); + let header = &card.signed_block_header.message; + store + .put_data_column_sidecar( + header.slot, + &header.hash_tree_root(), + card.index, + Vec::new(), + ) + .expect("store the column"); + assert_eq!( + cheap_checks(&seen_columns(), &store, &card, 0, slot_start_ms(&store, 5)), + Err(Outcome::Ignore(IgnoreReason::AlreadyStored)) + ); + } + + #[test] + fn a_sidecar_whose_parent_was_never_seen_is_queued() { + let store = store(0); + assert_eq!( + stateful_checks(&store, &sidecar(5, 1, 0)), + Outcome::Queue(QueueReason::ParentUnknown) + ); + } + + #[test] + fn a_known_parent_with_no_cached_state_is_queued() { + let mut store = store(0); + let parent_root = Root::repeat_byte(0x11); + store + .insert_pending_block(parent_root, parent_block(4)) + .expect("insert pending parent"); + + let mut card = sidecar(5, 1, 0); + card.signed_block_header.message.parent_root = parent_root; + + // The parent is known but uncached, and no head state is cached + // either, so no signature check applies: `queue_unless_forged` + // returns the plain queue reason. + assert_eq!( + stateful_checks(&store, &card), + Outcome::Queue(QueueReason::ParentNotReady) + ); + } + + #[test] + fn an_unknown_parent_with_a_cached_head_state_is_judged_on_its_signature() { + let store = store(0); + let config = store.config(); + let proposer: ValidatorIndex = 3; + // `store()` sets the head to `Root::ZERO`, the same root + // `queue_unless_forged` reads through `Store::head`. + let head_state = fulu_parent(proposer); + store.cache_state( + CacheKey::BlockState(Root::ZERO), + Arc::new(head_state.clone()), + ); + + let mut card = sidecar(head_state.slot() + 1, proposer, 0); + card.signed_block_header.message.parent_root = Root::repeat_byte(0x22); + + // The default signature verifies against nobody. + assert_eq!( + stateful_checks(&store, &card), + Outcome::Reject(RejectReason::BadSignature) + ); + + // The same header, properly signed by the head state's proposer. + let good = signed(card, &head_state, &config, proposer); + assert_eq!( + stateful_checks(&store, &good), + Outcome::Queue(QueueReason::ParentUnknown) + ); + } + + #[test] + fn a_sidecar_not_after_its_parent_is_rejected() { + let mut store = store(0); + let proposer: ValidatorIndex = 3; + let parent = fulu_parent(proposer); + let parent_root = Root::repeat_byte(0x33); + store + .insert_pending_block(parent_root, parent_block(parent.slot())) + .expect("insert pending parent"); + store.cache_state(CacheKey::BlockState(parent_root), Arc::new(parent.clone())); + + let mut card = sidecar(parent.slot(), proposer, 0); + card.signed_block_header.message.parent_root = parent_root; + + assert_eq!( + stateful_checks(&store, &card), + Outcome::Reject(RejectReason::NotAfterParent) + ); + } + + #[test] + fn a_sidecar_off_the_finalized_chain_is_rejected() { + let mut store = store(0); + let config = store.config(); + let proposer: ValidatorIndex = 3; + let parent = fulu_parent(proposer); + let parent_root = Root::repeat_byte(0x44); + let fork_root = Root::repeat_byte(0x55); + + store + .insert_pending_block(parent_root, parent_block(parent.slot())) + .expect("insert pending parent"); + store.cache_state(CacheKey::BlockState(parent_root), Arc::new(parent.clone())); + // The chain from `parent_root` reaches the finalized epoch's start + // (slot 0, since the store is finalized at slot 0) at `fork_root` + // rather than the store's finalized root (`Root::ZERO`): a fork. + store.insert_live_chain_entry(parent.slot(), parent_root, fork_root); + store.insert_live_chain_entry(0, fork_root, fork_root); + + let mut card = sidecar(parent.slot() + 1, proposer, 0); + card.signed_block_header.message.parent_root = parent_root; + // Properly signed: the signature and proposer-identity checks now run + // before the finalized-ancestry walk, so an unsigned header would be + // rejected on that instead of ever reaching the walk this test means + // to exercise. + let card = signed(card, &parent, &config, proposer); + + assert_eq!( + stateful_checks(&store, &card), + Outcome::Reject(RejectReason::FinalizedNotAncestor) + ); + } + + #[test] + fn a_failed_finalized_ancestry_walk_checks_the_parents_signature_before_queuing() { + let mut store = store(0); + let config = store.config(); + let proposer: ValidatorIndex = 3; + let parent = fulu_parent(proposer); + let parent_root = Root::repeat_byte(0x66); + + store + .insert_pending_block(parent_root, parent_block(parent.slot())) + .expect("insert pending parent"); + store.cache_state(CacheKey::BlockState(parent_root), Arc::new(parent.clone())); + // No `LiveChain` row for the parent: the finalized-ancestry walk + // cannot finish. + + let mut card = sidecar(parent.slot() + 1, proposer, 0); + card.signed_block_header.message.parent_root = parent_root; + + // The default signature verifies against nobody. + assert_eq!( + stateful_checks(&store, &card), + Outcome::Reject(RejectReason::BadSignature) + ); + + // The same header, properly signed by the parent's own proposer. + let good = signed(card, &parent, &config, proposer); + assert_eq!( + stateful_checks(&store, &good), + Outcome::Queue(QueueReason::ParentNotReady) + ); + } + + fn signed( + mut sidecar: DataColumnSidecar, + state: &BeaconState, + config: &Config, + signer: ValidatorIndex, + ) -> DataColumnSidecar { + let header = &sidecar.signed_block_header.message; + let fork_version = + config.fork_version(config.fork_at_epoch(compute_epoch_at_slot(header.slot))); + let domain = compute_domain( + DOMAIN_BEACON_PROPOSER, + fork_version, + state.genesis_validators_root(), + ); + let signing_root = compute_signing_root(header.hash_tree_root(), domain); + let signature = + secret_key_for(signer as usize).sign(signing_root.as_slice(), bls::DST, &[]); + sidecar.signed_block_header.signature = BlsSignature(signature.to_bytes()); + sidecar + } + + /// A correctly signed header from a validator the parent's state knows, + /// but not the one its lookahead names for the slot: `stateful_checks` + /// must still reject it, on `WrongProposer` rather than accepting it or + /// mistaking the mismatch for a bad signature. + #[test] + fn a_correctly_signed_header_from_the_wrong_proposer_is_rejected() { + let mut store = store(0); + let config = store.config(); + let expected_proposer: ValidatorIndex = 3; + let signer: ValidatorIndex = 5; + let parent = fulu_parent(expected_proposer); + let parent_root = Root::repeat_byte(0x77); + + store + .insert_pending_block(parent_root, parent_block(parent.slot())) + .expect("insert pending parent"); + store.cache_state(CacheKey::BlockState(parent_root), Arc::new(parent.clone())); + + let mut card = sidecar(parent.slot() + 1, signer, 0); + card.signed_block_header.message.parent_root = parent_root; + // A real signature from validator 5, who is not who the lookahead + // (fixed to `expected_proposer` for every slot by `fulu_parent`) + // names for this slot. + let card = signed(card, &parent, &config, signer); + + assert_eq!( + stateful_checks(&store, &card), + Outcome::Reject(RejectReason::WrongProposer) + ); + } + + /// [`lookahead_covers`] answers only whether the parent state's + /// lookahead can name a proposer for the slot at all; a real proposer + /// mismatch is [`RejectReason::WrongProposer`], checked earlier in + /// [`stateful_checks`] and covered there by + /// [`a_correctly_signed_header_from_the_wrong_proposer_is_rejected`]. + #[test] + fn a_slot_past_the_lookahead_is_queued() { + let parent = fulu_parent(3); + let slot = parent.slot() + 1; + assert_eq!(lookahead_covers(&parent, slot), Ok(())); + + let window_start = compute_start_slot_at_epoch(compute_epoch_at_slot(parent.slot())); + let beyond = window_start + preset::PROPOSER_LOOKAHEAD_LENGTH as Slot; + assert_eq!( + lookahead_covers(&parent, beyond), + Err(Outcome::Queue(QueueReason::ShufflingUnavailable)) + ); + } + + /// The signature and proposer-identity checks run before the lookahead + /// check in [`stateful_checks`], so a forged header for a slot the + /// lookahead cannot place is rejected rather than queued. + #[test] + fn a_forged_signature_past_the_lookahead_is_rejected_before_queuing() { + let mut store = store(0); + let proposer: ValidatorIndex = 3; + let parent = fulu_parent(proposer); + let parent_root = Root::repeat_byte(0x88); + + store + .insert_pending_block(parent_root, parent_block(parent.slot())) + .expect("insert pending parent"); + store.cache_state(CacheKey::BlockState(parent_root), Arc::new(parent.clone())); + // No `LiveChain` row is set up: the forged signature must be caught + // at the signature check, well before the finalized-ancestry walk + // (let alone the lookahead check at the very end) is ever reached. + + let window_start = compute_start_slot_at_epoch(compute_epoch_at_slot(parent.slot())); + let beyond = window_start + preset::PROPOSER_LOOKAHEAD_LENGTH as Slot; + let mut card = sidecar(beyond, proposer, 0); + card.signed_block_header.message.parent_root = parent_root; + // The default signature verifies against nobody. + + assert_eq!( + stateful_checks(&store, &card), + Outcome::Reject(RejectReason::BadSignature) + ); + } + + // -- chain_checks -------------------------------------------------------- + + /// A store holding `parent_root` as a block, with `parent`'s post-state + /// cached against it. + fn store_with_parent(parent: &BeaconState, parent_root: Root) -> Store { + let mut store = store(0); + store + .insert_pending_block(parent_root, parent_block(parent.slot())) + .expect("insert pending parent"); + store.cache_state(CacheKey::BlockState(parent_root), Arc::new(parent.clone())); + store + } + + /// `sidecar(slot, proposer, 0)` under `parent_root`, signed by `signer`. + fn signed_child( + parent: &BeaconState, + parent_root: Root, + slot: Slot, + proposer: ValidatorIndex, + signer: ValidatorIndex, + config: &Config, + ) -> DataColumnSidecar { + let mut card = sidecar(slot, proposer, 0); + card.signed_block_header.message.parent_root = parent_root; + signed(card, parent, config, signer) + } + + #[test] + fn chain_checks_drop_what_gossips_cheap_checks_would() { + let store = store(32); + let now = slot_start_ms(&store, 40); + + let mut malformed = sidecar(40, 1, 0); + malformed.kzg_commitments = Default::default(); + assert_eq!( + chain_checks(&store, &malformed, now), + ChainVerdict::Drop(Outcome::Reject(RejectReason::Malformed)) + ); + assert_eq!( + chain_checks(&store, &sidecar(41, 1, 0), now - (DISPARITY + 100)), + ChainVerdict::Drop(Outcome::Ignore(IgnoreReason::FutureSlot)) + ); + assert_eq!( + chain_checks(&store, &sidecar(32, 1, 0), now), + ChainVerdict::Drop(Outcome::Ignore(IgnoreReason::Finalized)) + ); + + let stored = sidecar(40, 1, 0); + let header = &stored.signed_block_header.message; + store + .put_data_column_sidecar(header.slot, &header.hash_tree_root(), 0, Vec::new()) + .expect("store the column"); + assert_eq!( + chain_checks(&store, &stored, now), + ChainVerdict::Drop(Outcome::Ignore(IgnoreReason::AlreadyStored)) + ); + } + + #[test] + fn chain_checks_park_a_sidecar_whose_parent_has_no_state() { + let store = store(0); + assert_eq!( + chain_checks(&store, &sidecar(5, 1, 0), slot_start_ms(&store, 5)), + ChainVerdict::AwaitParent + ); + } + + /// The head-state signature check gossip runs before queuing runs before + /// parking here too, so a forged header takes no `PendingDataColumns` row. + #[test] + fn chain_checks_drop_a_forged_header_rather_than_park_it() { + let store = store(0); + let proposer: ValidatorIndex = 3; + let head_state = fulu_parent(proposer); + store.cache_state( + CacheKey::BlockState(Root::ZERO), + Arc::new(head_state.clone()), + ); + let slot = head_state.slot() + 1; + let mut card = sidecar(slot, proposer, 0); + card.signed_block_header.message.parent_root = Root::repeat_byte(0x22); + + assert_eq!( + chain_checks(&store, &card, slot_start_ms(&store, slot)), + ChainVerdict::Drop(Outcome::Reject(RejectReason::BadSignature)) + ); + } + + #[test] + fn chain_checks_reject_a_header_from_the_wrong_proposer() { + let parent = fulu_parent(3); + let parent_root = Root::repeat_byte(0x99); + let store = store_with_parent(&parent, parent_root); + let config = store.config(); + let slot = parent.slot() + 1; + let card = signed_child(&parent, parent_root, slot, 5, 5, &config); + + assert_eq!( + chain_checks(&store, &card, slot_start_ms(&store, slot)), + ChainVerdict::Drop(Outcome::Reject(RejectReason::WrongProposer)) + ); + } + + /// Where gossip queues, `chain_checks` drops: the parent already has a + /// post-state, so no import will ever let the walk finish, and parking the + /// sidecar would only wait for an import that has already happened. + #[test] + fn chain_checks_drop_a_sidecar_whose_ancestry_walk_cannot_finish() { + let proposer: ValidatorIndex = 3; + let parent = fulu_parent(proposer); + let parent_root = Root::repeat_byte(0xaa); + let store = store_with_parent(&parent, parent_root); + let config = store.config(); + let slot = parent.slot() + 1; + let card = signed_child(&parent, parent_root, slot, proposer, proposer, &config); + + assert_eq!( + chain_checks(&store, &card, slot_start_ms(&store, slot)), + ChainVerdict::Drop(Outcome::Queue(QueueReason::ParentNotReady)) + ); + } + + /// Past the parent state's lookahead, gossip queues the sidecar; the + /// chain checks instead advance a clone of that state and judge the + /// proposer against it. The parent descends from the finalized block + /// here, so the finalized-ancestry walk passes and the placeholder + /// inclusion proof is the first rule after the proposer check to fail. + #[test] + fn past_the_lookahead_chain_checks_advance_the_parent_state_instead_of_queuing() { + let parent = fulu_parent(3); + let parent_root = Root::repeat_byte(0xbb); + let mut store = store_with_parent(&parent, parent_root); + // The parent's own parent is the finalized root (`Root::ZERO`, at + // slot 0), where the walk stops. + store.insert_live_chain_entry(parent.slot(), parent_root, Root::ZERO); + store.insert_live_chain_entry(0, Root::ZERO, Root::ZERO); + let config = store.config(); + let window_start = compute_start_slot_at_epoch(compute_epoch_at_slot(parent.slot())); + let beyond = window_start + preset::PROPOSER_LOOKAHEAD_LENGTH as Slot; + let now = slot_start_ms(&store, beyond); + let expected = + advanced_proposer(&parent, beyond, &config).expect("the test state advances"); + let other = (expected + 1) % 8; + + let from_expected = signed_child(&parent, parent_root, beyond, expected, expected, &config); + assert_eq!( + stateful_checks(&store, &from_expected), + Outcome::Reject(RejectReason::InclusionProof), + "gossip reaches the same proof, having skipped the proposer check" + ); + assert_eq!( + chain_checks(&store, &from_expected, now), + ChainVerdict::Drop(Outcome::Reject(RejectReason::InclusionProof)) + ); + + let from_other = signed_child(&parent, parent_root, beyond, other, other, &config); + assert_eq!( + chain_checks(&store, &from_other, now), + ChainVerdict::Drop(Outcome::Reject(RejectReason::WrongProposer)) + ); + } +} diff --git a/crates/blockchain/state_transition/src/beacon/gossip/mod.rs b/crates/blockchain/state_transition/src/beacon/gossip/mod.rs new file mode 100644 index 000000000..2acc7077c --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/gossip/mod.rs @@ -0,0 +1,553 @@ +//! Gossip validation for the beacon topics this node consumes. +//! +//! The rules are the specification's `validate_*_gossip` functions +//! (`p2p-interface.md`), split by cost. `cheap_checks` read only the message, +//! the clock and the store's own metadata, so the p2p actor runs them inline. +//! `stateful_checks` read states and verify signatures and proofs, so they run +//! on a blocking thread. The spec's conformance vectors run both, in order. + +pub mod aggregate; +pub mod attestation; +pub mod block; +pub mod column; +#[cfg(test)] +pub(crate) mod test_support; + +// Re-exported at the module's own top level, alongside `SeenBlocks` and +// `SeenColumns`: every seen cache lives at the same path regardless of which +// topic's submodule defines it. +pub use aggregate::SeenAggregates; +pub use attestation::SeenAttestations; + +use std::num::NonZeroUsize; + +use lru::LruCache; + +use crate::beacon::config::Config; +use crate::beacon::constants::MAXIMUM_GOSSIP_CLOCK_DISPARITY; +use crate::beacon::containers::BeaconState; +use crate::beacon::fork_choice::{self, Store}; +use crate::beacon::helpers::accessors::get_block_root_at_slot; +use crate::beacon::helpers::misc::compute_start_slot_at_epoch; +use crate::beacon::precheck::PrecheckError; +use crate::beacon::primitives::{Epoch, Root, Slot, ValidatorIndex}; + +/// A gossip message's verdict. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Outcome { + /// Propagate it, and hand the object to the chain. + Accept, + /// Do not propagate it, but hand the object to the chain, which parks it + /// until what it is missing arrives: the specification's "MAY be queued". + /// A column passes [`column::chain_checks`] on the way, since the chain + /// keeps a column without checking it. + Queue(QueueReason), + /// Do not propagate it, and drop it. Not the sender's fault. + Ignore(IgnoreReason), + /// Do not propagate it, and drop it. The sender forwarded something invalid. + Reject(RejectReason), +} + +impl Outcome { + /// `(outcome, reason)` metric label values. Both are fixed per variant, so + /// a message's contents can never add a label value. + pub fn labels(&self) -> (&'static str, &'static str) { + match self { + Self::Accept => ("accept", "valid"), + Self::Queue(reason) => ("queue", reason.label()), + Self::Ignore(reason) => ("ignore", reason.label()), + Self::Reject(reason) => ("reject", reason.label()), + } + } +} + +/// Why an object was queued rather than judged. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum QueueReason { + /// Its parent block has never been seen. + ParentUnknown, + /// Its parent is stored but has no cached post-state yet: held for its + /// columns, still importing, or evicted from the state cache. Also used + /// when the finalized-ancestry walk cannot finish: a `LiveChain` row is + /// missing for a block on the way, as after a late `invalidate_subtree`. + ParentNotReady, + /// Its slot is outside the parent state's proposer lookahead. + ShufflingUnavailable, +} + +impl QueueReason { + pub fn label(&self) -> &'static str { + match self { + Self::ParentUnknown => "parent_unknown", + Self::ParentNotReady => "parent_not_ready", + Self::ShufflingUnavailable => "shuffling_unavailable", + } + } +} + +/// Why a message was ignored. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IgnoreReason { + FutureSlot, + Finalized, + AlreadySeen, + AlreadyStored, + /// A topic this node subscribes to but has no validator for yet. + NoConsumer, + /// Every stateful-validation permit was taken. + Overloaded, + /// Validation panicked. + Internal, + /// An attestation's slot is in neither the current nor the previous epoch. + OutsideEpochWindow, + /// An aggregate adds no bit that one already accepted for the same data + /// and committee does not have. + CoveredBits, + /// The block an attestation votes for has never been seen. + UnknownBlock, + /// The voted block is known but its post-state is not cached. + StateUnavailable, + /// The finalized checkpoint is not an ancestor of the voted block. IGNORE + /// for attestations, where blocks and columns REJECT. + FinalizedNotAncestor, + /// An ancestor lies outside what the state's `block_roots` can answer. + AncestryUnknown, +} + +impl IgnoreReason { + pub fn label(&self) -> &'static str { + match self { + Self::FutureSlot => "future_slot", + Self::Finalized => "finalized", + Self::AlreadySeen => "already_seen", + Self::AlreadyStored => "already_stored", + Self::NoConsumer => "no_consumer", + Self::Overloaded => "overloaded", + Self::Internal => "internal", + Self::OutsideEpochWindow => "outside_epoch_window", + Self::CoveredBits => "covered_bits", + Self::UnknownBlock => "unknown_block", + Self::StateUnavailable => "state_unavailable", + Self::FinalizedNotAncestor => "finalized_not_ancestor", + Self::AncestryUnknown => "ancestry_unknown", + } + } +} + +/// Why a message was rejected. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RejectReason { + Decompress, + Decode, + WrongSubnet, + Malformed, + TooManyBlobs, + NotAfterParent, + WrongProposer, + UnknownProposer, + BadSignature, + FinalizedNotAncestor, + PayloadTimestamp, + InclusionProof, + Kzg, + /// An attestation's target epoch is not its slot's epoch. + EpochMismatch, + /// An aggregate with no aggregation bit set. + NoParticipants, + /// Electra and later require `data.index` to be zero. + NonZeroDataIndex, + /// An aggregate's `committee_bits` does not name exactly one committee. + CommitteeBits, + /// The committee index is not below the slot's committee count. + CommitteeIndex, + /// `aggregation_bits` is not the committee's length. + BitsLength, + /// The selection proof does not select the aggregator. + NotAggregator, + /// The aggregator or attester is not a member of the named committee. + NotInCommittee, + /// A validator index the state has no validator for. + UnknownValidator, + /// The selection proof's signature is invalid. + SelectionProof, + /// The aggregator's signature over the `AggregateAndProof` is invalid. + AggregatorSignature, + /// The aggregate attestation's own signature is invalid. + AggregateSignature, + /// The target is not the voted block's ancestor at the target epoch. + TargetNotAncestor, +} + +impl RejectReason { + pub fn label(&self) -> &'static str { + match self { + Self::Decompress => "decompress", + Self::Decode => "decode", + Self::WrongSubnet => "wrong_subnet", + Self::Malformed => "malformed", + Self::TooManyBlobs => "too_many_blobs", + Self::NotAfterParent => "not_after_parent", + Self::WrongProposer => "wrong_proposer", + Self::UnknownProposer => "unknown_proposer", + Self::BadSignature => "bad_signature", + Self::FinalizedNotAncestor => "finalized_not_ancestor", + Self::PayloadTimestamp => "payload_timestamp", + Self::InclusionProof => "inclusion_proof", + Self::Kzg => "kzg", + Self::EpochMismatch => "epoch_mismatch", + Self::NoParticipants => "no_participants", + Self::NonZeroDataIndex => "non_zero_data_index", + Self::CommitteeBits => "committee_bits", + Self::CommitteeIndex => "committee_index", + Self::BitsLength => "bits_length", + Self::NotAggregator => "not_aggregator", + Self::NotInCommittee => "not_in_committee", + Self::UnknownValidator => "unknown_validator", + Self::SelectionProof => "selection_proof", + Self::AggregatorSignature => "aggregator_signature", + Self::AggregateSignature => "aggregate_signature", + Self::TargetNotAncestor => "target_not_ancestor", + } + } +} + +impl From for RejectReason { + fn from(err: PrecheckError) -> Self { + match err { + PrecheckError::NotAfterParent { .. } => Self::NotAfterParent, + PrecheckError::WrongProposer { .. } => Self::WrongProposer, + PrecheckError::UnknownProposer { .. } => Self::UnknownProposer, + PrecheckError::BadSignature => Self::BadSignature, + } + } +} + +/// The first valid block per `(slot, proposer)`, the key the specification's +/// `seen.proposer_slots` uses. +/// +/// Bounded by capacity rather than pruned on finality, so a slot fabricated far +/// in the future cannot grow it. +pub struct SeenBlocks(LruCache<(Slot, ValidatorIndex), Root>); + +impl SeenBlocks { + pub fn new(capacity: NonZeroUsize) -> Self { + Self(LruCache::new(capacity)) + } + + pub fn contains(&self, slot: Slot, proposer: ValidatorIndex) -> bool { + self.0.contains(&(slot, proposer)) + } + + /// Record `root` as the first valid block for its `(slot, proposer)`. + /// Returns `false`, changing nothing, when one is already recorded. + pub fn record(&mut self, slot: Slot, proposer: ValidatorIndex, root: Root) -> bool { + if self.0.contains(&(slot, proposer)) { + return false; + } + self.0.put((slot, proposer), root); + true + } +} + +/// The first valid sidecar per `(slot, proposer, column index)`. +/// +/// Bounded the same way as [`SeenBlocks`]. +pub struct SeenColumns(LruCache<(Slot, ValidatorIndex, u64), ()>); + +impl SeenColumns { + pub fn new(capacity: NonZeroUsize) -> Self { + Self(LruCache::new(capacity)) + } + + pub fn contains(&self, slot: Slot, proposer: ValidatorIndex, index: u64) -> bool { + self.0.contains(&(slot, proposer, index)) + } + + /// Record the first valid sidecar for its key. Returns `false`, changing + /// nothing, when one is already recorded. + pub fn record(&mut self, slot: Slot, proposer: ValidatorIndex, index: u64) -> bool { + if self.0.contains(&(slot, proposer, index)) { + return false; + } + self.0.put((slot, proposer, index), ()); + true + } +} + +/// The specification's `compute_time_at_slot_ms`: the clock reading at the +/// start of `slot`. +pub(crate) fn slot_start_ms(config: &Config, slot: Slot) -> u64 { + config + .genesis_time_ms() + .saturating_add(slot.saturating_mul(config.slot_duration_ms)) +} + +/// The specification's `is_future_slot`: `slot` starts later than `now_ms` +/// plus the gossip clock disparity allowance. +pub(crate) fn is_future_slot(config: &Config, slot: Slot, now_ms: u64) -> bool { + slot_start_ms(config, slot) > now_ms.saturating_add(MAXIMUM_GOSSIP_CLOCK_DISPARITY) +} + +/// The specification's `is_within_epoch`: the clock, with the gossip clock +/// disparity allowance on both ends, falls somewhere in `epoch`'s own span of +/// slots. +/// +/// Built from the same two-sided bound `is_within_slot_range` states +/// generally (`phase0/p2p-interface.md`), specialised to a whole epoch's +/// worth of slots: the window's far edge is the *next* epoch's first slot, +/// since `is_within_slot_range`'s own `slot_range` argument is inclusive of +/// its start and the epoch has `SLOTS_PER_EPOCH` slots. +pub(crate) fn is_within_epoch(config: &Config, epoch: Epoch, now_ms: u64) -> bool { + let start_ms = slot_start_ms(config, compute_start_slot_at_epoch(epoch)); + if now_ms.saturating_add(MAXIMUM_GOSSIP_CLOCK_DISPARITY) < start_ms { + return false; + } + let next_epoch_start_ms = slot_start_ms(config, compute_start_slot_at_epoch(epoch + 1)); + if next_epoch_start_ms.saturating_add(MAXIMUM_GOSSIP_CLOCK_DISPARITY) < now_ms { + return false; + } + true +} + +/// The specification's `is_current_or_previous_epoch` (`deneb/p2p-interface.md`): +/// whether the clock places `epoch` within the disparity-widened current or +/// previous epoch's window. Aggregates and subnet attestations both reject +/// (as an `IGNORE`) an epoch outside this pair, on top of +/// [`is_future_slot`]'s own per-slot check: a slot can be non-future yet still +/// name an epoch more than one boundary stale, which this catches instead. +pub(crate) fn is_current_or_previous_epoch(config: &Config, epoch: Epoch, now_ms: u64) -> bool { + is_within_epoch(config, epoch, now_ms) || is_within_epoch(config, epoch + 1, now_ms) +} + +/// Which block `state`'s own history names as the ancestor at `slot`, given +/// that `state` is `at_block_root`'s post-state. +/// +/// `get_block_root_at_slot` only answers for a slot strictly before the +/// state's own (a state cannot look up the `block_roots` entry its own block +/// is about to write). `at_block_root` is the right answer for its own slot +/// and, since every caller here only ever asks for a checkpoint at or before +/// the vote block, for anything at or after it too. `None` means the slot +/// lies outside `state`'s `SLOTS_PER_HISTORICAL_ROOT` window: this state +/// simply cannot answer, which is not the same as there being no ancestor, +/// so callers treat it as unknown (`IGNORE`) rather than a failed check +/// (`REJECT`). +/// +/// Shared by [`aggregate`] and [`attestation`] for both of their ancestry +/// checks (the target checkpoint and the finalized checkpoint), since both +/// read it off the same vote block's post-state; see that state's choice +/// documented on [`aggregate::stateful_checks`]. +pub(crate) fn ancestor_at(state: &BeaconState, at_block_root: Root, slot: Slot) -> Option { + if slot >= state.slot() { + Some(at_block_root) + } else { + get_block_root_at_slot(state, slot).ok() + } +} + +/// The first slot of the store's finalized epoch. +pub(crate) fn finalized_start_slot(store: &Store) -> Slot { + compute_start_slot_at_epoch(store.beacon_finalized_checkpoint().epoch) +} + +/// Where a block's chain stands relative to the finalized checkpoint. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum FinalizedAncestry { + /// The finalized checkpoint is an ancestor. + Descends, + /// The chain passes the finalized epoch at a different block: a fork. + Conflicts, + /// The walk could not finish: a block on the way has no `LiveChain` row. + Unknown, +} + +impl FinalizedAncestry { + /// The verdict this ancestry alone dictates, shared by `block` and + /// `column`'s `stateful_checks`. + pub(crate) fn verdict(self) -> Result<(), Outcome> { + match self { + Self::Descends => Ok(()), + Self::Conflicts => Err(Outcome::Reject(RejectReason::FinalizedNotAncestor)), + Self::Unknown => Err(Outcome::Queue(QueueReason::ParentNotReady)), + } + } +} + +/// Where `root`'s chain stands relative to the finalized checkpoint. The same +/// walk the chain actor's `parent_is_on_the_finalized_chain` does, but +/// three-way rather than a bool: `fork_choice::get_checkpoint_block` erroring +/// means a row is missing from `LiveChain`, which happens after +/// `fork_choice::invalidate_subtree` deletes a late-invalidated parent's rows +/// while its cached state stays, not that `root`'s chain has forked away from +/// the finalized checkpoint. A caller that folded that into "not an ancestor" +/// would REJECT a child of a parent whose payload was invalidated, penalizing +/// peers who forwarded it before they learned of the invalidation, where the +/// specification asks for IGNORE. +pub(crate) fn finalized_ancestry(store: &Store, root: Root) -> FinalizedAncestry { + let finalized = store.beacon_finalized_checkpoint(); + let index = store.block_index(); + match fork_choice::get_checkpoint_block(&index, root, finalized.epoch) { + Ok(ancestor) if ancestor == finalized.root => FinalizedAncestry::Descends, + Ok(_) => FinalizedAncestry::Conflicts, + Err(_) => FinalizedAncestry::Unknown, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn capacity(n: usize) -> NonZeroUsize { + NonZeroUsize::new(n).expect("non-zero") + } + + #[test] + fn a_slot_is_future_only_past_the_clock_disparity() { + let config = Config { + genesis_time: 100, + ..Config::mainnet() + }; + let slot_start = 100_000 + config.slot_duration_ms; + assert!(!is_future_slot( + &config, + 1, + slot_start - MAXIMUM_GOSSIP_CLOCK_DISPARITY + )); + assert!(is_future_slot( + &config, + 1, + slot_start - MAXIMUM_GOSSIP_CLOCK_DISPARITY - 1 + )); + } + + #[test] + fn a_block_key_records_once() { + let mut seen = SeenBlocks::new(capacity(4)); + assert!(!seen.contains(10, 3)); + assert!(seen.record(10, 3, Root::repeat_byte(1))); + assert!(seen.contains(10, 3)); + // An equivocating second block for the same key does not replace it. + assert!(!seen.record(10, 3, Root::repeat_byte(2))); + assert!(!seen.contains(10, 4)); + } + + #[test] + fn a_column_key_records_once_per_index() { + let mut seen = SeenColumns::new(capacity(4)); + assert!(seen.record(10, 3, 0)); + assert!(!seen.record(10, 3, 0)); + assert!(seen.record(10, 3, 1)); + } + + #[test] + fn the_caches_forget_their_oldest_entry_past_capacity() { + let mut seen = SeenBlocks::new(capacity(2)); + seen.record(1, 0, Root::ZERO); + seen.record(2, 0, Root::ZERO); + seen.record(3, 0, Root::ZERO); + assert!(!seen.contains(1, 0)); + assert!(seen.contains(3, 0)); + } + + #[test] + fn labels_name_the_outcome_and_the_reason() { + assert_eq!(Outcome::Accept.labels(), ("accept", "valid")); + assert_eq!( + Outcome::Queue(QueueReason::ParentUnknown).labels(), + ("queue", "parent_unknown") + ); + assert_eq!( + Outcome::Reject(RejectReason::from(PrecheckError::BadSignature)).labels(), + ("reject", "bad_signature") + ); + } + + #[test] + fn an_epoch_window_holds_only_within_the_clock_disparity_at_either_edge() { + let config = Config { + genesis_time: 0, + ..Config::mainnet() + }; + let epoch = 5; + let start_ms = slot_start_ms(&config, compute_start_slot_at_epoch(epoch)); + let next_start_ms = slot_start_ms(&config, compute_start_slot_at_epoch(epoch + 1)); + + // The near edge: the disparity allowance lets the clock run early. + assert!(is_within_epoch( + &config, + epoch, + start_ms - MAXIMUM_GOSSIP_CLOCK_DISPARITY + )); + assert!(!is_within_epoch( + &config, + epoch, + start_ms - MAXIMUM_GOSSIP_CLOCK_DISPARITY - 1 + )); + // The far edge: the same allowance lets the clock run late. + assert!(is_within_epoch( + &config, + epoch, + next_start_ms + MAXIMUM_GOSSIP_CLOCK_DISPARITY + )); + assert!(!is_within_epoch( + &config, + epoch, + next_start_ms + MAXIMUM_GOSSIP_CLOCK_DISPARITY + 1 + )); + } + + #[test] + fn current_or_previous_epoch_covers_exactly_two_epochs() { + use crate::beacon::preset; + + let config = Config { + genesis_time: 0, + ..Config::mainnet() + }; + let epoch = 5; + // Comfortably inside the epoch rather than at either boundary: right + // at a boundary, the clock disparity allowance deliberately makes + // the adjacent epoch's window overlap too (covered by + // `an_epoch_window_holds_only_within_the_clock_disparity_at_either_edge`), + // which would widen this to three epochs instead of the two this + // test means to pin down. + let now_ms = slot_start_ms(&config, compute_start_slot_at_epoch(epoch)) + + preset::SLOTS_PER_EPOCH / 2 * config.slot_duration_ms; + + assert!(is_current_or_previous_epoch(&config, epoch, now_ms)); + assert!(is_current_or_previous_epoch(&config, epoch - 1, now_ms)); + assert!(!is_current_or_previous_epoch(&config, epoch - 2, now_ms)); + assert!(!is_current_or_previous_epoch(&config, epoch + 1, now_ms)); + } + + #[test] + fn ancestor_at_answers_itself_at_or_after_its_own_slot_and_block_roots_before_it() { + use crate::beacon::preset; + + let mut state = crate::beacon::helpers::test_state::with_validators(4); + let at_block_root = Root::repeat_byte(0xaa); + let historical_slot = state.slot() - 1; + let historical_root = Root::repeat_byte(0x11); + state.block_roots_mut()[historical_slot as usize % preset::SLOTS_PER_HISTORICAL_ROOT] = + historical_root; + + // Strictly before the state's own slot: reads `block_roots`. + assert_eq!( + ancestor_at(&state, at_block_root, historical_slot), + Some(historical_root) + ); + // At, or after, the state's own slot: the block itself. + assert_eq!( + ancestor_at(&state, at_block_root, state.slot()), + Some(at_block_root) + ); + assert_eq!( + ancestor_at(&state, at_block_root, state.slot() + 5), + Some(at_block_root) + ); + + // Older than the state's `SLOTS_PER_HISTORICAL_ROOT` window: this + // state cannot answer, so the ancestor is unknown rather than absent. + *state.slot_mut() = preset::SLOTS_PER_HISTORICAL_ROOT as Slot + 10; + assert_eq!(ancestor_at(&state, at_block_root, 0), None); + } +} diff --git a/crates/blockchain/state_transition/src/beacon/gossip/test_support.rs b/crates/blockchain/state_transition/src/beacon/gossip/test_support.rs new file mode 100644 index 000000000..dd35862cb --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/gossip/test_support.rs @@ -0,0 +1,94 @@ +//! Test scaffolding shared by `block`'s, `column`'s, `aggregate`'s and +//! `attestation`'s unit tests: a bare store anchored at a chosen finalized +//! slot, a keyed fulu parent state whose lookahead names one proposer, and +//! the seen-cache and clock helpers every module's tests build on. +//! +//! `precheck.rs` keeps its own `fulu_parent`: it is built through that +//! module's own multi-fork `keyed_state` helper, which this module has no use +//! for, so its shape genuinely differs rather than merely being copy-pasted. + +use std::num::NonZeroUsize; +use std::sync::Arc; + +use ethlambda_storage::backend::InMemoryBackend; +use ethlambda_types::checkpoint::Checkpoint; + +use super::aggregate::SeenAggregates; +use super::attestation::SeenAttestations; +use super::{SeenBlocks, SeenColumns}; +use crate::beacon::config::Config; +use crate::beacon::containers::BeaconState; +use crate::beacon::fork::ForkName; +use crate::beacon::fork_choice::Store; +use crate::beacon::helpers::test_state::{secret_key_for, with_validators_at}; +use crate::beacon::primitives::{BlsPubkey, Root, Slot, ValidatorIndex}; + +pub(crate) const GENESIS_TIME: u64 = 1_000; + +/// A store with no blocks, finalized at `finalized_slot`, fulu from genesis. +pub(crate) fn store(finalized_slot: Slot) -> Store { + Store::init_beacon( + Arc::new(InMemoryBackend::new()), + GENESIS_TIME, + Config::mainnet().with_fork_epoch(ForkName::Fulu, 0), + Root::ZERO, + Checkpoint { + root: Root::ZERO, + slot: finalized_slot, + }, + finalized_slot, + ) +} + +/// The millisecond clock reading at the start of `slot`, under `store`'s +/// config. +pub(crate) fn slot_start_ms(store: &Store, slot: Slot) -> u64 { + let config = store.config(); + config.genesis_time_ms() + slot * config.slot_duration_ms +} + +/// An empty [`SeenBlocks`] cache, sized generously for a test's handful of +/// messages. +pub(crate) fn seen_blocks() -> SeenBlocks { + SeenBlocks::new(NonZeroUsize::new(8).expect("non-zero")) +} + +/// The [`seen_blocks`] counterpart for columns. +pub(crate) fn seen_columns() -> SeenColumns { + SeenColumns::new(NonZeroUsize::new(8).expect("non-zero")) +} + +/// The [`seen_blocks`] counterpart for aggregates: both capacities sized the +/// same generous way, since a test's handful of messages never approaches +/// either bound. +pub(crate) fn seen_aggregates() -> SeenAggregates { + let capacity = NonZeroUsize::new(8).expect("non-zero"); + SeenAggregates::new(capacity, capacity) +} + +/// The [`seen_blocks`] counterpart for subnet attestations. +pub(crate) fn seen_attestations() -> SeenAttestations { + SeenAttestations::new(NonZeroUsize::new(8).expect("non-zero")) +} + +/// A fulu state of eight keyed validators whose lookahead names `proposer` +/// for every slot in its window. +pub(crate) fn fulu_parent(proposer: ValidatorIndex) -> BeaconState { + let mut state = with_validators_at(ForkName::Fulu, 8); + for index in 0..8 { + state + .validator_mut(index as ValidatorIndex) + .expect("eight validators") + .pubkey = BlsPubkey(secret_key_for(index).sk_to_pk().to_bytes()); + } + if let BeaconState::Fulu(fulu_state) = &mut state { + for entry in fulu_state.proposer_lookahead.iter_mut() { + *entry = proposer; + } + } + // The pubkey writes above are buffered in the registry tree, and the + // tests cache this state behind an `Arc`, which `Store::cache_state` + // refuses to hold unflushed. + state.apply_pending_mutations(); + state +} diff --git a/crates/blockchain/state_transition/src/beacon/hash.rs b/crates/blockchain/state_transition/src/beacon/hash.rs new file mode 100644 index 000000000..8e135994a --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/hash.rs @@ -0,0 +1,40 @@ +//! SHA-256, the specification's `hash` function. + +use sha2::{Digest, Sha256}; + +use crate::beacon::primitives::{Bytes32, H256}; + +/// The specification's `hash(data)`. +pub fn hash(data: &[u8]) -> Bytes32 { + H256(Sha256::digest(data).into()) +} + +/// Hashes the concatenation of two byte strings. +/// +/// The specification writes this as `hash(a + b)`, which appears in seed +/// derivation, the shuffling, and merkle proof verification. +pub fn hash_concat(a: &[u8], b: &[u8]) -> Bytes32 { + let mut hasher = Sha256::new(); + hasher.update(a); + hasher.update(b); + H256(hasher.finalize().into()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn matches_known_digest() { + // The SHA-256 of the empty string. + assert_eq!( + hex::encode(hash(&[]).0), + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + } + + #[test] + fn concat_matches_hashing_the_joined_bytes() { + assert_eq!(hash_concat(b"ab", b"cd"), hash(b"abcd")); + } +} diff --git a/crates/blockchain/state_transition/src/beacon/helpers/accessors.rs b/crates/blockchain/state_transition/src/beacon/helpers/accessors.rs new file mode 100644 index 000000000..677e895e8 --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/helpers/accessors.rs @@ -0,0 +1,670 @@ +//! Beacon state accessors. +//! +//! The specification reads its constants from global scope. Here the preset +//! values are compile-time constants, but the configuration values are not, so +//! any accessor needing one takes a [`Config`]. That is the only systematic +//! difference between these signatures and the spec's. + +use std::sync::Arc; + +// Re-exported at this old path (`crate::beacon::helpers::accessors::CommitteeCache`, +// and so on), so every existing import of the type this module used to define +// is unaffected by its move to `ethlambda-storage`; see [`CommitteeCacheExt`] +// below for what this crate still contributes. +pub use ethlambda_storage::{CommitteeCache, Lookup, ShufflingKey}; + +use crate::beacon::config::Config; +use crate::beacon::constants; +use crate::beacon::containers::BeaconState; +use crate::beacon::error::Result; +use crate::beacon::fork::ForkName; +use crate::beacon::hash::hash; +use crate::beacon::preset; +use crate::beacon::primitives::{ + Bytes32, CommitteeIndex, Domain, DomainType, Epoch, Gwei, Root, Slot, ValidatorIndex, +}; + +use super::misc::{ + compute_domain, compute_epoch_at_slot, compute_start_slot_at_epoch, fork_version_at_epoch, +}; +use super::predicates::is_active_validator; +use super::shuffling::{compute_committee, shuffle_list}; + +// [`EpochCommittees`] and the position arithmetic it shares with +// [`get_beacon_committee`] now live in `ethlambda-types`, re-exported here at +// their old path: see that crate's `beacon::committees` module for why, and +// [`build_epoch_committees`] below for the derivation that stays on this +// side. +pub use crate::beacon::committees::{EpochCommittees, committee_number}; + +/// The epoch the state is currently in. +pub fn get_current_epoch(state: &BeaconState) -> Epoch { + compute_epoch_at_slot(state.slot()) +} + +/// The epoch before the current one, clamped at genesis. +/// +/// Clamped rather than allowed to underflow, since the genesis epoch has no +/// predecessor but the reward and justification logic still asks for one. +pub fn get_previous_epoch(state: &BeaconState) -> Epoch { + let current = get_current_epoch(state); + if current == constants::GENESIS_EPOCH { + constants::GENESIS_EPOCH + } else { + current - 1 + } +} + +/// The block root at a recent slot. +/// +/// Fails outside the retained window: the state keeps only +/// `SLOTS_PER_HISTORICAL_ROOT` roots, so asking for an older slot is a fault +/// rather than a miss. +pub fn get_block_root_at_slot(state: &BeaconState, slot: Slot) -> Result { + crate::beacon::verify( + slot < state.slot() && state.slot() <= slot + preset::SLOTS_PER_HISTORICAL_ROOT as u64, + "slot < state.slot <= slot + SLOTS_PER_HISTORICAL_ROOT", + )?; + Ok(state.block_roots()[slot as usize % preset::SLOTS_PER_HISTORICAL_ROOT]) +} + +/// The block root at the start of a recent epoch, which is what a checkpoint +/// names. +pub fn get_block_root(state: &BeaconState, epoch: Epoch) -> Result { + get_block_root_at_slot(state, compute_start_slot_at_epoch(epoch)) +} + +/// The randao mix at a recent epoch. +pub fn get_randao_mix(state: &BeaconState, epoch: Epoch) -> Bytes32 { + state.randao_mix(epoch) +} + +/// The validators active at `epoch`. +pub fn get_active_validator_indices(state: &BeaconState, epoch: Epoch) -> Vec { + state + .validators() + .iter() + .enumerate() + .filter(|(_, validator)| is_active_validator(validator, epoch)) + .map(|(index, _)| index as ValidatorIndex) + .collect() +} + +/// How many validators may enter or leave per epoch. +/// +/// Proportional to the active set, with a floor, so that a small chain still +/// makes progress and a large one cannot be turned over quickly enough to +/// threaten finality. +pub fn get_validator_churn_limit(state: &BeaconState, config: &Config) -> u64 { + let active = get_active_validator_indices(state, get_current_epoch(state)).len() as u64; + config + .min_per_epoch_churn_limit + .max(active / config.churn_limit_quotient) +} + +/// The seed for `epoch` and `domain_type`. +/// +/// The mix is read from far enough back that the seed for an epoch is fixed +/// before that epoch's committees matter, which is what makes shuffling +/// unpredictable but not manipulable. The specification adds +/// `EPOCHS_PER_HISTORICAL_VECTOR` before subtracting to avoid underflowing near +/// genesis, and this keeps that form. +pub fn get_seed(state: &BeaconState, epoch: Epoch, domain_type: DomainType) -> Bytes32 { + let lookback = + epoch + preset::EPOCHS_PER_HISTORICAL_VECTOR as u64 - preset::MIN_SEED_LOOKAHEAD - 1; + let mix = get_randao_mix(state, lookback); + + let mut input = Vec::with_capacity(4 + 8 + 32); + input.extend_from_slice(&domain_type); + input.extend_from_slice(&epoch.to_le_bytes()); + input.extend_from_slice(&mix.0); + hash(&input) +} + +/// How many committees a slot with `active_count` active validators splits +/// into: at least one, so a small chain still produces committees, and at +/// most `MAX_COMMITTEES_PER_SLOT`. +/// +/// The half of [`get_committee_count_per_slot`] that does not need a state, +/// split out so [`build_epoch_committees`] can share one +/// [`get_active_validator_indices`] scan between this and its own committee +/// derivation, rather than [`get_committee_count_per_slot`] repeating the scan +/// the caller already did to get `active_count` in the first place. +fn committee_count_per_slot(active_count: u64) -> u64 { + let ideal = active_count / preset::SLOTS_PER_EPOCH / preset::TARGET_COMMITTEE_SIZE; + ideal.clamp(1, preset::MAX_COMMITTEES_PER_SLOT as u64) +} + +/// How many committees each slot of `epoch` has. +/// +/// At least one, so a small chain still produces committees, and at most +/// `MAX_COMMITTEES_PER_SLOT`. +pub fn get_committee_count_per_slot(state: &BeaconState, epoch: Epoch) -> u64 { + committee_count_per_slot(get_active_validator_indices(state, epoch).len() as u64) +} + +/// `epoch`'s committees as `state` names them: one scan of the active +/// validator set, this epoch's shuffle seed, and an in-place shuffle over +/// that set. +/// +/// This is the derivation half of what used to be `EpochCommittees::new` +/// before that type moved to `ethlambda-types` (see this module's top-level +/// re-export): the type itself cannot depend on this crate's shuffle +/// computation ([`shuffle_list`]) or its [`hash`](crate::beacon::hash) +/// module, so the derivation stays a free function here rather than a method +/// on the type. [`CommitteeCacheExt::committees`] is this function's only +/// caller outside tests; every other consumer goes through the cache. +/// +/// Electra's `get_attesting_indices` needs one committee per bit set in one +/// attestation's `committee_bits`, up to `MAX_COMMITTEES_PER_SLOT` of them, +/// and a block carries up to `MAX_ATTESTATIONS_ELECTRA` attestations; +/// `stf::electra::process_attestation` walks the same committees again to +/// check the aggregation-bit lengths, and fork choice walks them a third time +/// when it replays the block's attestations into the latest-message store. +/// Derived one at a time, each of those committees costs a scan of the whole +/// validator registry plus a `SHUFFLE_ROUND_COUNT`-round shuffle *per member*. +/// Shared through one [`EpochCommittees`], they cost one scan and one +/// whole-epoch shuffle for the lot; see that type's own documentation for why +/// its members are stored already shuffled, and why it is worth building only +/// when several committees will follow. +/// +/// # Why the active set is not memoized on `epoch` or `seed` alone +/// +/// [`get_active_validator_indices`] reads `activation_epoch` and `exit_epoch` +/// off every validator in `state.validators()`, so it is a function of the +/// state's registry, not of `epoch` or `seed` alone. Two different states can +/// share an epoch number, or even a seed (it comes from a RANDAO mix fixed +/// before either state's fork point, so two sibling branches diverging +/// afterward share it exactly) while disagreeing on which validators are +/// active. That is precisely the situation fork choice holds concurrent +/// states for, and precisely what the spec fixtures construct on purpose. A +/// cross-call cache therefore has to key on the state's *history*, which is +/// what [`ShufflingKey`] does; see [`shuffling_key`]. +pub fn build_epoch_committees(state: &BeaconState, epoch: Epoch) -> EpochCommittees { + let active_indices = get_active_validator_indices(state, epoch); + let committees_per_slot = committee_count_per_slot(active_indices.len() as u64); + let seed = get_seed(state, epoch, constants::DOMAIN_BEACON_ATTESTER); + // The active set's own buffer becomes the shuffled set, so a build holds + // one validator-sized list at a time, not a permutation and a gathered + // copy beside it. + let shuffled = shuffle_list(active_indices, seed); + EpochCommittees::new(epoch, shuffled, committees_per_slot) +} + +/// `epoch`'s shuffling key as `state` sees it, or `None` if `state` cannot +/// name the deciding block: the state is not yet past the deciding slot (the +/// genesis state, asked about its own first epochs, is the case that reaches +/// this), or the deciding slot has fallen out of the state's +/// `SLOTS_PER_HISTORICAL_ROOT` window. +/// +/// `None` is not a failure. It means this lookup cannot be keyed, so the +/// caller derives the committees for itself alone, without caching them. +/// +/// [`ShufflingKey`] (`ethlambda-storage`) is the same key lighthouse calls an +/// `AttestationShufflingId`, and for the same reason. An epoch `E`'s +/// committees are fixed by two values and nothing else: the active validator +/// set at `E`, and the shuffle seed at `E`. The seed is the RANDAO mix from +/// epoch `E - MIN_SEED_LOOKAHEAD - 1`, complete once that epoch ends. The +/// active set moves only through `activation_epoch` and `exit_epoch`, and +/// every assignment to either (`process_registry_updates`, +/// `initiate_validator_exit`, and the consolidations and slashings that reach +/// it) goes through `compute_activation_exit_epoch`, which lands at least +/// `MAX_SEED_LOOKAHEAD` epochs ahead of the epoch making the change. So no +/// block after the end of `E - 2` can alter either input. +/// +/// The block root at the last slot of `E - 2` therefore identifies the +/// history that determines `E`'s committees: two states agreeing on it agree +/// on the committees of `E`, however much they disagree about everything +/// since. Epoch and decision root together are what makes a cross-state cache +/// sound where `epoch` or `seed` alone would not be. +/// +/// # Epochs 0 and 1 +/// +/// Neither has an `E - 2` to end, so both take the genesis block, at slot 0, +/// as their deciding block, which is what lighthouse's saturating decision +/// slot does too. Nothing after genesis can reach either input for them. +/// Their seeds read the two RANDAO mixes just below +/// `EPOCHS_PER_HISTORICAL_VECTOR`, which no block writes until the chain is +/// nearly that many epochs old, and by then slot 0 has long left every +/// state's `SLOTS_PER_HISTORICAL_ROOT` window, so the key can no longer be +/// named. A change to the active set made at any epoch lands at least +/// `MAX_SEED_LOOKAHEAD` epochs later, which is past both. +fn shuffling_key(state: &BeaconState, epoch: Epoch) -> Option { + // Saturating, so that epochs 0 and 1 land on the genesis block's slot; + // see this function's documentation for why that is sound. + let decision_slot = compute_start_slot_at_epoch(epoch.saturating_sub(1)).saturating_sub(1); + let decision_root = get_block_root_at_slot(state, decision_slot).ok()?; + Some(ShufflingKey { + epoch, + decision_root, + }) +} + +/// Extends `ethlambda-storage`'s [`CommitteeCache`] with the consensus logic +/// that keys and derives its entries. +/// +/// Kept here, as a trait implemented for a foreign type, rather than as +/// inherent methods on [`CommitteeCache`] itself, because deriving a key or +/// an [`EpochCommittees`] needs a [`BeaconState`]: `ethlambda-storage` cannot +/// depend on this crate (state transition depends on storage, not the other +/// way around), so it cannot implement these methods itself. A call site +/// that used to hold `committees: &mut CommitteeCache` now holds +/// `committees: &CommitteeCache` (or an `Arc` it derefs +/// through) plus this trait in scope. +pub trait CommitteeCacheExt { + /// `epoch`'s committees as `state` names them, derived once and then + /// served to every later caller naming the same shuffling. + /// + /// Hands back an `Arc` rather than a borrow so the caller can go on to + /// mutate the state (which block processing does between attestations) + /// while still holding the committees. They stay valid across those + /// mutations for exactly the reason [`shuffling_key`] gives, and nothing + /// in the cache borrows the state it was built from. + fn committees(&self, state: &BeaconState, epoch: Epoch) -> Arc; + + /// Pins the shufflings the canonical head's children will ask for, so + /// eviction never drops them: `head_state`'s previous, current, and next + /// epochs'. `head_state` must be block `head_root`'s post-state. + /// + /// The keys come from `head_state`'s own `block_roots`, the way every + /// lookup computes its own. Each epoch's deciding slot falls before the + /// head's slot, and a descendant of the head inherits every root below + /// that slot unchanged, so a lookup from any state built on the head + /// names exactly these keys. Only the pinning is replaced here: whatever + /// the previous head pinned stays resident until an insertion evicts it + /// on epoch like any other entry. + fn update_head(&self, head_root: Root, head_state: &BeaconState); +} + +impl CommitteeCacheExt for CommitteeCache { + fn committees(&self, state: &BeaconState, epoch: Epoch) -> Arc { + let Some(key) = shuffling_key(state, epoch) else { + // Unkeyable: derive it for this caller alone rather than risk + // serving it to a state whose history was never compared. + crate::metrics::inc_committee_cache_lookups("unkeyable"); + return Arc::new(build_epoch_committees(state, epoch)); + }; + + let (committees, lookup) = self.get_or_init(key, || build_epoch_committees(state, epoch)); + crate::metrics::inc_committee_cache_lookups(match lookup { + Lookup::Hit => "hit", + Lookup::Miss => "miss", + }); + committees + } + + fn update_head(&self, head_root: Root, head_state: &BeaconState) { + let current = get_current_epoch(head_state); + let epochs = [get_previous_epoch(head_state), current, current + 1]; + let keys = epochs.map(|epoch| shuffling_key(head_state, epoch)); + self.pin_head(head_root, keys); + } +} + +/// The committee at `slot` with index `index`. +/// +/// One epoch's active set is shuffled once and then split across every slot and +/// committee of that epoch, so the committee index is a position within that +/// single split rather than an independent draw. +/// +/// The specification's own per-member derivation: one active-set scan, then +/// one `SHUFFLE_ROUND_COUNT`-round shuffle for each member of this committee +/// alone. That is the cheapest way to get one committee and the most +/// expensive way to get many, so anything deriving several committees of an +/// epoch should hold a [`CommitteeCache`] and go through +/// [`CommitteeCacheExt::committees`] instead; see [`EpochCommittees`] for what +/// the difference costs. +/// +/// Kept apart from [`EpochCommittees`] rather than built on it, so the tests +/// holding that type to this function compare two independent derivations +/// of the same committee. +pub fn get_beacon_committee( + state: &BeaconState, + slot: Slot, + index: CommitteeIndex, +) -> Result> { + let epoch = compute_epoch_at_slot(slot); + let active_indices = get_active_validator_indices(state, epoch); + let committees_per_slot = committee_count_per_slot(active_indices.len() as u64); + compute_committee( + &active_indices, + get_seed(state, epoch, constants::DOMAIN_BEACON_ATTESTER), + committee_number(slot, committees_per_slot, index)?, + committees_per_slot * preset::SLOTS_PER_EPOCH, + ) +} + +/// The proposer for the state's current slot, dispatching on fork for the +/// two places `compute_proposer_index` (`beacon-chain.md`'s "Misc" section) +/// changes: the acceptance test electra widens (EIP-7251), and fulu's move to +/// a precomputed lookahead window instead of a shuffle run on demand +/// (EIP-7917). +/// +/// Every fork-invariant caller in this module (block header validation, +/// RANDAO, slashing's proposer reward, and every driver in [`crate::beacon::stf`] +/// that reads a block's proposer) reaches this function unconditionally, with +/// no fork of its own to dispatch on, so the dispatch has to live here rather +/// than at each of those call sites. That is also why this cannot simply stay +/// [`super::shuffling::compute_proposer_index`] called with a different +/// `max_effective_balance`: electra's own version +/// ([`super::electra::compute_proposer_index`]) changes the width of the +/// random draw itself, not only the ceiling it is weighed against, and fulu's +/// version ([`super::fulu::get_beacon_proposer_index`]) does not shuffle at +/// all. +pub fn get_beacon_proposer_index(state: &BeaconState) -> Result { + // Fulu moves this off the read path entirely: `process_proposer_lookahead` + // (an epoch-processing step, not implemented in this module) precomputes + // the whole window ahead of time, so this becomes a lookup into it rather + // than a shuffle run now. See `crate::beacon::helpers::fulu`'s own module docs for + // why a seed, and therefore a proposer, is only ever knowable that far + // ahead of time in the first place. + if state.fork_name() == ForkName::Fulu { + return super::fulu::get_beacon_proposer_index(state); + } + + let epoch = get_current_epoch(state); + + let seed_base = get_seed(state, epoch, constants::DOMAIN_BEACON_PROPOSER); + let mut input = Vec::with_capacity(40); + input.extend_from_slice(&seed_base.0); + input.extend_from_slice(&state.slot().to_le_bytes()); + let seed = hash(&input); + + let indices = get_active_validator_indices(state, epoch); + if state.fork_name() == ForkName::Electra { + super::electra::compute_proposer_index(&indices, seed, |index| { + Ok(state.validator(index)?.effective_balance) + }) + } else { + super::shuffling::compute_proposer_index( + &indices, + seed, + preset::MAX_EFFECTIVE_BALANCE, + |index| Ok(state.validator(index)?.effective_balance), + ) + } +} + +/// The combined effective balance of `indices`. +/// +/// Floored at one increment so that callers dividing by it cannot divide by zero, +/// which is why the specification defines it this way rather than as a plain sum. +pub fn get_total_balance(state: &BeaconState, indices: &[ValidatorIndex]) -> Result { + let mut total: Gwei = 0; + for index in indices { + total = total.saturating_add(state.validator(*index)?.effective_balance); + } + Ok(total.max(preset::EFFECTIVE_BALANCE_INCREMENT)) +} + +/// The combined effective balance of the currently active validators. +pub fn get_total_active_balance(state: &BeaconState) -> Result { + let indices = get_active_validator_indices(state, get_current_epoch(state)); + get_total_balance(state, &indices) +} + +/// The signing domain for `domain_type` at `epoch`, or at the current epoch when +/// none is given. +pub fn get_domain(state: &BeaconState, domain_type: DomainType, epoch: Option) -> Domain { + let epoch = epoch.unwrap_or_else(|| get_current_epoch(state)); + let fork_version = fork_version_at_epoch(state.fork(), epoch); + compute_domain(domain_type, fork_version, state.genesis_validators_root()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::beacon::helpers::test_state::with_validators; + + #[test] + fn previous_epoch_is_clamped_at_genesis() { + let mut state = crate::beacon::helpers::test_state::with_validators(4); + *state.slot_mut() = 0; + assert_eq!(get_previous_epoch(&state), constants::GENESIS_EPOCH); + + *state.slot_mut() = preset::SLOTS_PER_EPOCH * 3; + assert_eq!(get_previous_epoch(&state), 2); + } + + #[test] + fn block_root_outside_the_window_is_an_error() { + let state = crate::beacon::helpers::test_state::with_validators(4); + // The current slot itself is not retained: the window is strictly past. + assert!(get_block_root_at_slot(&state, state.slot()).is_err()); + assert!(get_block_root_at_slot(&state, state.slot() - 1).is_ok()); + } + + #[test] + fn committees_cover_every_active_validator_once_per_epoch() { + // Across a whole epoch, every active validator must be assigned exactly + // one committee slot, since the epoch's committees are one permutation + // split up. + let count = 64; + let state = crate::beacon::helpers::test_state::with_validators(count); + let epoch = get_current_epoch(&state); + let per_slot = get_committee_count_per_slot(&state, epoch); + + let mut all = Vec::new(); + for slot_offset in 0..preset::SLOTS_PER_EPOCH { + let slot = compute_start_slot_at_epoch(epoch) + slot_offset; + for index in 0..per_slot { + all.extend(get_beacon_committee(&state, slot, index).unwrap()); + } + } + all.sort_unstable(); + assert_eq!(all, (0..count as u64).collect::>()); + } + + /// The whole-epoch permutation must place each committee exactly where the + /// specification's own per-member derivation does. [`get_beacon_committee`] + /// still derives committees that way, so this pins the two to each other + /// rather than to a recorded expectation: a divergence here is a consensus + /// split, and it would not show up as a panic or an out-of-range index, + /// only as a different committee. + /// + /// Run at two registry sizes, neither a multiple of the epoch's committee + /// count, so the split's rounding puts committees of different lengths side + /// by side; and the larger past the size that gives each slot more than one + /// committee. An even split with one committee per slot would leave both of + /// those boundary computations untested. + #[test] + fn sliced_committees_match_the_per_member_derivation() { + let several_per_slot = preset::SLOTS_PER_EPOCH * preset::TARGET_COMMITTEE_SIZE * 2 + 1; + + for count in [100, several_per_slot as usize] { + let state = with_validators(count); + let epoch = get_current_epoch(&state); + let committees = build_epoch_committees(&state, epoch); + let per_slot = committees.committees_per_slot(); + if count as u64 == several_per_slot { + assert!( + per_slot > 1, + "{count} validators gave one committee per slot" + ); + } + + let mut lengths = Vec::new(); + for slot_offset in 0..preset::SLOTS_PER_EPOCH { + let slot = compute_start_slot_at_epoch(epoch) + slot_offset; + for index in 0..per_slot { + let sliced = committees.committee(slot, index).unwrap(); + let expected = get_beacon_committee(&state, slot, index).unwrap(); + assert_eq!( + sliced, + expected.as_slice(), + "{count} validators, slot {slot}, committee {index}" + ); + lengths.push(sliced.len()); + } + } + assert_ne!( + lengths.iter().min(), + lengths.iter().max(), + "{count} validators split evenly, so the rounding went untested" + ); + } + } + + /// A second lookup of the same `(state, epoch)` must serve the first + /// lookup's `EpochCommittees`, which is the whole point of the cache: the + /// shuffling is what an import spends its time on, and every attestation in + /// a block asks for the same one. + #[test] + fn a_repeat_lookup_is_served_from_the_cache() { + // Far enough in that the state can name epoch `slot`'s deciding block; + // the genesis state cannot key its own epochs, which + // `an_unkeyable_epoch_is_not_cached` covers separately. + let mut state = with_validators(64); + *state.slot_mut() = preset::SLOTS_PER_EPOCH * 4; + let epoch = get_current_epoch(&state); + let cache = CommitteeCache::default(); + + let first = cache.committees(&state, epoch); + let second = cache.committees(&state, epoch); + + assert!( + Arc::ptr_eq(&first, &second), + "the second lookup rebuilt the shuffling instead of reusing it" + ); + } + + /// The head's previous, current, and next shufflings survive eviction + /// pressure from many other distinct branches, even though the epoch rule + /// alone would drop the oldest of them first. Pinning is checked + /// externally, through [`CommitteeCacheExt::committees`] and + /// `Arc::ptr_eq`, rather than by inspecting `CommitteeCache`'s own + /// entries: those are private to `ethlambda-storage`, whose own tests + /// cover the eviction bookkeeping directly. What this test covers instead + /// is [`CommitteeCacheExt::update_head`]'s derivation of the three pinned + /// keys from a real head state. + #[test] + fn the_heads_shufflings_are_never_evicted() { + let head_epoch = *keyable_epochs().start() + 1; + let head_state = state_on_branch(1, head_epoch); + let head_root = Root::repeat_byte(0xaa); + let cache = CommitteeCache::default(); + + let pinned_epochs = [head_epoch - 1, head_epoch, head_epoch + 1]; + let pinned: Vec> = pinned_epochs + .iter() + .map(|&epoch| cache.committees(&head_state, epoch)) + .collect(); + + cache.update_head(head_root, &head_state); + assert_eq!(cache.head_root(), Some(head_root)); + + // Enough further, unrelated branches (at `LOOKUP_EPOCH`, distinct from + // every pinned epoch's decision root) to force many evictions. + for branch in 2..100u8 { + cache.committees(&state_on_branch(branch, LOOKUP_EPOCH), LOOKUP_EPOCH); + } + + for (&epoch, original) in pinned_epochs.iter().zip(pinned.iter()) { + let refetched = cache.committees(&head_state, epoch); + assert!( + Arc::ptr_eq(original, &refetched), + "pinned epoch {epoch} was evicted" + ); + } + } + + /// The epoch the branch-pressure tests look up from. + const LOOKUP_EPOCH: Epoch = 9; + + /// Epochs a state at [`LOOKUP_EPOCH`] can key under either preset. The + /// minimal preset's `SLOTS_PER_HISTORICAL_ROOT` window reaches back only a + /// few epochs, and a key needs its deciding slot inside it, so this is + /// narrower than the mainnet preset alone would allow. + fn keyable_epochs() -> std::ops::RangeInclusive { + LOOKUP_EPOCH - 6..=LOOKUP_EPOCH + 1 + } + + /// A state at `epoch`'s first slot whose every block root is `branch`'s + /// marker, so two branches key every epoch under different deciding + /// roots: distinct entries without needing distinct epochs, which the + /// minimal preset's short window has too few of to overfill the cache. + fn state_on_branch(branch: u8, epoch: Epoch) -> BeaconState { + let mut state = with_validators(64); + *state.slot_mut() = compute_start_slot_at_epoch(epoch); + for slot in 0..preset::SLOTS_PER_HISTORICAL_ROOT { + state.block_roots_mut()[slot] = Root::repeat_byte(branch); + } + state + } + + /// Past slot 0, epochs 0 and 1 are keyed like any other, on the genesis + /// block's root, so the chain's first attestations share a shuffling + /// rather than each deriving its own. A state that descends from a + /// different genesis block must not be served that entry. + #[test] + fn the_first_two_epochs_are_keyed_on_the_genesis_block() { + let state = with_validators(64); + let cache = CommitteeCache::default(); + + for epoch in [constants::GENESIS_EPOCH, constants::GENESIS_EPOCH + 1] { + let first = cache.committees(&state, epoch); + let second = cache.committees(&state, epoch); + assert!(Arc::ptr_eq(&first, &second), "epoch {epoch} was not cached"); + } + + let genesis_epoch = cache.committees(&state, constants::GENESIS_EPOCH); + let mut other_genesis = state.clone(); + other_genesis.block_roots_mut()[0] = Root::repeat_byte(0x01); + let other = cache.committees(&other_genesis, constants::GENESIS_EPOCH); + assert!( + !Arc::ptr_eq(&genesis_epoch, &other), + "a state from another genesis block was served this one's shuffling" + ); + } + + /// An epoch whose deciding block the state cannot name is derived but not + /// stored. Genesis is the case that reaches this in practice: the genesis + /// state sits at slot 0, which is the deciding slot of its own first two + /// epochs, and a state cannot name the root of the slot it is at. Serving + /// such a lookup from a key it does not really have is exactly the + /// unsoundness [`shuffling_key`] exists to prevent. + #[test] + fn an_unkeyable_epoch_is_not_cached() { + let mut state = with_validators(64); + *state.slot_mut() = 0; + let cache = CommitteeCache::default(); + + let first = cache.committees(&state, constants::GENESIS_EPOCH); + let second = cache.committees(&state, constants::GENESIS_EPOCH); + + assert!( + !Arc::ptr_eq(&first, &second), + "an unkeyable lookup was served from the cache" + ); + assert_eq!(first.committees_per_slot(), second.committees_per_slot()); + } + + #[test] + fn total_balance_is_floored_at_one_increment() { + // An empty set must not yield zero, since callers divide by this. + let state = crate::beacon::helpers::test_state::with_validators(4); + assert_eq!( + get_total_balance(&state, &[]).unwrap(), + preset::EFFECTIVE_BALANCE_INCREMENT + ); + } + + #[test] + fn proposer_is_drawn_from_the_active_set() { + let state = crate::beacon::helpers::test_state::with_validators(32); + let proposer = get_beacon_proposer_index(&state).unwrap(); + assert!(proposer < 32); + } + + #[test] + fn churn_limit_respects_its_floor() { + let config = Config::mainnet(); + // A tiny validator set falls below the proportional limit, so the floor + // is what applies. + let state = crate::beacon::helpers::test_state::with_validators(4); + assert_eq!( + get_validator_churn_limit(&state, &config), + config.min_per_epoch_churn_limit + ); + } +} diff --git a/crates/blockchain/state_transition/src/beacon/helpers/altair.rs b/crates/blockchain/state_transition/src/beacon/helpers/altair.rs new file mode 100644 index 000000000..eaa008c2e --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/helpers/altair.rs @@ -0,0 +1,595 @@ +//! Altair's helper functions. +//! +//! Altair's two headline changes are sync committees and a rewrite of how +//! attestations earn a reward. [`get_next_sync_committee_indices`] and +//! [`get_next_sync_committee`] draw the rotating committee that lets a light +//! client follow the chain's head from a single aggregate signature rather +//! than replaying the whole state transition. Everything else here replaces +//! phase0's accumulate-then-replay `PendingAttestation`s with three +//! per-validator, per-epoch bits (a [`ParticipationFlags`]): [`add_flag`] and +//! [`has_flag`] are the bit operations, [`get_attestation_participation_flag_indices`] +//! is where an attestation earns its flags at processing time instead of +//! waiting for the epoch boundary the way phase0 does, and +//! [`get_unslashed_participating_indices`], [`get_flag_index_deltas`], and +//! [`get_inactivity_penalty_deltas`] are the epoch-boundary reward and penalty +//! accounting that reads those flags back. +//! +//! [`get_eligible_validator_indices`](super::finality::get_eligible_validator_indices) +//! and [`is_in_inactivity_leak`](super::finality::is_in_inactivity_leak) +//! are reused from phase0's rewards module rather than redefined here: the +//! specification does not modify either of them in altair, and both are +//! already written against `BeaconState`'s fork-invariant accessors rather +//! than phase0's concrete struct, so nothing about them is phase0-specific. +//! +//! # Why some functions take `config` and others do not +//! +//! Every quantity these functions read is either a specification constant, a +//! preset value, or (for [`get_inactivity_penalty_deltas`]'s +//! `INACTIVITY_SCORE_BIAS`) a configuration value: the altair specification's +//! own tables list it under "Configuration" rather than "Preset", since a +//! network is free to retune how fast an inactive validator's score rises +//! without changing the shape of any SSZ container. That is the only function +//! below that takes a [`Config`]; the rest need nothing a network could vary. + +use super::finality::{get_eligible_validator_indices, is_in_inactivity_leak}; +use crate::beacon::bls; +use crate::beacon::config::Config; +use crate::beacon::constants; +use crate::beacon::containers::shared::AttestationData; +use crate::beacon::containers::{BeaconState, altair}; +use crate::beacon::error::{Error, Result}; +use crate::beacon::fork::ForkName; +use crate::beacon::hash::hash; +use crate::beacon::preset; +use crate::beacon::primitives::{Epoch, Gwei, ParticipationFlags, ValidatorIndex}; + +use super::accessors::{ + get_active_validator_indices, get_block_root, get_block_root_at_slot, get_current_epoch, + get_previous_epoch, get_seed, get_total_active_balance, get_total_balance, +}; +use super::math::integer_squareroot; +use super::shuffling::compute_shuffled_index; + +// --------------------------------------------------------------------------- +// Misc +// --------------------------------------------------------------------------- + +/// Sets `flag_index`'s bit in `flags`, leaving every other bit as it was. +pub fn add_flag(flags: ParticipationFlags, flag_index: usize) -> ParticipationFlags { + let flag = 1u8 << flag_index; + flags | flag +} + +/// Whether `flag_index`'s bit is set in `flags`. +pub fn has_flag(flags: ParticipationFlags, flag_index: usize) -> bool { + let flag = 1u8 << flag_index; + flags & flag == flag +} + +// --------------------------------------------------------------------------- +// Beacon state accessors +// --------------------------------------------------------------------------- + +/// The sync committee indices, with possible duplicates, for the sync +/// committee period starting next epoch. +/// +/// Rejection sampling weighted by effective balance, the same shape as +/// [`super::shuffling::compute_proposer_index`]: a candidate is drawn +/// uniformly from the shuffled active set and accepted with probability +/// proportional to its effective balance. The difference is that this keeps +/// drawing until it has accepted `SYNC_COMMITTEE_SIZE` candidates rather than +/// stopping at the first one, and it never deduplicates, so the same +/// validator can end up holding more than one of the committee's seats. Both +/// of those are load-bearing: a committee member's voting weight is meant to +/// scale with effective balance, and giving a heavy validator more than one +/// seat (in expectation) is how that happens without the committee itself +/// tracking per-seat weights. +/// +/// Altair's own version of the draw, unmodified through deneb. +/// [`get_next_sync_committee`] is what chooses between this and electra's +/// widened draw, so nothing here needs to know that a later fork changes it. +pub fn get_next_sync_committee_indices(state: &BeaconState) -> Result> { + let epoch = get_current_epoch(state) + 1; + + // `2**8 - 1`, the largest value a single random byte can take. Named + // rather than left as a literal, matching `compute_proposer_index`'s + // identical rejection-sampling shape. + const MAX_RANDOM_BYTE: u64 = u8::MAX as u64; + + let active_validator_indices = get_active_validator_indices(state, epoch); + let active_validator_count = active_validator_indices.len() as u64; + crate::beacon::verify( + active_validator_count > 0, + "len(active_validator_indices) > 0", + )?; + let seed = get_seed(state, epoch, constants::DOMAIN_SYNC_COMMITTEE); + + let mut i: u64 = 0; + let mut sync_committee_indices = Vec::with_capacity(preset::SYNC_COMMITTEE_SIZE); + while sync_committee_indices.len() < preset::SYNC_COMMITTEE_SIZE { + let shuffled_index = + compute_shuffled_index(i % active_validator_count, active_validator_count, seed)?; + // `shuffled_index` is mathematically guaranteed to be within + // `active_validator_indices`, since `compute_shuffled_index` returns a + // permutation of `0..active_validator_count`; indexed directly here + // rather than defensively, the same way `compute_proposer_index` reads + // its own shuffled candidate. + let candidate_index = active_validator_indices[shuffled_index as usize]; + + let mut random_input = Vec::with_capacity(32 + 8); + random_input.extend_from_slice(&seed.0); + random_input.extend_from_slice(&(i / 32).to_le_bytes()); + let random_byte = hash(&random_input).0[(i % 32) as usize] as u64; + + let effective_balance = state.validator(candidate_index)?.effective_balance; + if effective_balance * MAX_RANDOM_BYTE >= preset::MAX_EFFECTIVE_BALANCE * random_byte { + sync_committee_indices.push(candidate_index); + } + i += 1; + } + Ok(sync_committee_indices) +} + +/// The sync committee for the period starting next epoch, with possible +/// pubkey duplicates. +/// +/// Only meant to be called at a sync committee period boundary (or when +/// upgrading a state to altair): calling it at any other slot still returns +/// an answer, but not one anything reads, since `current_sync_committee` and +/// `next_sync_committee` only change at that boundary. +/// +/// Electra's specification modifies the indices draw itself (widening the +/// acceptance test's random value from one byte to two, and swapping in +/// [`preset::MAX_EFFECTIVE_BALANCE_ELECTRA`]) rather than anything in this +/// function, so [`crate::beacon::helpers::electra::get_next_sync_committee_indices`] +/// coexists with this file's own version instead of replacing it, the same +/// way `crate::beacon::helpers::accessors::get_beacon_proposer_index` is where +/// [`crate::beacon::helpers::shuffling::compute_proposer_index`] and +/// [`crate::beacon::helpers::electra::compute_proposer_index`] are chosen between. +/// This function is that dispatch point for the sync committee draw: it is +/// the one caller every fork reaches unconditionally at the period boundary +/// (see [`crate::beacon::stf::epoch::altair::process_sync_committee_updates`]), so +/// picking the fork-appropriate indices function here, rather than in that +/// caller, is what lets fulu (which reuses this whole function) draw a +/// correctly weighted committee too. +pub fn get_next_sync_committee(state: &BeaconState) -> Result { + let indices = match state.fork_name() { + ForkName::Electra | ForkName::Fulu => { + crate::beacon::helpers::electra::get_next_sync_committee_indices(state)? + } + _ => get_next_sync_committee_indices(state)?, + }; + let mut pubkeys = Vec::with_capacity(indices.len()); + for index in &indices { + pubkeys.push(state.validator(*index)?.pubkey); + } + let aggregate_pubkey = bls::eth_aggregate_pubkeys(&pubkeys)?; + Ok(altair::SyncCommittee { + pubkeys: pubkeys.try_into()?, + aggregate_pubkey, + }) +} + +/// The reward every one-increment slice of a validator's effective balance +/// earns for a single timely, correct component of its attestation. +/// +/// Phase0 computes a reward proportional to the validator's whole effective +/// balance and divides the result by `BASE_REWARDS_PER_EPOCH` to split it +/// across the source, target, and head components. Altair instead scales +/// from this smaller, per-increment unit and multiplies back up by the +/// validator's own increment count in [`get_base_reward`], which is what lets +/// [`get_flag_index_deltas`] weight a flag's reward by how many increments of +/// stake actually earned it rather than assuming every attestation splits the +/// same fixed fraction. +pub fn get_base_reward_per_increment(state: &BeaconState) -> Result { + let total_active_balance = get_total_active_balance(state)?; + Ok( + preset::EFFECTIVE_BALANCE_INCREMENT * preset::BASE_REWARD_FACTOR + / integer_squareroot(total_active_balance), + ) +} + +/// The base reward for the validator at `index`. +/// +/// Altair's version of this function: it drops phase0's division by +/// `BASE_REWARDS_PER_EPOCH` (there is no fixed four-way split of a fixed +/// reward any more, since [`get_flag_index_deltas`] weighs each flag by +/// [`crate::beacon::constants::PARTICIPATION_FLAG_WEIGHTS`] instead) and reads +/// [`get_base_reward_per_increment`] rather than the total active balance +/// directly. See [`crate::beacon::stf::epoch::rewards::get_base_reward`] for phase0's +/// version; the two coexist in different modules rather than one replacing +/// the other, since fork dispatch happens at the call site, not here. +pub fn get_base_reward(state: &BeaconState, index: ValidatorIndex) -> Result { + let effective_balance = state.validator(index)?.effective_balance; + let increments = effective_balance / preset::EFFECTIVE_BALANCE_INCREMENT; + Ok(increments * get_base_reward_per_increment(state)?) +} + +/// The active, unslashed validators that had `flag_index` set for `epoch`. +/// +/// `epoch` must be the current or previous epoch, since those are the only +/// two altair keeps a participation record for (`current_epoch_participation` +/// and `previous_epoch_participation`, mirroring the two-epoch window phase0 +/// keeps for `PendingAttestation`s). +/// +/// Ascending and duplicate-free: it is built by filtering +/// [`get_active_validator_indices`], which already returns indices in that +/// order, so callers may binary-search it the way +/// [`get_flag_index_deltas`] does. +pub fn get_unslashed_participating_indices( + state: &BeaconState, + flag_index: usize, + epoch: Epoch, +) -> Result> { + crate::beacon::verify( + epoch == get_previous_epoch(state) || epoch == get_current_epoch(state), + "epoch in (get_previous_epoch(state), get_current_epoch(state))", + )?; + + let (previous_epoch_participation, current_epoch_participation, _) = + state.altair_validator_lists()?; + let epoch_participation = if epoch == get_current_epoch(state) { + current_epoch_participation + } else { + previous_epoch_participation + }; + + let mut participating_indices = Vec::new(); + for index in get_active_validator_indices(state, epoch) { + let flags = + epoch_participation + .get(index as usize) + .copied() + .ok_or(Error::IndexOutOfBounds { + index: index as usize, + len: epoch_participation.len(), + })?; + if has_flag(flags, flag_index) && !state.validator(index)?.slashed { + participating_indices.push(index); + } + } + Ok(participating_indices) +} + +/// Which of the three participation flags an attestation with `data`, +/// included after `inclusion_delay` slots, satisfies. +/// +/// The three checks nest: a target vote can only be timely-and-correct if the +/// source vote already was, and a head vote can only be timely-and-correct if +/// the target vote already was. That nesting is what `is_matching_target` +/// and `is_matching_head` encode by including the previous check in their own +/// condition, rather than the three being independent. +/// +/// Fails if the source does not match the justified checkpoint the target's +/// epoch should have voted from: the specification asserts this +/// unconditionally, so a caller (`process_attestation`, not implemented in +/// this file) is expected to have already rejected such an attestation before +/// this runs. +pub fn get_attestation_participation_flag_indices( + state: &BeaconState, + data: &AttestationData, + inclusion_delay: u64, +) -> Result> { + // Matching source. + let justified_checkpoint = if data.target.epoch == get_current_epoch(state) { + state.current_justified_checkpoint() + } else { + state.previous_justified_checkpoint() + }; + let is_matching_source = data.source == justified_checkpoint; + + // Matching target. + let target_root = get_block_root(state, data.target.epoch)?; + let target_root_matches = data.target.root == target_root; + let is_matching_target = is_matching_source && target_root_matches; + + // Matching head. + let head_root = get_block_root_at_slot(state, data.slot)?; + let head_root_matches = data.beacon_block_root == head_root; + let is_matching_head = is_matching_target && head_root_matches; + + crate::beacon::verify(is_matching_source, "is_matching_source")?; + + let mut participation_flag_indices = Vec::new(); + if is_matching_source && inclusion_delay <= integer_squareroot(preset::SLOTS_PER_EPOCH) { + participation_flag_indices.push(constants::TIMELY_SOURCE_FLAG_INDEX); + } + if is_matching_target && inclusion_delay <= preset::SLOTS_PER_EPOCH { + participation_flag_indices.push(constants::TIMELY_TARGET_FLAG_INDEX); + } + if is_matching_head && inclusion_delay == preset::MIN_ATTESTATION_INCLUSION_DELAY { + participation_flag_indices.push(constants::TIMELY_HEAD_FLAG_INDEX); + } + + Ok(participation_flag_indices) +} + +/// The reward and penalty for one participation flag, for every validator. +/// +/// Reuses [`get_eligible_validator_indices`] and [`is_in_inactivity_leak`] +/// from phase0's rewards module unchanged, since the specification does not +/// touch either of them in altair. +/// +/// During an inactivity leak, a matching validator earns nothing here for +/// this flag rather than the balance-weighted share the non-leaking branch +/// computes: unlike phase0 (which pays the full base reward during a leak +/// and lets [`get_inactivity_penalty_deltas`] claw an equivalent amount back), +/// altair simply withholds the reward outright, so there is nothing to claw +/// back and [`get_inactivity_penalty_deltas`] only ever penalizes. +pub fn get_flag_index_deltas( + state: &BeaconState, + flag_index: usize, +) -> Result<(Vec, Vec)> { + let validator_count = state.validators().len(); + let mut rewards = vec![0; validator_count]; + let mut penalties = vec![0; validator_count]; + + let previous_epoch = get_previous_epoch(state); + let unslashed_participating_indices = + get_unslashed_participating_indices(state, flag_index, previous_epoch)?; + let weight = constants::PARTICIPATION_FLAG_WEIGHTS[flag_index]; + let unslashed_participating_balance = + get_total_balance(state, &unslashed_participating_indices)?; + let unslashed_participating_increments = + unslashed_participating_balance / preset::EFFECTIVE_BALANCE_INCREMENT; + let active_increments = get_total_active_balance(state)? / preset::EFFECTIVE_BALANCE_INCREMENT; + + // Hoisted out of the loop below, where the specification writes + // `get_base_reward(state, index)` per eligible validator. That helper is + // `increments * get_base_reward_per_increment(state)`, and the second + // factor is `get_total_active_balance`, an unconditional `O(registry + // size)` scan with no cache of its own. That is the same quantity + // `active_increments` above already paid for, just run through a + // different formula (`get_base_reward_per_increment` divides by + // `integer_squareroot`, `active_increments` does not), so it is not + // reusable as-is and has to be hoisted on its own. + // + // [`process_epoch::electra::process_epoch`] calls this (via + // `process_epoch::altair::process_rewards_and_penalties`) once per + // [`crate::beacon::constants::PARTICIPATION_FLAG_WEIGHTS`] entry, three times per + // epoch boundary. At mainnet's ~1M validators, the unhoisted form is + // three separate million-element scans per *eligible validator*, effectively + // unbounded, for what this function already computes once above. This is + // the same bug already fixed in `process_attestation`'s per-attester loop + // (see that function's own comment), left unfixed here because it runs + // once per epoch rather than once per block and so never showed up in a + // profile that did not cross an epoch boundary. + // + // Measured directly: `tests::measures_the_cost_of_get_flag_index_deltas` + // times this call at 2^15 validators. Unhoisted, that call took ~11.9s; + // hoisted, ~384us: roughly 31,000x at that scale, and the gap widens + // further at mainnet's ~2^20 validators, since the unhoisted form is + // O(n^2) (`1024x` slower again at that size) while this is O(n) (`32x` + // slower again, same as every other size-dependent cost in this crate). + let base_reward_per_increment = get_base_reward_per_increment(state)?; + + for index in get_eligible_validator_indices(state) { + // `get_base_reward(state, index)` inlined against the hoisted + // per-increment value, in the helper's own order of operations so + // the result is bit-identical. + let increments = + state.validator(index)?.effective_balance / preset::EFFECTIVE_BALANCE_INCREMENT; + let base_reward = increments * base_reward_per_increment; + if unslashed_participating_indices + .binary_search(&index) + .is_ok() + { + if !is_in_inactivity_leak(state) { + let reward_numerator = base_reward * weight * unslashed_participating_increments; + rewards[index as usize] += + reward_numerator / (active_increments * constants::WEIGHT_DENOMINATOR); + } + } else if flag_index != constants::TIMELY_HEAD_FLAG_INDEX { + penalties[index as usize] += base_reward * weight / constants::WEIGHT_DENOMINATOR; + } + } + Ok((rewards, penalties)) +} + +/// The inactivity penalty for every validator; altair pays no reward for +/// this, only a penalty, so the reward side of the pair is always zero. +/// +/// Unlike phase0's version, this does not gate on [`is_in_inactivity_leak`] +/// at all: every eligible validator missing a timely target vote pays a +/// penalty regardless of whether the chain is currently leaking. That is +/// consistent with [`get_flag_index_deltas`] no longer paying (and needing to +/// claw back) a reward during a leak; there is no cancellation left to +/// arrange here. +/// +/// Scales with `inactivity_scores`, which only the epoch-processing side of +/// altair (not implemented in this file) updates. A validator's score rises +/// without bound the longer it stays offline through a leak, so the balance +/// scaling below is checked rather than left to wrap. +/// +/// The quotient in the penalty's denominator is retuned once more after +/// altair: bellatrix's own specification modifies this exact function to +/// swap in `INACTIVITY_PENALTY_QUOTIENT_BELLATRIX`, and no fork after +/// bellatrix retunes it again, so [`preset::retuned::inactivity_penalty_quotient`] +/// is what tells altair's own value apart from every later fork's. +pub fn get_inactivity_penalty_deltas( + state: &BeaconState, + config: &Config, +) -> Result<(Vec, Vec)> { + let validator_count = state.validators().len(); + let rewards = vec![0; validator_count]; + let mut penalties = vec![0; validator_count]; + + let previous_epoch = get_previous_epoch(state); + let matching_target_indices = get_unslashed_participating_indices( + state, + constants::TIMELY_TARGET_FLAG_INDEX, + previous_epoch, + )?; + + let (_, _, inactivity_scores) = state.altair_validator_lists()?; + + for index in get_eligible_validator_indices(state) { + if matching_target_indices.binary_search(&index).is_err() { + let effective_balance = state.validator(index)?.effective_balance; + let inactivity_score = + inactivity_scores + .get(index as usize) + .copied() + .ok_or(Error::IndexOutOfBounds { + index: index as usize, + len: inactivity_scores.len(), + })?; + + let penalty_numerator = effective_balance.checked_mul(inactivity_score).ok_or( + Error::ArithmeticOverflow("effective_balance * inactivity_scores[index]"), + )?; + let inactivity_penalty_quotient = + preset::retuned::inactivity_penalty_quotient(state.fork_name()); + let penalty_denominator = config.inactivity_score_bias * inactivity_penalty_quotient; + penalties[index as usize] += penalty_numerator / penalty_denominator; + } + } + + Ok((rewards, penalties)) +} + +// --------------------------------------------------------------------------- +// Fork projection +// --------------------------------------------------------------------------- + +/// The altair state, immutably, or an error naming the function that needs one. +/// +/// There is no mutable sibling, and the reason is worth recording, because it +/// was a real bug and a tempting one. The fields altair introduces +/// (`previous_epoch_participation`, `current_epoch_participation`, +/// `inactivity_scores`, and the two sync committees) exist unchanged through +/// fulu, so a projection to a concrete `altair::BeaconState` rejects every one +/// of bellatrix through fulu with [`Error::UnsupportedForFork`]: precisely the +/// states that matter. Every reader of those fields goes through +/// [`BeaconState::altair_validator_lists`] or [`BeaconState::sync_committees`] +/// instead, which answer for every fork that carries them. +/// +/// This immutable one survives for a genuinely different reason: +/// [`crate::beacon::upgrade::upgrade_to_bellatrix`] reads an actual, concrete altair +/// state to build the bellatrix state that succeeds it. That is a real need for +/// `altair::BeaconState` specifically, and a fork-upgrade function is exactly +/// the case a per-fork projection is right for, since it only ever runs on the +/// one fork it upgrades from. +/// +/// The distinction generalises: project to a concrete per-fork state when the +/// *return type* has to be that fork's, and reach through a `BeaconState` +/// accessor when the *fields* are shared. Confusing the two produced three +/// separate bugs in this module. +pub(crate) fn altair_state_ref<'a>( + state: &'a BeaconState, + function: &'static str, +) -> Result<&'a altair::BeaconState> { + match state { + BeaconState::Altair(state) => Ok(state), + other => Err(Error::UnsupportedForFork { + function, + fork: other.fork_name(), + }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// An altair state with `count` fully active, full-balance validators, + /// positioned the same way `crate::beacon::helpers::test_state::with_validators` + /// positions its phase0 state: one epoch in, so the previous epoch and the + /// block-root history window both have entries. + /// + /// A thin wrapper around the shared fork-parameterised builder: see + /// [`crate::beacon::helpers::test_state::with_validators_at`] for the construction + /// this and every other fork's test module used to duplicate. + fn altair_state_with_validators(count: usize) -> BeaconState { + crate::beacon::helpers::test_state::with_validators_at(ForkName::Altair, count) + } + + #[test] + fn has_flag_and_add_flag_round_trip_every_flag_index() { + for flag_index in [ + constants::TIMELY_SOURCE_FLAG_INDEX, + constants::TIMELY_TARGET_FLAG_INDEX, + constants::TIMELY_HEAD_FLAG_INDEX, + ] { + let flags: ParticipationFlags = 0; + assert!(!has_flag(flags, flag_index)); + let flags = add_flag(flags, flag_index); + assert!(has_flag(flags, flag_index)); + } + } + + #[test] + fn add_flag_is_idempotent() { + let once = add_flag(0, constants::TIMELY_SOURCE_FLAG_INDEX); + let twice = add_flag(once, constants::TIMELY_SOURCE_FLAG_INDEX); + assert_eq!(once, twice); + } + + #[test] + fn add_flag_leaves_other_bits_alone() { + let flags = add_flag(0, constants::TIMELY_SOURCE_FLAG_INDEX); + let flags = add_flag(flags, constants::TIMELY_TARGET_FLAG_INDEX); + assert!(has_flag(flags, constants::TIMELY_SOURCE_FLAG_INDEX)); + assert!(has_flag(flags, constants::TIMELY_TARGET_FLAG_INDEX)); + assert!(!has_flag(flags, constants::TIMELY_HEAD_FLAG_INDEX)); + } + + #[test] + fn next_sync_committee_indices_are_exactly_sync_committee_size_and_may_repeat() { + // Far fewer active validators than the sync committee has seats, so by + // the pigeonhole principle every draw with replacement must repeat + // someone, regardless of which preset the crate was built against. + let state = altair_state_with_validators(4); + let indices = get_next_sync_committee_indices(&state).unwrap(); + + assert_eq!(indices.len(), preset::SYNC_COMMITTEE_SIZE); + assert!(indices.iter().all(|index| *index < 4)); + + let mut seen = std::collections::HashSet::new(); + assert!( + indices.iter().any(|index| !seen.insert(*index)), + "drawing SYNC_COMMITTEE_SIZE seats from 4 validators must repeat someone", + ); + } + + /// Measures [`get_flag_index_deltas`]'s cost at a validator count large + /// enough to show the shape of the fix, without actually running the + /// unhoisted form at mainnet scale: see the doc comment inside + /// [`get_flag_index_deltas`] for why that call was, before this change, + /// one `get_total_active_balance` scan (`O(registry size)`) per + /// *eligible validator*, i.e. `O(registry size squared)` overall. + /// + /// Run at `VALIDATOR_COUNT` (2^15) rather than mainnet's ~2^20: the fixed + /// form is `O(n)`, so its mainnet-scale cost extrapolates from this + /// number by the `32x` size ratio; the bug's form is `O(n^2)`, so its + /// mainnet-scale cost would have extrapolated by `32^2 = 1024x` instead. + /// Comparing the two at 2^15 already shows which regime each is in + /// without spending the hours the unhoisted form would need to finish + /// one call at mainnet scale. + /// + /// Only prints the raw per-call time, deliberately not a baked-in + /// mainnet extrapolation: which multiplier (`32x` or `1024x`) applies + /// depends on which form of the function this binary was built against, + /// which this test has no way to know from the outside. + /// + /// Not part of `make test-beacon`'s pass/fail contract, for the same + /// reason as `fork_choice`'s own benchmarks: it always succeeds as long + /// as the deltas compute, and exists to print a number (`--nocapture`). + #[test] + fn measures_the_cost_of_get_flag_index_deltas() { + use std::time::Instant; + + const VALIDATOR_COUNT: usize = 32_768; + const ITERATIONS: u32 = 5; + + let state = altair_state_with_validators(VALIDATOR_COUNT); + + let start = Instant::now(); + for _ in 0..ITERATIONS { + get_flag_index_deltas(&state, constants::TIMELY_SOURCE_FLAG_INDEX) + .expect("deltas compute over a well-formed state"); + } + let elapsed = start.elapsed() / ITERATIONS; + println!("get_flag_index_deltas, {VALIDATOR_COUNT} validators -> {elapsed:?}/call"); + } +} diff --git a/crates/blockchain/state_transition/src/beacon/helpers/attestation.rs b/crates/blockchain/state_transition/src/beacon/helpers/attestation.rs new file mode 100644 index 000000000..e69452311 --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/helpers/attestation.rs @@ -0,0 +1,102 @@ +//! Turning an aggregate attestation into the attesters it covers. +//! +//! An [`Attestation`] names its attesters as a bitfield over a committee, which +//! is compact on the wire but useless for signature verification, since that +//! needs public keys and so needs indices. These three functions bridge the two +//! forms. +//! +//! These take phase0's attestation containers. Electra reshapes them, widening +//! the aggregation bits from one committee to a whole slot's worth and adding a +//! committee bitfield, so it gets its own versions rather than reusing these. + +use crate::beacon::containers::BeaconState; +use crate::beacon::containers::phase0::{Attestation, AttestingIndices, IndexedAttestation}; +use crate::beacon::error::Result; +use crate::beacon::helpers::accessors::{CommitteeCache, CommitteeCacheExt, get_domain}; +use crate::beacon::helpers::misc::{compute_epoch_at_slot, compute_signing_root}; +use crate::beacon::helpers::predicates::are_indices_sorted_and_unique; +use crate::beacon::primitives::{HashTreeRoot as _, ValidatorIndex}; +use crate::beacon::{bls, constants}; + +/// The committee members whose bit is set in `attestation`, in ascending order. +/// +/// The specification returns a `Set` here and sorts it in +/// [`get_indexed_attestation`]. Sorting here instead gives every caller the same +/// order, which is what they all want: `get_indexed_attestation` because +/// [`is_valid_indexed_attestation`] requires sorted indices, and the epoch +/// accounting because it treats the result as a set. +/// +/// The sort is not cosmetic. A committee is a *shuffled* slice of the validator +/// registry, so walking it in position order yields attesters in shuffle order, +/// which is almost never ascending. +/// +/// `committees` supplies the slot's shuffling, derived for the whole epoch at +/// once. That pays off across the many attestations a block carries, which is +/// the reuse this parameter exists for, but it is more work than one +/// committee's own derivation: a caller with a single attestation and no cache +/// to share it through pays for the whole epoch to read one committee of it. +/// See [`CommitteeCache`]. +pub fn get_attesting_indices( + state: &BeaconState, + attestation: &Attestation, + committees: &CommitteeCache, +) -> Result> { + let epoch = compute_epoch_at_slot(attestation.data.slot); + let epoch_committees = committees.committees(state, epoch); + let committee = epoch_committees.committee(attestation.data.slot, attestation.data.index)?; + let mut indices: Vec = committee + .iter() + .enumerate() + .filter(|(position, _)| attestation.aggregation_bits.get(*position).unwrap_or(false)) + .map(|(_, index)| *index) + .collect(); + indices.sort_unstable(); + Ok(indices) +} + +/// The same attestation with its attesters named rather than bit-encoded. +pub fn get_indexed_attestation( + state: &BeaconState, + attestation: &Attestation, + committees: &CommitteeCache, +) -> Result { + let indices = get_attesting_indices(state, attestation, committees)?; + Ok(IndexedAttestation { + attesting_indices: AttestingIndices::try_from(indices)?, + data: attestation.data, + signature: attestation.signature, + }) +} + +/// Whether an indexed attestation names a valid attester set and carries their +/// aggregate signature. +/// +/// Empty is invalid, and so is any ordering other than sorted and unique. Both +/// checks matter for more than tidiness: a repeated index would let one validator +/// be counted twice, and an unsorted list would make the same attester set have +/// more than one encoding, and so more than one root. +pub fn is_valid_indexed_attestation( + state: &BeaconState, + indexed_attestation: &IndexedAttestation, +) -> bool { + let indices: &[ValidatorIndex] = &indexed_attestation.attesting_indices; + if indices.is_empty() || !are_indices_sorted_and_unique(indices) { + return false; + } + + let mut pubkeys = Vec::with_capacity(indices.len()); + for index in indices { + match state.validator(*index) { + Ok(validator) => pubkeys.push(validator.pubkey), + Err(_) => return false, + } + } + + let domain = get_domain( + state, + constants::DOMAIN_BEACON_ATTESTER, + Some(indexed_attestation.data.target.epoch), + ); + let signing_root = compute_signing_root(indexed_attestation.data.hash_tree_root(), domain); + bls::fast_aggregate_verify(&pubkeys, signing_root, &indexed_attestation.signature) +} diff --git a/crates/blockchain/state_transition/src/beacon/helpers/capella.rs b/crates/blockchain/state_transition/src/beacon/helpers/capella.rs new file mode 100644 index 000000000..da190912f --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/helpers/capella.rs @@ -0,0 +1,144 @@ +//! Capella's new predicates: the three checks that decide whether a validator +//! is owed a payout from the withdrawal sweep. +//! +//! Before this fork a validator's balance could shrink but never leave the +//! consensus layer, so nothing needed to ask "is this validator owed money +//! right now". Capella's sweep (`get_expected_withdrawals`, not implemented in +//! this file) asks exactly that once per validator it visits, and these three +//! predicates are the answer: [`has_eth1_withdrawal_credential`] gates the +//! other two on the validator having upgraded its withdrawal credentials at +//! all, [`is_fully_withdrawable_validator`] is true once the validator has +//! exited and its withdrawable epoch has passed, and +//! [`is_partially_withdrawable_validator`] is true for a still-active +//! validator sitting on more balance than it can earn rewards on. +//! +//! These three versions serve capella through deneb. Electra replaces all +//! three ([`crate::beacon::helpers::electra::has_execution_withdrawal_credential`], +//! `is_fully_withdrawable_validator`, `is_partially_withdrawable_validator`) +//! rather than reusing them: EIP-7251 adds a second, compounding withdrawal +//! credential prefix that the fully- and partially-withdrawable checks also +//! need to accept, and replaces the flat [`preset::MAX_EFFECTIVE_BALANCE`] +//! ceiling in the partial check with a per-validator maximum that depends on +//! which credential a validator holds. That is different enough in shape +//! (an extra prefix to check, and a ceiling that is no longer a single +//! constant) that sharing an implementation between the two forks would mean +//! threading electra's parameters through capella's call sites for no benefit +//! to either; the specification itself lists electra's versions as replacing +//! these outright rather than extending them, so this module and electra's +//! coexist rather than one calling the other. + +use crate::beacon::constants; +use crate::beacon::containers::shared::Validator; +use crate::beacon::preset; +use crate::beacon::primitives::{Epoch, Gwei}; + +/// Whether `validator`'s withdrawal credentials have been upgraded to an +/// execution address. +/// +/// Until this is true, the validator's stake has nowhere to be paid out to: +/// the raw BLS credential every validator starts with names a public key, not +/// an execution-layer account, so `has_eth1_withdrawal_credential`'s two +/// callers below both gate on it first. +pub fn has_eth1_withdrawal_credential(validator: &Validator) -> bool { + validator.withdrawal_credentials.0[0] == constants::ETH1_ADDRESS_WITHDRAWAL_PREFIX +} + +/// Whether `validator` should have its entire balance swept out. +/// +/// True once the validator is both past its withdrawable epoch (so it is done +/// being slashable, see [`super::predicates::is_slashable_validator`]) and +/// still holding a positive balance: a validator already swept to zero has +/// nothing left to pay out, so the sweep can skip it without checking the +/// epoch condition again. +pub fn is_fully_withdrawable_validator(validator: &Validator, balance: Gwei, epoch: Epoch) -> bool { + has_eth1_withdrawal_credential(validator) + && validator.withdrawable_epoch <= epoch + && balance > 0 +} + +/// Whether `validator` should have its excess balance (above +/// [`preset::MAX_EFFECTIVE_BALANCE`]) swept out while it keeps validating. +/// +/// Restricted to a validator already at the full effective balance: a +/// validator below that ceiling is still earning rewards on every increment +/// of its actual balance, so nothing above the ceiling exists yet to call +/// excess. +pub fn is_partially_withdrawable_validator(validator: &Validator, balance: Gwei) -> bool { + let has_max_effective_balance = validator.effective_balance == preset::MAX_EFFECTIVE_BALANCE; + let has_excess_balance = balance > preset::MAX_EFFECTIVE_BALANCE; + has_eth1_withdrawal_credential(validator) && has_max_effective_balance && has_excess_balance +} + +#[cfg(test)] +mod tests { + use super::*; + + fn validator_with_prefix(prefix: u8) -> Validator { + let mut withdrawal_credentials = crate::beacon::primitives::Bytes32::ZERO; + withdrawal_credentials.0[0] = prefix; + Validator { + withdrawal_credentials, + effective_balance: preset::MAX_EFFECTIVE_BALANCE, + withdrawable_epoch: 10, + ..Default::default() + } + } + + #[test] + fn only_the_eth1_prefix_counts_as_an_eth1_withdrawal_credential() { + assert!(!has_eth1_withdrawal_credential(&validator_with_prefix( + constants::BLS_WITHDRAWAL_PREFIX + ))); + assert!(has_eth1_withdrawal_credential(&validator_with_prefix( + constants::ETH1_ADDRESS_WITHDRAWAL_PREFIX + ))); + // Electra's compounding prefix is not eth1: this predicate serves + // capella through deneb only, and never sees that prefix in practice, + // but it should not be mistaken for the one it does recognize. + assert!(!has_eth1_withdrawal_credential(&validator_with_prefix( + constants::COMPOUNDING_WITHDRAWAL_PREFIX + ))); + } + + #[test] + fn full_withdrawability_needs_the_credential_the_epoch_and_a_balance() { + let validator = validator_with_prefix(constants::ETH1_ADDRESS_WITHDRAWAL_PREFIX); + + // Before the withdrawable epoch: not yet. + assert!(!is_fully_withdrawable_validator(&validator, 1, 5)); + // At and after the withdrawable epoch, with a balance: withdrawable. + assert!(is_fully_withdrawable_validator(&validator, 1, 10)); + assert!(is_fully_withdrawable_validator(&validator, 1, 20)); + // Nothing left to pay out. + assert!(!is_fully_withdrawable_validator(&validator, 0, 20)); + + // Without the eth1 credential, never withdrawable regardless of epoch + // or balance. + let bls_validator = validator_with_prefix(constants::BLS_WITHDRAWAL_PREFIX); + assert!(!is_fully_withdrawable_validator(&bls_validator, 1, 20)); + } + + #[test] + fn partial_withdrawability_needs_the_credential_and_excess_above_the_ceiling() { + let mut validator = validator_with_prefix(constants::ETH1_ADDRESS_WITHDRAWAL_PREFIX); + + // At the ceiling, no excess yet. + assert!(!is_partially_withdrawable_validator( + &validator, + preset::MAX_EFFECTIVE_BALANCE + )); + // Above the ceiling: the excess is withdrawable. + assert!(is_partially_withdrawable_validator( + &validator, + preset::MAX_EFFECTIVE_BALANCE + 1 + )); + + // Below the full effective balance, excess balance does not count: + // the validator has not maxed out what it can earn rewards on yet. + validator.effective_balance -= 1; + assert!(!is_partially_withdrawable_validator( + &validator, + preset::MAX_EFFECTIVE_BALANCE + 1 + )); + } +} diff --git a/crates/blockchain/state_transition/src/beacon/helpers/electra.rs b/crates/blockchain/state_transition/src/beacon/helpers/electra.rs new file mode 100644 index 000000000..b2afcf7c0 --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/helpers/electra.rs @@ -0,0 +1,1421 @@ +//! Electra's new and changed helper functions. +//! +//! Electra's headline change is EIP-7251: a validator's effective balance is no +//! longer pinned to one value. A validator that upgrades its withdrawal +//! credentials to the new compounding prefix +//! ([`constants::COMPOUNDING_WITHDRAWAL_PREFIX`]) can hold up to +//! [`preset::MAX_EFFECTIVE_BALANCE_ELECTRA`] rather than the fixed +//! `MAX_EFFECTIVE_BALANCE` every validator was capped at before. Nearly every +//! function below exists because of that one change, in three groups. +//! +//! **The withdrawal credential predicates** ([`is_compounding_withdrawal_credential`], +//! [`has_compounding_withdrawal_credential`], [`has_execution_withdrawal_credential`], +//! and [`get_max_effective_balance`]) are what let [`is_fully_withdrawable_validator`] +//! and [`is_partially_withdrawable_validator`] ask "what is this validator's +//! ceiling" instead of assuming one fixed answer for everyone. +//! [`has_eth1_withdrawal_credential`] is transcribed here too even though +//! electra does not touch it: it is capella's predicate +//! (`specs/capella/beacon-chain.md`), needed unmodified by +//! [`has_execution_withdrawal_credential`], and this module has no shared +//! cross-fork module to put a single reused predicate in instead. +//! +//! **The churn limit becomes balance-based.** Phase0 rate-limits activation and +//! exit by counting validators: at most one fixed-size churn limit's worth of +//! them may enter or leave the registry per epoch, and every validator counts +//! the same because every validator's effective balance was the same. Once a +//! single validator can hold what used to be dozens of validators' worth of +//! stake, counting validators no longer bounds how much *stake* can move in +//! one epoch: one compounding validator's exit could strand as much +//! finality-relevant weight as an old-style validator committee's worth all at +//! once. So electra rebuilds the limit in Gwei instead of a headcount. +//! [`get_balance_churn_limit`] is phase0's `get_validator_churn_limit` with +//! that swap, and [`get_activation_exit_churn_limit`] and +//! [`get_consolidation_churn_limit`] split that one Gwei budget between the +//! two things competing for it, so a busy exit queue cannot alone starve +//! consolidations (or the reverse). +//! +//! **The exit and consolidation queues track a spendable balance, not just a +//! target epoch.** Phase0's exit queue only ever needed an epoch, since every +//! validator consumed the same one "seat" of churn and the queue epoch was +//! answer enough. A balance-based limit cannot work that way: two small exits +//! in the same epoch might together still fit under that epoch's churn, even +//! though the first one alone already used most of it, while a single huge +//! exit might need several epochs' worth of churn before it can go through at +//! all. [`compute_exit_epoch_and_update_churn`] and +//! [`compute_consolidation_epoch_and_update_churn`] carry that as an +//! `(earliest_epoch, balance_to_consume)` cursor on the state: +//! `balance_to_consume` is how much of `earliest_epoch`'s budget is still +//! unspent, refilled to a full epoch's churn only once an exit or +//! consolidation actually needs to push the cursor past the epoch it +//! currently sits on. [`initiate_validator_exit`] replaces phase0's version +//! (`crate::beacon::helpers::mutators::initiate_validator_exit`) with one that defers +//! to [`compute_exit_epoch_and_update_churn`] instead of scanning every +//! validator's `exit_epoch` to find the queue's current occupancy. +//! +//! [`switch_to_compounding_validator`] and [`queue_excess_active_balance`] are +//! the deposit side of the same change: raising a validator's ceiling, or a +//! deposit that pushes a validator's balance above +//! [`preset::MIN_ACTIVATION_BALANCE`], both risk crediting a large amount of +//! new active stake in a single slot, so rather than applying the balance +//! immediately, both instead queue the excess as a +//! [`electra::PendingDeposit`] that epoch processing (not implemented in this +//! file) drains a bounded amount of at a time. +//! [`queue_entire_balance_and_reset_validator`] is the same queue-then-drain +//! pattern applied to a whole balance rather than an excess over a ceiling. +//! It is **not** itself a named helper in `specs/electra/beacon-chain.md`: +//! it is extracted verbatim from a loop body in `specs/electra/fork.md`'s +//! `upgrade_to_electra` (the fork-upgrade function, implemented elsewhere in +//! this module), which resets every not-yet-activated validator's balance to +//! zero at the fork boundary and queues the whole amount as a pending +//! deposit rather than losing it. It is factored out here so the +//! fork-upgrade code does not have to duplicate +//! [`queue_excess_active_balance`]'s pending-deposit construction by hand. +//! +//! **[`get_attesting_indices`], [`get_indexed_attestation`], and +//! [`is_valid_indexed_attestation`]** exist here, rather than reusing +//! [`crate::beacon::helpers::attestation`]'s phase0 versions, for an unrelated reason +//! (EIP-7549, not EIP-7251): from electra on, one [`electra::Attestation`] +//! can name every committee in a slot instead of one, via the new +//! `committee_bits` field, so `aggregation_bits` widens to match and the +//! function reading attester positions out of it has to walk each named +//! committee at its own offset within that wider bitfield. See +//! [`crate::beacon::helpers::attestation`]'s module doc for why phase0's versions do +//! not generalize (different concrete container types, not just a wider +//! bound), and [`get_attesting_indices`]'s own doc for the offset arithmetic. +//! +//! [`compute_proposer_index`] and [`get_next_sync_committee_indices`] are not +//! on this module's list of required electra signatures, but this file adds +//! them anyway: electra's specification modifies both of them (widening the +//! acceptance test's random draw from one byte to two, and swapping in +//! [`preset::MAX_EFFECTIVE_BALANCE_ELECTRA`]), both fall within this file's +//! assigned "Predicates" and "Beacon state accessors" sections of +//! `specs/electra/beacon-chain.md`, and every electra (and fulu) block needs a +//! correctly weighted proposer draw and, at the sync committee period +//! boundary, a correctly weighted committee draw. They mirror +//! [`crate::beacon::helpers::shuffling::compute_proposer_index`] and +//! [`crate::beacon::helpers::altair::get_next_sync_committee_indices`] respectively, +//! which remain phase0's and altair's own unmodified versions for the earlier +//! forks that still use them. +//! +//! [`crate::beacon::helpers::accessors::get_beacon_proposer_index`] is what actually +//! reaches [`compute_proposer_index`] for an electra state (fulu reads +//! `proposer_lookahead` instead, precomputed through this same function by +//! [`crate::beacon::helpers::fulu::compute_proposer_indices`]): that accessor is the +//! one call site every fork-invariant caller of "the" proposer index already +//! goes through unconditionally, so it is where the fork dispatch lives +//! rather than in each of those callers. +//! +//! # The fork projection +//! +//! `pending_deposits`, `pending_partial_withdrawals`, `exit_balance_to_consume`, +//! `earliest_exit_epoch`, `consolidation_balance_to_consume`, and +//! `earliest_consolidation_epoch` are state fields with no fork-invariant +//! accessor on [`BeaconState`]. Unlike altair's participation flags (see +//! [`crate::beacon::helpers::altair::altair_state`]'s doc), these fields are not +//! electra-only: fulu keeps every one of them unchanged (see the [`fulu`] +//! module doc), so [`electra_state`] and [`electra_state_ref`] match both +//! `BeaconState::Electra` and `BeaconState::Fulu` rather than only the +//! former. A projection that matched only `Electra`, the way +//! [`crate::beacon::helpers::altair::altair_state`] matches only `Altair`, would +//! silently break every one of these functions on a fulu state, which is the +//! single easiest mistake to make copying that shape without also copying +//! the reasoning behind it. + +use crate::beacon::bls; +use crate::beacon::config::Config; +use crate::beacon::constants::{self, FAR_FUTURE_EPOCH}; +use crate::beacon::containers::shared::Validator; +use crate::beacon::containers::{BeaconState, electra, fulu}; +use crate::beacon::error::{Error, Result}; +use crate::beacon::hash::hash; +use crate::beacon::preset; +use crate::beacon::primitives::{ + BLS_SIGNATURE_SIZE, BlsSignature, Bytes32, CommitteeIndex, Epoch, Gwei, HashTreeRoot as _, + ValidatorIndex, +}; + +use super::accessors::{ + CommitteeCache, CommitteeCacheExt, get_active_validator_indices, get_current_epoch, get_domain, + get_seed, get_total_active_balance, +}; +use super::math::bytes_to_uint64; +use super::misc::{compute_activation_exit_epoch, compute_epoch_at_slot, compute_signing_root}; +use super::predicates::are_indices_sorted_and_unique; +use super::shuffling::compute_shuffled_index; + +// --------------------------------------------------------------------------- +// Predicates +// --------------------------------------------------------------------------- + +/// Whether `validator` has an `0x01`-prefixed "eth1" withdrawal credential. +/// +/// Capella's predicate, not electra's: transcribed from +/// `specs/capella/beacon-chain.md` because [`has_execution_withdrawal_credential`] +/// needs it unmodified and this module has nowhere else to put a single +/// cross-fork predicate reused this way. +pub fn has_eth1_withdrawal_credential(validator: &Validator) -> bool { + validator.withdrawal_credentials.0[0] == constants::ETH1_ADDRESS_WITHDRAWAL_PREFIX +} + +/// Whether `withdrawal_credentials` is `0x02`-prefixed ("compounding"). +pub fn is_compounding_withdrawal_credential(withdrawal_credentials: Bytes32) -> bool { + withdrawal_credentials.0[0] == constants::COMPOUNDING_WITHDRAWAL_PREFIX +} + +/// Whether `validator` has an `0x02`-prefixed "compounding" withdrawal +/// credential. +pub fn has_compounding_withdrawal_credential(validator: &Validator) -> bool { + is_compounding_withdrawal_credential(validator.withdrawal_credentials) +} + +/// Whether `validator` has an `0x01`- or `0x02`-prefixed withdrawal +/// credential, i.e. can actually receive a withdrawal at all (an +/// un-upgraded `0x00` BLS credential cannot). +pub fn has_execution_withdrawal_credential(validator: &Validator) -> bool { + has_eth1_withdrawal_credential(validator) || has_compounding_withdrawal_credential(validator) +} + +/// Whether `validator` is fully withdrawable at `epoch`. +/// +/// Modified from phase0/capella only in which credential predicate it uses: +/// [`has_execution_withdrawal_credential`] accepts either upgraded prefix +/// rather than only the `0x01` one, since a compounding validator is just as +/// withdrawable as an eth1 one once past its `withdrawable_epoch`. +pub fn is_fully_withdrawable_validator(validator: &Validator, balance: Gwei, epoch: Epoch) -> bool { + has_execution_withdrawal_credential(validator) + && validator.withdrawable_epoch <= epoch + && balance > 0 +} + +/// Whether `validator` is partially withdrawable, i.e. sitting at its own +/// ceiling with a real excess balance above it. +/// +/// Modified from phase0/capella to compare against +/// [`get_max_effective_balance`] rather than the single fixed +/// `MAX_EFFECTIVE_BALANCE`: a compounding validator's "at the ceiling, with +/// excess above it" now means its own, higher ceiling, not everyone else's. +pub fn is_partially_withdrawable_validator(validator: &Validator, balance: Gwei) -> bool { + let max_effective_balance = get_max_effective_balance(validator); + let has_max_effective_balance = validator.effective_balance == max_effective_balance; + let has_excess_balance = balance > max_effective_balance; + has_execution_withdrawal_credential(validator) + && has_max_effective_balance + && has_excess_balance +} + +/// Whether `validator` may join the activation queue. +/// +/// Modified from phase0's version (`crate::beacon::helpers::predicates::is_eligible_for_activation_queue`) +/// to require only [`preset::MIN_ACTIVATION_BALANCE`] or more, rather than +/// exactly the old `MAX_EFFECTIVE_BALANCE`: electra's minimum activation +/// balance is deliberately lower than the new compounding ceiling, so a +/// validator can activate well before it ever tops out. +pub fn is_eligible_for_activation_queue(validator: &Validator) -> bool { + validator.activation_eligibility_epoch == FAR_FUTURE_EPOCH + && validator.effective_balance >= preset::MIN_ACTIVATION_BALANCE +} + +/// Electra's replacement for [`crate::beacon::helpers::shuffling::compute_proposer_index`]: +/// a proposer sampled from `indices`, weighted by effective balance. +/// +/// Two things change from phase0's version, both from EIP-7251. The +/// acceptance test compares against [`preset::MAX_EFFECTIVE_BALANCE_ELECTRA`] +/// rather than a caller-supplied (and necessarily lower, pre-electra) ceiling, +/// since a compounding validator's effective balance can now actually reach +/// that higher value. And the random draw widens from a single random byte to +/// a two-byte (16-bit) value: an 8-bit draw can only ever resolve balance +/// differences to one part in 256, which was fine when every validator's +/// effective balance sat at the same value, but is too coarse a filter now +/// that effective balance can vary by a factor of dozens. +/// +/// Like the phase0 version, this takes `effective_balance_of` rather than a +/// state directly, so it stays independent of which fork's state it reads. +pub fn compute_proposer_index( + indices: &[ValidatorIndex], + seed: Bytes32, + mut effective_balance_of: impl FnMut(ValidatorIndex) -> Result, +) -> Result { + crate::beacon::verify(!indices.is_empty(), "len(indices) > 0")?; + + // `2**16 - 1`, the largest value a two-byte little-endian draw can take. + const MAX_RANDOM_VALUE: u64 = u16::MAX as u64; + let total = indices.len() as u64; + + let mut i = 0u64; + loop { + let shuffled = compute_shuffled_index(i % total, total, seed)?; + let candidate = indices[shuffled as usize]; + + let mut random_input = Vec::with_capacity(32 + 8); + random_input.extend_from_slice(&seed.0); + random_input.extend_from_slice(&(i / 16).to_le_bytes()); + let random_bytes = hash(&random_input); + let offset = ((i % 16) * 2) as usize; + let random_value = bytes_to_uint64(&random_bytes.0[offset..offset + 2]); + + let effective_balance = effective_balance_of(candidate)?; + if effective_balance * MAX_RANDOM_VALUE + >= preset::MAX_EFFECTIVE_BALANCE_ELECTRA * random_value + { + return Ok(candidate); + } + + i += 1; + } +} + +/// Whether an indexed attestation names a valid attester set and carries +/// their aggregate signature. +/// +/// Logic identical to [`crate::beacon::helpers::attestation::is_valid_indexed_attestation`]; +/// this exists as its own copy only because [`electra::IndexedAttestation`] +/// is a different concrete type from phase0's `IndexedAttestation`, bounded +/// by `MAX_VALIDATORS_PER_SLOT` rather than `MAX_VALIDATORS_PER_COMMITTEE` +/// (EIP-7549: one electra attestation can span every committee in a slot). +pub fn is_valid_indexed_attestation( + state: &BeaconState, + indexed_attestation: &electra::IndexedAttestation, +) -> bool { + let indices: &[ValidatorIndex] = &indexed_attestation.attesting_indices; + if indices.is_empty() || !are_indices_sorted_and_unique(indices) { + return false; + } + + let mut pubkeys = Vec::with_capacity(indices.len()); + for index in indices { + match state.validator(*index) { + Ok(validator) => pubkeys.push(validator.pubkey), + Err(_) => return false, + } + } + + let domain = get_domain( + state, + constants::DOMAIN_BEACON_ATTESTER, + Some(indexed_attestation.data.target.epoch), + ); + let signing_root = compute_signing_root(indexed_attestation.data.hash_tree_root(), domain); + bls::fast_aggregate_verify(&pubkeys, signing_root, &indexed_attestation.signature) +} + +// --------------------------------------------------------------------------- +// Misc +// --------------------------------------------------------------------------- + +/// The committee indices `committee_bits` names, i.e. the positions of its +/// set bits, in ascending order. +/// +/// [`get_attesting_indices`] reads this to know which committees an +/// [`electra::Attestation`] covers and in what order its widened +/// `aggregation_bits` names their members. +pub fn get_committee_indices(committee_bits: &electra::CommitteeBits) -> Vec { + (0..committee_bits.len()) + .filter(|&index| committee_bits.get(index).unwrap_or(false)) + .map(|index| index as CommitteeIndex) + .collect() +} + +/// The effective balance ceiling for `validator`: the higher +/// [`preset::MAX_EFFECTIVE_BALANCE_ELECTRA`] for a compounding validator, or +/// [`preset::MIN_ACTIVATION_BALANCE`] for everyone else. +/// +/// Named "max effective balance" rather than "ceiling" to match the +/// specification, even though [`preset::MIN_ACTIVATION_BALANCE`] is also the +/// minimum a validator needs to activate at all: pre-electra those two +/// numbers were different constants that happened never to both matter to +/// the same validator at once, and electra reuses the minimum as the +/// non-compounding ceiling rather than introducing a third value. +pub fn get_max_effective_balance(validator: &Validator) -> Gwei { + if has_compounding_withdrawal_credential(validator) { + preset::MAX_EFFECTIVE_BALANCE_ELECTRA + } else { + preset::MIN_ACTIVATION_BALANCE + } +} + +// --------------------------------------------------------------------------- +// Beacon state accessors +// --------------------------------------------------------------------------- + +/// The churn limit for the current epoch, in Gwei. +/// +/// Phase0's `get_validator_churn_limit` +/// (`crate::beacon::helpers::accessors::get_validator_churn_limit`) counts +/// validators, which only bounds a balance amount because every validator's +/// effective balance used to be the same fixed value. Once a compounding +/// validator can hold up to `MAX_EFFECTIVE_BALANCE_ELECTRA`, the same +/// headcount no longer bounds the same amount of stake, so this counts Gwei +/// directly instead: the same fraction of total active balance +/// (`CHURN_LIMIT_QUOTIENT`), floored at +/// [`Config::min_per_epoch_churn_limit_electra`] rather than a +/// validator-count floor, and rounded down to a whole +/// `EFFECTIVE_BALANCE_INCREMENT` so the budget always divides evenly into the +/// unit every balance change is already rounded to. +pub fn get_balance_churn_limit(state: &BeaconState, config: &Config) -> Result { + let total_active_balance = get_total_active_balance(state)?; + let churn = config + .min_per_epoch_churn_limit_electra + .max(total_active_balance / config.churn_limit_quotient); + Ok(churn - churn % preset::EFFECTIVE_BALANCE_INCREMENT) +} + +/// The portion of [`get_balance_churn_limit`] set aside for activations and +/// exits, as opposed to consolidations. +/// +/// Capped separately from the combined budget +/// ([`Config::max_per_epoch_activation_exit_churn_limit`]) so that on a very +/// large validator set, activations and exits cannot alone consume the whole +/// churn budget and starve consolidations of any share at all. +pub fn get_activation_exit_churn_limit(state: &BeaconState, config: &Config) -> Result { + Ok(config + .max_per_epoch_activation_exit_churn_limit + .min(get_balance_churn_limit(state, config)?)) +} + +/// The portion of [`get_balance_churn_limit`] left over for consolidations +/// once [`get_activation_exit_churn_limit`] has taken its share. +pub fn get_consolidation_churn_limit(state: &BeaconState, config: &Config) -> Result { + let balance_churn_limit = get_balance_churn_limit(state, config)?; + let activation_exit_churn_limit = get_activation_exit_churn_limit(state, config)?; + // `get_activation_exit_churn_limit` is a `min` against `balance_churn_limit`, + // so it can never exceed it, and this subtraction cannot underflow. + Ok(balance_churn_limit - activation_exit_churn_limit) +} + +/// The total amount queued in [`electra::PendingPartialWithdrawal`]s for +/// `index`, not yet paid out. +/// +/// Read by electra's execution layer consolidation request processing (not +/// implemented in this file) to refuse consolidating a validator that still +/// has a partial withdrawal in flight: consolidating it out from under that +/// withdrawal would leave nothing left to pay the withdrawal from. +pub fn get_pending_balance_to_withdraw(state: &BeaconState, index: ValidatorIndex) -> Result { + let fields = electra_state_ref(state, "get_pending_balance_to_withdraw")?; + let mut total: Gwei = 0; + for withdrawal in fields.pending_partial_withdrawals().iter() { + if withdrawal.validator_index == index { + total = total.saturating_add(withdrawal.amount); + } + } + Ok(total) +} + +/// The committee members whose bit is set in `attestation`, in ascending +/// order. +/// +/// EIP-7549 moves the committee index out of `AttestationData` and lets one +/// attestation cover every committee in a slot, so `aggregation_bits` is now +/// the concatenation of each named committee's member bits, in the same +/// ascending committee-index order [`get_committee_indices`] returns them +/// in. Reading attester `position` out of committee `committee_index`'s +/// segment therefore means indexing `aggregation_bits` at `committee_offset + +/// position`, where `committee_offset` is the total length of every +/// previously-*named* committee, not the committee's own index or position +/// in the loop: a naive `committee_index * committee_size` offset would +/// misalign as soon as two named committees differ in size, and skipping an +/// *unnamed* committee (one whose bit is clear in `committee_bits`) must not +/// advance the offset at all, since that committee's members never appear in +/// `aggregation_bits` to begin with. Accumulating `committee_offset` from +/// each named committee's own `len()`, in the order [`get_committee_indices`] +/// visits them, is what keeps the running offset correct regardless of which +/// (possibly non-contiguous) committee indices are actually named. +/// +/// Sorted at the end for the same reason +/// [`crate::beacon::helpers::attestation::get_attesting_indices`] sorts: the +/// specification returns a set, and a committee is a *shuffled* slice of the +/// registry, so reading one in position order yields attester indices in +/// shuffle order, almost never ascending. Not deduplicated: a slot's +/// committees partition its active set (see +/// `crate::beacon::helpers::accessors`'s +/// `committees_cover_every_active_validator_once_per_epoch` test), so the +/// same validator index cannot appear under two different named committees, +/// and a single committee cannot name the same position twice. +/// +/// `committees` is what keeps this one active-set scan and one shuffle rather +/// than `MAX_COMMITTEES_PER_SLOT` of each: every committee named by one +/// attestation belongs to the same slot, and so to the same epoch's shuffling. +/// See [`CommitteeCache`] for how far that sharing reaches beyond this call. +pub fn get_attesting_indices( + state: &BeaconState, + attestation: &electra::Attestation, + committees: &CommitteeCache, +) -> Result> { + let committee_indices = get_committee_indices(&attestation.committee_bits); + let epoch_committees = + committees.committees(state, compute_epoch_at_slot(attestation.data.slot)); + + let mut indices = Vec::new(); + let mut committee_offset = 0usize; + for committee_index in committee_indices { + let committee = epoch_committees.committee(attestation.data.slot, committee_index)?; + for (position, attester_index) in committee.iter().enumerate() { + let bit = committee_offset + position; + if attestation.aggregation_bits.get(bit).unwrap_or(false) { + indices.push(*attester_index); + } + } + committee_offset += committee.len(); + } + + indices.sort_unstable(); + Ok(indices) +} + +/// The same attestation with its attesters named rather than bit-encoded. +pub fn get_indexed_attestation( + state: &BeaconState, + attestation: &electra::Attestation, + committees: &CommitteeCache, +) -> Result { + let indices = get_attesting_indices(state, attestation, committees)?; + Ok(electra::IndexedAttestation { + attesting_indices: electra::AttestingIndices::try_from(indices)?, + data: attestation.data, + signature: attestation.signature, + }) +} + +/// Electra's replacement for +/// [`crate::beacon::helpers::altair::get_next_sync_committee_indices`]: the sync +/// committee indices, with possible duplicates, for the sync committee +/// period starting next epoch. +/// +/// Same EIP-7251 change as [`compute_proposer_index`]: the acceptance test's +/// ceiling becomes [`preset::MAX_EFFECTIVE_BALANCE_ELECTRA`] and its random +/// draw widens from one byte to two, for the same reasons. +pub fn get_next_sync_committee_indices(state: &BeaconState) -> Result> { + let epoch = get_current_epoch(state) + 1; + + // `2**16 - 1`, the largest value a two-byte little-endian draw can take. + const MAX_RANDOM_VALUE: u64 = u16::MAX as u64; + + let active_validator_indices = get_active_validator_indices(state, epoch); + let active_validator_count = active_validator_indices.len() as u64; + crate::beacon::verify( + active_validator_count > 0, + "len(active_validator_indices) > 0", + )?; + let seed = get_seed(state, epoch, constants::DOMAIN_SYNC_COMMITTEE); + + let mut i: u64 = 0; + let mut sync_committee_indices = Vec::with_capacity(preset::SYNC_COMMITTEE_SIZE); + while sync_committee_indices.len() < preset::SYNC_COMMITTEE_SIZE { + let shuffled_index = + compute_shuffled_index(i % active_validator_count, active_validator_count, seed)?; + let candidate_index = active_validator_indices[shuffled_index as usize]; + + let mut random_input = Vec::with_capacity(32 + 8); + random_input.extend_from_slice(&seed.0); + random_input.extend_from_slice(&(i / 16).to_le_bytes()); + let random_bytes = hash(&random_input); + let offset = ((i % 16) * 2) as usize; + let random_value = bytes_to_uint64(&random_bytes.0[offset..offset + 2]); + + let effective_balance = state.validator(candidate_index)?.effective_balance; + if effective_balance * MAX_RANDOM_VALUE + >= preset::MAX_EFFECTIVE_BALANCE_ELECTRA * random_value + { + sync_committee_indices.push(candidate_index); + } + i += 1; + } + Ok(sync_committee_indices) +} + +// --------------------------------------------------------------------------- +// Beacon state mutators +// --------------------------------------------------------------------------- + +/// Puts a validator into the exit queue. +/// +/// Modified from phase0's version +/// (`crate::beacon::helpers::mutators::initiate_validator_exit`) to compute the exit +/// epoch through [`compute_exit_epoch_and_update_churn`] instead of scanning +/// every validator's `exit_epoch` for the queue's current occupancy: that +/// scan counted validators, which stopped being a valid proxy for churned +/// balance the moment validators stopped all weighing the same. +pub fn initiate_validator_exit( + state: &mut BeaconState, + index: ValidatorIndex, + config: &Config, +) -> Result<()> { + if state.validator(index)?.exit_epoch != FAR_FUTURE_EPOCH { + return Ok(()); + } + + let effective_balance = state.validator(index)?.effective_balance; + let exit_queue_epoch = compute_exit_epoch_and_update_churn(state, effective_balance, config)?; + + // See `crate::beacon::helpers::mutators::initiate_validator_exit` for why this is + // checked rather than left to wrap: a validator already queued with an + // exit epoch close to `FAR_FUTURE_EPOCH` could otherwise overflow into a + // withdrawable epoch in the past. + let withdrawable = exit_queue_epoch + .checked_add(config.min_validator_withdrawability_delay) + .ok_or(Error::ArithmeticOverflow( + "exit_queue_epoch + MIN_VALIDATOR_WITHDRAWABILITY_DELAY", + ))?; + + let validator = state.validator_mut(index)?; + validator.exit_epoch = exit_queue_epoch; + validator.withdrawable_epoch = withdrawable; + Ok(()) +} + +/// Advances electra's exit-queue cursor for an exit of `exit_balance`, +/// returning the epoch it may take effect at. +/// +/// This is where the "balance to consume" cursor this module's doc describes +/// actually lives: `earliest_exit_epoch` is the earliest epoch that still has +/// unspent churn, and `exit_balance_to_consume` is how much of that epoch's +/// budget remains. A new epoch's budget is only opened (refilled to a full +/// [`get_activation_exit_churn_limit`]) once the cursor actually needs to move +/// past the epoch it currently sits on; until then, a later, smaller exit in +/// the same epoch spends whatever an earlier one left over instead of always +/// waiting for a fresh epoch. +pub fn compute_exit_epoch_and_update_churn( + state: &mut BeaconState, + exit_balance: Gwei, + config: &Config, +) -> Result { + let current_epoch = get_current_epoch(state); + let per_epoch_churn = get_activation_exit_churn_limit(state, config)?; + + let mut fields = electra_state(state, "compute_exit_epoch_and_update_churn")?; + + let mut earliest_exit_epoch = fields + .earliest_exit_epoch() + .max(compute_activation_exit_epoch(current_epoch)); + + // A later epoch than the cursor currently sits on: that epoch has not + // spent any of its churn yet, so its budget starts full. Otherwise the + // cursor has not moved, and whatever it left unspent carries over. + let mut exit_balance_to_consume = if fields.earliest_exit_epoch() < earliest_exit_epoch { + per_epoch_churn + } else { + fields.exit_balance_to_consume() + }; + + if exit_balance > exit_balance_to_consume { + let balance_to_process = exit_balance - exit_balance_to_consume; + // Ceiling division: how many additional epochs' worth of churn this + // exit needs beyond what the current epoch has left. + let additional_epochs = balance_to_process + .checked_sub(1) + .and_then(|value| value.checked_div(per_epoch_churn)) + .and_then(|value| value.checked_add(1)) + .ok_or(Error::ArithmeticOverflow( + "(exit_balance - exit_balance_to_consume - 1) / per_epoch_churn + 1", + ))?; + let additional_churn = + additional_epochs + .checked_mul(per_epoch_churn) + .ok_or(Error::ArithmeticOverflow( + "additional_epochs * per_epoch_churn", + ))?; + earliest_exit_epoch = + earliest_exit_epoch + .checked_add(additional_epochs) + .ok_or(Error::ArithmeticOverflow( + "earliest_exit_epoch + additional_epochs", + ))?; + exit_balance_to_consume = exit_balance_to_consume + .checked_add(additional_churn) + .ok_or(Error::ArithmeticOverflow( + "exit_balance_to_consume + additional_epochs * per_epoch_churn", + ))?; + } + + *fields.exit_balance_to_consume_mut() = exit_balance_to_consume + .checked_sub(exit_balance) + .ok_or(Error::ArithmeticOverflow( + "exit_balance_to_consume - exit_balance", + ))?; + *fields.earliest_exit_epoch_mut() = earliest_exit_epoch; + + Ok(earliest_exit_epoch) +} + +/// Advances electra's consolidation-queue cursor for a consolidation moving +/// `consolidation_balance`, returning the epoch it may take effect at. +/// +/// The consolidation-side counterpart of +/// [`compute_exit_epoch_and_update_churn`], carrying the exact same +/// `(earliest_epoch, balance_to_consume)` cursor shape but drawing from +/// [`get_consolidation_churn_limit`]'s separate budget instead of the +/// activation/exit one, so a burst of consolidations cannot also drain the +/// budget an unrelated exit needs. +pub fn compute_consolidation_epoch_and_update_churn( + state: &mut BeaconState, + consolidation_balance: Gwei, + config: &Config, +) -> Result { + let current_epoch = get_current_epoch(state); + let per_epoch_churn = get_consolidation_churn_limit(state, config)?; + + let mut fields = electra_state(state, "compute_consolidation_epoch_and_update_churn")?; + + let mut earliest_consolidation_epoch = fields + .earliest_consolidation_epoch() + .max(compute_activation_exit_epoch(current_epoch)); + + let mut consolidation_balance_to_consume = + if fields.earliest_consolidation_epoch() < earliest_consolidation_epoch { + per_epoch_churn + } else { + fields.consolidation_balance_to_consume() + }; + + if consolidation_balance > consolidation_balance_to_consume { + let balance_to_process = consolidation_balance - consolidation_balance_to_consume; + let additional_epochs = balance_to_process + .checked_sub(1) + .and_then(|value| value.checked_div(per_epoch_churn)) + .and_then(|value| value.checked_add(1)) + .ok_or(Error::ArithmeticOverflow( + "(consolidation_balance - consolidation_balance_to_consume - 1) / per_epoch_churn + 1", + ))?; + let additional_churn = + additional_epochs + .checked_mul(per_epoch_churn) + .ok_or(Error::ArithmeticOverflow( + "additional_epochs * per_epoch_churn", + ))?; + earliest_consolidation_epoch = earliest_consolidation_epoch + .checked_add(additional_epochs) + .ok_or(Error::ArithmeticOverflow( + "earliest_consolidation_epoch + additional_epochs", + ))?; + consolidation_balance_to_consume = consolidation_balance_to_consume + .checked_add(additional_churn) + .ok_or(Error::ArithmeticOverflow( + "consolidation_balance_to_consume + additional_epochs * per_epoch_churn", + ))?; + } + + *fields.consolidation_balance_to_consume_mut() = consolidation_balance_to_consume + .checked_sub(consolidation_balance) + .ok_or(Error::ArithmeticOverflow( + "consolidation_balance_to_consume - consolidation_balance", + ))?; + *fields.earliest_consolidation_epoch_mut() = earliest_consolidation_epoch; + + Ok(earliest_consolidation_epoch) +} + +/// Upgrades a validator to a compounding withdrawal credential, queuing +/// whatever balance is already above [`preset::MIN_ACTIVATION_BALANCE`] the +/// same way a fresh deposit above it would be. +pub fn switch_to_compounding_validator( + state: &mut BeaconState, + index: ValidatorIndex, +) -> Result<()> { + state.validator_mut(index)?.withdrawal_credentials.0[0] = + constants::COMPOUNDING_WITHDRAWAL_PREFIX; + queue_excess_active_balance(state, index) +} + +/// Caps a validator's balance at [`preset::MIN_ACTIVATION_BALANCE`], queuing +/// anything above that as a [`electra::PendingDeposit`] rather than +/// crediting it as active stake immediately. +/// +/// Does nothing if the balance is already at or below the cap: called after +/// [`switch_to_compounding_validator`] raises a validator's ceiling, and +/// after a deposit, both of which are ordinary, common-case calls that most +/// of the time have nothing to queue. +pub fn queue_excess_active_balance(state: &mut BeaconState, index: ValidatorIndex) -> Result<()> { + let balance = state.balance(index)?; + if balance <= preset::MIN_ACTIVATION_BALANCE { + return Ok(()); + } + + let excess_balance = balance - preset::MIN_ACTIVATION_BALANCE; + // `state.balance(index)?` above already proved `index` is in range, and + // `balances` is always exactly as long as `validators`, so this indexing + // cannot panic. + state.balances_mut()[index as usize] = preset::MIN_ACTIVATION_BALANCE; + + let deposit = placeholder_pending_deposit(state.validator(index)?, excess_balance); + electra_state(state, "queue_excess_active_balance")? + .pending_deposits_mut() + .push(deposit)?; + Ok(()) +} + +/// Zeroes a validator's balance and effective balance, resets its activation +/// eligibility to "not yet eligible", and queues the balance it held as a +/// [`electra::PendingDeposit`]. +/// +/// Not itself a named helper in `specs/electra/beacon-chain.md`. It is +/// extracted verbatim from a loop body in `specs/electra/fork.md`'s +/// `upgrade_to_electra`, which applies exactly this to every not-yet-active +/// validator at the electra fork boundary rather than losing its balance: +/// [`switch_to_compounding_validator`] and [`queue_excess_active_balance`] +/// already queue a validator's *excess* balance the same way, and the fork +/// upgrade needs the same construction for a validator's *entire* balance, +/// so this factors the shared shape into one function rather than +/// duplicating the pending-deposit literal at both call sites. +pub fn queue_entire_balance_and_reset_validator( + state: &mut BeaconState, + index: ValidatorIndex, +) -> Result<()> { + let balance = state.balance(index)?; + // See `queue_excess_active_balance` for why this indexing cannot panic. + state.balances_mut()[index as usize] = 0; + + let validator = state.validator_mut(index)?; + validator.effective_balance = 0; + validator.activation_eligibility_epoch = FAR_FUTURE_EPOCH; + + let deposit = placeholder_pending_deposit(state.validator(index)?, balance); + electra_state(state, "queue_entire_balance_and_reset_validator")? + .pending_deposits_mut() + .push(deposit)?; + Ok(()) +} + +// --------------------------------------------------------------------------- +// Fork projection +// --------------------------------------------------------------------------- + +/// The compressed encoding of the identity element of G2 +/// (`specs/altair/bls.md`'s `G2_POINT_AT_INFINITY`), used as the placeholder +/// signature on a synthetic pending deposit that never carried a real proof +/// of possession. +/// +/// [`crate::beacon::bls`] keeps its own copy of this same encoding for +/// `eth_fast_aggregate_verify`'s empty-committee case, but does not export +/// it, so this file builds its own rather than reaching into that module's +/// internals. +fn g2_point_at_infinity() -> BlsSignature { + let mut bytes = [0u8; BLS_SIGNATURE_SIZE]; + // The top two bits are the compression and infinity flags; setting both + // and leaving every other bit zero is the point at infinity's compressed + // encoding. + bytes[0] = 0b1100_0000; + BlsSignature(bytes) +} + +/// A [`electra::PendingDeposit`] standing in for a balance that never +/// belonged to a real, signed deposit: the shared shape +/// [`queue_excess_active_balance`] and +/// [`queue_entire_balance_and_reset_validator`] both build, differing only in +/// how much balance they queue. +fn placeholder_pending_deposit(validator: &Validator, amount: Gwei) -> electra::PendingDeposit { + electra::PendingDeposit { + pubkey: validator.pubkey, + withdrawal_credentials: validator.withdrawal_credentials, + amount, + signature: g2_point_at_infinity(), + slot: constants::GENESIS_SLOT, + } +} + +/// Either fork whose state carries electra's balance-churn accounting and +/// pending-deposit/withdrawal queues (EIP-7251) unchanged: electra itself, or +/// fulu, which never redefines any of the fields read through this. See this +/// module's doc for why both are accepted, and [`electra_state`] for the +/// mutable counterpart. +pub(crate) enum ElectraOrFulu<'a> { + Electra(&'a electra::BeaconState), + Fulu(&'a fulu::BeaconState), +} + +impl<'a> ElectraOrFulu<'a> { + /// Partial withdrawals queued but not yet paid out, read by + /// [`get_pending_balance_to_withdraw`]. + pub(crate) fn pending_partial_withdrawals(&self) -> &electra::PendingPartialWithdrawals { + match self { + ElectraOrFulu::Electra(state) => &state.pending_partial_withdrawals, + ElectraOrFulu::Fulu(state) => &state.pending_partial_withdrawals, + } + } +} + +/// The mutable counterpart of [`ElectraOrFulu`]. See [`electra_state`]. +pub(crate) enum ElectraOrFuluMut<'a> { + Electra(&'a mut electra::BeaconState), + Fulu(&'a mut fulu::BeaconState), +} + +impl<'a> ElectraOrFuluMut<'a> { + pub(crate) fn earliest_exit_epoch(&self) -> Epoch { + match self { + ElectraOrFuluMut::Electra(state) => state.earliest_exit_epoch, + ElectraOrFuluMut::Fulu(state) => state.earliest_exit_epoch, + } + } + + pub(crate) fn earliest_exit_epoch_mut(&mut self) -> &mut Epoch { + match self { + ElectraOrFuluMut::Electra(state) => &mut state.earliest_exit_epoch, + ElectraOrFuluMut::Fulu(state) => &mut state.earliest_exit_epoch, + } + } + + pub(crate) fn exit_balance_to_consume(&self) -> Gwei { + match self { + ElectraOrFuluMut::Electra(state) => state.exit_balance_to_consume, + ElectraOrFuluMut::Fulu(state) => state.exit_balance_to_consume, + } + } + + pub(crate) fn exit_balance_to_consume_mut(&mut self) -> &mut Gwei { + match self { + ElectraOrFuluMut::Electra(state) => &mut state.exit_balance_to_consume, + ElectraOrFuluMut::Fulu(state) => &mut state.exit_balance_to_consume, + } + } + + pub(crate) fn earliest_consolidation_epoch(&self) -> Epoch { + match self { + ElectraOrFuluMut::Electra(state) => state.earliest_consolidation_epoch, + ElectraOrFuluMut::Fulu(state) => state.earliest_consolidation_epoch, + } + } + + pub(crate) fn earliest_consolidation_epoch_mut(&mut self) -> &mut Epoch { + match self { + ElectraOrFuluMut::Electra(state) => &mut state.earliest_consolidation_epoch, + ElectraOrFuluMut::Fulu(state) => &mut state.earliest_consolidation_epoch, + } + } + + pub(crate) fn consolidation_balance_to_consume(&self) -> Gwei { + match self { + ElectraOrFuluMut::Electra(state) => state.consolidation_balance_to_consume, + ElectraOrFuluMut::Fulu(state) => state.consolidation_balance_to_consume, + } + } + + pub(crate) fn consolidation_balance_to_consume_mut(&mut self) -> &mut Gwei { + match self { + ElectraOrFuluMut::Electra(state) => &mut state.consolidation_balance_to_consume, + ElectraOrFuluMut::Fulu(state) => &mut state.consolidation_balance_to_consume, + } + } + + /// Deposits queued but not yet credited to the validator registry, pushed + /// to by [`queue_excess_active_balance`] and + /// [`queue_entire_balance_and_reset_validator`]. + pub(crate) fn pending_deposits_mut(&mut self) -> &mut electra::PendingDeposits { + match self { + ElectraOrFuluMut::Electra(state) => &mut state.pending_deposits, + ElectraOrFuluMut::Fulu(state) => &mut state.pending_deposits, + } + } +} + +/// The electra-or-fulu state, mutably, or an error naming the function that +/// needs one. See [`ElectraOrFuluMut`] and this module's doc for why both +/// forks are accepted. +pub(crate) fn electra_state<'a>( + state: &'a mut BeaconState, + function: &'static str, +) -> Result> { + match state { + BeaconState::Electra(state) => Ok(ElectraOrFuluMut::Electra(state)), + BeaconState::Fulu(state) => Ok(ElectraOrFuluMut::Fulu(state)), + other => Err(Error::UnsupportedForFork { + function, + fork: other.fork_name(), + }), + } +} + +/// The electra-or-fulu state, immutably. See [`electra_state`]. +pub(crate) fn electra_state_ref<'a>( + state: &'a BeaconState, + function: &'static str, +) -> Result> { + match state { + BeaconState::Electra(state) => Ok(ElectraOrFulu::Electra(state)), + BeaconState::Fulu(state) => Ok(ElectraOrFulu::Fulu(state)), + other => Err(Error::UnsupportedForFork { + function, + fork: other.fork_name(), + }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::beacon::containers::shared::AttestationData; + use crate::beacon::fork::ForkName; + use crate::beacon::helpers::accessors::get_beacon_committee; + + /// An electra state with `count` fully active, full-balance validators, + /// positioned the same way `crate::beacon::helpers::test_state::with_validators` + /// positions its phase0 state: one epoch in, so the previous epoch and + /// the block-root history window both have entries. + /// + /// A thin wrapper around the shared fork-parameterised builder: see + /// [`crate::beacon::helpers::test_state::with_validators_at`] for the construction + /// this and every other fork's test module used to duplicate. + fn electra_state_with_validators(count: usize) -> BeaconState { + crate::beacon::helpers::test_state::with_validators_at(ForkName::Electra, count) + } + + /// A fulu state, otherwise identical to [`electra_state_with_validators`], + /// used only to prove [`electra_state`]/[`electra_state_ref`] accept fulu + /// too and are not accidentally scoped to `BeaconState::Electra` alone. + fn fulu_state_with_validators(count: usize) -> BeaconState { + crate::beacon::helpers::test_state::with_validators_at(ForkName::Fulu, count) + } + + // -- Predicates --------------------------------------------------------- + + #[test] + fn withdrawal_credential_predicates_read_the_prefix_byte() { + let mut validator = Validator { + withdrawal_credentials: Bytes32::ZERO, + ..Default::default() + }; + // 0x00: a raw BLS credential, before the validator has upgraded. + assert!(!has_eth1_withdrawal_credential(&validator)); + assert!(!has_compounding_withdrawal_credential(&validator)); + assert!(!has_execution_withdrawal_credential(&validator)); + + validator.withdrawal_credentials.0[0] = constants::ETH1_ADDRESS_WITHDRAWAL_PREFIX; + assert!(has_eth1_withdrawal_credential(&validator)); + assert!(!has_compounding_withdrawal_credential(&validator)); + assert!(has_execution_withdrawal_credential(&validator)); + + validator.withdrawal_credentials.0[0] = constants::COMPOUNDING_WITHDRAWAL_PREFIX; + assert!(!has_eth1_withdrawal_credential(&validator)); + assert!(has_compounding_withdrawal_credential(&validator)); + assert!(has_execution_withdrawal_credential(&validator)); + } + + #[test] + fn max_effective_balance_depends_on_the_compounding_credential() { + let mut validator = Validator { + withdrawal_credentials: Bytes32::ZERO, + ..Default::default() + }; + validator.withdrawal_credentials.0[0] = constants::ETH1_ADDRESS_WITHDRAWAL_PREFIX; + assert_eq!( + get_max_effective_balance(&validator), + preset::MIN_ACTIVATION_BALANCE + ); + + validator.withdrawal_credentials.0[0] = constants::COMPOUNDING_WITHDRAWAL_PREFIX; + assert_eq!( + get_max_effective_balance(&validator), + preset::MAX_EFFECTIVE_BALANCE_ELECTRA + ); + } + + #[test] + fn partially_withdrawable_requires_both_a_full_ceiling_and_excess_balance() { + let mut validator = Validator { + withdrawal_credentials: Bytes32::ZERO, + effective_balance: preset::MIN_ACTIVATION_BALANCE, + ..Default::default() + }; + validator.withdrawal_credentials.0[0] = constants::ETH1_ADDRESS_WITHDRAWAL_PREFIX; + + assert!(!is_partially_withdrawable_validator( + &validator, + preset::MIN_ACTIVATION_BALANCE + )); + assert!(is_partially_withdrawable_validator( + &validator, + preset::MIN_ACTIVATION_BALANCE + 1 + )); + + // No longer sitting at its own ceiling, so no longer withdrawable + // even with a nominally excess balance. + validator.effective_balance = preset::MIN_ACTIVATION_BALANCE - 1; + assert!(!is_partially_withdrawable_validator( + &validator, + preset::MIN_ACTIVATION_BALANCE + 1 + )); + } + + #[test] + fn activation_queue_eligibility_accepts_balance_at_or_above_the_minimum() { + let mut validator = Validator { + activation_eligibility_epoch: FAR_FUTURE_EPOCH, + effective_balance: preset::MIN_ACTIVATION_BALANCE, + ..Default::default() + }; + assert!(is_eligible_for_activation_queue(&validator)); + + // Unlike phase0's version (exact equality with MAX_EFFECTIVE_BALANCE), + // electra also accepts a compounding validator's higher balance. + validator.effective_balance = preset::MAX_EFFECTIVE_BALANCE_ELECTRA; + assert!(is_eligible_for_activation_queue(&validator)); + + validator.effective_balance = preset::MIN_ACTIVATION_BALANCE - 1; + assert!(!is_eligible_for_activation_queue(&validator)); + } + + #[test] + fn compute_proposer_index_prefers_a_full_balance_under_the_electra_ceiling() { + let indices: Vec = (0..16).collect(); + let max = preset::MAX_EFFECTIVE_BALANCE_ELECTRA; + + let mut chose_the_rich_one = 0; + for trial in 0..32u8 { + let chosen = compute_proposer_index(&indices, Bytes32::repeat_byte(trial), |index| { + Ok(if index == 3 { max } else { 1 }) + }) + .unwrap(); + if chosen == 3 { + chose_the_rich_one += 1; + } + } + assert!( + chose_the_rich_one > 16, + "the full-balance validator was chosen {chose_the_rich_one} times out of 32" + ); + } + + #[test] + fn compute_proposer_index_rejects_an_empty_set() { + assert!(compute_proposer_index(&[], Bytes32::ZERO, |_| Ok(1)).is_err()); + } + + // -- Misc ----------------------------------------------------------- + + #[test] + fn get_committee_indices_lists_only_the_set_bits_in_ascending_order() { + let mut bits = electra::CommitteeBits::default(); + // `CommitteeBits` is exactly `MAX_COMMITTEES_PER_SLOT` bits long, and + // minimal's preset shrinks that bound far below mainnet's, so the + // highest index this vector actually holds has to come from the + // constant itself: a literal high enough to be interesting under + // mainnet (e.g. 4) is out of bounds under minimal. + let last = preset::MAX_COMMITTEES_PER_SLOT - 1; + bits.set(last, true).unwrap(); + bits.set(1, true).unwrap(); + assert_eq!( + get_committee_indices(&bits), + vec![1, last as CommitteeIndex] + ); + } + + // -- Beacon state accessors ---------------------------------------------- + + #[test] + fn activation_exit_and_consolidation_churn_partition_the_balance_churn_limit() { + let config = Config::mainnet(); + let state = electra_state_with_validators(64); + + let balance_churn = get_balance_churn_limit(&state, &config).unwrap(); + let activation_exit_churn = get_activation_exit_churn_limit(&state, &config).unwrap(); + let consolidation_churn = get_consolidation_churn_limit(&state, &config).unwrap(); + + assert_eq!(activation_exit_churn + consolidation_churn, balance_churn); + } + + #[test] + fn balance_churn_limit_respects_its_electra_floor() { + let config = Config::mainnet(); + // A tiny active set falls below the proportional limit, so the + // electra-specific floor applies, not phase0's smaller one. + let state = electra_state_with_validators(4); + assert_eq!( + get_balance_churn_limit(&state, &config).unwrap(), + config.min_per_epoch_churn_limit_electra, + ); + } + + #[test] + fn pending_balance_to_withdraw_sums_only_the_matching_validator() { + let mut state = electra_state_with_validators(4); + if let BeaconState::Electra(inner) = &mut state { + inner.pending_partial_withdrawals = vec![ + electra::PendingPartialWithdrawal { + validator_index: 0, + amount: 10, + withdrawable_epoch: 5, + }, + electra::PendingPartialWithdrawal { + validator_index: 1, + amount: 20, + withdrawable_epoch: 5, + }, + electra::PendingPartialWithdrawal { + validator_index: 0, + amount: 30, + withdrawable_epoch: 6, + }, + ] + .try_into() + .unwrap(); + } else { + unreachable!("just built as Electra"); + } + + assert_eq!(get_pending_balance_to_withdraw(&state, 0).unwrap(), 40); + assert_eq!(get_pending_balance_to_withdraw(&state, 1).unwrap(), 20); + assert_eq!(get_pending_balance_to_withdraw(&state, 2).unwrap(), 0); + } + + #[test] + fn get_attesting_indices_returns_ascending_indices_across_noncontiguous_committees() { + // Enough active validators that a slot splits into more than one + // committee under either preset this module compiles for. Exactly how + // many committees (and what size) `get_committee_count_per_slot` + // lands on differs by preset: mainnet's larger `TARGET_COMMITTEE_SIZE` + // but higher `MAX_COMMITTEES_PER_SLOT` ceiling divides this count one + // way, minimal's smaller versions of both divide it another way. So + // this test reads the committees it actually gets back rather than + // asserting a size tied to one preset's arithmetic; what it exercises + // is the committee-offset bookkeeping in `get_attesting_indices`, not + // any preset's specific committee count or size. Fewer validators + // would risk naming only one committee under some preset, which + // cannot expose a misaligned offset. + let count = 8192; + let state = electra_state_with_validators(count); + let slot = state.slot(); + + // Committee indices 0 and 2, deliberately non-contiguous: naming + // index 1's slot but skipping it must not shift where index 2's + // segment starts in `aggregation_bits`. This needs at least three + // committees per slot, true under both presets at this validator + // count. + let committee_0 = get_beacon_committee(&state, slot, 0).unwrap(); + let committee_2 = get_beacon_committee(&state, slot, 2).unwrap(); + assert!(!committee_0.is_empty(), "test needs a non-empty committee"); + // `count` divides evenly by the total committee count under both + // presets, so every committee in this epoch is exactly the same + // size; asserting that (rather than a preset-specific literal) is + // what lets the rest of this test use `committee_0.len()` as the + // offset into committee 2's segment. + assert_eq!(committee_0.len(), committee_2.len()); + + let mut committee_bits = electra::CommitteeBits::default(); + committee_bits.set(0, true).unwrap(); + committee_bits.set(2, true).unwrap(); + + let mut aggregation_bits = + electra::AggregationBits::with_length(committee_0.len() + committee_2.len()).unwrap(); + // Two members of the first named committee's own segment. + aggregation_bits.set(0, true).unwrap(); + aggregation_bits.set(5, true).unwrap(); + // Two members of the second named committee, offset past the whole + // first committee's segment (not past committee index 1's, which + // this attestation never names and which must not consume any + // offset at all). + aggregation_bits.set(committee_0.len() + 3, true).unwrap(); + aggregation_bits + .set(committee_0.len() + committee_2.len() - 1, true) + .unwrap(); + + let attestation = electra::Attestation { + aggregation_bits, + data: AttestationData { + slot, + ..Default::default() + }, + signature: BlsSignature::default(), + committee_bits, + }; + + let indices = + get_attesting_indices(&state, &attestation, &CommitteeCache::default()).unwrap(); + + let mut expected = vec![ + committee_0[0], + committee_0[5], + committee_2[3], + committee_2[committee_2.len() - 1], + ]; + expected.sort_unstable(); + + assert_eq!(indices, expected); + assert!( + indices.windows(2).all(|pair| pair[0] < pair[1]), + "get_attesting_indices must return a strictly ascending, duplicate-free list" + ); + } + + #[test] + fn get_indexed_attestation_carries_the_attesting_indices_and_signature() { + // 16 validators split across a whole epoch's worth of committee slots + // (32 under the mainnet preset) would leave most of those slots + // empty; enough validators that every slot's single committee is + // reliably non-empty is what this test actually needs. + let state = electra_state_with_validators(1024); + let slot = state.slot(); + let committee = get_beacon_committee(&state, slot, 0).unwrap(); + + let mut committee_bits = electra::CommitteeBits::default(); + committee_bits.set(0, true).unwrap(); + let mut aggregation_bits = electra::AggregationBits::with_length(committee.len()).unwrap(); + aggregation_bits.set(0, true).unwrap(); + + let attestation = electra::Attestation { + aggregation_bits, + data: AttestationData { + slot, + ..Default::default() + }, + signature: BlsSignature([9; BLS_SIGNATURE_SIZE]), + committee_bits, + }; + + let indexed = + get_indexed_attestation(&state, &attestation, &CommitteeCache::default()).unwrap(); + assert_eq!(&*indexed.attesting_indices, &[committee[0]]); + assert_eq!(indexed.signature, attestation.signature); + assert_eq!(indexed.data, attestation.data); + } + + #[test] + fn next_sync_committee_indices_are_exactly_sync_committee_size() { + let state = electra_state_with_validators(4); + let indices = get_next_sync_committee_indices(&state).unwrap(); + assert_eq!(indices.len(), preset::SYNC_COMMITTEE_SIZE); + assert!(indices.iter().all(|index| *index < 4)); + } + + // -- Beacon state mutators ----------------------------------------------- + + #[test] + fn initiate_validator_exit_is_idempotent_and_sets_withdrawable_epoch() { + let config = Config::mainnet(); + let mut state = electra_state_with_validators(4); + + initiate_validator_exit(&mut state, 0, &config).unwrap(); + let first_exit_epoch = state.validator(0).unwrap().exit_epoch; + assert_ne!(first_exit_epoch, FAR_FUTURE_EPOCH); + assert_eq!( + state.validator(0).unwrap().withdrawable_epoch, + first_exit_epoch + config.min_validator_withdrawability_delay, + ); + + initiate_validator_exit(&mut state, 0, &config).unwrap(); + assert_eq!(state.validator(0).unwrap().exit_epoch, first_exit_epoch); + } + + #[test] + fn a_large_exit_consumes_more_than_one_epoch_of_churn() { + let config = Config::mainnet(); + let mut state = electra_state_with_validators(4); + + let per_epoch_churn = get_activation_exit_churn_limit(&state, &config).unwrap(); + let baseline = compute_activation_exit_epoch(get_current_epoch(&state)); + + let exit_epoch = + compute_exit_epoch_and_update_churn(&mut state, per_epoch_churn * 2, &config).unwrap(); + assert!(exit_epoch > baseline); + } + + #[test] + fn two_small_exits_in_the_same_epoch_share_its_leftover_churn() { + let config = Config::mainnet(); + let mut state = electra_state_with_validators(4); + let per_epoch_churn = get_activation_exit_churn_limit(&state, &config).unwrap(); + + let first_epoch = + compute_exit_epoch_and_update_churn(&mut state, per_epoch_churn / 4, &config).unwrap(); + let second_epoch = + compute_exit_epoch_and_update_churn(&mut state, per_epoch_churn / 4, &config).unwrap(); + + // Together they are still under one epoch's churn limit, so the + // cursor lets the second exit spend what the first left over instead + // of always waiting for a fresh epoch. + assert_eq!(first_epoch, second_epoch); + } + + #[test] + fn switch_to_compounding_validator_sets_the_prefix_and_queues_excess() { + let mut state = electra_state_with_validators(4); + state.balances_mut()[0] = preset::MIN_ACTIVATION_BALANCE + 1_000_000_000; + + switch_to_compounding_validator(&mut state, 0).unwrap(); + + assert!(has_compounding_withdrawal_credential( + state.validator(0).unwrap() + )); + assert_eq!(state.balance(0).unwrap(), preset::MIN_ACTIVATION_BALANCE); + } + + #[test] + fn queue_excess_active_balance_caps_the_balance_and_queues_the_rest() { + let mut state = electra_state_with_validators(4); + let pubkey = state.validator(0).unwrap().pubkey; + state.balances_mut()[0] = preset::MIN_ACTIVATION_BALANCE + 5_000_000_000; + + queue_excess_active_balance(&mut state, 0).unwrap(); + + assert_eq!(state.balance(0).unwrap(), preset::MIN_ACTIVATION_BALANCE); + let BeaconState::Electra(inner) = &state else { + unreachable!("just built as Electra"); + }; + assert_eq!(inner.pending_deposits.len(), 1); + assert_eq!(inner.pending_deposits[0].amount, 5_000_000_000); + assert_eq!(inner.pending_deposits[0].pubkey, pubkey); + } + + #[test] + fn queue_excess_active_balance_does_nothing_at_or_below_the_minimum() { + let mut state = electra_state_with_validators(4); + state.balances_mut()[0] = preset::MIN_ACTIVATION_BALANCE; + queue_excess_active_balance(&mut state, 0).unwrap(); + let BeaconState::Electra(inner) = &state else { + unreachable!("just built as Electra"); + }; + assert!(inner.pending_deposits.is_empty()); + } + + #[test] + fn queue_entire_balance_and_reset_validator_zeroes_the_validator() { + let mut state = electra_state_with_validators(4); + state.balances_mut()[0] = 12_000_000_000; + + queue_entire_balance_and_reset_validator(&mut state, 0).unwrap(); + + assert_eq!(state.balance(0).unwrap(), 0); + let validator = state.validator(0).unwrap(); + assert_eq!(validator.effective_balance, 0); + assert_eq!(validator.activation_eligibility_epoch, FAR_FUTURE_EPOCH); + + let BeaconState::Electra(inner) = &state else { + unreachable!("just built as Electra"); + }; + assert_eq!(inner.pending_deposits.len(), 1); + assert_eq!(inner.pending_deposits[0].amount, 12_000_000_000); + } + + // -- Fork projection ------------------------------------------------------ + + #[test] + fn electra_state_ref_accepts_both_electra_and_fulu_but_not_phase0() { + let electra_state_value = electra_state_with_validators(4); + assert!(electra_state_ref(&electra_state_value, "test").is_ok()); + + let fulu_state_value = fulu_state_with_validators(4); + assert!(electra_state_ref(&fulu_state_value, "test").is_ok()); + + let phase0_state_value = crate::beacon::helpers::test_state::with_validators(4); + assert!(electra_state_ref(&phase0_state_value, "test").is_err()); + } + + #[test] + fn electra_state_mut_accepts_both_electra_and_fulu_but_not_phase0() { + let mut electra_state_value = electra_state_with_validators(4); + assert!(electra_state(&mut electra_state_value, "test").is_ok()); + + let mut fulu_state_value = fulu_state_with_validators(4); + assert!(electra_state(&mut fulu_state_value, "test").is_ok()); + + let mut phase0_state_value = crate::beacon::helpers::test_state::with_validators(4); + assert!(electra_state(&mut phase0_state_value, "test").is_err()); + } + + #[test] + fn compute_exit_epoch_and_update_churn_works_on_a_fulu_state_too() { + let config = Config::mainnet(); + let mut state = fulu_state_with_validators(4); + let baseline = compute_activation_exit_epoch(get_current_epoch(&state)); + + let exit_epoch = compute_exit_epoch_and_update_churn(&mut state, 1, &config).unwrap(); + assert_eq!(exit_epoch, baseline); + } +} diff --git a/crates/blockchain/state_transition/src/beacon/helpers/finality.rs b/crates/blockchain/state_transition/src/beacon/helpers/finality.rs new file mode 100644 index 000000000..9c945c9bd --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/helpers/finality.rs @@ -0,0 +1,56 @@ +//! Epoch-boundary accessors that every fork shares. +//! +//! The specification introduces these under phase0's epoch processing, and no +//! later fork changes them, so both phase0's and altair's reward accounting call +//! the same three functions. They live in `helpers` rather than in either fork's +//! reward module so that neither has to depend on the other: altair's helpers +//! previously reached into phase0's `stf::epoch::rewards` for them, which made +//! `helpers` depend on `stf` where the dependency otherwise runs the other way. + +use crate::beacon::containers::BeaconState; +use crate::beacon::preset; +use crate::beacon::primitives::{Epoch, ValidatorIndex}; + +use super::accessors::get_previous_epoch; +use super::predicates::is_active_validator; + +/// How many epochs finality has lagged behind the previous epoch. +/// +/// Zero whenever finality is keeping pace with attestation processing; grows +/// by one every further epoch the chain fails to finalize, which is what lets +/// [`is_in_inactivity_leak`] and the inactivity penalty scale with how long the +/// stall has lasted rather than firing at a fixed severity. +pub fn get_finality_delay(state: &BeaconState) -> Epoch { + get_previous_epoch(state) - state.finalized_checkpoint().epoch +} + +/// Whether the chain has gone long enough without finalizing that inactive +/// validators' balances should start leaking away. +/// +/// The leak exists so a large inactive or adversarial minority cannot stall +/// finality forever while keeping its stake intact: the longer finality +/// stalls, the faster an inactive validator's share of the active set shrinks, +/// until the honest, active minority eventually clears the two-thirds +/// threshold on its own. +pub fn is_in_inactivity_leak(state: &BeaconState) -> bool { + get_finality_delay(state) > preset::MIN_EPOCHS_TO_INACTIVITY_PENALTY +} + +/// Validators whose participation this epoch's rewards and penalties account +/// for: every currently active validator, plus one already exited but not yet +/// past its withdrawable epoch, so a validator that leaves the active set +/// still pays (or earns) whatever this epoch's accounting owes it up to that +/// point. +pub fn get_eligible_validator_indices(state: &BeaconState) -> Vec { + let previous_epoch = get_previous_epoch(state); + state + .validators() + .iter() + .enumerate() + .filter(|(_, validator)| { + is_active_validator(validator, previous_epoch) + || (validator.slashed && previous_epoch + 1 < validator.withdrawable_epoch) + }) + .map(|(index, _)| index as ValidatorIndex) + .collect() +} diff --git a/crates/blockchain/state_transition/src/beacon/helpers/fulu.rs b/crates/blockchain/state_transition/src/beacon/helpers/fulu.rs new file mode 100644 index 000000000..d488a0f7d --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/helpers/fulu.rs @@ -0,0 +1,279 @@ +//! Fulu's proposer lookahead. +//! +//! Every fork through electra computes a slot's proposer on demand: it +//! shuffles that slot's epoch's active set under a seed drawn from the +//! randao mix, and that seed only exists once the mix it reads is +//! `MIN_SEED_LOOKAHEAD` epochs old (see [`super::accessors::get_seed`]). A +//! validator therefore cannot know its own proposer duty for next epoch, +//! only for the current one, because next epoch's seed is not fixed yet. +//! +//! Fulu breaks that coupling by moving the computation off the read path. +//! `BeaconState::proposer_lookahead` (`crate::beacon::containers::fulu::BeaconState`) +//! precomputes a rolling window of upcoming proposers, one epoch at a time, +//! as far ahead as a seed is ever knowable: [`initialize_proposer_lookahead`] +//! fills the window from scratch at genesis and when upgrading to fulu, and +//! the specification's `process_proposer_lookahead` (`beacon-chain.md`'s +//! "Epoch processing", not implemented in this file, see below) shifts it +//! forward by one epoch at every later epoch boundary. Reading a duty is then +//! a lookup into whatever the window last computed, rather than a shuffle run +//! at the moment something asks: [`get_beacon_proposer_index`] becomes +//! `state.proposer_lookahead[state.slot % SLOTS_PER_EPOCH]`, an index into +//! already-settled answers instead of a computation over the current +//! shuffling seed. +//! +//! [`get_beacon_proposer_index`] here does not replace +//! [`super::accessors::get_beacon_proposer_index`] in place: the two coexist, +//! fulu's reading the precomputed window and every earlier fork's computing +//! on demand, and that accessor dispatches between them by fork itself, +//! rather than each of *its* own callers doing so, the same split +//! [`super::altair::altair_state_ref`] documents for altair's +//! participation-flag fields. A state older than fulu has no +//! `proposer_lookahead` to read, so [`get_beacon_proposer_index`] fails +//! through [`fulu_state_ref`] rather than falling back to the on-demand +//! computation. +//! +//! [`compute_proposer_indices`] and [`get_beacon_proposer_indices`] are the +//! building blocks the window is filled from. Both take a [`BeaconState`] of +//! any fork, not only fulu's, because both read the state only through +//! fork-invariant accessors (`get_active_validator_indices`, `get_seed`, +//! `state.validator`) and never touch `proposer_lookahead` themselves; the +//! specification's own `upgrade_to_fulu` (`fork.md`) relies on exactly that, +//! calling [`initialize_proposer_lookahead`] on the electra state being +//! upgraded, one slot before a `proposer_lookahead` field exists anywhere to +//! read back out of. +//! +//! This file does not implement `process_proposer_lookahead` itself: that +//! function mutates state at an epoch boundary, and every other fork's +//! equivalent epoch-processing step lives in `crate::beacon::stf`, not in a +//! `helpers::` module (see [`super::altair`]'s module docs, which make +//! the same point about `process_attestation`). [`fulu_state`] is kept +//! alongside [`fulu_state_ref`] for whenever that step is written, the same +//! way altair keeps a mutable state projection ready for its own +//! not-yet-written processing steps. + +use crate::beacon::constants; +use crate::beacon::containers::{BeaconState, fulu}; +use crate::beacon::error::{Error, Result}; +use crate::beacon::hash::hash; +use crate::beacon::preset; +use crate::beacon::primitives::{Bytes32, Epoch, ValidatorIndex}; + +use super::accessors::{get_active_validator_indices, get_current_epoch, get_seed}; +use super::electra::compute_proposer_index; +use super::misc::compute_start_slot_at_epoch; + +// --------------------------------------------------------------------------- +// Misc +// --------------------------------------------------------------------------- + +/// The proposer for every slot of `epoch`, drawn from `indices` under `seed`. +/// +/// One shuffling seed per epoch is not enough on its own: every slot of that +/// epoch would otherwise draw the same proposer. The specification mixes +/// `seed` with each slot number first (shadowing its own `seed` parameter +/// with the result, one hash per slot, inside a single list comprehension); +/// this keeps that per-slot hash in its own binding, `slot_seed`, instead. +/// +/// Calls [`super::electra::compute_proposer_index`], not +/// [`super::shuffling::compute_proposer_index`]: fulu's specification builds +/// on electra's (`beacon-chain.md`'s own introduction says as much), so the +/// `compute_proposer_index` this function's own spec text calls is already +/// electra's widened-draw, `MAX_EFFECTIVE_BALANCE_ELECTRA`-weighted version, +/// not phase0's. A fulu validator can hold exactly the same balances an +/// electra one can, so nothing here would justify falling back to the +/// narrower, pre-electra acceptance test. +pub fn compute_proposer_indices( + state: &BeaconState, + epoch: Epoch, + seed: Bytes32, + indices: &[ValidatorIndex], +) -> Result> { + let start_slot = compute_start_slot_at_epoch(epoch); + + let mut proposer_indices = Vec::with_capacity(preset::SLOTS_PER_EPOCH as usize); + for offset in 0..preset::SLOTS_PER_EPOCH { + let mut input = Vec::with_capacity(32 + 8); + input.extend_from_slice(&seed.0); + input.extend_from_slice(&(start_slot + offset).to_le_bytes()); + let slot_seed = hash(&input); + + let proposer_index = compute_proposer_index(indices, slot_seed, |index| { + Ok(state.validator(index)?.effective_balance) + })?; + proposer_indices.push(proposer_index); + } + Ok(proposer_indices) +} + +// --------------------------------------------------------------------------- +// Beacon state accessors +// --------------------------------------------------------------------------- + +/// The proposer for every slot of `epoch`, computed fresh from that epoch's +/// active set and shuffling seed. +/// +/// What [`initialize_proposer_lookahead`] calls once per epoch of the window, +/// and what the specification's `process_proposer_lookahead` (not implemented +/// in this file, see the module docs) calls once more each epoch boundary to +/// extend the window by one epoch. +pub fn get_beacon_proposer_indices( + state: &BeaconState, + epoch: Epoch, +) -> Result> { + let indices = get_active_validator_indices(state, epoch); + let seed = get_seed(state, epoch, constants::DOMAIN_BEACON_PROPOSER); + compute_proposer_indices(state, epoch, seed, &indices) +} + +/// The proposer for the state's current slot. +/// +/// Fulu's replacement for [`super::accessors::get_beacon_proposer_index`]: a +/// lookup into the current epoch's slice of `proposer_lookahead` rather than +/// a shuffle computed on demand. See the module docs for why that lookup is +/// possible at all and why the on-demand version is not simply reused here. +pub fn get_beacon_proposer_index(state: &BeaconState) -> Result { + let fulu_state = fulu_state_ref(state, "get_beacon_proposer_index")?; + let index = (state.slot() % preset::SLOTS_PER_EPOCH) as usize; + fulu_state + .proposer_lookahead + .get(index) + .copied() + .ok_or(Error::IndexOutOfBounds { + index, + len: fulu_state.proposer_lookahead.len(), + }) +} + +// --------------------------------------------------------------------------- +// Fork upgrade +// --------------------------------------------------------------------------- + +/// The full lookahead window for `state`'s current epoch: every proposer from +/// the start of the current epoch through `MIN_SEED_LOOKAHEAD` full epochs +/// beyond it. +/// +/// Used to seed `BeaconState::proposer_lookahead` from nothing, at genesis and +/// when upgrading to fulu, which is the only time the whole window has to be +/// computed at once; every later epoch only needs one more epoch appended; see +/// the module docs for why that step is not implemented here. The loop runs +/// `MIN_SEED_LOOKAHEAD + 1` times (the current epoch, plus that many ahead of +/// it) because that many epochs of `SLOTS_PER_EPOCH` proposers each is exactly +/// [`preset::PROPOSER_LOOKAHEAD_LENGTH`] slots, the window's fixed length. +pub fn initialize_proposer_lookahead(state: &BeaconState) -> Result> { + let current_epoch = get_current_epoch(state); + + let mut lookahead = Vec::with_capacity(preset::PROPOSER_LOOKAHEAD_LENGTH); + for offset in 0..=preset::MIN_SEED_LOOKAHEAD { + lookahead.extend(get_beacon_proposer_indices(state, current_epoch + offset)?); + } + Ok(lookahead) +} + +// --------------------------------------------------------------------------- +// Fork projection +// --------------------------------------------------------------------------- + +/// The fulu state, mutably, or an error naming the function that needs one. +/// +/// Only [`get_beacon_proposer_index`] needs this: it is the one function in +/// this module that reads `proposer_lookahead` itself rather than working +/// through fork-invariant accessors. Kept alongside [`fulu_state_ref`] for +/// `process_proposer_lookahead`, which will need to write through this same +/// projection once it is written (see the module docs); nothing in this file +/// calls it yet. +#[allow(dead_code)] +pub(crate) fn fulu_state<'a>( + state: &'a mut BeaconState, + function: &'static str, +) -> Result<&'a mut fulu::BeaconState> { + match state { + BeaconState::Fulu(state) => Ok(state), + other => Err(Error::UnsupportedForFork { + function, + fork: other.fork_name(), + }), + } +} + +/// The fulu state, immutably. See [`fulu_state`]. +pub(crate) fn fulu_state_ref<'a>( + state: &'a BeaconState, + function: &'static str, +) -> Result<&'a fulu::BeaconState> { + match state { + BeaconState::Fulu(state) => Ok(state), + other => Err(Error::UnsupportedForFork { + function, + fork: other.fork_name(), + }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::beacon::fork::ForkName; + + /// A fulu state with `count` fully active, full-balance validators and a + /// `proposer_lookahead` filled by [`initialize_proposer_lookahead`], + /// positioned the same way `crate::beacon::helpers::test_state::with_validators` + /// positions its phase0 state: one epoch in, so the previous epoch and + /// the block-root history window both have entries. + /// + /// The shared builder leaves `proposer_lookahead` zeroed, since that field + /// is fulu-specific and most of this module's per-fork test states never + /// touch it; this is the caller that does, so it runs the real + /// computation on top before handing the state back. + fn fulu_state_with_validators(count: usize) -> BeaconState { + let mut state = + crate::beacon::helpers::test_state::with_validators_at(ForkName::Fulu, count); + let lookahead = initialize_proposer_lookahead(&state).unwrap(); + if let BeaconState::Fulu(fulu_state) = &mut state { + fulu_state.proposer_lookahead = lookahead.try_into().expect( + "initialize_proposer_lookahead returns exactly PROPOSER_LOOKAHEAD_LENGTH indices", + ); + } + state + } + + #[test] + fn initialize_proposer_lookahead_fills_the_whole_window() { + let state = fulu_state_with_validators(32); + let lookahead = initialize_proposer_lookahead(&state).unwrap(); + assert_eq!(lookahead.len(), preset::PROPOSER_LOOKAHEAD_LENGTH); + assert!(lookahead.iter().all(|index| *index < 32)); + } + + #[test] + fn initialize_proposer_lookahead_matches_get_beacon_proposer_indices_epoch_by_epoch() { + // The window is just those two (or more, at MIN_SEED_LOOKAHEAD greater + // than one) epochs' worth of proposers concatenated, so recomputing + // each epoch independently must reproduce the same slice. + let state = fulu_state_with_validators(32); + let current_epoch = get_current_epoch(&state); + let lookahead = initialize_proposer_lookahead(&state).unwrap(); + + let mut expected = Vec::new(); + for offset in 0..=preset::MIN_SEED_LOOKAHEAD { + expected.extend(get_beacon_proposer_indices(&state, current_epoch + offset).unwrap()); + } + assert_eq!(lookahead, expected); + } + + #[test] + fn get_beacon_proposer_index_reads_the_current_slot_out_of_the_lookahead() { + let state = fulu_state_with_validators(32); + let expected = if let BeaconState::Fulu(fulu_state) = &state { + fulu_state.proposer_lookahead[(state.slot() % preset::SLOTS_PER_EPOCH) as usize] + } else { + unreachable!() + }; + assert_eq!(get_beacon_proposer_index(&state).unwrap(), expected); + } + + #[test] + fn get_beacon_proposer_index_rejects_a_state_older_than_fulu() { + let phase0_state = crate::beacon::helpers::test_state::with_validators(4); + assert!(get_beacon_proposer_index(&phase0_state).is_err()); + } +} diff --git a/crates/blockchain/state_transition/src/beacon/helpers/math.rs b/crates/blockchain/state_transition/src/beacon/helpers/math.rs new file mode 100644 index 000000000..d0a53254f --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/helpers/math.rs @@ -0,0 +1,95 @@ +//! The specification's math helpers. + +use crate::beacon::constants::{UINT64_MAX, UINT64_MAX_SQRT}; +use crate::beacon::primitives::{Bytes32, H256}; + +/// The largest integer `x` such that `x * x <= n`. +/// +/// Newton's method, as the specification writes it. The maximum input is special +/// cased because the general algorithm's first step would overflow on it. +pub fn integer_squareroot(n: u64) -> u64 { + if n == UINT64_MAX { + return UINT64_MAX_SQRT; + } + let mut x = n; + let mut y = x.div_ceil(2); + while y < x { + x = y; + y = (x + n / x) / 2; + } + x +} + +/// The exclusive-or of two 32-byte strings. +pub fn xor(a: Bytes32, b: Bytes32) -> Bytes32 { + let mut out = [0u8; 32]; + for (index, byte) in out.iter_mut().enumerate() { + *byte = a.0[index] ^ b.0[index]; + } + H256(out) +} + +/// Interprets up to eight bytes as a little-endian integer, which is the +/// specification's `bytes_to_uint64`. +/// +/// Shorter input is zero-extended rather than rejected, since the specification +/// applies this to hash prefixes of a fixed width. +pub fn bytes_to_uint64(data: &[u8]) -> u64 { + let mut buffer = [0u8; 8]; + let take = data.len().min(8); + buffer[..take].copy_from_slice(&data[..take]); + u64::from_le_bytes(buffer) +} + +/// `a - b`, saturating at zero. +/// +/// The specification names this because Python integers do not wrap, so a +/// subtraction that would go negative has to be written explicitly. Rust's +/// `saturating_sub` is the same operation, and this exists so call sites can read +/// like the spec. +pub fn saturating_sub(a: u64, b: u64) -> u64 { + a.saturating_sub(b) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn integer_squareroot_matches_the_definition() { + for n in 0u64..100 { + let root = integer_squareroot(n); + assert!(root * root <= n, "{root}^2 should be at most {n}"); + assert!( + (root + 1) * (root + 1) > n, + "{root} should be the largest such integer for {n}" + ); + } + assert_eq!(integer_squareroot(0), 0); + assert_eq!(integer_squareroot(1), 1); + assert_eq!(integer_squareroot(u64::MAX), UINT64_MAX_SQRT); + } + + #[test] + fn integer_squareroot_handles_perfect_squares_and_their_neighbours() { + // The loop's exit condition is where an off-by-one would hide, so check + // either side of a boundary. + assert_eq!(integer_squareroot(15), 3); + assert_eq!(integer_squareroot(16), 4); + assert_eq!(integer_squareroot(17), 4); + } + + #[test] + fn xor_is_its_own_inverse() { + let a = Bytes32::repeat_byte(0xa5); + let b = Bytes32::repeat_byte(0x3c); + assert_eq!(xor(xor(a, b), b), a); + } + + #[test] + fn bytes_to_uint64_is_little_endian_and_zero_extends() { + assert_eq!(bytes_to_uint64(&[1, 0, 0, 0, 0, 0, 0, 0]), 1); + assert_eq!(bytes_to_uint64(&[0, 1]), 256); + assert_eq!(bytes_to_uint64(&[0xff; 8]), u64::MAX); + } +} diff --git a/crates/blockchain/state_transition/src/beacon/helpers/misc.rs b/crates/blockchain/state_transition/src/beacon/helpers/misc.rs new file mode 100644 index 000000000..78926e24e --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/helpers/misc.rs @@ -0,0 +1,98 @@ +//! Slot and epoch arithmetic, signing domains, and merkle branch verification. +//! +//! These are the helpers that depend on nothing but their arguments, so unlike +//! the accessors in [`super::accessors`] they never take a state. Slot/epoch +//! arithmetic and the signing domains now live in `ethlambda-types` and are +//! re-exported below at their old path. What is still implemented here is +//! merkle branch verification, which needs this crate's `hash`, and +//! [`compute_activation_exit_epoch`], which reads this crate's preset. + +use crate::beacon::hash::hash; +use crate::beacon::preset; +use crate::beacon::primitives::{Bytes32, Epoch, Root}; + +// Relocated to `ethlambda-types` so that consumers which only sign or verify a +// message can reach them without this crate's `blst`, `c-kzg` and RocksDB +// dependencies. Re-exported at the old path so every use site inside this +// module is unchanged. +pub use ethlambda_types::beacon::signing::{ + compute_deposit_domain, compute_domain, compute_epoch_at_slot, compute_signing_root, + compute_start_slot_at_epoch, fork_version_at_epoch, +}; + +/// The epoch at which an activation or exit initiated during `epoch` takes +/// effect. +/// +/// The delay exists so that the committee shuffling for an epoch is already +/// settled before validators can join or leave it, which is what stops an +/// attacker from steering their own committee assignment. +pub fn compute_activation_exit_epoch(epoch: Epoch) -> Epoch { + epoch + 1 + preset::MAX_SEED_LOOKAHEAD +} + +// The root binding a fork version to a chain's genesis validator set, which +// mixes both into every signing domain. It lives in `ethlambda-types` beside +// `compute_fork_digest`, which needs it and which the networking crate needs in +// turn; re-exported here at its old path, since every signing domain below is +// built from it. +pub use ethlambda_types::beacon::fork_digest::compute_fork_data_root; + +/// Whether `leaf` at `index` is proven by `branch` against `root`. +/// +/// The bit of `index` at each level decides which side the sibling goes on, so a +/// branch only verifies at the position it was generated for. +pub fn is_valid_merkle_branch( + leaf: Bytes32, + branch: &[Bytes32], + depth: u64, + index: u64, + root: Root, +) -> bool { + if branch.len() < depth as usize { + return false; + } + + let mut value = leaf; + for level in 0..depth { + let sibling = branch[level as usize]; + // Whether this leaf is the right child at this level. + let on_the_right = (index / 2u64.pow(level as u32)) % 2 == 1; + value = if on_the_right { + hash(&[sibling.0, value.0].concat()) + } else { + hash(&[value.0, sibling.0].concat()) + }; + } + value == root +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn merkle_branch_verifies_only_at_its_own_index() { + // A two-leaf tree: root = hash(left + right). + let left = Bytes32::repeat_byte(1); + let right = Bytes32::repeat_byte(2); + let root = hash(&[left.0, right.0].concat()); + + assert!(is_valid_merkle_branch(left, &[right], 1, 0, root)); + assert!(is_valid_merkle_branch(right, &[left], 1, 1, root)); + // The same leaf and branch at the wrong index must not verify. + assert!(!is_valid_merkle_branch(left, &[right], 1, 1, root)); + } + + #[test] + fn merkle_branch_rejects_a_short_branch() { + // A branch shorter than the claimed depth would index out of bounds, so + // it has to be rejected rather than panicking. + assert!(!is_valid_merkle_branch( + Bytes32::ZERO, + &[], + 1, + 0, + Root::ZERO + )); + } +} diff --git a/crates/blockchain/state_transition/src/beacon/helpers/mod.rs b/crates/blockchain/state_transition/src/beacon/helpers/mod.rs new file mode 100644 index 000000000..3449b26aa --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/helpers/mod.rs @@ -0,0 +1,27 @@ +//! The specification's helper functions. +//! +//! Grouped the way the specification groups them: math, predicates, the +//! committee shuffle, slot and epoch arithmetic with signing domains, state +//! accessors, and state mutators. +//! +//! One systematic difference from the spec's own signatures: the spec reads its +//! parameters from global scope, whereas here preset values are compile-time +//! constants but configuration values are not, so any function needing a +//! configuration value takes a [`crate::beacon::config::Config`]. Everything else keeps +//! the spec's name and argument order, so a call site can be checked against the +//! spec line by line. + +pub mod accessors; +pub mod altair; +pub mod attestation; +pub mod capella; +pub mod electra; +pub mod finality; +pub mod fulu; +pub mod math; +pub mod misc; +pub mod mutators; +pub mod predicates; +pub mod shuffling; +#[cfg(any(test, feature = "test-utils"))] +pub mod test_state; diff --git a/crates/blockchain/state_transition/src/beacon/helpers/mutators.rs b/crates/blockchain/state_transition/src/beacon/helpers/mutators.rs new file mode 100644 index 000000000..6f37c7360 --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/helpers/mutators.rs @@ -0,0 +1,270 @@ +//! Beacon state mutators. +//! +//! These are the specification's four functions that change a state in place: +//! the two balance adjustments, and the two ways a validator leaves. + +use crate::beacon::ForkName; +use crate::beacon::config::Config; +use crate::beacon::constants::FAR_FUTURE_EPOCH; +use crate::beacon::containers::BeaconState; +use crate::beacon::error::{Error, Result}; +use crate::beacon::preset; +use crate::beacon::primitives::{Epoch, Gwei, ValidatorIndex}; + +use super::accessors::{get_beacon_proposer_index, get_current_epoch, get_validator_churn_limit}; +use super::misc::compute_activation_exit_epoch; + +/// Adds `delta` to a validator's balance. +pub fn increase_balance(state: &mut BeaconState, index: ValidatorIndex, delta: Gwei) -> Result<()> { + let balance = state + .balances_mut() + .get_mut(index as usize) + .ok_or(crate::beacon::Error::UnknownValidator(index))?; + *balance = balance.saturating_add(delta); + Ok(()) +} + +/// Subtracts `delta` from a validator's balance, flooring at zero. +/// +/// Saturating rather than checked: the specification defines a balance as +/// unsigned and explicitly floors this at zero, since a penalty larger than the +/// remaining balance is normal rather than an error. +pub fn decrease_balance(state: &mut BeaconState, index: ValidatorIndex, delta: Gwei) -> Result<()> { + let balance = state + .balances_mut() + .get_mut(index as usize) + .ok_or(crate::beacon::Error::UnknownValidator(index))?; + *balance = balance.saturating_sub(delta); + Ok(()) +} + +/// Puts a validator into the exit queue. +/// +/// Does nothing if it is already exiting, so this is safe to call more than once +/// for the same validator, which is what lets `slash_validator` call it +/// unconditionally. +/// +/// The queue epoch is the later of the earliest permissible exit and the last +/// epoch already in use, pushed out by one more if that epoch is already at the +/// churn limit. Rate limiting exits is what stops a large fraction of the +/// validator set from leaving fast enough to strand finality. +pub fn initiate_validator_exit( + state: &mut BeaconState, + index: ValidatorIndex, + config: &Config, +) -> Result<()> { + if state.validator(index)?.exit_epoch != FAR_FUTURE_EPOCH { + return Ok(()); + } + + let earliest = compute_activation_exit_epoch(get_current_epoch(state)); + let mut exit_queue_epoch = state + .validators() + .iter() + .map(|validator| validator.exit_epoch) + .filter(|epoch| *epoch != FAR_FUTURE_EPOCH) + .chain(core::iter::once(earliest)) + .max() + .unwrap_or(earliest); + + let churn_at_that_epoch = state + .validators() + .iter() + .filter(|validator| validator.exit_epoch == exit_queue_epoch) + .count() as u64; + if churn_at_that_epoch >= get_validator_churn_limit(state, config) { + exit_queue_epoch += 1; + } + + // A validator already in the queue with an exit epoch just short of + // `FAR_FUTURE_EPOCH` drags `exit_queue_epoch` up with it, and adding the + // withdrawability delay to that overflows. The specification treats a `uint64` + // overflow as invalid rather than wrapping, and wrapping here would be worse + // than a rejection: it would produce a withdrawable epoch in the past and let + // the validator withdraw immediately. + let withdrawable = exit_queue_epoch + .checked_add(config.min_validator_withdrawability_delay) + .ok_or(Error::ArithmeticOverflow( + "exit_queue_epoch + MIN_VALIDATOR_WITHDRAWABILITY_DELAY", + ))?; + let validator = state.validator_mut(index)?; + validator.exit_epoch = exit_queue_epoch; + validator.withdrawable_epoch = withdrawable; + Ok(()) +} + +/// Slashes a validator: exits it, penalizes it, and pays the reporter. +/// +/// The immediate penalty is only a fraction of the effective balance. The rest of +/// the punishment is applied at the epoch boundary and scales with how much of +/// the validator set was slashed around the same time, which is what makes a +/// coordinated attack far more expensive than an isolated mistake. Recording the +/// balance in `slashings` here is what lets that later computation see it. +/// +/// `whistleblower_index` defaults to the current proposer when not given. +/// +/// One copy of this function serves every fork. The immediate penalty's divisor +/// falls at altair, bellatrix, and electra, and the reporter's divisor rises at +/// electra, all without changing anything else here, so both are selected by +/// fork through [`preset::retuned`] rather than by redefining the function per +/// fork the way the specification does. +/// +/// Altair does also restate `proposer_reward` as +/// `whistleblower_reward * PROPOSER_WEIGHT / WEIGHT_DENOMINATOR` in place of +/// phase0's division by `PROPOSER_REWARD_QUOTIENT`. Those two agree exactly for +/// every input, since `PROPOSER_WEIGHT / WEIGHT_DENOMINATOR` reduces to the same +/// fraction, so this keeps phase0's spelling for all forks rather than selecting +/// between two expressions that cannot disagree. +/// +/// The exit it starts, however, is genuinely fork-dependent, which is what +/// [`initiate_validator_exit_for_fork`] exists to resolve. +/// Starts `index`'s exit under the rules of the state's own fork. +/// +/// Electra replaces [`initiate_validator_exit`] outright +/// ([`crate::beacon::helpers::electra::initiate_validator_exit`], EIP-7251): the exit +/// epoch comes from a balance-denominated churn budget rather than from +/// counting how many validators are already queued. Every other caller of +/// either version sits in a module that belongs to one fork and so names the +/// version it wants directly. [`slash_validator`] is the exception, being one +/// function that serves all seven forks, so it is the one place that has to +/// ask the state which rule applies. +/// +/// Reaching for phase0's version here instead is not a quiet inaccuracy: a +/// slashing at electra would set a different `exit_epoch`, and leave the +/// state's churn cursor unadvanced, so every subsequent exit in the same epoch +/// would queue wrongly too. +fn initiate_validator_exit_for_fork( + state: &mut BeaconState, + index: ValidatorIndex, + config: &Config, +) -> Result<()> { + match state.fork_name() { + ForkName::Electra | ForkName::Fulu => { + crate::beacon::helpers::electra::initiate_validator_exit(state, index, config) + } + _ => initiate_validator_exit(state, index, config), + } +} + +pub fn slash_validator( + state: &mut BeaconState, + slashed_index: ValidatorIndex, + whistleblower_index: Option, + config: &Config, +) -> Result<()> { + let epoch = get_current_epoch(state); + initiate_validator_exit_for_fork(state, slashed_index, config)?; + + let effective_balance = state.validator(slashed_index)?.effective_balance; + + let validator = state.validator_mut(slashed_index)?; + validator.slashed = true; + validator.withdrawable_epoch = validator + .withdrawable_epoch + .max(epoch + preset::EPOCHS_PER_SLASHINGS_VECTOR as Epoch); + + let slot = epoch as usize % preset::EPOCHS_PER_SLASHINGS_VECTOR; + let slashings = state.slashings_mut(); + slashings[slot] = slashings[slot].saturating_add(effective_balance); + + let fork = state.fork_name(); + let penalty_quotient = preset::retuned::min_slashing_penalty_quotient(fork); + decrease_balance(state, slashed_index, effective_balance / penalty_quotient)?; + + let proposer_index = get_beacon_proposer_index(state)?; + let whistleblower_index = whistleblower_index.unwrap_or(proposer_index); + let reward_quotient = preset::retuned::whistleblower_reward_quotient(fork); + let whistleblower_reward = effective_balance / reward_quotient; + let proposer_reward = whistleblower_reward / preset::PROPOSER_REWARD_QUOTIENT; + + increase_balance(state, proposer_index, proposer_reward)?; + increase_balance( + state, + whistleblower_index, + whistleblower_reward - proposer_reward, + )?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decreasing_below_zero_floors_at_zero() { + let mut state = crate::beacon::helpers::test_state::with_validators(4); + decrease_balance(&mut state, 0, Gwei::MAX).unwrap(); + assert_eq!(state.balance(0).unwrap(), 0); + } + + #[test] + fn increasing_and_decreasing_are_inverses() { + let mut state = crate::beacon::helpers::test_state::with_validators(4); + let before = state.balance(1).unwrap(); + increase_balance(&mut state, 1, 500).unwrap(); + assert_eq!(state.balance(1).unwrap(), before + 500); + decrease_balance(&mut state, 1, 500).unwrap(); + assert_eq!(state.balance(1).unwrap(), before); + } + + #[test] + fn an_unknown_validator_is_an_error() { + let mut state = crate::beacon::helpers::test_state::with_validators(4); + assert!(increase_balance(&mut state, 99, 1).is_err()); + assert!(decrease_balance(&mut state, 99, 1).is_err()); + } + + #[test] + fn initiating_an_exit_twice_leaves_the_first_one_alone() { + let config = Config::mainnet(); + let mut state = crate::beacon::helpers::test_state::with_validators(8); + + initiate_validator_exit(&mut state, 0, &config).unwrap(); + let first = state.validator(0).unwrap().exit_epoch; + assert_ne!(first, FAR_FUTURE_EPOCH); + + // A second call must not push the validator further out. + initiate_validator_exit(&mut state, 0, &config).unwrap(); + assert_eq!(state.validator(0).unwrap().exit_epoch, first); + } + + #[test] + fn exit_sets_a_withdrawable_epoch_after_the_exit() { + let config = Config::mainnet(); + let mut state = crate::beacon::helpers::test_state::with_validators(8); + initiate_validator_exit(&mut state, 0, &config).unwrap(); + + let validator = state.validator(0).unwrap(); + assert_eq!( + validator.withdrawable_epoch, + validator.exit_epoch + config.min_validator_withdrawability_delay + ); + } + + #[test] + fn slashing_marks_exits_penalizes_and_records() { + let config = Config::mainnet(); + let mut state = crate::beacon::helpers::test_state::with_validators(32); + let effective_balance = state.validator(3).unwrap().effective_balance; + let balance_before = state.balance(3).unwrap(); + + slash_validator(&mut state, 3, None, &config).unwrap(); + + let validator = state.validator(3).unwrap(); + assert!(validator.slashed); + assert_ne!(validator.exit_epoch, FAR_FUTURE_EPOCH); + + // The effective balance is recorded for the epoch boundary's + // proportional penalty. + let epoch = get_current_epoch(&state); + assert_eq!( + state.slashings()[epoch as usize % preset::EPOCHS_PER_SLASHINGS_VECTOR], + effective_balance + ); + + // The immediate penalty is only a fraction of the effective balance, so + // the validator keeps most of its balance for now. + assert!(state.balance(3).unwrap() < balance_before); + assert!(state.balance(3).unwrap() > balance_before / 2); + } +} diff --git a/crates/blockchain/state_transition/src/beacon/helpers/predicates.rs b/crates/blockchain/state_transition/src/beacon/helpers/predicates.rs new file mode 100644 index 000000000..13635c0db --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/helpers/predicates.rs @@ -0,0 +1,167 @@ +//! Predicates over validators and attestations. + +use crate::beacon::constants::FAR_FUTURE_EPOCH; +use crate::beacon::containers::shared::{AttestationData, Validator}; +use crate::beacon::preset; +use crate::beacon::primitives::Epoch; + +/// Whether `validator` is active at `epoch`. +pub fn is_active_validator(validator: &Validator, epoch: Epoch) -> bool { + validator.activation_epoch <= epoch && epoch < validator.exit_epoch +} + +/// Whether `validator` may join the activation queue. +/// +/// Requires the full effective balance, not merely a positive one: a partially +/// funded validator waits until its balance is topped up. +pub fn is_eligible_for_activation_queue(validator: &Validator) -> bool { + validator.activation_eligibility_epoch == FAR_FUTURE_EPOCH + && validator.effective_balance == preset::MAX_EFFECTIVE_BALANCE +} + +/// Whether `validator` may be activated, given the finalized epoch. +/// +/// Activation waits for the queue placement itself to be finalized, so that a +/// reorg cannot retroactively change who was activated when. +pub fn is_eligible_for_activation(validator: &Validator, finalized_epoch: Epoch) -> bool { + validator.activation_eligibility_epoch <= finalized_epoch + && validator.activation_epoch == FAR_FUTURE_EPOCH +} + +/// Whether `validator` can still be slashed at `epoch`. +/// +/// Remains true until the withdrawable epoch rather than the exit epoch, which is +/// what keeps an offence punishable for a while after the validator leaves. +pub fn is_slashable_validator(validator: &Validator, epoch: Epoch) -> bool { + !validator.slashed + && validator.activation_epoch <= epoch + && epoch < validator.withdrawable_epoch +} + +/// Whether two attestations are slashable under the Casper FFG rules. +/// +/// Two cases. A double vote is two different attestations for the same target +/// epoch. A surround vote is one attestation whose source and target strictly +/// enclose the other's, which is the equivocation that would let a validator +/// support two conflicting finalizations. +pub fn is_slashable_attestation_data(data_1: &AttestationData, data_2: &AttestationData) -> bool { + let double_vote = data_1 != data_2 && data_1.target.epoch == data_2.target.epoch; + let surround_vote = + data_1.source.epoch < data_2.source.epoch && data_2.target.epoch < data_1.target.epoch; + double_vote || surround_vote +} + +/// Whether a list of attesting indices is sorted and free of duplicates, which +/// the specification requires of an `IndexedAttestation`. +/// +/// Canonical ordering matters because the attestation's root, and therefore +/// slashing evidence built on it, would otherwise depend on the order a client +/// happened to produce. +pub fn are_indices_sorted_and_unique(indices: &[u64]) -> bool { + !indices.is_empty() && indices.windows(2).all(|pair| pair[0] < pair[1]) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::beacon::containers::shared::Checkpoint; + + fn validator() -> Validator { + Validator { + activation_epoch: 5, + exit_epoch: 10, + withdrawable_epoch: 20, + effective_balance: preset::MAX_EFFECTIVE_BALANCE, + activation_eligibility_epoch: FAR_FUTURE_EPOCH, + ..Default::default() + } + } + + #[test] + fn activity_is_a_half_open_interval() { + let validator = validator(); + assert!(!is_active_validator(&validator, 4)); + assert!(is_active_validator(&validator, 5)); + assert!(is_active_validator(&validator, 9)); + assert!(!is_active_validator(&validator, 10)); + } + + #[test] + fn slashability_outlasts_activity() { + let validator = validator(); + // Past the exit epoch but before the withdrawable epoch: no longer + // active, still slashable. + assert!(!is_active_validator(&validator, 15)); + assert!(is_slashable_validator(&validator, 15)); + assert!(!is_slashable_validator(&validator, 20)); + } + + #[test] + fn an_already_slashed_validator_is_not_slashable_again() { + let mut validator = validator(); + validator.slashed = true; + assert!(!is_slashable_validator(&validator, 6)); + } + + #[test] + fn activation_queue_eligibility_needs_the_full_balance() { + let mut validator = validator(); + assert!(is_eligible_for_activation_queue(&validator)); + + validator.effective_balance -= 1; + assert!(!is_eligible_for_activation_queue(&validator)); + } + + fn data(source: Epoch, target: Epoch, root: u8) -> AttestationData { + AttestationData { + source: Checkpoint { + epoch: source, + root: Default::default(), + }, + target: Checkpoint { + epoch: target, + root: crate::beacon::primitives::Root::repeat_byte(root), + }, + ..Default::default() + } + } + + #[test] + fn identical_attestations_are_not_slashable() { + let one = data(1, 2, 0); + assert!(!is_slashable_attestation_data(&one, &one)); + } + + #[test] + fn a_double_vote_is_slashable() { + // Same target epoch, different content. + let a = data(1, 2, 1); + let b = data(1, 2, 2); + assert!(is_slashable_attestation_data(&a, &b)); + } + + #[test] + fn a_surround_vote_is_slashable_in_one_direction_only() { + // a's span strictly encloses b's. + let a = data(1, 6, 1); + let b = data(2, 5, 2); + assert!(is_slashable_attestation_data(&a, &b)); + // The predicate is asymmetric: the caller checks both orders. + assert!(!is_slashable_attestation_data(&b, &a)); + } + + #[test] + fn non_overlapping_attestations_are_not_slashable() { + let a = data(1, 2, 1); + let b = data(3, 4, 2); + assert!(!is_slashable_attestation_data(&a, &b)); + } + + #[test] + fn indices_must_be_sorted_unique_and_non_empty() { + assert!(are_indices_sorted_and_unique(&[0, 1, 5])); + assert!(!are_indices_sorted_and_unique(&[])); + assert!(!are_indices_sorted_and_unique(&[1, 1])); + assert!(!are_indices_sorted_and_unique(&[5, 1])); + } +} diff --git a/crates/blockchain/state_transition/src/beacon/helpers/shuffling.rs b/crates/blockchain/state_transition/src/beacon/helpers/shuffling.rs new file mode 100644 index 000000000..2865cceed --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/helpers/shuffling.rs @@ -0,0 +1,389 @@ +//! The committee shuffle. +//! +//! The specification shuffles with the "swap-or-not" construction, which has two +//! properties the beacon chain needs. It is a permutation, so every validator +//! lands in exactly one committee. And it can be evaluated for a single index +//! without computing the whole permutation, which is what lets a client work out +//! one committee without shuffling the entire registry. +//! +//! The cost is that shuffling one index runs `SHUFFLE_ROUND_COUNT` rounds of +//! hashing, so computing a whole committee this way rehashes the same rounds +//! repeatedly. That is the specification's own formulation and what the fixtures +//! pin down, so [`compute_shuffled_index`] and [`compute_committee`] implement it +//! as written. [`shuffle_list`] computes the same permutation for a whole list at +//! once, which is what deriving every committee of an epoch actually wants. + +use crate::beacon::error::{Error, Result}; +use crate::beacon::hash::hash; +use crate::beacon::preset; +use crate::beacon::primitives::{Bytes32, Gwei, ValidatorIndex}; + +use super::math::bytes_to_uint64; + +/// Where `index` ends up after shuffling a set of `index_count` items under +/// `seed`. +/// +/// Fails if `index` is not in range, which the specification asserts. +pub fn compute_shuffled_index(index: u64, index_count: u64, seed: Bytes32) -> Result { + crate::beacon::verify(index < index_count, "index < index_count")?; + + let mut index = index; + for round in 0..preset::SHUFFLE_ROUND_COUNT { + let round_byte = round as u8; + + // The pivot for this round, derived from the seed and the round number. + let mut pivot_input = Vec::with_capacity(33); + pivot_input.extend_from_slice(&seed.0); + pivot_input.push(round_byte); + let pivot = bytes_to_uint64(&hash(&pivot_input).0[0..8]) % index_count; + + // The position `index` would swap with, and the higher of the two, which + // is the one the decision bit is drawn for. Taking the maximum is what + // makes the swap symmetric, and therefore a permutation. + let flip = (pivot + index_count - index) % index_count; + let position = index.max(flip); + + // One bit out of a hash covering a 256-position window, so a whole + // window's decisions come from a single hash. + let mut source_input = Vec::with_capacity(37); + source_input.extend_from_slice(&seed.0); + source_input.push(round_byte); + source_input.extend_from_slice(&((position / 256) as u32).to_le_bytes()); + let source = hash(&source_input); + + let byte = source.0[((position % 256) / 8) as usize]; + let bit = (byte >> (position % 8)) % 2; + if bit == 1 { + index = flip; + } + } + + Ok(index) +} + +/// The whole-list form of [`compute_shuffled_index`], applied to `list`: +/// position `i` of the result holds `list[compute_shuffled_index(i, n, seed)]` +/// for every `i` in `0..n`, where `n` is `list.len()`. That is the order +/// [`compute_committee`] reads its `indices` in, so shuffling an epoch's active +/// set through this once lays out every committee of the epoch as a contiguous +/// run of the result. +/// +/// Shuffles `list` in place and hands the same buffer back, so the caller's +/// active set becomes the shuffled set with no second list alongside it. +/// +/// # How +/// +/// Each swap-or-not round is an involution on positions: it pairs `i` with +/// `(pivot - i) mod n`, and swaps a pair when the round's source bit at the +/// higher of the two positions is set. So a round needs one decision per +/// *pair*, not per position, and the pairs split into two runs that can each +/// be walked in order: `i + j = pivot` for `i, j` in `0..=pivot`, and +/// `i + j = pivot + n` for `i, j` in `pivot + 1..n`. Each run is walked from +/// its outer ends inward, with `j` the higher member, stepping down: the +/// source hash covers a 256-position window, so `j` rehashes only on crossing +/// into the next window down, and reloads its byte only every eighth +/// position. Self-paired positions (`i == j`) are fixed points and never +/// visited. +/// +/// Rounds run last to first. [`compute_shuffled_index`] applies round `0` +/// first to an *index*, so gathering a *list* through the same rounds has to +/// apply them in the opposite order for position `i` to end up holding +/// `list[compute_shuffled_index(i)]`. +/// +/// The algorithm is protolambda's, as lighthouse ships it in +/// `swap_or_not_shuffle::shuffle_list` with `forwards = false`. Verified +/// position by position against [`compute_shuffled_index`] by +/// [`tests::whole_list_shuffle_matches_the_per_index_shuffle`]: a mistake in +/// the pairing would still yield *a* permutation, quietly wrong, rather than a +/// panic. +pub fn shuffle_list(mut list: Vec, seed: Bytes32) -> Vec { + let n = list.len(); + if n < 2 { + // No pair to swap. `compute_shuffled_index` would also divide by `n` + // below, and for `n == 1` the only position always flips to itself. + return list; + } + + // `seed || round || window`, the layout both of the specification's hash + // inputs share: the pivot hashes the first 33 bytes, a source window all + // 37. Written once per round and patched per window, rather than rebuilt. + let mut input = [0u8; 37]; + input[..32].copy_from_slice(&seed.0); + let source = |input: &mut [u8; 37], position: usize| { + // `position` is below `VALIDATOR_REGISTRY_LIMIT`, so its window number + // fits the specification's `uint32`. + input[33..].copy_from_slice(&((position / 256) as u32).to_le_bytes()); + hash(&input[..]).0 + }; + + for round in (0..preset::SHUFFLE_ROUND_COUNT).rev() { + input[32] = round as u8; + let pivot = (bytes_to_uint64(&hash(&input[..33]).0[0..8]) % n as u64) as usize; + + // Pairs `(i, pivot - i)`, `i` below the midpoint of `0..=pivot`. + let mut window = source(&mut input, pivot); + let mut byte = window[(pivot % 256) / 8]; + for i in 0..pivot.div_ceil(2) { + let j = pivot - i; + if j % 256 == 255 { + window = source(&mut input, j); + } + if j % 8 == 7 { + byte = window[(j % 256) / 8]; + } + if (byte >> (j % 8)) & 1 == 1 { + list.swap(i, j); + } + } + + // Pairs `(i, pivot + n - i)`, `i` from `pivot + 1` up to the midpoint + // of `pivot + 1..n`, so `j` walks down from `n - 1`. + let last = n - 1; + let mut window = source(&mut input, last); + let mut byte = window[(last % 256) / 8]; + for (step, i) in (pivot + 1..(pivot + n).div_ceil(2)).enumerate() { + let j = last - step; + if j % 256 == 255 { + window = source(&mut input, j); + } + if j % 8 == 7 { + byte = window[(j % 256) / 8]; + } + if (byte >> (j % 8)) & 1 == 1 { + list.swap(i, j); + } + } + } + + list +} + +/// The `index`-th of `count` committees drawn from `indices` under `seed`. +pub fn compute_committee( + indices: &[ValidatorIndex], + seed: Bytes32, + index: u64, + count: u64, +) -> Result> { + crate::beacon::verify(count > 0, "count > 0")?; + + // Checked because `index` is only as bounded as the attestation it came + // from, and the specification's `uint64` arithmetic raises where a release + // build would wrap into a real committee's bounds. + let overflow = || Error::ArithmeticOverflow("len(indices) * (index + 1)"); + let total = indices.len() as u64; + let start = total.checked_mul(index).ok_or_else(overflow)? / count; + let end = index + .checked_add(1) + .and_then(|past_index| total.checked_mul(past_index)) + .ok_or_else(overflow)? + / count; + + let mut committee = Vec::with_capacity((end - start) as usize); + for position in start..end { + let shuffled = compute_shuffled_index(position, total, seed)?; + let validator = indices + .get(shuffled as usize) + .ok_or(Error::IndexOutOfBounds { + index: shuffled as usize, + len: indices.len(), + })?; + committee.push(*validator); + } + Ok(committee) +} + +/// A proposer sampled from `indices`, weighted by effective balance. +/// +/// Rejection sampling rather than a weighted draw: a candidate is picked +/// uniformly, then accepted with probability proportional to its effective +/// balance. That keeps the result computable from the seed alone, with no running +/// total to agree on, at the cost of an unbounded (but in practice very short) +/// number of attempts. +/// +/// `effective_balance_of` returns the effective balance for a validator index, so +/// this stays independent of which fork's state it is reading. +/// +/// Serves phase0 through deneb only. Electra widens the acceptance test's +/// random draw from one byte to two and weighs against +/// [`preset::MAX_EFFECTIVE_BALANCE_ELECTRA`] rather than a caller-supplied +/// ceiling (EIP-7251: a compounding validator's effective balance can now +/// reach values an 8-bit draw no longer discriminates finely enough between), +/// so from electra on the acceptance test itself changes, not only the +/// ceiling passed in here: [`crate::beacon::helpers::electra::compute_proposer_index`] +/// is electra's (and fulu's) own copy, not a caller of this one with a +/// different `max_effective_balance`. +/// [`crate::beacon::helpers::accessors::get_beacon_proposer_index`] is where the two +/// are dispatched between by fork; do not call this one directly for a state +/// that might be electra or later. +pub fn compute_proposer_index( + indices: &[ValidatorIndex], + seed: Bytes32, + max_effective_balance: Gwei, + mut effective_balance_of: impl FnMut(ValidatorIndex) -> Result, +) -> Result { + crate::beacon::verify(!indices.is_empty(), "len(indices) > 0")?; + + const MAX_RANDOM_BYTE: u64 = u8::MAX as u64; + let total = indices.len() as u64; + + let mut attempt = 0u64; + loop { + let shuffled = compute_shuffled_index(attempt % total, total, seed)?; + let candidate = indices[shuffled as usize]; + + let mut random_input = Vec::with_capacity(40); + random_input.extend_from_slice(&seed.0); + random_input.extend_from_slice(&(attempt / 32).to_le_bytes()); + let random_byte = hash(&random_input).0[(attempt % 32) as usize] as u64; + + let effective_balance = effective_balance_of(candidate)?; + if effective_balance * MAX_RANDOM_BYTE >= max_effective_balance * random_byte { + return Ok(candidate); + } + + attempt += 1; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shuffling_is_a_permutation() { + // Every index must map to a distinct index in range, otherwise a + // validator would land in two committees or none. + let seed = Bytes32::repeat_byte(0x42); + let count = 25u64; + + let mut seen = vec![false; count as usize]; + for index in 0..count { + let shuffled = compute_shuffled_index(index, count, seed).unwrap(); + assert!(shuffled < count); + assert!(!seen[shuffled as usize], "{shuffled} produced twice"); + seen[shuffled as usize] = true; + } + assert!(seen.into_iter().all(|hit| hit)); + } + + #[test] + fn shuffling_depends_on_the_seed() { + let count = 20u64; + let a: Vec = (0..count) + .map(|i| compute_shuffled_index(i, count, Bytes32::repeat_byte(1)).unwrap()) + .collect(); + let b: Vec = (0..count) + .map(|i| compute_shuffled_index(i, count, Bytes32::repeat_byte(2)).unwrap()) + .collect(); + assert_ne!(a, b); + } + + #[test] + fn an_out_of_range_index_is_rejected() { + assert!(compute_shuffled_index(5, 5, Bytes32::ZERO).is_err()); + } + + #[test] + fn committees_partition_the_validator_set() { + // Splitting into `count` committees must cover every validator exactly + // once, since the split is over positions of one permutation. + let indices: Vec = (0..40).collect(); + let seed = Bytes32::repeat_byte(7); + let count = 4; + + let mut all = Vec::new(); + for index in 0..count { + all.extend(compute_committee(&indices, seed, index, count).unwrap()); + } + all.sort_unstable(); + assert_eq!(all, indices); + } + + #[test] + fn proposer_selection_prefers_a_full_balance() { + // With one full-balance validator and the rest at a token balance, the + // full one should be chosen overwhelmingly often. This checks the + // acceptance test is the right way round, which a uniform draw would not + // catch. + let indices: Vec = (0..16).collect(); + let max = 32_000_000_000u64; + + let mut chose_the_rich_one = 0; + for trial in 0..32u8 { + let chosen = + compute_proposer_index(&indices, Bytes32::repeat_byte(trial), max, |index| { + Ok(if index == 3 { max } else { 1 }) + }) + .unwrap(); + if chosen == 3 { + chose_the_rich_one += 1; + } + } + assert!( + chose_the_rich_one > 16, + "the full-balance validator was chosen {chose_the_rich_one} times out of 32" + ); + } + + #[test] + fn proposer_selection_rejects_an_empty_set() { + assert!(compute_proposer_index(&[], Bytes32::ZERO, 1, |_| Ok(1)).is_err()); + } + + /// [`shuffle_list`] is a from-scratch reimplementation of the permutation + /// [`compute_shuffled_index`] computes one position at a time, walking each + /// round's swap pairs instead of each position. A bug in the pairing would + /// produce *a* permutation, quietly wrong, not a panic or an out-of-range + /// value, so this checks every position against the per-index function + /// directly, across sizes small enough to be exhaustive and large enough + /// to cross several 256-position hash windows, and across several seeds so + /// no single seed's pivots hide a bug. + /// + /// The list shuffled is not `0..n` but values distinct from their own + /// positions, so a result that permuted positions the wrong way round + /// (scattering `list[i]` to `compute_shuffled_index(i)` rather than + /// gathering from it) cannot pass by coincidence. Covers 0, 1, and 2 + /// explicitly (nothing to shuffle, a single fixed point, and the smallest + /// real swap), and sizes on both sides of a window boundary, where an + /// off-by-one in the rehash condition would show up. + #[test] + fn whole_list_shuffle_matches_the_per_index_shuffle() { + let seeds = [ + Bytes32::ZERO, + Bytes32::repeat_byte(0xff), + Bytes32::repeat_byte(0x42), + Bytes32::repeat_byte(0x17), + ]; + let sizes = [ + 0u64, 1, 2, 3, 4, 5, 16, 25, 100, 255, 256, 257, 511, 512, 1000, 1023, 1024, 1025, 2049, + ]; + + for seed in seeds { + for &count in &sizes { + let list: Vec = (0..count).map(|i| i * 3 + 7).collect(); + let shuffled = shuffle_list(list.clone(), seed); + assert_eq!(shuffled.len(), list.len(), "count={count}, seed={seed:?}"); + + for position in 0..count { + let source = compute_shuffled_index(position, count, seed) + .expect("position is in range by construction"); + assert_eq!( + shuffled[position as usize], list[source as usize], + "count={count}, seed={seed:?}, position={position}" + ); + } + } + } + } + + #[test] + fn empty_shuffle_has_no_positions() { + // `compute_shuffled_index` has no valid input at all when + // `index_count` is 0 (every index is out of range), so the whole-list + // form's only sensible answer is the empty list, checked here rather + // than folded into the sweep above since there is no per-index call to + // compare it against. + assert!(shuffle_list(Vec::new(), Bytes32::ZERO).is_empty()); + } +} diff --git a/crates/blockchain/state_transition/src/beacon/helpers/test_state.rs b/crates/blockchain/state_transition/src/beacon/helpers/test_state.rs new file mode 100644 index 000000000..4344108e5 --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/helpers/test_state.rs @@ -0,0 +1,541 @@ +//! A minimal state builder shared by the helper tests. +//! +//! Several helpers can only be exercised against a state with a populated +//! validator registry and correctly sized history vectors, and building one by +//! hand in each test would bury the assertion under setup. Test-only: compiled +//! for this crate's tests, and for other crates' tests through the `test-utils` +//! feature (the Beacon API's endpoints need a real registry too); nothing in the +//! crate's public surface depends on it. +//! +//! [`with_validators`] builds a phase0 state; [`with_validators_at`] +//! generalises it to every other fork. Each fork used to grow its own +//! near-duplicate of the same struct literal, one per test module that first +//! needed a state in that fork's shape: `crate::beacon::helpers::altair`, +//! `crate::beacon::stf::bellatrix`, `crate::beacon::stf::capella` and +//! `crate::beacon::stf::epoch::capella` (identical to each other), +//! `crate::beacon::stf::epoch::registry`, `crate::beacon::helpers::electra` (which grew two, +//! electra's and a fulu one) and `crate::beacon::stf::epoch::electra` (identical to +//! `helpers::electra`'s electra one), and `crate::beacon::stf::fulu`, +//! `crate::beacon::helpers::fulu`, and `crate::beacon::stf::epoch::fulu` (the latter two +//! identical to each other). This module is now the one place that shape is +//! built; a caller whose test needs something beyond the default (a +//! caller-supplied execution payload header, real BLS pubkeys, a state +//! positioned many epochs past genesis) builds the default here and then +//! applies its own small, documented override, the same way it would change +//! any other already-built state's field. + +use crate::beacon::constants; +use crate::beacon::containers::shared::{ + Balances, EpochParticipation, InactivityScores, Validator, +}; +use crate::beacon::containers::{ + BeaconState, BlockRoots, RandaoMixes, Slashings, altair, bellatrix, capella, deneb, electra, + fulu, phase0, +}; +use crate::beacon::fork::ForkName; +use crate::beacon::lean_fork_unreachable; +use crate::beacon::preset; +use crate::beacon::primitives::{ + BlsPubkey, BlsSignature, Bytes32, ExecutionAddress, ExecutionBlockHash, Gwei, Root, Uint256, +}; + +/// A deterministic but genuinely valid BLS public key for validator `index`. +/// +/// A zero pubkey is not a curve point, so anything that aggregates or validates +/// keys rejects it. That makes the all-default validator unusable for the sync +/// committee, which aggregates its members' keys, and it would silently limit +/// every later fork's tests in the same way. Deriving a real key from the index +/// keeps the state reproducible while letting the BLS paths run. +fn pubkey_for(index: usize) -> BlsPubkey { + BlsPubkey(secret_key_for(index).sk_to_pk().to_bytes()) +} + +/// Validator `index`'s signature over `message`, under the key every state this +/// module builds registers for it, so a test can produce signatures those +/// states verify. +pub fn sign_for(index: usize, message: Root) -> BlsSignature { + let signature = secret_key_for(index).sign(message.as_slice(), crate::beacon::bls::DST, &[]); + BlsSignature(signature.to_bytes()) +} + +/// The secret key behind [`pubkey_for`]`(index)`, for a test that needs a +/// validator's signature to verify. +pub fn secret_key_for(index: usize) -> blst::min_pk::SecretKey { + let mut ikm = [0u8; 32]; + ikm[..8].copy_from_slice(&(index as u64 + 1).to_le_bytes()); + blst::min_pk::SecretKey::key_gen(&ikm, &[]) + .expect("32 bytes of input material is enough for key generation") +} + +/// A phase0 state with `count` fully active, full-balance validators, positioned +/// one epoch in so that the previous epoch exists and the block root window has +/// entries behind it. +pub fn with_validators(count: usize) -> BeaconState { + let validators: Vec = (0..count) + .map(|index| Validator { + pubkey: pubkey_for(index), + effective_balance: preset::MAX_EFFECTIVE_BALANCE, + activation_eligibility_epoch: 0, + activation_epoch: 0, + exit_epoch: constants::FAR_FUTURE_EPOCH, + withdrawable_epoch: constants::FAR_FUTURE_EPOCH, + ..Default::default() + }) + .collect(); + + BeaconState::Phase0(phase0::BeaconState { + genesis_time: 0, + genesis_validators_root: Root::ZERO, + slot: preset::SLOTS_PER_EPOCH, + fork: Default::default(), + latest_block_header: Default::default(), + block_roots: vec![Root::ZERO; preset::SLOTS_PER_HISTORICAL_ROOT] + .try_into() + .expect("the vector is built at its exact length"), + state_roots: vec![Root::ZERO; preset::SLOTS_PER_HISTORICAL_ROOT] + .try_into() + .expect("the vector is built at its exact length"), + historical_roots: Default::default(), + eth1_data: Default::default(), + eth1_data_votes: Default::default(), + eth1_deposit_index: 0, + validators: validators + .try_into() + .expect("count is far below VALIDATOR_REGISTRY_LIMIT"), + balances: vec![preset::MAX_EFFECTIVE_BALANCE; count] + .try_into() + .expect("count is far below VALIDATOR_REGISTRY_LIMIT"), + randao_mixes: vec![Bytes32::ZERO; preset::EPOCHS_PER_HISTORICAL_VECTOR] + .try_into() + .expect("the vector is built at its exact length"), + slashings: vec![0; preset::EPOCHS_PER_SLASHINGS_VECTOR] + .try_into() + .expect("the vector is built at its exact length"), + previous_epoch_attestations: Default::default(), + current_epoch_attestations: Default::default(), + justification_bits: Default::default(), + previous_justified_checkpoint: Default::default(), + current_justified_checkpoint: Default::default(), + finalized_checkpoint: Default::default(), + }) +} + +/// The [`with_validators`] counterpart for every fork phase0 through fulu. +/// +/// Builds `count` fully active, full-balance validators, positioned one epoch +/// in exactly like [`with_validators`], with every field a later fork adds +/// (participation flags, inactivity scores, sync committees, an execution +/// payload header, the withdrawal cursor and historical summaries, the +/// electra pending queues, fulu's proposer lookahead) left at its own +/// all-default placeholder. A test that needs one of those fields to hold +/// something other than the placeholder builds this and overrides it +/// afterward; see `crate::beacon::stf::electra::tests::electra_state_with_validators` +/// for an example that overrides several at once. +pub fn with_validators_at(fork: ForkName, count: usize) -> BeaconState { + match fork { + ForkName::Phase0 => with_validators(count), + ForkName::Altair => BeaconState::Altair(altair_state(count)), + ForkName::Bellatrix => BeaconState::Bellatrix(bellatrix_state(count)), + ForkName::Capella => BeaconState::Capella(capella_state(count)), + ForkName::Deneb => BeaconState::Deneb(deneb_state(count)), + ForkName::Electra => BeaconState::Electra(electra_state(count)), + ForkName::Fulu => BeaconState::Fulu(fulu_state(count)), + // A test asking for a lean state from a beacon builder, which no fixture + // fork name can produce; the `fork:` form, since the argument is the + // whole input. + ForkName::Lean => lean_fork_unreachable("with_validators_at"), + } +} + +/// [`with_validators_at`], with every validator's public key replaced by the +/// real one [`sign_for`] signs under. +/// +/// Every fork but phase0 leaves the keys at the all-zero default, which is +/// cheaper to build and all most tests need; a test that looks validators up +/// by key, or verifies their signatures, needs them distinct and real. +pub fn with_signing_validators_at(fork: ForkName, count: usize) -> BeaconState { + let mut state = with_validators_at(fork, count); + for index in 0..count { + state + .validator_mut(index as u64) + .expect("index is within the registry just built") + .pubkey = pubkey_for(index); + } + state +} + +// -- Pieces shared by every fork's state literal below ----------------------- +// +// None of these are fork-specific on their own; what varies fork to fork is +// which of them a given `BeaconState` variant has a field for, which is why +// `with_validators_at`'s match arms below still need one struct literal per +// fork rather than a single generic constructor. + +/// `count` validators, each with `effective_balance` and otherwise eligible, +/// active since genesis, and never exiting. +fn full_validators( + count: usize, + effective_balance: Gwei, +) -> crate::beacon::containers::shared::Validators { + let validators: Vec = (0..count) + .map(|_| Validator { + effective_balance, + activation_eligibility_epoch: 0, + activation_epoch: 0, + exit_epoch: constants::FAR_FUTURE_EPOCH, + withdrawable_epoch: constants::FAR_FUTURE_EPOCH, + ..Default::default() + }) + .collect(); + validators + .try_into() + .expect("count is far below VALIDATOR_REGISTRY_LIMIT") +} + +/// `count` balances, each matching [`full_validators`]'s `effective_balance`. +fn full_balances(count: usize, effective_balance: Gwei) -> Balances { + vec![effective_balance; count] + .try_into() + .expect("count is far below VALIDATOR_REGISTRY_LIMIT") +} + +/// An all-zero `block_roots`/`state_roots` vector: both fields share this +/// exact type, so one builder serves either. +fn zero_root_vector() -> BlockRoots { + vec![Root::ZERO; preset::SLOTS_PER_HISTORICAL_ROOT] + .try_into() + .expect("the vector is built at its exact length") +} + +fn zero_randao_mixes() -> RandaoMixes { + vec![Bytes32::ZERO; preset::EPOCHS_PER_HISTORICAL_VECTOR] + .try_into() + .expect("the vector is built at its exact length") +} + +fn zero_slashings() -> Slashings { + vec![0; preset::EPOCHS_PER_SLASHINGS_VECTOR] + .try_into() + .expect("the vector is built at its exact length") +} + +/// An all-zero `previous_epoch_participation`/`current_epoch_participation` +/// vector, one entry per validator. +fn zero_participation(count: usize) -> EpochParticipation { + vec![0; count] + .try_into() + .expect("count is far below VALIDATOR_REGISTRY_LIMIT") +} + +fn zero_inactivity_scores(count: usize) -> InactivityScores { + vec![0; count] + .try_into() + .expect("count is far below VALIDATOR_REGISTRY_LIMIT") +} + +/// A sync committee with every seat at its all-default (invalid-as-a-curve- +/// point) pubkey. Good enough for tests that only need the field populated at +/// the right length, not for anything that aggregates or verifies against it. +fn empty_sync_committee() -> altair::SyncCommittee { + altair::SyncCommittee { + pubkeys: vec![BlsPubkey::default(); preset::SYNC_COMMITTEE_SIZE] + .try_into() + .expect("built at exactly SYNC_COMMITTEE_SIZE"), + aggregate_pubkey: BlsPubkey::default(), + } +} + +/// An all-default execution payload header in bellatrix's shape (no +/// `withdrawals_root`, no blob fields), standing in for the genesis payload. +fn empty_bellatrix_execution_payload_header() -> bellatrix::ExecutionPayloadHeader { + bellatrix::ExecutionPayloadHeader { + parent_hash: ExecutionBlockHash::ZERO, + fee_recipient: ExecutionAddress::ZERO, + state_root: Bytes32::ZERO, + receipts_root: Bytes32::ZERO, + logs_bloom: vec![0u8; preset::BYTES_PER_LOGS_BLOOM] + .try_into() + .expect("built at exactly BYTES_PER_LOGS_BLOOM"), + prev_randao: Bytes32::ZERO, + block_number: 0, + gas_limit: 0, + gas_used: 0, + timestamp: 0, + extra_data: Default::default(), + base_fee_per_gas: Uint256::ZERO, + block_hash: ExecutionBlockHash::ZERO, + transactions_root: Root::ZERO, + } +} + +/// The capella-shaped counterpart of +/// [`empty_bellatrix_execution_payload_header`]: adds `withdrawals_root`. +fn empty_capella_execution_payload_header() -> capella::ExecutionPayloadHeader { + capella::ExecutionPayloadHeader { + parent_hash: ExecutionBlockHash::ZERO, + fee_recipient: ExecutionAddress::ZERO, + state_root: Bytes32::ZERO, + receipts_root: Bytes32::ZERO, + logs_bloom: vec![0u8; preset::BYTES_PER_LOGS_BLOOM] + .try_into() + .expect("built at exactly BYTES_PER_LOGS_BLOOM"), + prev_randao: Bytes32::ZERO, + block_number: 0, + gas_limit: 0, + gas_used: 0, + timestamp: 0, + extra_data: Default::default(), + base_fee_per_gas: Uint256::ZERO, + block_hash: ExecutionBlockHash::ZERO, + transactions_root: Root::ZERO, + withdrawals_root: Root::ZERO, + } +} + +/// The deneb-shaped counterpart of [`empty_capella_execution_payload_header`]: +/// adds the blob fields. Electra and fulu keep this same shape unchanged (see +/// `crate::beacon::containers` module docs), so this builds their header too. +fn empty_deneb_execution_payload_header() -> deneb::ExecutionPayloadHeader { + deneb::ExecutionPayloadHeader { + parent_hash: ExecutionBlockHash::ZERO, + fee_recipient: ExecutionAddress::ZERO, + state_root: Bytes32::ZERO, + receipts_root: Bytes32::ZERO, + logs_bloom: vec![0u8; preset::BYTES_PER_LOGS_BLOOM] + .try_into() + .expect("built at exactly BYTES_PER_LOGS_BLOOM"), + prev_randao: Bytes32::ZERO, + block_number: 0, + gas_limit: 0, + gas_used: 0, + timestamp: 0, + extra_data: Default::default(), + base_fee_per_gas: Uint256::ZERO, + block_hash: ExecutionBlockHash::ZERO, + transactions_root: Root::ZERO, + withdrawals_root: Root::ZERO, + blob_gas_used: 0, + excess_blob_gas: 0, + } +} + +// -- One struct literal per fork --------------------------------------------- + +fn altair_state(count: usize) -> altair::BeaconState { + altair::BeaconState { + genesis_time: 0, + genesis_validators_root: Root::ZERO, + slot: preset::SLOTS_PER_EPOCH, + fork: Default::default(), + latest_block_header: Default::default(), + block_roots: zero_root_vector(), + state_roots: zero_root_vector(), + historical_roots: Default::default(), + eth1_data: Default::default(), + eth1_data_votes: Default::default(), + eth1_deposit_index: 0, + validators: full_validators(count, preset::MAX_EFFECTIVE_BALANCE), + balances: full_balances(count, preset::MAX_EFFECTIVE_BALANCE), + randao_mixes: zero_randao_mixes(), + slashings: zero_slashings(), + previous_epoch_participation: zero_participation(count), + current_epoch_participation: zero_participation(count), + justification_bits: Default::default(), + previous_justified_checkpoint: Default::default(), + current_justified_checkpoint: Default::default(), + finalized_checkpoint: Default::default(), + inactivity_scores: zero_inactivity_scores(count), + current_sync_committee: empty_sync_committee(), + next_sync_committee: empty_sync_committee(), + } +} + +fn bellatrix_state(count: usize) -> bellatrix::BeaconState { + bellatrix::BeaconState { + genesis_time: 0, + genesis_validators_root: Root::ZERO, + slot: preset::SLOTS_PER_EPOCH, + fork: Default::default(), + latest_block_header: Default::default(), + block_roots: zero_root_vector(), + state_roots: zero_root_vector(), + historical_roots: Default::default(), + eth1_data: Default::default(), + eth1_data_votes: Default::default(), + eth1_deposit_index: 0, + validators: full_validators(count, preset::MAX_EFFECTIVE_BALANCE), + balances: full_balances(count, preset::MAX_EFFECTIVE_BALANCE), + randao_mixes: zero_randao_mixes(), + slashings: zero_slashings(), + previous_epoch_participation: zero_participation(count), + current_epoch_participation: zero_participation(count), + justification_bits: Default::default(), + previous_justified_checkpoint: Default::default(), + current_justified_checkpoint: Default::default(), + finalized_checkpoint: Default::default(), + inactivity_scores: zero_inactivity_scores(count), + current_sync_committee: empty_sync_committee(), + next_sync_committee: empty_sync_committee(), + // Thrown away by every real caller, which needs a specific payload + // (or lack of one) under test and overrides this right after; see + // `crate::beacon::stf::bellatrix::tests::bellatrix_state_with_validators`. + latest_execution_payload_header: empty_bellatrix_execution_payload_header(), + } +} + +fn capella_state(count: usize) -> capella::BeaconState { + capella::BeaconState { + genesis_time: 0, + genesis_validators_root: Root::ZERO, + slot: preset::SLOTS_PER_EPOCH, + fork: Default::default(), + latest_block_header: Default::default(), + block_roots: zero_root_vector(), + state_roots: zero_root_vector(), + historical_roots: Default::default(), + eth1_data: Default::default(), + eth1_data_votes: Default::default(), + eth1_deposit_index: 0, + validators: full_validators(count, preset::MAX_EFFECTIVE_BALANCE), + balances: full_balances(count, preset::MAX_EFFECTIVE_BALANCE), + randao_mixes: zero_randao_mixes(), + slashings: zero_slashings(), + previous_epoch_participation: zero_participation(count), + current_epoch_participation: zero_participation(count), + justification_bits: Default::default(), + previous_justified_checkpoint: Default::default(), + current_justified_checkpoint: Default::default(), + finalized_checkpoint: Default::default(), + inactivity_scores: zero_inactivity_scores(count), + current_sync_committee: empty_sync_committee(), + next_sync_committee: empty_sync_committee(), + latest_execution_payload_header: empty_capella_execution_payload_header(), + next_withdrawal_index: 0, + next_withdrawal_validator_index: 0, + historical_summaries: Default::default(), + } +} + +fn deneb_state(count: usize) -> deneb::BeaconState { + deneb::BeaconState { + genesis_time: 0, + genesis_validators_root: Root::ZERO, + slot: preset::SLOTS_PER_EPOCH, + fork: Default::default(), + latest_block_header: Default::default(), + block_roots: zero_root_vector(), + state_roots: zero_root_vector(), + historical_roots: Default::default(), + eth1_data: Default::default(), + eth1_data_votes: Default::default(), + eth1_deposit_index: 0, + validators: full_validators(count, preset::MAX_EFFECTIVE_BALANCE), + balances: full_balances(count, preset::MAX_EFFECTIVE_BALANCE), + randao_mixes: zero_randao_mixes(), + slashings: zero_slashings(), + previous_epoch_participation: zero_participation(count), + current_epoch_participation: zero_participation(count), + justification_bits: Default::default(), + previous_justified_checkpoint: Default::default(), + current_justified_checkpoint: Default::default(), + finalized_checkpoint: Default::default(), + inactivity_scores: zero_inactivity_scores(count), + current_sync_committee: empty_sync_committee(), + next_sync_committee: empty_sync_committee(), + latest_execution_payload_header: empty_deneb_execution_payload_header(), + next_withdrawal_index: 0, + next_withdrawal_validator_index: 0, + historical_summaries: Default::default(), + } +} + +fn electra_state(count: usize) -> electra::BeaconState { + electra::BeaconState { + genesis_time: 0, + genesis_validators_root: Root::ZERO, + slot: preset::SLOTS_PER_EPOCH, + fork: Default::default(), + latest_block_header: Default::default(), + block_roots: zero_root_vector(), + state_roots: zero_root_vector(), + historical_roots: Default::default(), + eth1_data: Default::default(), + eth1_data_votes: Default::default(), + eth1_deposit_index: 0, + validators: full_validators(count, preset::MIN_ACTIVATION_BALANCE), + balances: full_balances(count, preset::MIN_ACTIVATION_BALANCE), + randao_mixes: zero_randao_mixes(), + slashings: zero_slashings(), + previous_epoch_participation: zero_participation(count), + current_epoch_participation: zero_participation(count), + justification_bits: Default::default(), + previous_justified_checkpoint: Default::default(), + current_justified_checkpoint: Default::default(), + finalized_checkpoint: Default::default(), + inactivity_scores: zero_inactivity_scores(count), + current_sync_committee: empty_sync_committee(), + next_sync_committee: empty_sync_committee(), + latest_execution_payload_header: empty_deneb_execution_payload_header(), + next_withdrawal_index: 0, + next_withdrawal_validator_index: 0, + historical_summaries: Default::default(), + deposit_requests_start_index: constants::UNSET_DEPOSIT_REQUESTS_START_INDEX, + deposit_balance_to_consume: 0, + exit_balance_to_consume: 0, + earliest_exit_epoch: 0, + consolidation_balance_to_consume: 0, + earliest_consolidation_epoch: 0, + pending_deposits: Default::default(), + pending_partial_withdrawals: Default::default(), + pending_consolidations: Default::default(), + } +} + +fn fulu_state(count: usize) -> fulu::BeaconState { + fulu::BeaconState { + genesis_time: 0, + genesis_validators_root: Root::ZERO, + slot: preset::SLOTS_PER_EPOCH, + fork: Default::default(), + latest_block_header: Default::default(), + block_roots: zero_root_vector(), + state_roots: zero_root_vector(), + historical_roots: Default::default(), + eth1_data: Default::default(), + eth1_data_votes: Default::default(), + eth1_deposit_index: 0, + validators: full_validators(count, preset::MAX_EFFECTIVE_BALANCE), + balances: full_balances(count, preset::MAX_EFFECTIVE_BALANCE), + randao_mixes: zero_randao_mixes(), + slashings: zero_slashings(), + previous_epoch_participation: zero_participation(count), + current_epoch_participation: zero_participation(count), + justification_bits: Default::default(), + previous_justified_checkpoint: Default::default(), + current_justified_checkpoint: Default::default(), + finalized_checkpoint: Default::default(), + inactivity_scores: zero_inactivity_scores(count), + current_sync_committee: empty_sync_committee(), + next_sync_committee: empty_sync_committee(), + latest_execution_payload_header: empty_deneb_execution_payload_header(), + next_withdrawal_index: 0, + next_withdrawal_validator_index: 0, + historical_summaries: Default::default(), + deposit_requests_start_index: constants::UNSET_DEPOSIT_REQUESTS_START_INDEX, + deposit_balance_to_consume: 0, + exit_balance_to_consume: 0, + earliest_exit_epoch: 0, + consolidation_balance_to_consume: 0, + earliest_consolidation_epoch: 0, + pending_deposits: Default::default(), + pending_partial_withdrawals: Default::default(), + pending_consolidations: Default::default(), + // Left at zero rather than run through `initialize_proposer_lookahead`: + // that is a derived value a fulu-specific test can compute for itself + // and override (see `crate::beacon::helpers::fulu::tests::fulu_state_with_validators`), + // and this builder has no fulu-specific import to spare for it. + proposer_lookahead: vec![0; preset::PROPOSER_LOOKAHEAD_LENGTH] + .try_into() + .expect("the vector is built at its exact length"), + } +} diff --git a/crates/blockchain/state_transition/src/beacon/kzg.rs b/crates/blockchain/state_transition/src/beacon/kzg.rs new file mode 100644 index 000000000..f6c892a81 --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/kzg.rs @@ -0,0 +1,968 @@ +//! KZG polynomial commitments: EIP-4844 blobs (deneb) and their EIP-7594 cell +//! proofs (fulu). +//! +//! This module wraps [`c-kzg`](https://github.com/ethereum/c-kzg-4844), the C +//! library the specification's own polynomial-commitments documents describe, +//! and exposes it under the specification's own function names so call sites +//! read like `specs/deneb/polynomial-commitments.md` and +//! `specs/fulu/polynomial-commitments-sampling.md` rather than like c-kzg's +//! Rust bindings. +//! +//! c-kzg's public API stops short of two helpers those documents define and +//! whose fixture suites call directly: `compute_challenge` and +//! `compute_verify_cell_kzg_proof_batch_challenge`. Both build a Fiat-Shamir +//! transcript (a domain separator, some fixed-width integers, and the +//! arguments' raw bytes), hash it, and reduce the digest modulo the BLS +//! scalar field. c-kzg keeps them as private steps of its own +//! `compute_blob_kzg_proof`/`verify_blob_kzg_proof` and +//! `verify_cell_kzg_proof_batch` and does not export them, so they are +//! reimplemented here directly from the spec text. +//! +//! Every function accepts untrusted bytes and returns [`Result`] rather than +//! panicking. The deneb spec says as much of its own public functions: they +//! "MUST accept raw bytes as input and perform the required cryptographic +//! normalization before invoking any internal functions" (polynomial +//! commitments, introduction). A blob of the wrong length, a field element +//! that is not canonically reduced, or a byte string that does not decode to +//! a curve point is exactly the input that normalization step exists to +//! reject, and the fixture suites downloaded for this module test that +//! rejection directly: many cases have `output: null`, meaning the operation +//! must fail rather than panic. + +use std::sync::LazyLock; + +use num_bigint::BigUint; + +use crate::beacon::error::{Error, Result, verify}; +use crate::beacon::hash::hash; +use crate::beacon::primitives::{Bytes32, H256, KzgCommitment, KzgProof}; + +/// The cells (equivalently, proofs) produced from one extended blob: the +/// fulu spec's `CELLS_PER_EXT_BLOB`-length vectors. +type CellsPerExtBlob = [c_kzg::Cell; c_kzg::CELLS_PER_EXT_BLOB]; +/// A proof for each cell of an extended blob. +type ProofsPerExtBlob = [KzgProof; c_kzg::CELLS_PER_EXT_BLOB]; + +/// The Ethereum mainnet trusted setup, parsed once. +/// +/// The setup is four thousand G1 points in both monomial and Lagrange form +/// plus sixty-five G2 points, embedded in the `c-kzg` binary by the +/// `ethereum_kzg_settings` feature. Parsing that is not free, and every +/// function in this module needs the result, so a per-call load would repeat +/// the parse on every gossiped blob and every block. `LazyLock` runs it +/// exactly once, on first use; every later call reuses the same settings. +/// c-kzg's own `ethereum_kzg_settings` helper happens to cache internally as +/// well, but wrapping it here keeps that behavior a property of this module's +/// code rather than an implementation detail of c-kzg's that this module would +/// otherwise be relying on implicitly. +/// +/// The precompute argument (0-15) trades memory for faster +/// `verify_kzg_proof`/`verify_blob_kzg_proof`. Nothing in this module verifies +/// KZG proofs in a hot loop, so 0 keeps the smaller footprint rather than +/// spending memory on precomputed tables this module would rarely use. +static KZG_SETTINGS: LazyLock<&'static c_kzg::KzgSettings> = + LazyLock::new(|| c_kzg::ethereum_kzg_settings(0)); + +/// Scalar field modulus of BLS12-381: the deneb spec's `BLS_MODULUS`, copied +/// verbatim from its constants table. +const BLS_MODULUS_DECIMAL: &str = + "52435875175126190479447740508185965837690552500527637822603658699938581184513"; + +/// `BLS_MODULUS`, parsed once rather than on every challenge computed. +static BLS_MODULUS: LazyLock = LazyLock::new(|| { + BigUint::parse_bytes(BLS_MODULUS_DECIMAL.as_bytes(), 10) + .expect("BLS_MODULUS_DECIMAL is a valid base-10 literal") +}); + +/// Parses raw bytes into a c-kzg blob, rejecting anything but exactly +/// `BYTES_PER_BLOB` bytes. +fn to_blob(bytes: &[u8]) -> Result { + c_kzg::Blob::from_bytes(bytes).map_err(|_| Error::SpecAssert("len(blob) == BYTES_PER_BLOB")) +} + +/// Converts a c-kzg commitment into this module's [`KzgCommitment`], so this +/// module's public functions deal in one commitment type rather than leaking +/// c-kzg's. +fn commitment_from_c_kzg(raw: c_kzg::KzgCommitment) -> KzgCommitment { + KzgCommitment(raw.to_bytes().into_inner()) +} + +/// Converts a c-kzg proof into this module's [`KzgProof`]. +fn proof_from_c_kzg(raw: c_kzg::KzgProof) -> KzgProof { + KzgProof(raw.to_bytes().into_inner()) +} + +/// Converts a boxed array of c-kzg proofs into this module's proof type, +/// keeping the result on the heap throughout (`CELLS_PER_EXT_BLOB` proofs of +/// `KZG_POINT_SIZE` bytes each is small, but there is no reason to round-trip +/// it through the stack). +fn proofs_from_c_kzg( + raw: Box<[c_kzg::KzgProof; c_kzg::CELLS_PER_EXT_BLOB]>, +) -> Box { + let converted: Vec = raw.iter().map(|proof| proof_from_c_kzg(*proof)).collect(); + converted + .into_boxed_slice() + .try_into() + .expect("converted has exactly CELLS_PER_EXT_BLOB elements, one per input proof") +} + +/// Converts a c-kzg field element into this module's [`Bytes32`]. +fn bytes32_from_c_kzg(raw: c_kzg::Bytes32) -> Bytes32 { + H256(*raw.as_ref()) +} + +/// The specification's `blob_to_kzg_commitment` +/// (`specs/deneb/polynomial-commitments.md#blob_to_kzg_commitment`). +/// +/// Fails if `blob` is not exactly `BYTES_PER_BLOB` bytes, or if any of its +/// field elements is not canonically reduced modulo `BLS_MODULUS`. +pub fn blob_to_kzg_commitment(blob: &[u8]) -> Result { + let blob = to_blob(blob)?; + KZG_SETTINGS + .blob_to_kzg_commitment(&blob) + .map(commitment_from_c_kzg) + .map_err(|_| Error::SpecAssert("every field element in blob is < BLS_MODULUS")) +} + +/// The specification's `compute_kzg_proof` +/// (`specs/deneb/polynomial-commitments.md#compute_kzg_proof`). +/// +/// Returns the proof that the polynomial `blob` represents evaluates to `y` +/// at `z`, along with `y` itself. Fails if `blob` is the wrong length, if any +/// of its field elements is not canonical, or if `z` is not canonical. +pub fn compute_kzg_proof(blob: &[u8], z: &Bytes32) -> Result<(KzgProof, Bytes32)> { + let blob = to_blob(blob)?; + let z_bytes = c_kzg::Bytes32::new(z.0); + KZG_SETTINGS + .compute_kzg_proof(&blob, &z_bytes) + .map(|(proof, y)| (proof_from_c_kzg(proof), bytes32_from_c_kzg(y))) + .map_err(|_| Error::SpecAssert("blob and z are valid inputs to compute_kzg_proof")) +} + +/// The specification's `compute_blob_kzg_proof` +/// (`specs/deneb/polynomial-commitments.md#compute_blob_kzg_proof`). +/// +/// This is the proof gossiped alongside a blob and its commitment, evaluated +/// at the Fiat-Shamir challenge point `compute_challenge` derives from them, +/// rather than at a caller-chosen point. Fails if `blob` is invalid, or if +/// `commitment` is not `blob`'s commitment. +pub fn compute_blob_kzg_proof(blob: &[u8], commitment: &KzgCommitment) -> Result { + let blob = to_blob(blob)?; + let commitment_bytes = c_kzg::Bytes48::new(commitment.0); + KZG_SETTINGS + .compute_blob_kzg_proof(&blob, &commitment_bytes) + .map(proof_from_c_kzg) + .map_err(|_| Error::SpecAssert("commitment is a valid KZG commitment to blob")) +} + +/// The specification's `verify_kzg_proof` +/// (`specs/deneb/polynomial-commitments.md#verify_kzg_proof`). +/// +/// Checks that `proof` attests that the polynomial committed to by +/// `commitment` evaluates to `y` at `z`. Fails, rather than returning `false`, +/// if any of the byte strings do not decode to a valid point or a canonical +/// field element. +pub fn verify_kzg_proof( + commitment: &KzgCommitment, + z: &Bytes32, + y: &Bytes32, + proof: &KzgProof, +) -> Result { + let commitment_bytes = c_kzg::Bytes48::new(commitment.0); + let z_bytes = c_kzg::Bytes32::new(z.0); + let y_bytes = c_kzg::Bytes32::new(y.0); + let proof_bytes = c_kzg::Bytes48::new(proof.0); + KZG_SETTINGS + .verify_kzg_proof(&commitment_bytes, &z_bytes, &y_bytes, &proof_bytes) + .map_err(|_| Error::SpecAssert("commitment, z, y, and proof decode to valid points")) +} + +/// The specification's `verify_blob_kzg_proof` +/// (`specs/deneb/polynomial-commitments.md#verify_blob_kzg_proof`). +/// +/// Checks that `proof` attests that `commitment` commits to `blob`, evaluated +/// at the Fiat-Shamir challenge `compute_challenge` derives from them. Fails, +/// rather than returning `false`, on malformed input. +pub fn verify_blob_kzg_proof( + blob: &[u8], + commitment: &KzgCommitment, + proof: &KzgProof, +) -> Result { + let blob = to_blob(blob)?; + let commitment_bytes = c_kzg::Bytes48::new(commitment.0); + let proof_bytes = c_kzg::Bytes48::new(proof.0); + KZG_SETTINGS + .verify_blob_kzg_proof(&blob, &commitment_bytes, &proof_bytes) + .map_err(|_| Error::SpecAssert("proof attests that commitment commits to blob")) +} + +/// The specification's `verify_blob_kzg_proof_batch` +/// (`specs/deneb/polynomial-commitments.md#verify_blob_kzg_proof_batch`). +/// +/// Verifies many (blob, commitment, proof) triples with one random linear +/// combination rather than one pairing check per triple. `blobs`, +/// `commitments`, and `proofs` must have equal length; fails, rather than +/// returning `false`, if they do not, or if any element is malformed. +pub fn verify_blob_kzg_proof_batch( + blobs: &[&[u8]], + commitments: &[KzgCommitment], + proofs: &[KzgProof], +) -> Result { + let mut c_kzg_blobs = Vec::with_capacity(blobs.len()); + for blob in blobs { + c_kzg_blobs.push(to_blob(blob)?); + } + let commitment_bytes: Vec = commitments + .iter() + .map(|commitment| c_kzg::Bytes48::new(commitment.0)) + .collect(); + let proof_bytes: Vec = proofs + .iter() + .map(|proof| c_kzg::Bytes48::new(proof.0)) + .collect(); + KZG_SETTINGS + .verify_blob_kzg_proof_batch(&c_kzg_blobs, &commitment_bytes, &proof_bytes) + .map_err(|_| Error::SpecAssert("blobs, commitments, and proofs have equal, valid entries")) +} + +/// The specification's `compute_cells` +/// (`specs/fulu/polynomial-commitments-sampling.md#compute_cells`). +/// +/// Extends `blob`'s polynomial by a factor of two and splits the extension +/// into `CELLS_PER_EXT_BLOB` cells, without the accompanying proofs; see +/// [`compute_cells_and_kzg_proofs`] when the proofs are needed too. Fails if +/// `blob` is invalid. +pub fn compute_cells(blob: &[u8]) -> Result> { + let blob = to_blob(blob)?; + KZG_SETTINGS + .compute_cells(&blob) + .map_err(|_| Error::SpecAssert("blob is the evaluation of a valid polynomial")) +} + +/// The specification's `compute_cells_and_kzg_proofs` +/// (`specs/fulu/polynomial-commitments-sampling.md#compute_cells_and_kzg_proofs`). +/// +/// Same extension as [`compute_cells`], plus a KZG proof for each cell. +/// Fails if `blob` is invalid. +pub fn compute_cells_and_kzg_proofs( + blob: &[u8], +) -> Result<(Box, Box)> { + let blob = to_blob(blob)?; + let (cells, proofs) = KZG_SETTINGS + .compute_cells_and_kzg_proofs(&blob) + .map_err(|_| Error::SpecAssert("blob is the evaluation of a valid polynomial"))?; + Ok((cells, proofs_from_c_kzg(proofs))) +} + +/// The specification's `recover_cells_and_kzg_proofs` +/// (`specs/fulu/polynomial-commitments-sampling.md#recover_cells_and_kzg_proofs`). +/// +/// Reconstructs every cell and proof of an extended blob from a partial set, +/// via Reed-Solomon erasure decoding. `cell_indices` and `cells` must have +/// equal length, with no repeated index; fails if they do not, or if too few +/// cells are given to recover the rest. +pub fn recover_cells_and_kzg_proofs( + cell_indices: &[u64], + cells: &[c_kzg::Cell], +) -> Result<(Box, Box)> { + let (cells, proofs) = KZG_SETTINGS + .recover_cells_and_kzg_proofs(cell_indices, cells) + .map_err(|_| Error::SpecAssert("cell_indices and cells recover a valid extended blob"))?; + Ok((cells, proofs_from_c_kzg(proofs))) +} + +/// The specification's `verify_cell_kzg_proof_batch` +/// (`specs/fulu/polynomial-commitments-sampling.md#verify_cell_kzg_proof_batch`). +/// +/// Checks that each `cell` is the evaluation of the polynomial `commitment` +/// commits to, over the domain `cell_index` selects, using `proof`. +/// `commitments`, `cell_indices`, `cells`, and `proofs` must have equal +/// length; fails, rather than returning `false`, if they do not, or if any +/// entry is malformed. +pub fn verify_cell_kzg_proof_batch( + commitments: &[KzgCommitment], + cell_indices: &[u64], + cells: &[c_kzg::Cell], + proofs: &[KzgProof], +) -> Result { + let commitment_bytes: Vec = commitments + .iter() + .map(|commitment| c_kzg::Bytes48::new(commitment.0)) + .collect(); + let proof_bytes: Vec = proofs + .iter() + .map(|proof| c_kzg::Bytes48::new(proof.0)) + .collect(); + KZG_SETTINGS + .verify_cell_kzg_proof_batch(&commitment_bytes, cell_indices, cells, &proof_bytes) + .map_err(|_| { + Error::SpecAssert("commitments, cell_indices, cells, and proofs are consistent") + }) +} + +/// Domain separator for `compute_challenge`: the deneb spec's +/// `FIAT_SHAMIR_PROTOCOL_DOMAIN`, copied verbatim. +const FIAT_SHAMIR_PROTOCOL_DOMAIN: &[u8; 16] = b"FSBLOBVERIFY_V1_"; + +/// Domain separator for `compute_verify_cell_kzg_proof_batch_challenge`: the +/// fulu spec's `RANDOM_CHALLENGE_KZG_CELL_BATCH_DOMAIN`, copied verbatim. Not +/// to be confused with deneb's own `RANDOM_CHALLENGE_KZG_BATCH_DOMAIN`, a +/// different constant this module has no use for because +/// `verify_blob_kzg_proof_batch` above is delegated to c-kzg wholesale. +const RANDOM_CHALLENGE_KZG_CELL_BATCH_DOMAIN: &[u8; 16] = b"RCKZGCBATCH__V1_"; + +/// The specification's `hash_to_bls_field` +/// (`specs/deneb/polynomial-commitments.md#hash_to_bls_field`): hash `data` +/// and reduce the digest modulo `BLS_MODULUS`, interpreting both the digest +/// and the reduced value big-endian (`KZG_ENDIANNESS`). Shared by both +/// challenge functions below. +/// +/// Not exported: like `compute_challenge` and +/// `compute_verify_cell_kzg_proof_batch_challenge`, this is an internal +/// helper the spec document does not flag as a "Public method". +fn hash_to_bls_field(data: &[u8]) -> Bytes32 { + let digest = hash(data); + let value = BigUint::from_bytes_be(digest.as_slice()) % &*BLS_MODULUS; + let reduced = value.to_bytes_be(); + + // `reduced` is big-endian and shorter than 32 bytes whenever the value has + // leading zero bytes; right-align it so the missing bytes come out zero. + let mut bytes = [0u8; 32]; + bytes[32 - reduced.len()..].copy_from_slice(&reduced); + H256(bytes) +} + +/// The specification's `compute_challenge` +/// (`specs/deneb/polynomial-commitments.md#compute_challenge`): the +/// Fiat-Shamir challenge point at which `compute_blob_kzg_proof` and +/// `verify_blob_kzg_proof` evaluate `blob`'s polynomial. +/// +/// c-kzg computes this internally as a step of those two functions but does +/// not export it; it is reimplemented here directly from the spec text so +/// the standalone `compute_challenge` fixtures, which call it in isolation, +/// can be checked. +/// +/// Fails if `blob` is not exactly `BYTES_PER_BLOB` bytes. Unlike the +/// functions above, this never fails on the *content* of `blob` or +/// `commitment`: the transcript is their raw bytes, hashed, with no +/// curve-point or field-element validation performed along the way. +pub fn compute_challenge(blob: &[u8], commitment: &KzgCommitment) -> Result { + verify( + blob.len() == c_kzg::BYTES_PER_BLOB, + "len(blob) == BYTES_PER_BLOB", + )?; + + let mut data = Vec::with_capacity( + FIAT_SHAMIR_PROTOCOL_DOMAIN.len() + 16 + blob.len() + commitment.0.len(), + ); + data.extend_from_slice(FIAT_SHAMIR_PROTOCOL_DOMAIN); + // The degree of the polynomial, as a domain separator. The spec encodes + // it as a 16-byte big-endian integer here (`int.to_bytes(FIELD_ELEMENTS_PER_BLOB, 16, KZG_ENDIANNESS)`); + // `compute_verify_cell_kzg_proof_batch_challenge` below encodes every + // integer in its own transcript as 8 bytes instead, so the width is not + // interchangeable between the two functions. + data.extend_from_slice(&(c_kzg::FIELD_ELEMENTS_PER_BLOB as u128).to_be_bytes()); + data.extend_from_slice(blob); + data.extend_from_slice(commitment.as_ref()); + + Ok(hash_to_bls_field(&data)) +} + +/// The specification's `compute_verify_cell_kzg_proof_batch_challenge` +/// (`specs/fulu/polynomial-commitments-sampling.md#compute_verify_cell_kzg_proof_batch_challenge`): +/// the random challenge `r` the universal verification equation in +/// `verify_cell_kzg_proof_batch` uses to combine many cell proofs into one +/// check. +/// +/// c-kzg computes this internally as a step of its own +/// `verify_cell_kzg_proof_batch` but does not export it; it is reimplemented +/// here directly from the spec text so the standalone fixtures for it, which +/// call it in isolation, can be checked. +/// +/// `commitments` must already be deduplicated and `commitment_indices` must +/// map each coset back to its entry, exactly as `verify_cell_kzg_proof_batch` +/// constructs them internally; this function does not deduplicate on the +/// caller's behalf. `commitment_indices`, `cell_indices`, and `proofs` must +/// each have one entry per element of `cosets_evals`, and each coset's +/// evaluations must number `FIELD_ELEMENTS_PER_CELL`; this function returns +/// `Err` rather than indexing out of bounds if they do not. +pub fn compute_verify_cell_kzg_proof_batch_challenge( + commitments: &[KzgCommitment], + commitment_indices: &[u64], + cell_indices: &[u64], + cosets_evals: &[Vec], + proofs: &[KzgProof], +) -> Result { + verify( + commitment_indices.len() == cosets_evals.len(), + "commitment_indices has one entry per coset", + )?; + verify( + cell_indices.len() == cosets_evals.len(), + "cell_indices has one entry per coset", + )?; + verify( + proofs.len() == cosets_evals.len(), + "proofs has one entry per coset", + )?; + for coset_evals in cosets_evals { + verify( + coset_evals.len() == c_kzg::FIELD_ELEMENTS_PER_CELL, + "each coset has FIELD_ELEMENTS_PER_CELL evaluations", + )?; + } + + let mut data = Vec::new(); + data.extend_from_slice(RANDOM_CHALLENGE_KZG_CELL_BATCH_DOMAIN); + // Unlike `compute_challenge` above, every integer in this transcript is + // an 8-byte big-endian encoding (the spec's `int.to_bytes(_, 8, KZG_ENDIANNESS)`). + data.extend_from_slice(&(c_kzg::FIELD_ELEMENTS_PER_BLOB as u64).to_be_bytes()); + data.extend_from_slice(&(c_kzg::FIELD_ELEMENTS_PER_CELL as u64).to_be_bytes()); + data.extend_from_slice(&(commitments.len() as u64).to_be_bytes()); + data.extend_from_slice(&(cell_indices.len() as u64).to_be_bytes()); + for commitment in commitments { + data.extend_from_slice(commitment.as_ref()); + } + for (k, coset_evals) in cosets_evals.iter().enumerate() { + data.extend_from_slice(&commitment_indices[k].to_be_bytes()); + data.extend_from_slice(&cell_indices[k].to_be_bytes()); + for coset_eval in coset_evals { + data.extend_from_slice(coset_eval.as_slice()); + } + data.extend_from_slice(proofs[k].as_ref()); + } + + Ok(hash_to_bls_field(&data)) +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::path::{Path, PathBuf}; + + use serde_yaml_ng::Value; + + use super::*; + + /// Root of the downloaded consensus spec test fixtures. + /// + /// The fixture harness other stages of this module share does not exist + /// yet (it lands in a later stage), so this test module loads its own + /// small subset of it: KZG fixtures are plain YAML with hex strings, no + /// SSZ and no snappy compression, so no shared machinery is needed. + fn fixtures_root() -> PathBuf { + let root = + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../consensus-spec-tests/tests"); + if !root.exists() { + panic!( + "consensus spec test fixtures not found at {}; run `make consensus-spec-tests`", + root.display() + ); + } + root + } + + /// Loads every `data.yaml` under `general//kzg//kzg-mainnet/*/`. + fn cases(fork: &str, handler: &str) -> Vec { + let dir = fixtures_root() + .join("general") + .join(fork) + .join("kzg") + .join(handler) + .join("kzg-mainnet"); + let entries = fs::read_dir(&dir) + .unwrap_or_else(|err| panic!("reading fixture directory {}: {err}", dir.display())); + entries + .map(|entry| { + let case_dir = entry.expect("readable directory entry").path(); + let data = fs::read(case_dir.join("data.yaml")) + .unwrap_or_else(|err| panic!("reading {}: {err}", case_dir.display())); + serde_yaml_ng::from_slice(&data) + .unwrap_or_else(|err| panic!("parsing {}: {err}", case_dir.display())) + }) + .collect() + } + + /// Decodes a `0x`-prefixed hex string into heap-allocated bytes. Blobs + /// are `BYTES_PER_BLOB` (128 KiB) each, so this, not a stack array, is how + /// every hex field in this test module is decoded. + fn hex_bytes(value: &Value) -> Vec { + let hex_str = value.as_str().expect("value is a hex string"); + hex::decode( + hex_str + .strip_prefix("0x") + .expect("hex string has a 0x prefix"), + ) + .expect("value is valid hex") + } + + /// Decodes a 32-byte hex string, or `None` if it is not exactly 32 bytes. + /// A length mismatch is treated the same way the upstream c-kzg test + /// harness treats one: as a case whose expected output must be `null`, + /// since this module's typed KZG functions cannot even be called with a + /// malformed-length field element. + fn hex_bytes32(value: &Value) -> Option { + let bytes = hex_bytes(value); + let bytes: [u8; 32] = bytes.try_into().ok()?; + Some(H256(bytes)) + } + + /// Decodes a 48-byte hex string into a [`KzgCommitment`], or `None` if it + /// is not exactly 48 bytes. + fn hex_commitment(value: &Value) -> Option { + let bytes = hex_bytes(value); + let bytes: [u8; 48] = bytes.try_into().ok()?; + Some(KzgCommitment(bytes)) + } + + /// Decodes a 48-byte hex string into a [`KzgProof`], or `None` if it is + /// not exactly 48 bytes. + fn hex_proof(value: &Value) -> Option { + let bytes = hex_bytes(value); + let bytes: [u8; 48] = bytes.try_into().ok()?; + Some(KzgProof(bytes)) + } + + /// Decodes a `BYTES_PER_CELL`-byte hex string into a [`c_kzg::Cell`], or + /// `None` if the length is wrong. + fn hex_cell(value: &Value) -> Option { + c_kzg::Cell::from_bytes(&hex_bytes(value)).ok() + } + + #[test] + #[cfg_attr( + not(feature = "beacon-spec-tests"), + ignore = "needs the consensus-spec fixture tree; run `make consensus-spec-tests`" + )] + fn blob_to_kzg_commitment_matches_fixtures() { + let cases = cases("deneb", "blob_to_kzg_commitment"); + assert!( + !cases.is_empty(), + "no blob_to_kzg_commitment fixtures found" + ); + for case in &cases { + let blob = hex_bytes(&case["input"]["blob"]); + let expected = &case["output"]; + match blob_to_kzg_commitment(&blob) { + Ok(commitment) => { + assert_eq!(Some(commitment), hex_commitment(expected)); + } + Err(_) => assert!(expected.is_null()), + } + } + println!("blob_to_kzg_commitment: {} cases", cases.len()); + } + + #[test] + #[cfg_attr( + not(feature = "beacon-spec-tests"), + ignore = "needs the consensus-spec fixture tree; run `make consensus-spec-tests`" + )] + fn compute_kzg_proof_matches_fixtures() { + let cases = cases("deneb", "compute_kzg_proof"); + assert!(!cases.is_empty(), "no compute_kzg_proof fixtures found"); + for case in &cases { + let blob = hex_bytes(&case["input"]["blob"]); + let expected = &case["output"]; + let Some(z) = hex_bytes32(&case["input"]["z"]) else { + assert!(expected.is_null()); + continue; + }; + match compute_kzg_proof(&blob, &z) { + Ok((proof, y)) => { + let expected = expected.as_sequence().expect("output is [proof, y]"); + assert_eq!(Some(proof), hex_proof(&expected[0])); + assert_eq!(Some(y), hex_bytes32(&expected[1])); + } + Err(_) => assert!(expected.is_null()), + } + } + println!("compute_kzg_proof: {} cases", cases.len()); + } + + #[test] + #[cfg_attr( + not(feature = "beacon-spec-tests"), + ignore = "needs the consensus-spec fixture tree; run `make consensus-spec-tests`" + )] + fn compute_blob_kzg_proof_matches_fixtures() { + let cases = cases("deneb", "compute_blob_kzg_proof"); + assert!( + !cases.is_empty(), + "no compute_blob_kzg_proof fixtures found" + ); + for case in &cases { + let blob = hex_bytes(&case["input"]["blob"]); + let expected = &case["output"]; + let Some(commitment) = hex_commitment(&case["input"]["commitment"]) else { + assert!(expected.is_null()); + continue; + }; + match compute_blob_kzg_proof(&blob, &commitment) { + Ok(proof) => assert_eq!(Some(proof), hex_proof(expected)), + Err(_) => assert!(expected.is_null()), + } + } + println!("compute_blob_kzg_proof: {} cases", cases.len()); + } + + #[test] + #[cfg_attr( + not(feature = "beacon-spec-tests"), + ignore = "needs the consensus-spec fixture tree; run `make consensus-spec-tests`" + )] + fn verify_kzg_proof_matches_fixtures() { + let cases = cases("deneb", "verify_kzg_proof"); + assert!(!cases.is_empty(), "no verify_kzg_proof fixtures found"); + for case in &cases { + let expected = &case["output"]; + let input = &case["input"]; + let (Some(commitment), Some(z), Some(y), Some(proof)) = ( + hex_commitment(&input["commitment"]), + hex_bytes32(&input["z"]), + hex_bytes32(&input["y"]), + hex_proof(&input["proof"]), + ) else { + assert!(expected.is_null()); + continue; + }; + match verify_kzg_proof(&commitment, &z, &y, &proof) { + Ok(verified) => assert_eq!(Some(verified), expected.as_bool()), + Err(_) => assert!(expected.is_null()), + } + } + println!("verify_kzg_proof: {} cases", cases.len()); + } + + #[test] + #[cfg_attr( + not(feature = "beacon-spec-tests"), + ignore = "needs the consensus-spec fixture tree; run `make consensus-spec-tests`" + )] + fn verify_blob_kzg_proof_matches_fixtures() { + let cases = cases("deneb", "verify_blob_kzg_proof"); + assert!(!cases.is_empty(), "no verify_blob_kzg_proof fixtures found"); + for case in &cases { + let expected = &case["output"]; + let input = &case["input"]; + let blob = hex_bytes(&input["blob"]); + let (Some(commitment), Some(proof)) = ( + hex_commitment(&input["commitment"]), + hex_proof(&input["proof"]), + ) else { + assert!(expected.is_null()); + continue; + }; + match verify_blob_kzg_proof(&blob, &commitment, &proof) { + Ok(verified) => assert_eq!(Some(verified), expected.as_bool()), + Err(_) => assert!(expected.is_null()), + } + } + println!("verify_blob_kzg_proof: {} cases", cases.len()); + } + + #[test] + #[cfg_attr( + not(feature = "beacon-spec-tests"), + ignore = "needs the consensus-spec fixture tree; run `make consensus-spec-tests`" + )] + fn verify_blob_kzg_proof_batch_matches_fixtures() { + let cases = cases("deneb", "verify_blob_kzg_proof_batch"); + assert!( + !cases.is_empty(), + "no verify_blob_kzg_proof_batch fixtures found" + ); + for case in &cases { + let expected = &case["output"]; + let input = &case["input"]; + let blobs: Vec> = input["blobs"] + .as_sequence() + .expect("blobs is a list") + .iter() + .map(hex_bytes) + .collect(); + let blob_refs: Vec<&[u8]> = blobs.iter().map(Vec::as_slice).collect(); + + let commitments: Option> = input["commitments"] + .as_sequence() + .expect("commitments is a list") + .iter() + .map(hex_commitment) + .collect(); + let proofs: Option> = input["proofs"] + .as_sequence() + .expect("proofs is a list") + .iter() + .map(hex_proof) + .collect(); + let (Some(commitments), Some(proofs)) = (commitments, proofs) else { + assert!(expected.is_null()); + continue; + }; + + match verify_blob_kzg_proof_batch(&blob_refs, &commitments, &proofs) { + Ok(verified) => assert_eq!(Some(verified), expected.as_bool()), + Err(_) => assert!(expected.is_null()), + } + } + println!("verify_blob_kzg_proof_batch: {} cases", cases.len()); + } + + #[test] + #[cfg_attr( + not(feature = "beacon-spec-tests"), + ignore = "needs the consensus-spec fixture tree; run `make consensus-spec-tests`" + )] + fn compute_cells_matches_fixtures() { + let cases = cases("fulu", "compute_cells"); + assert!(!cases.is_empty(), "no compute_cells fixtures found"); + for case in &cases { + let blob = hex_bytes(&case["input"]["blob"]); + let expected = &case["output"]; + match compute_cells(&blob) { + Ok(cells) => { + let expected = expected.as_sequence().expect("output is a list of cells"); + assert_eq!(cells.len(), expected.len()); + for (cell, expected_cell) in cells.iter().zip(expected) { + assert_eq!(Some(*cell), hex_cell(expected_cell)); + } + } + Err(_) => assert!(expected.is_null()), + } + } + println!("compute_cells: {} cases", cases.len()); + } + + #[test] + #[cfg_attr( + not(feature = "beacon-spec-tests"), + ignore = "needs the consensus-spec fixture tree; run `make consensus-spec-tests`" + )] + fn compute_cells_and_kzg_proofs_matches_fixtures() { + let cases = cases("fulu", "compute_cells_and_kzg_proofs"); + assert!( + !cases.is_empty(), + "no compute_cells_and_kzg_proofs fixtures found" + ); + for case in &cases { + let blob = hex_bytes(&case["input"]["blob"]); + let expected = &case["output"]; + match compute_cells_and_kzg_proofs(&blob) { + Ok((cells, proofs)) => { + let expected = expected.as_sequence().expect("output is [cells, proofs]"); + let expected_cells = expected[0].as_sequence().expect("cells is a list"); + let expected_proofs = expected[1].as_sequence().expect("proofs is a list"); + for (cell, expected_cell) in cells.iter().zip(expected_cells) { + assert_eq!(Some(*cell), hex_cell(expected_cell)); + } + for (proof, expected_proof) in proofs.iter().zip(expected_proofs) { + assert_eq!(Some(*proof), hex_proof(expected_proof)); + } + } + Err(_) => assert!(expected.is_null()), + } + } + println!("compute_cells_and_kzg_proofs: {} cases", cases.len()); + } + + #[test] + #[cfg_attr( + not(feature = "beacon-spec-tests"), + ignore = "needs the consensus-spec fixture tree; run `make consensus-spec-tests`" + )] + fn recover_cells_and_kzg_proofs_matches_fixtures() { + let cases = cases("fulu", "recover_cells_and_kzg_proofs"); + assert!( + !cases.is_empty(), + "no recover_cells_and_kzg_proofs fixtures found" + ); + for case in &cases { + let expected = &case["output"]; + let input = &case["input"]; + let cell_indices: Vec = input["cell_indices"] + .as_sequence() + .expect("cell_indices is a list") + .iter() + .map(|v| v.as_u64().expect("cell index is an integer")) + .collect(); + let cells: Option> = input["cells"] + .as_sequence() + .expect("cells is a list") + .iter() + .map(hex_cell) + .collect(); + let Some(cells) = cells else { + assert!(expected.is_null()); + continue; + }; + + match recover_cells_and_kzg_proofs(&cell_indices, &cells) { + Ok((recovered_cells, recovered_proofs)) => { + let expected = expected.as_sequence().expect("output is [cells, proofs]"); + let expected_cells = expected[0].as_sequence().expect("cells is a list"); + let expected_proofs = expected[1].as_sequence().expect("proofs is a list"); + for (cell, expected_cell) in recovered_cells.iter().zip(expected_cells) { + assert_eq!(Some(*cell), hex_cell(expected_cell)); + } + for (proof, expected_proof) in recovered_proofs.iter().zip(expected_proofs) { + assert_eq!(Some(*proof), hex_proof(expected_proof)); + } + } + Err(_) => assert!(expected.is_null()), + } + } + println!("recover_cells_and_kzg_proofs: {} cases", cases.len()); + } + + #[test] + #[cfg_attr( + not(feature = "beacon-spec-tests"), + ignore = "needs the consensus-spec fixture tree; run `make consensus-spec-tests`" + )] + fn verify_cell_kzg_proof_batch_matches_fixtures() { + let cases = cases("fulu", "verify_cell_kzg_proof_batch"); + assert!( + !cases.is_empty(), + "no verify_cell_kzg_proof_batch fixtures found" + ); + for case in &cases { + let expected = &case["output"]; + let input = &case["input"]; + let cell_indices: Vec = input["cell_indices"] + .as_sequence() + .expect("cell_indices is a list") + .iter() + .map(|v| v.as_u64().expect("cell index is an integer")) + .collect(); + + let commitments: Option> = input["commitments"] + .as_sequence() + .expect("commitments is a list") + .iter() + .map(hex_commitment) + .collect(); + let cells: Option> = input["cells"] + .as_sequence() + .expect("cells is a list") + .iter() + .map(hex_cell) + .collect(); + let proofs: Option> = input["proofs"] + .as_sequence() + .expect("proofs is a list") + .iter() + .map(hex_proof) + .collect(); + let (Some(commitments), Some(cells), Some(proofs)) = (commitments, cells, proofs) + else { + assert!(expected.is_null()); + continue; + }; + + match verify_cell_kzg_proof_batch(&commitments, &cell_indices, &cells, &proofs) { + Ok(verified) => assert_eq!(Some(verified), expected.as_bool()), + Err(_) => assert!(expected.is_null()), + } + } + println!("verify_cell_kzg_proof_batch: {} cases", cases.len()); + } + + #[test] + #[cfg_attr( + not(feature = "beacon-spec-tests"), + ignore = "needs the consensus-spec fixture tree; run `make consensus-spec-tests`" + )] + fn compute_challenge_matches_fixtures() { + let cases = cases("deneb", "compute_challenge"); + assert!(!cases.is_empty(), "no compute_challenge fixtures found"); + for case in &cases { + let blob = hex_bytes(&case["input"]["blob"]); + let expected = &case["output"]; + let Some(commitment) = hex_commitment(&case["input"]["commitment"]) else { + assert!(expected.is_null()); + continue; + }; + match compute_challenge(&blob, &commitment) { + Ok(challenge) => assert_eq!(Some(challenge), hex_bytes32(expected)), + Err(_) => assert!(expected.is_null()), + } + } + println!("compute_challenge: {} cases", cases.len()); + } + + #[test] + #[cfg_attr( + not(feature = "beacon-spec-tests"), + ignore = "needs the consensus-spec fixture tree; run `make consensus-spec-tests`" + )] + fn compute_verify_cell_kzg_proof_batch_challenge_matches_fixtures() { + let cases = cases("fulu", "compute_verify_cell_kzg_proof_batch_challenge"); + assert!( + !cases.is_empty(), + "no compute_verify_cell_kzg_proof_batch_challenge fixtures found" + ); + for case in &cases { + let expected = &case["output"]; + let input = &case["input"]; + + let commitments: Option> = input["commitments"] + .as_sequence() + .expect("commitments is a list") + .iter() + .map(hex_commitment) + .collect(); + let commitment_indices: Vec = input["commitment_indices"] + .as_sequence() + .expect("commitment_indices is a list") + .iter() + .map(|v| v.as_u64().expect("commitment index is an integer")) + .collect(); + let cell_indices: Vec = input["cell_indices"] + .as_sequence() + .expect("cell_indices is a list") + .iter() + .map(|v| v.as_u64().expect("cell index is an integer")) + .collect(); + let cosets_evals: Option>> = input["cosets_evals"] + .as_sequence() + .expect("cosets_evals is a list") + .iter() + .map(|coset| { + coset + .as_sequence() + .expect("coset is a list of field elements") + .iter() + .map(hex_bytes32) + .collect::>>() + }) + .collect(); + let proofs: Option> = input["proofs"] + .as_sequence() + .expect("proofs is a list") + .iter() + .map(hex_proof) + .collect(); + let (Some(commitments), Some(cosets_evals), Some(proofs)) = + (commitments, cosets_evals, proofs) + else { + assert!(expected.is_null()); + continue; + }; + + match compute_verify_cell_kzg_proof_batch_challenge( + &commitments, + &commitment_indices, + &cell_indices, + &cosets_evals, + &proofs, + ) { + Ok(challenge) => assert_eq!(Some(challenge), hex_bytes32(expected)), + Err(_) => assert!(expected.is_null()), + } + } + println!( + "compute_verify_cell_kzg_proof_batch_challenge: {} cases", + cases.len() + ); + } +} diff --git a/crates/blockchain/state_transition/src/beacon/lean_boundary.rs b/crates/blockchain/state_transition/src/beacon/lean_boundary.rs new file mode 100644 index 000000000..c93cf5f83 --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/lean_boundary.rs @@ -0,0 +1,67 @@ +//! The one place this module names the lean half of `ethlambda-types`. +//! +//! [`crate::beacon::containers::BeaconState`] carries a `Lean` variant and +//! [`crate::beacon::ForkName`] a `Lean` name, because `ethlambda-types` holds one +//! state type for both chains. Nothing in this module can transition either: a +//! fixture case, a gossiped beacon block and a genesis deposit set all produce +//! beacon states, so a lean value here means the caller dispatched on the wrong +//! chain. That is a bug above this module rather than an input it can reject, so +//! these arms panic instead of widening every signature to a `Result` no correct +//! caller could ever see. +//! +//! Both carry the same wording as `ethlambda_types::beacon`'s own +//! `lean_state_unreachable` and `lean_fork_unreachable`, which are `pub(crate)` +//! there and so cannot simply be imported. Two functions rather than one taking a +//! discriminant, for the reason that crate split its own: they are two unrelated +//! diagnoses, and rejoining them would only make each caller say which it meant. + +/// Panics, naming the beacon accessor a lean state reached. +/// +/// This is the state-shaped boundary: reaching it means a caller dispatched on +/// the wrong thing. For the fork-shaped one see [`lean_fork_unreachable`], kept +/// separate because the two are crossed by different mistakes. +/// +/// `#[track_caller]` so the panic still reports the arm's own file and line, the +/// way an `unreachable!` written inline there would have. +#[cold] +#[track_caller] +pub(crate) fn lean_state_unreachable(function: &str) -> ! { + unreachable!( + "lean state reached a beacon accessor ({function}); \ + BlockChainServer must dispatch on fork_name() before this point" + ) +} + +/// Panics, naming the Beacon Chain-only function [`crate::beacon::ForkName::Lean`] +/// reached. +/// +/// The fork-shaped counterpart to [`lean_state_unreachable`]: lean is not a point +/// on the beacon fork schedule at all, so no state is involved and the fault is +/// the argument the caller passed rather than the state it dispatched on. +#[cold] +#[track_caller] +pub(crate) fn lean_fork_unreachable(function: &str) -> ! { + unreachable!( + "ForkName::Lean reached a Beacon Chain function ({function}); \ + lean is not a point on the beacon fork schedule, so the caller \ + passed a fork it should have dispatched on first" + ) +} + +/// Panics, naming the Beacon Chain-only function a lean block reached. +/// +/// The block-shaped counterpart to [`lean_state_unreachable`], for the same +/// reason this module exists at all: `ethlambda_types`'s own +/// `lean_block_unreachable` is `pub(crate)` there and cannot be imported here. +/// [`crate::beacon::containers::SignedBeaconBlock`] carries a `Lean` variant so +/// the storage layer can take one block type; nothing in this crate's beacon +/// `stf` module transitions it, so reaching this function still means a caller +/// dispatched on the wrong chain. +#[cold] +#[track_caller] +pub(crate) fn lean_block_unreachable(function: &str) -> ! { + unreachable!( + "lean block reached a beacon accessor ({function}); \ + BlockChainServer must dispatch on fork_name() before this point" + ) +} diff --git a/crates/blockchain/state_transition/src/beacon/mod.rs b/crates/blockchain/state_transition/src/beacon/mod.rs new file mode 100644 index 000000000..fc9e41458 --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/mod.rs @@ -0,0 +1,101 @@ +//! The Ethereum Beacon Chain consensus specification, phase0 through fulu. +//! +//! This implements [`ethereum/consensus-specs`][specs]: the beacon state +//! transition function, the fork choice store, and the helpers and cryptography +//! they need. It is verified against the spec test fixtures released with the +//! specification, pinned at the version in the `Makefile`. +//! +//! The Beacon Chain is a different protocol from the Lean consensus the rest of +//! this crate implements, and this module implements only the former. The two sit +//! in one crate so that a caller dispatching on a state's fork can reach either +//! chain's rules without them living in separate dependency trees; they share no +//! code, nothing above this module reads anything inside it, and nothing here +//! reads lean's own modules. +//! +//! One thing they genuinely do share is [`ethlambda_types`], which carries the +//! beacon containers, presets, configuration and primitives under its own +//! `beacon` namespace. Those stayed out of this crate because the storage and +//! networking crates need them and must not depend on `blst` and `c-kzg`, which +//! this module does pull in. Everything they define is re-exported here at its +//! old path, so a use site inside this module names [`crate::beacon::containers`], [`crate::beacon::preset`] or +//! [`crate::beacon::config`] as if they were still local modules. +//! +//! Those two C libraries are consequently on the lean binary's dependency path, +//! since `ethlambda-blockchain` and `ethlambda-rpc` depend on this crate. This +//! module is not feature-gated, so that cost is unconditional; only the tests +//! that read the multi-gigabyte fixture tree are gated, behind +//! `beacon-spec-tests`. +//! +//! One consequence reaches every match in this module. `ethlambda-types` gives +//! [`crate::beacon::containers::BeaconState`] and [`crate::beacon::ForkName`] a `Lean` variant, so that one +//! state type can carry either chain. No lean value can reach this module through +//! a beacon fixture or a beacon block, so those arms panic through +//! `lean_boundary`'s two functions rather than returning a `Result`: reaching one +//! means a caller dispatched on the wrong chain, which is a bug in the caller and +//! not a state this code can transition. +//! +//! # How forks are represented +//! +//! Containers that change between forks are defined once per fork (in +//! `ethlambda-types`, re-exported here), as plain structs that derive their SSZ +//! encoding and merkleization, and wrapped in an enum +//! ([`crate::beacon::containers::BeaconState`] and friends). Deriving the SSZ traits is the +//! reason for that shape: the per-fork field lists are not a growing tail. +//! Two of phase0's fields are *replaced* in altair, one field changes type in +//! five separate forks, and the state's merkle tree gains a level at electra, so +//! a single container with fork-conditional serialization would have to +//! reproduce all of that by hand. Derived codecs get it from the struct +//! definition instead. +//! +//! State transition functions take the enum, read through the accessors +//! generated in `containers`, and match on the fork only where the +//! specification itself changes behavior, so a match arm can be reviewed +//! against the spec's own diff. Functions that exist only from some fork onward +//! return [`crate::beacon::Error::UnsupportedForFork`] for earlier ones. +//! +//! No `macro_rules!` is defined here at all. The ones covering bulk boilerplate, +//! chiefly the fork-invariant state accessors in `containers`, went to +//! `ethlambda-types` with the definitions they generate, and there are no +//! procedural macros beyond the SSZ derives. +//! +//! # Presets +//! +//! Container bounds are compile-time constants, so the preset is a compile-time +//! choice: mainnet by default, minimal with the `preset-minimal` feature. See +//! [`crate::beacon::preset`]. Values that only affect fork *scheduling* are runtime +//! configuration instead, in [`crate::beacon::config`], because the `transition` fixture suite +//! sets fork epochs per case. +//! +//! [specs]: https://github.com/ethereum/consensus-specs + +pub mod aggregate; +pub mod attestation_pool; +pub mod block_production; +pub mod bls; +pub mod das; +pub mod fork_choice; +pub mod genesis; +pub mod gossip; +pub mod hash; +pub mod helpers; +pub mod kzg; +pub mod precheck; +pub mod stf; +pub mod upgrade; + +mod lean_boundary; + +// The types this module transitions, at the paths they had while they lived here. +// A plain re-export rather than `pub mod x { pub use ... }` wrappers: this way +// `crate::beacon::containers::phase0::BeaconState` and +// `ethlambda_types::beacon::containers::phase0::BeaconState` are one type by one +// name, so a caller holding either spelling can hand it straight to this module. +pub use ethlambda_types::beacon::{ + committees, config, constants, containers, error, fork, preset, primitives, +}; + +pub use error::{Error, Result, verify}; +pub use fork::ForkName; +pub(crate) use lean_boundary::{ + lean_block_unreachable, lean_fork_unreachable, lean_state_unreachable, +}; diff --git a/crates/blockchain/state_transition/src/beacon/precheck.rs b/crates/blockchain/state_transition/src/beacon/precheck.rs new file mode 100644 index 000000000..f2c9ee0ae --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/precheck.rs @@ -0,0 +1,358 @@ +//! The rules a beacon block can be judged by before its state transition runs. +//! +//! The specification lists these under `beacon_block` gossip validation +//! (`p2p-interface.md`), where they decide whether a block is worth +//! forwarding at all: +//! +//! - `[REJECT]` The block is from a higher slot than its parent. +//! - `[REJECT]` The block is proposed by the expected `proposer_index` for the +//! block's slot in the context of the current shuffling. When that cannot be +//! verified immediately, the block "MAY be queued for later processing" and +//! is `IGNORE`d rather than rejected. +//! - `[REJECT]` The proposer signature is valid with respect to the +//! `proposer_index` pubkey. +//! +//! The state transition checks each of these again, in `process_block_header` +//! and [`crate::beacon::stf::verify_block_signature`], so a block that passes +//! here can still fail its import. What this adds is timing: none of the rules +//! needs the block's post-state, only its parent's, and the signature alone +//! can be judged against any recent state. A caller can therefore refuse a +//! block before doing anything else with it, such as parking it until its +//! parent or its data columns arrive, which is exactly when a block that would +//! fail its import costs the most to keep. + +use crate::beacon::bls; +use crate::beacon::config::Config; +use crate::beacon::constants::DOMAIN_BEACON_PROPOSER; +use crate::beacon::containers::{BeaconState, SignedBeaconBlock}; +use crate::beacon::helpers::misc::{ + compute_domain, compute_epoch_at_slot, compute_signing_root, compute_start_slot_at_epoch, +}; +use crate::beacon::primitives::{Root, Slot, ValidatorIndex}; + +/// Which rule a block broke. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum PrecheckError { + #[error("slot {slot} is not after its parent's slot {parent_slot}")] + NotAfterParent { slot: Slot, parent_slot: Slot }, + #[error( + "proposer {proposer} is not the proposer {expected} the parent's state fixed for this slot" + )] + WrongProposer { + proposer: ValidatorIndex, + expected: ValidatorIndex, + }, + #[error("proposer {proposer} names no validator in the parent's state")] + UnknownProposer { proposer: ValidatorIndex }, + #[error("the signature is not the proposer's over this block")] + BadSignature, +} + +impl PrecheckError { + /// A fixed name per rule, for a metric label: every variant maps to its + /// own constant, so a block's contents cannot add a label value. + pub fn label(&self) -> &'static str { + match self { + Self::NotAfterParent { .. } => "not_after_parent", + Self::WrongProposer { .. } => "wrong_proposer", + Self::UnknownProposer { .. } => "unknown_proposer", + Self::BadSignature => "bad_signature", + } + } +} + +/// The state a block is judged against. +#[derive(Debug, Clone, Copy)] +pub enum Reference<'a> { + /// The post-state of the block's own parent. Every rule applies. + Parent(&'a BeaconState), + /// Some recent state of the chain, for a block whose parent has no + /// post-state yet. Only the signature is checked, and only when this + /// state already holds the proposer. Validator indices never move, so a + /// key found here is the key the block was signed with; a validator newer + /// than this state is simply not one it can answer for, and such a block + /// passes rather than being refused on missing information. + Recent(&'a BeaconState), +} + +/// Judge `signed_block` by the rules in this module's documentation. +/// +/// `block_root` must be `signed_block.message_hash_tree_root()`. It is taken +/// rather than recomputed because every caller already has it, and a mainnet +/// block's root is a merkleization of the whole body. +/// +/// The signing domain comes from `config`'s fork schedule at the block's own +/// epoch, not from the reference state's `fork` field: the first block of a +/// new fork builds on a parent whose state has not been upgraded yet, and is +/// signed under the new fork's version. +pub fn precheck_block( + signed_block: &SignedBeaconBlock, + block_root: Root, + reference: Reference<'_>, + config: &Config, +) -> Result<(), PrecheckError> { + let slot = signed_block.slot(); + let proposer = signed_block.proposer_index(); + + let state = match reference { + Reference::Parent(parent_state) => { + let parent_slot = parent_state.slot(); + if slot <= parent_slot { + return Err(PrecheckError::NotAfterParent { slot, parent_slot }); + } + if let Some(expected) = fixed_proposer(parent_state, slot) + && expected != proposer + { + return Err(PrecheckError::WrongProposer { proposer, expected }); + } + parent_state + } + Reference::Recent(state) => state, + }; + + let pubkey = match (state.validator(proposer), reference) { + (Ok(validator), _) => &validator.pubkey, + (Err(_), Reference::Parent(_)) => { + return Err(PrecheckError::UnknownProposer { proposer }); + } + (Err(_), Reference::Recent(_)) => return Ok(()), + }; + + let fork_version = config.fork_version(config.fork_at_epoch(compute_epoch_at_slot(slot))); + let domain = compute_domain( + DOMAIN_BEACON_PROPOSER, + fork_version, + state.genesis_validators_root(), + ); + let signing_root = compute_signing_root(block_root, domain); + if !bls::verify(pubkey, signing_root, &signed_block.signature()) { + return Err(PrecheckError::BadSignature); + } + Ok(()) +} + +/// The proposer `parent_state` has already fixed for `slot`, if it has. +/// +/// Fulu's `proposer_lookahead` (EIP-7917) holds the proposers of the state's +/// current epoch and of the `MIN_SEED_LOOKAHEAD` epochs after it. Each entry +/// is fixed when it enters the window: epoch processing only shifts the +/// window along and appends a new last epoch. A slot inside the window is +/// therefore answered exactly as the state advanced to that slot would answer +/// it. Outside the window, and before fulu, answering means advancing the +/// state, which is the import's job; the specification lets such a block +/// through rather than rejecting it, and so does this. +pub(crate) fn fixed_proposer(parent_state: &BeaconState, slot: Slot) -> Option { + let BeaconState::Fulu(state) = parent_state else { + return None; + }; + let window_start = compute_start_slot_at_epoch(compute_epoch_at_slot(state.slot)); + let offset = usize::try_from(slot.checked_sub(window_start)?).ok()?; + state.proposer_lookahead.get(offset).copied() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::beacon::containers::phase0; + use crate::beacon::fork::ForkName; + use crate::beacon::helpers::test_state::{secret_key_for, with_validators_at}; + use crate::beacon::preset; + use crate::beacon::primitives::{BlsPubkey, BlsSignature}; + + /// A block by `proposer` at `slot`, signed by that validator's test key + /// under `config`'s fork version for the block's epoch. + /// + /// Phase0-shaped whatever the slot's fork: these rules read the header + /// fields and the block's root and never the body, so the cheapest body + /// to build stands in for a fork-coherent one. + fn signed_block( + state: &BeaconState, + config: &Config, + slot: Slot, + proposer: ValidatorIndex, + ) -> SignedBeaconBlock { + let mut block = phase0::SignedBeaconBlock { + message: phase0::BeaconBlock { + slot, + proposer_index: proposer, + parent_root: Root::repeat_byte(0x11), + state_root: Root::ZERO, + body: phase0::BeaconBlockBody { + randao_reveal: Default::default(), + eth1_data: Default::default(), + graffiti: Root::ZERO, + proposer_slashings: Default::default(), + attester_slashings: Default::default(), + attestations: Default::default(), + deposits: Default::default(), + voluntary_exits: Default::default(), + }, + }, + signature: Default::default(), + }; + let root = SignedBeaconBlock::Phase0(block.clone()).message_hash_tree_root(); + block.signature = sign(state, config, slot, proposer, root); + SignedBeaconBlock::Phase0(block) + } + + fn sign( + state: &BeaconState, + config: &Config, + slot: Slot, + signer: ValidatorIndex, + root: Root, + ) -> BlsSignature { + let fork_version = config.fork_version(config.fork_at_epoch(compute_epoch_at_slot(slot))); + let domain = compute_domain( + DOMAIN_BEACON_PROPOSER, + fork_version, + state.genesis_validators_root(), + ); + let signing_root = compute_signing_root(root, domain); + let signature = + secret_key_for(signer as usize).sign(signing_root.as_slice(), bls::DST, &[]); + BlsSignature(signature.to_bytes()) + } + + /// `with_validators_at(fork, 8)` with every validator given the public key + /// of [`secret_key_for`] its index. Only the phase0 builder derives real + /// keys; the later forks' leave them at the all-zero default, which no + /// signature verifies against. + fn keyed_state(fork: ForkName) -> BeaconState { + let mut state = with_validators_at(fork, 8); + for index in 0..8 { + let pubkey = secret_key_for(index).sk_to_pk().to_bytes(); + state + .validator_mut(index as ValidatorIndex) + .expect("the state has eight validators") + .pubkey = BlsPubkey(pubkey); + } + state + } + + /// A fulu state whose lookahead names `proposer` for every slot. + fn fulu_parent(proposer: ValidatorIndex) -> BeaconState { + let mut state = keyed_state(ForkName::Fulu); + if let BeaconState::Fulu(fulu_state) = &mut state { + for entry in fulu_state.proposer_lookahead.iter_mut() { + *entry = proposer; + } + } + state + } + + fn check( + block: &SignedBeaconBlock, + reference: Reference<'_>, + config: &Config, + ) -> Result<(), PrecheckError> { + precheck_block(block, block.message_hash_tree_root(), reference, config) + } + + #[test] + fn a_block_signed_by_its_expected_proposer_passes() { + let config = Config::mainnet(); + let parent = fulu_parent(3); + let block = signed_block(&parent, &config, parent.slot() + 1, 3); + assert_eq!(check(&block, Reference::Parent(&parent), &config), Ok(())); + } + + /// The shape seen on mainnet: a real block's bytes altered after signing + /// (one blob transaction re-encoded with its blobs), carrying the + /// original signature. The root it now has was never signed. + #[test] + fn a_block_altered_after_signing_is_refused() { + let config = Config::mainnet(); + let parent = fulu_parent(3); + let SignedBeaconBlock::Phase0(mut block) = + signed_block(&parent, &config, parent.slot() + 1, 3) + else { + unreachable!("signed_block builds a phase0 block"); + }; + block.message.body.graffiti = Root::repeat_byte(0xaa); + let altered = SignedBeaconBlock::Phase0(block); + assert_eq!( + check(&altered, Reference::Parent(&parent), &config), + Err(PrecheckError::BadSignature) + ); + // Judged against a recent state instead, for a block whose parent has + // not arrived, the signature alone still gives it away. + assert_eq!( + check(&altered, Reference::Recent(&parent), &config), + Err(PrecheckError::BadSignature) + ); + } + + #[test] + fn a_validly_signed_block_from_the_wrong_proposer_is_refused() { + let config = Config::mainnet(); + let parent = fulu_parent(3); + let block = signed_block(&parent, &config, parent.slot() + 1, 5); + assert_eq!( + check(&block, Reference::Parent(&parent), &config), + Err(PrecheckError::WrongProposer { + proposer: 5, + expected: 3 + }) + ); + } + + #[test] + fn a_slot_past_the_lookahead_skips_the_proposer_rule_but_not_the_signature() { + let config = Config::mainnet(); + let parent = fulu_parent(3); + let window_start = compute_start_slot_at_epoch(compute_epoch_at_slot(parent.slot())); + let beyond = window_start + preset::PROPOSER_LOOKAHEAD_LENGTH as Slot; + let block = signed_block(&parent, &config, beyond, 5); + assert_eq!(check(&block, Reference::Parent(&parent), &config), Ok(())); + } + + #[test] + fn a_block_not_after_its_parent_is_refused() { + let config = Config::mainnet(); + let parent = fulu_parent(3); + let block = signed_block(&parent, &config, parent.slot(), 3); + assert_eq!( + check(&block, Reference::Parent(&parent), &config), + Err(PrecheckError::NotAfterParent { + slot: parent.slot(), + parent_slot: parent.slot() + }) + ); + } + + #[test] + fn a_proposer_missing_from_the_parents_state_is_refused_but_not_from_a_recent_one() { + let config = Config::mainnet(); + // A pre-fulu parent, so the lookahead cannot answer first. + let parent = keyed_state(ForkName::Electra); + let block = signed_block(&parent, &config, parent.slot() + 1, 3); + let SignedBeaconBlock::Phase0(mut unknown) = block else { + unreachable!("signed_block builds a phase0 block"); + }; + unknown.message.proposer_index = 100; + let unknown = SignedBeaconBlock::Phase0(unknown); + assert_eq!( + check(&unknown, Reference::Parent(&parent), &config), + Err(PrecheckError::UnknownProposer { proposer: 100 }) + ); + assert_eq!(check(&unknown, Reference::Recent(&parent), &config), Ok(())); + } + + /// The first block of a fork is signed under the new version while its + /// parent's state still carries the old `fork`, so reading the domain off + /// the state would refuse every fork's first block. + #[test] + fn the_first_block_of_a_fork_is_judged_under_the_new_forks_version() { + let parent = keyed_state(ForkName::Electra); + let next_epoch = compute_epoch_at_slot(parent.slot()) + 1; + let config = Config::mainnet().with_fork_epoch(ForkName::Fulu, next_epoch); + let first_fulu_slot = compute_start_slot_at_epoch(next_epoch); + assert_ne!( + config.fork_version(ForkName::Fulu), + config.fork_version(ForkName::Electra) + ); + let block = signed_block(&parent, &config, first_fulu_slot, 3); + assert_eq!(check(&block, Reference::Parent(&parent), &config), Ok(())); + } +} diff --git a/crates/blockchain/state_transition/src/beacon/stf/altair.rs b/crates/blockchain/state_transition/src/beacon/stf/altair.rs new file mode 100644 index 000000000..22b320f52 --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/stf/altair.rs @@ -0,0 +1,780 @@ +//! Altair's two block-processing changes. +//! +//! Phase0 cannot score an attestation the moment it arrives: whether an +//! attester's vote turns out to matter depends on facts that are not settled +//! until the epoch boundary (whether the target became the epoch's canonical +//! block, whether the chain finalized at all), so phase0 defers scoring by +//! appending a whole `PendingAttestation` to the state and replaying the +//! backlog in [`crate::beacon::stf::epoch`]. Altair restructures the state instead of +//! the schedule: [`crate::beacon::helpers::altair::get_attestation_participation_flag_indices`] +//! already knows, the moment an attestation is processed, exactly which of the +//! three timeliness conditions it satisfies, so this module's +//! [`process_attestation`] scores it right there, flips the matching +//! participation bits, and pays the including proposer immediately rather than +//! waiting for an epoch boundary to replay anything. That is both cheaper (one +//! bit per validator per epoch instead of a growing list of whole +//! attestations) and bounded (the participation record can never grow past the +//! size of the validator registry, unlike phase0's attestation lists). +//! +//! [`process_sync_aggregate`] is new in altair, with no phase0 analogue: it +//! pays the rotating sync committee (see [`crate::beacon::helpers::altair`] for how +//! that committee is drawn) for having signed the previous slot's block root. +//! The reward split between the committee and the proposer, and between reward +//! and penalty, is fixed entirely by [`crate::beacon::constants`]'s weight constants +//! rather than anything computed here, so a non-participating member is +//! charged precisely the `participant_reward` a participating one earns: the +//! two are the same number applied with opposite sign, not two independently +//! derived quantities that happen to match. + +use crate::beacon::bls; +use crate::beacon::constants; +use crate::beacon::containers::{BeaconState, altair, phase0}; +use crate::beacon::error::{Error, Result, verify}; +use crate::beacon::helpers::accessors::{ + CommitteeCache, CommitteeCacheExt, get_beacon_proposer_index, get_block_root_at_slot, + get_current_epoch, get_domain, get_previous_epoch, get_total_active_balance, +}; +use crate::beacon::helpers::altair::{ + add_flag, get_attestation_participation_flag_indices, get_base_reward_per_increment, has_flag, +}; +use crate::beacon::helpers::attestation::{get_indexed_attestation, is_valid_indexed_attestation}; +use crate::beacon::helpers::misc::{compute_epoch_at_slot, compute_signing_root}; +use crate::beacon::helpers::mutators::{decrease_balance, increase_balance}; +use crate::beacon::preset; +use crate::beacon::primitives::{BlsPubkey, Gwei, ParticipationFlags, ValidatorIndex}; +use std::collections::HashMap; + +// --------------------------------------------------------------------------- +// Attestations +// --------------------------------------------------------------------------- + +/// Scores an attestation against the three timeliness conditions and pays the +/// including proposer for whichever of them it newly satisfies. +/// +/// Shares its entire validation prologue with phase0's +/// [`crate::beacon::stf::operations::process_attestation`] (target epoch, inclusion +/// window, committee shape, then the signature): none of that changed in +/// altair. What changed is what happens to a *valid* attestation. Phase0 +/// appends it, whole, to one of two epoch-scoped lists and leaves every reward +/// decision for the epoch boundary. Altair instead asks +/// [`get_attestation_participation_flag_indices`] which of the three +/// timeliness flags this attestation satisfies, sets exactly the ones each +/// attester does not already have (a repeat vote earns nothing twice), and +/// converts each newly-set flag directly into a share of the proposer's +/// reward, paid before this function returns rather than at the next epoch +/// boundary. +/// +/// Reading which flags are already set and computing the reward for newly-set +/// ones (`get_base_reward`) only ever needs `&state`, while flipping the bits +/// needs `&mut state`; those two cannot be interleaved in one pass the way the +/// specification's single loop does, since holding the mutable participation +/// borrow across a call that needs to read the rest of `state` does not +/// borrow-check (see `process_effective_balance_updates` in +/// `crate::beacon::stf::epoch` for the same trade-off spelled out in more depth). This +/// runs as two passes instead: a read phase that decides which flags each +/// attester newly earns and totals the proposer's reward for them, then a +/// write phase that only ever applies what the first phase already decided. +/// +/// Takes no [`crate::beacon::config::Config`]: every quantity this needs is either a +/// [`crate::beacon::constants`] weight or a [`preset`] value, and the specification +/// lists none of them under a network's "Configuration" table. +pub fn process_attestation( + state: &mut BeaconState, + attestation: &phase0::Attestation, + committees: &CommitteeCache, +) -> Result<()> { + let data = attestation.data; + let current_epoch = get_current_epoch(state); + let previous_epoch = get_previous_epoch(state); + + verify( + data.target.epoch == previous_epoch || data.target.epoch == current_epoch, + "data.target.epoch in (get_previous_epoch(state), get_current_epoch(state))", + )?; + verify( + data.target.epoch == compute_epoch_at_slot(data.slot), + "data.target.epoch == compute_epoch_at_slot(data.slot)", + )?; + + // Both bounds are driven directly by `data.slot`, which comes straight off + // the wire, so a hostile value close to `u64::MAX` must not be allowed to + // wrap either bound into something that vacuously accepts the attestation. + let min_slot = data + .slot + .checked_add(preset::MIN_ATTESTATION_INCLUSION_DELAY) + .ok_or(Error::ArithmeticOverflow( + "data.slot + MIN_ATTESTATION_INCLUSION_DELAY", + ))?; + let max_slot = data + .slot + .checked_add(preset::SLOTS_PER_EPOCH) + .ok_or(Error::ArithmeticOverflow("data.slot + SLOTS_PER_EPOCH"))?; + verify( + min_slot <= state.slot() && state.slot() <= max_slot, + "data.slot + MIN_ATTESTATION_INCLUSION_DELAY <= state.slot <= data.slot + SLOTS_PER_EPOCH", + )?; + // The committee count read off the shared shuffling rather than through + // `get_committee_count_per_slot`, which would scan the whole registry per + // attestation for the same value. + let epoch_committees = committees.committees(state, data.target.epoch); + verify( + data.index < epoch_committees.committees_per_slot(), + "data.index < get_committee_count_per_slot(state, data.target.epoch)", + )?; + + let committee_len = epoch_committees.committee(data.slot, data.index)?.len(); + verify( + attestation.aggregation_bits.len() == committee_len, + "len(attestation.aggregation_bits) == len(committee)", + )?; + + // Safe: `min_slot <= state.slot()` above and `min_slot >= data.slot` (the + // inclusion delay is non-negative), so `data.slot <= state.slot()`. + let inclusion_delay = state.slot() - data.slot; + let participation_flag_indices = + get_attestation_participation_flag_indices(state, &data, inclusion_delay)?; + + let indexed_attestation = get_indexed_attestation(state, attestation, committees)?; + verify( + is_valid_indexed_attestation(state, &indexed_attestation), + "is_valid_indexed_attestation(state, get_indexed_attestation(state, attestation))", + )?; + + // Read phase: for every attester, decide which flags this attestation + // newly satisfies and add up the proposer's reward for granting them. + // Everything here only ever reads `state`, so it can run to completion + // before the write phase below needs a mutable borrow of the same + // participation list. + // + // `indexed_attestation`'s indices rather than a second + // `get_attesting_indices` call, for the reason given on the same line in + // `electra::process_attestation`. + let attesting_indices = indexed_attestation.attesting_indices.to_vec(); + let current_epoch_target = data.target.epoch == current_epoch; + let (previous_epoch_participation, current_epoch_participation, _) = + state.altair_validator_lists()?; + let epoch_participation = if current_epoch_target { + current_epoch_participation + } else { + previous_epoch_participation + }; + + // Hoisted: see the comment on the same line in `electra::process_attestation`. + let base_reward_per_increment = get_base_reward_per_increment(state)?; + + let mut proposer_reward_numerator: Gwei = 0; + let mut updates: Vec<(ValidatorIndex, ParticipationFlags)> = Vec::new(); + for index in attesting_indices { + let current_flags = + epoch_participation + .get(index as usize) + .copied() + .ok_or(Error::IndexOutOfBounds { + index: index as usize, + len: epoch_participation.len(), + })?; + + let mut new_flags: ParticipationFlags = 0; + for &flag_index in &participation_flag_indices { + if has_flag(current_flags, flag_index) { + continue; + } + new_flags = add_flag(new_flags, flag_index); + let weight = constants::PARTICIPATION_FLAG_WEIGHTS[flag_index]; + // `get_base_reward(state, index)` inlined against the hoisted + // per-increment value, in the helper's own order of operations so + // the result is bit-identical. See `electra::process_attestation` + // for why the hoist is not a tidy-up but the difference between + // importing a block at mainnet scale and not. + let increments = + state.validator(index)?.effective_balance / preset::EFFECTIVE_BALANCE_INCREMENT; + let reward = (increments * base_reward_per_increment) + .checked_mul(weight) + .ok_or(Error::ArithmeticOverflow( + "get_base_reward(state, index) * weight", + ))?; + proposer_reward_numerator = proposer_reward_numerator + .checked_add(reward) + .ok_or(Error::ArithmeticOverflow("proposer_reward_numerator"))?; + } + if new_flags != 0 { + updates.push((index, new_flags)); + } + } + + // Write phase: apply exactly the flags the read phase decided on. Nothing + // from here on reads `state` any further, so this is free to take the + // mutable borrow the read phase could not. + let (previous_epoch_participation, current_epoch_participation, _) = + state.altair_validator_lists_mut()?; + let epoch_participation = if current_epoch_target { + current_epoch_participation + } else { + previous_epoch_participation + }; + let epoch_participation_len = epoch_participation.len(); + for (index, new_flags) in updates { + let flags = epoch_participation + .get_mut(index as usize) + .ok_or(Error::IndexOutOfBounds { + index: index as usize, + len: epoch_participation_len, + })?; + *flags |= new_flags; + } + + // The weights partition `WEIGHT_DENOMINATOR` between the three flags, the + // sync committee, and the proposer (see `crate::beacon::constants`'s module + // documentation), so taking the proposer's share out of the denominator + // and multiplying back up by the full denominator is what turns "the + // proposer's flat share of a reward" into the multiplier that inflates a + // validator's own reward for a flag into what the proposer earns for + // having included the vote that granted it. + const NON_PROPOSER_WEIGHT: u64 = constants::WEIGHT_DENOMINATOR - constants::PROPOSER_WEIGHT; + const PROPOSER_REWARD_DENOMINATOR: u64 = + NON_PROPOSER_WEIGHT * constants::WEIGHT_DENOMINATOR / constants::PROPOSER_WEIGHT; + let proposer_reward = proposer_reward_numerator / PROPOSER_REWARD_DENOMINATOR; + let proposer_index = get_beacon_proposer_index(state)?; + increase_balance(state, proposer_index, proposer_reward)?; + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Sync aggregate +// --------------------------------------------------------------------------- + +/// Verifies the sync committee's aggregate signature over the previous slot's +/// block root, then pays each participating committee member (and the +/// proposer, once per participant) and penalizes each one that did not. +/// +/// The specification chooses which pubkeys to aggregate by a three-way branch: +/// reuse the committee's precomputed `aggregate_pubkey` when everyone signed, +/// subtract the non-participants from it via elliptic-curve point negation +/// when more than half did, or aggregate the participants directly otherwise. +/// All three branches assert the identical claim, that the signature verifies +/// against the aggregate of exactly the participating members, so the branch +/// is a `blst`-level performance optimization rather than a difference in what +/// is checked. [`crate::beacon::bls`] has no point-subtraction primitive (nothing else +/// in this module needs one) and its own aggregation already batches every +/// point in a single pass, so this collapses the three cases into one direct +/// aggregation of the participating members' pubkeys. +/// [`bls::eth_fast_aggregate_verify`]'s own convention for an empty list +/// (accept only when the signature is the point at infinity) covers the case +/// nobody participated exactly the way the specification's own "less than +/// half" branch does when zero bits are set. +/// +/// Every reward and penalty here is the same `participant_reward`, applied +/// with opposite sign: [`crate::beacon::constants::SYNC_REWARD_WEIGHT`] and +/// [`crate::beacon::constants::PROPOSER_WEIGHT`] fix the whole split ahead of time, so +/// there is no independently-derived penalty formula that could drift out of +/// sync with the reward one. +/// +/// Resolving each committee seat's validator index needs a linear scan of +/// `state.validators` (sync committee membership is recorded as pubkeys, not +/// indices), and the specification does that scan inside the very loop that +/// also calls `get_beacon_proposer_index` and adjusts balances. Doing the scan +/// there would need `state` borrowed immutably (to search) and mutably (to +/// pay or penalize) at the same time, which does not borrow-check; this +/// resolves every seat's index, and the proposer's index, into owned values +/// first, then runs the payment loop against those, the same shape +/// `process_effective_balance_updates` (`crate::beacon::stf::epoch`) uses for the +/// identical reason. +/// +/// Takes no [`crate::beacon::config::Config`]: [`constants::DOMAIN_SYNC_COMMITTEE`] +/// and the weight constants are fixed, and [`preset::SYNC_COMMITTEE_SIZE`], +/// [`preset::EFFECTIVE_BALANCE_INCREMENT`], and [`preset::SLOTS_PER_EPOCH`] +/// are preset values; the specification lists none of them under a network's +/// "Configuration" table. +pub fn process_sync_aggregate( + state: &mut BeaconState, + sync_aggregate: &altair::SyncAggregate, +) -> Result<()> { + let committee_bits = &sync_aggregate.sync_committee_bits; + + let (current_sync_committee, _) = state.sync_committees()?; + let committee_pubkeys = ¤t_sync_committee.pubkeys; + + let mut participant_pubkeys = Vec::new(); + for (position, pubkey) in committee_pubkeys.iter().enumerate() { + if committee_bits.get(position).unwrap_or(false) { + participant_pubkeys.push(*pubkey); + } + } + + // `max(state.slot, 1) - 1`, rewritten as a saturating subtraction: the two + // are equal for every `Slot`, including genesis, and this way there is no + // intermediate `max` to explain. + let previous_slot = state.slot().saturating_sub(1); + let domain = get_domain( + state, + constants::DOMAIN_SYNC_COMMITTEE, + Some(compute_epoch_at_slot(previous_slot)), + ); + let signing_root = compute_signing_root(get_block_root_at_slot(state, previous_slot)?, domain); + verify( + bls::eth_fast_aggregate_verify( + &participant_pubkeys, + signing_root, + &sync_aggregate.sync_committee_signature, + ), + "eth_fast_aggregate_verify(participant_pubkeys, signing_root, sync_aggregate.sync_committee_signature)", + )?; + + // Resolve every committee seat's validator index up front; see this + // function's documentation for why the mutation loop below cannot do its + // own scan of `state.validators` the way the specification's single loop + // does. + // + // One pass over the registry, not one per seat. The specification writes + // this as a search per committee member, which is `SYNC_COMMITTEE_SIZE` + // scans of the whole validator set: at mainnet's ~1M validators that is + // hundreds of millions of 48-byte pubkey comparisons per block, and it was + // measured as the single largest cost in a live import, dwarfing the + // signature verification above it. Inverting the loop makes it one scan + // against a committee-sized map. + // + // A pubkey may hold more than one seat, since sync-committee selection + // samples with replacement, so each key maps to every seat it occupies + // rather than to one. + let mut seats_by_pubkey: HashMap<&BlsPubkey, Vec> = HashMap::new(); + for (seat, pubkey) in committee_pubkeys.iter().enumerate() { + seats_by_pubkey.entry(pubkey).or_default().push(seat); + } + let mut resolved: Vec> = vec![None; committee_pubkeys.len()]; + for (index, validator) in state.validators().iter().enumerate() { + if let Some(seats) = seats_by_pubkey.get(&validator.pubkey) { + for &seat in seats { + resolved[seat] = Some(index as ValidatorIndex); + } + } + } + let committee_indices: Vec = resolved + .into_iter() + .map(|index| { + index.ok_or(Error::SpecAssert( + "state.current_sync_committee.pubkeys[i] in [v.pubkey for v in state.validators]", + )) + }) + .collect::>>()?; + + let total_active_increments = + get_total_active_balance(state)? / preset::EFFECTIVE_BALANCE_INCREMENT; + let total_base_rewards = get_base_reward_per_increment(state)? + .checked_mul(total_active_increments) + .ok_or(Error::ArithmeticOverflow( + "get_base_reward_per_increment(state) * total_active_increments", + ))?; + let max_participant_rewards = total_base_rewards + .checked_mul(constants::SYNC_REWARD_WEIGHT) + .ok_or(Error::ArithmeticOverflow( + "total_base_rewards * SYNC_REWARD_WEIGHT", + ))? + / constants::WEIGHT_DENOMINATOR + / preset::SLOTS_PER_EPOCH; + let participant_reward = max_participant_rewards / preset::SYNC_COMMITTEE_SIZE as u64; + + const NON_PROPOSER_WEIGHT: u64 = constants::WEIGHT_DENOMINATOR - constants::PROPOSER_WEIGHT; + let proposer_reward = participant_reward + .checked_mul(constants::PROPOSER_WEIGHT) + .ok_or(Error::ArithmeticOverflow( + "participant_reward * PROPOSER_WEIGHT", + ))? + / NON_PROPOSER_WEIGHT; + + // Stable across every iteration below: nothing in this loop changes the + // slot or the seed a proposer is drawn from, so resolving it once here + // (rather than once per seat, as the specification's own pseudocode does) + // is a straightforward optimization, not a behavior change. + let proposer_index = get_beacon_proposer_index(state)?; + + for (position, participant_index) in committee_indices.into_iter().enumerate() { + if committee_bits.get(position).unwrap_or(false) { + increase_balance(state, participant_index, participant_reward)?; + increase_balance(state, proposer_index, proposer_reward)?; + } else { + decrease_balance(state, participant_index, participant_reward)?; + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use blst::min_pk::SecretKey; + + use super::*; + use crate::beacon::containers::altair::SyncCommittee; + use crate::beacon::containers::shared::{AttestationData, Checkpoint, Validator}; + use crate::beacon::helpers::accessors::get_beacon_committee; + use crate::beacon::primitives::{ + BLS_SIGNATURE_SIZE, BlsPubkey, BlsSignature, Bytes32, HashTreeRoot as _, Root, + }; + + /// An altair state with `count` fully active, full-balance validators, each + /// with a real (signable) BLS keypair, positioned one epoch in so the + /// previous epoch and the block-root history window both have entries. + /// + /// A near-duplicate of `crate::beacon::helpers::altair::tests::altair_state_with_validators`, + /// which builds the same shape of state but with placeholder (unsigned, + /// invalid) keys; that builder cannot be reused here since it is private to + /// its module and returns no secret keys, and this module has no shared + /// altair test-state builder yet. The two should probably be merged into + /// one the next time either needs a change. + fn altair_state_with_keypairs(count: usize) -> (BeaconState, Vec) { + let secret_keys: Vec = (0..count) + .map(|index| { + let mut ikm = [0u8; 32]; + ikm[..8].copy_from_slice(&(index as u64 + 1).to_le_bytes()); + SecretKey::key_gen(&ikm, &[]) + .expect("32 bytes of input material is enough for key generation") + }) + .collect(); + + let validators: Vec = secret_keys + .iter() + .map(|secret_key| Validator { + pubkey: BlsPubkey(secret_key.sk_to_pk().to_bytes()), + effective_balance: preset::MAX_EFFECTIVE_BALANCE, + activation_eligibility_epoch: 0, + activation_epoch: 0, + exit_epoch: constants::FAR_FUTURE_EPOCH, + withdrawable_epoch: constants::FAR_FUTURE_EPOCH, + ..Default::default() + }) + .collect(); + + let empty_sync_committee = || SyncCommittee { + pubkeys: vec![BlsPubkey::default(); preset::SYNC_COMMITTEE_SIZE] + .try_into() + .expect("built at exactly SYNC_COMMITTEE_SIZE"), + aggregate_pubkey: BlsPubkey::default(), + }; + + let state = BeaconState::Altair(altair::BeaconState { + genesis_time: 0, + genesis_validators_root: Root::ZERO, + slot: preset::SLOTS_PER_EPOCH, + fork: Default::default(), + latest_block_header: Default::default(), + block_roots: vec![Root::ZERO; preset::SLOTS_PER_HISTORICAL_ROOT] + .try_into() + .expect("the vector is built at its exact length"), + state_roots: vec![Root::ZERO; preset::SLOTS_PER_HISTORICAL_ROOT] + .try_into() + .expect("the vector is built at its exact length"), + historical_roots: Default::default(), + eth1_data: Default::default(), + eth1_data_votes: Default::default(), + eth1_deposit_index: 0, + validators: validators + .try_into() + .expect("count is far below VALIDATOR_REGISTRY_LIMIT"), + balances: vec![preset::MAX_EFFECTIVE_BALANCE; count] + .try_into() + .expect("count is far below VALIDATOR_REGISTRY_LIMIT"), + randao_mixes: vec![Bytes32::ZERO; preset::EPOCHS_PER_HISTORICAL_VECTOR] + .try_into() + .expect("the vector is built at its exact length"), + slashings: vec![0; preset::EPOCHS_PER_SLASHINGS_VECTOR] + .try_into() + .expect("the vector is built at its exact length"), + previous_epoch_participation: vec![0; count] + .try_into() + .expect("count is far below VALIDATOR_REGISTRY_LIMIT"), + current_epoch_participation: vec![0; count] + .try_into() + .expect("count is far below VALIDATOR_REGISTRY_LIMIT"), + justification_bits: Default::default(), + previous_justified_checkpoint: Default::default(), + current_justified_checkpoint: Default::default(), + finalized_checkpoint: Default::default(), + inactivity_scores: vec![0; count] + .try_into() + .expect("count is far below VALIDATOR_REGISTRY_LIMIT"), + current_sync_committee: empty_sync_committee(), + next_sync_committee: empty_sync_committee(), + }); + + (state, secret_keys) + } + + /// `specs/altair/bls.md`'s `G2_POINT_AT_INFINITY`: the compressed encoding + /// of the identity element of G2, and the only signature + /// `eth_fast_aggregate_verify` accepts for an empty participant list. + fn g2_point_at_infinity() -> BlsSignature { + let mut bytes = [0u8; BLS_SIGNATURE_SIZE]; + bytes[0] = 0b1100_0000; + BlsSignature(bytes) + } + + #[test] + fn process_attestation_sets_new_flags_once_and_pays_the_proposer_only_for_them() { + let (mut state, secret_keys) = altair_state_with_keypairs(64); + + // The last slot of the previous epoch: `state.slot()` sits at + // `SLOTS_PER_EPOCH` (one epoch in, matching `altair_state_with_keypairs`), + // so an attestation for this slot has an inclusion delay of exactly + // `MIN_ATTESTATION_INCLUSION_DELAY`, which is what makes it eligible + // for all three timeliness flags rather than just source and target. + let data_slot = preset::SLOTS_PER_EPOCH - 1; + let target_epoch = compute_epoch_at_slot(data_slot); + assert_eq!(target_epoch, 0, "the previous epoch, by construction"); + + let committee = get_beacon_committee(&state, data_slot, 0).expect("committee exists"); + assert!( + !committee.is_empty(), + "64 validators spread finely enough across the shuffle that every \ + slot's first committee has at least one member, under either preset" + ); + + let data = AttestationData { + slot: data_slot, + index: 0, + // Matches the all-zero synthetic block-root history exactly, which + // is what makes both the target and the head vote "matching". + beacon_block_root: Root::ZERO, + source: Checkpoint::default(), + target: Checkpoint { + epoch: target_epoch, + root: Root::ZERO, + }, + }; + + let mut aggregation_bits = + crate::beacon::containers::phase0::AggregationBits::with_length(committee.len()) + .expect("committee.len() is far below MAX_VALIDATORS_PER_COMMITTEE"); + for position in 0..committee.len() { + aggregation_bits.set(position, true).unwrap(); + } + + let domain = get_domain( + &state, + constants::DOMAIN_BEACON_ATTESTER, + Some(target_epoch), + ); + let signing_root = compute_signing_root(data.hash_tree_root(), domain); + const DST: &[u8] = b"BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_"; + let signatures: Vec = committee + .iter() + .map(|&index| { + BlsSignature( + secret_keys[index as usize] + .sign(signing_root.as_slice(), DST, &[]) + .to_bytes(), + ) + }) + .collect(); + let signature = bls::aggregate(&signatures).unwrap(); + + let attestation = phase0::Attestation { + aggregation_bits, + data, + signature, + }; + + let proposer_index = get_beacon_proposer_index(&state).unwrap(); + let balance_before = state.balance(proposer_index).unwrap(); + + process_attestation(&mut state, &attestation, &CommitteeCache::default()).unwrap(); + + let (previous_epoch_participation, _, _) = state.altair_validator_lists().unwrap(); + for &index in &committee { + let flags = previous_epoch_participation[index as usize]; + assert!( + has_flag(flags, constants::TIMELY_SOURCE_FLAG_INDEX), + "validator {index} should have earned the timely-source flag" + ); + assert!( + has_flag(flags, constants::TIMELY_TARGET_FLAG_INDEX), + "validator {index} should have earned the timely-target flag" + ); + assert!( + has_flag(flags, constants::TIMELY_HEAD_FLAG_INDEX), + "validator {index} should have earned the timely-head flag, since \ + the inclusion delay is exactly MIN_ATTESTATION_INCLUSION_DELAY" + ); + } + + let balance_after = state.balance(proposer_index).unwrap(); + assert!( + balance_after > balance_before, + "the proposer must be paid for newly granting three flags to every attester" + ); + + // Idempotency: every flag this attestation could grant is already set, + // so reprocessing the identical attestation must grant nothing new, and + // the proposer's balance must not move. + let balance_before_replay = state.balance(proposer_index).unwrap(); + process_attestation(&mut state, &attestation, &CommitteeCache::default()).unwrap(); + let balance_after_replay = state.balance(proposer_index).unwrap(); + assert_eq!( + balance_before_replay, balance_after_replay, + "a repeat vote must earn nothing, since every flag it could set is already set" + ); + } + + #[test] + fn process_sync_aggregate_rewards_participants_and_penalizes_a_repeated_non_participant() { + // Five validators: the first four hold one participating seat each, + // and the fifth's key fills every one of the remaining committee + // seats, all marked non-participating. That exercises the same + // validator index being resolved, and paid or penalized, more than + // once per call, which is exactly what the borrow-checker fix (resolve + // every seat's index before the mutation loop) has to get right. + let (mut state, secret_keys) = altair_state_with_keypairs(5); + + let participant_pubkeys: Vec = secret_keys[0..4] + .iter() + .map(|secret_key| BlsPubkey(secret_key.sk_to_pk().to_bytes())) + .collect(); + let filler_pubkey = BlsPubkey(secret_keys[4].sk_to_pk().to_bytes()); + + let mut pubkeys_vec = participant_pubkeys.clone(); + pubkeys_vec.resize(preset::SYNC_COMMITTEE_SIZE, filler_pubkey); + let aggregate_pubkey = bls::eth_aggregate_pubkeys(&pubkeys_vec).unwrap(); + let pubkeys = pubkeys_vec + .try_into() + .expect("built at exactly SYNC_COMMITTEE_SIZE"); + + let mut sync_committee_bits = altair::SyncCommitteeBits::default(); + for position in 0..participant_pubkeys.len() { + sync_committee_bits.set(position, true).unwrap(); + } + + { + let (current_sync_committee, _) = state.sync_committees_mut().unwrap(); + *current_sync_committee = SyncCommittee { + pubkeys, + aggregate_pubkey, + }; + } + + let previous_slot = state.slot() - 1; + let domain = get_domain( + &state, + constants::DOMAIN_SYNC_COMMITTEE, + Some(compute_epoch_at_slot(previous_slot)), + ); + // Matches the all-zero synthetic block-root history. + let signing_root = compute_signing_root(Root::ZERO, domain); + + const DST: &[u8] = b"BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_"; + let signatures: Vec = secret_keys[0..4] + .iter() + .map(|secret_key| { + BlsSignature( + secret_key + .sign(signing_root.as_slice(), DST, &[]) + .to_bytes(), + ) + }) + .collect(); + let sync_committee_signature = bls::aggregate(&signatures).unwrap(); + + let sync_aggregate = altair::SyncAggregate { + sync_committee_bits, + sync_committee_signature, + }; + + let proposer_index = get_beacon_proposer_index(&state).unwrap(); + let balances_before: Vec = (0..5u64) + .map(|index| state.balance(index).unwrap()) + .collect(); + + process_sync_aggregate(&mut state, &sync_aggregate).unwrap(); + + // Independently derived expected rewards, sharing only the + // already-tested building blocks (`get_total_active_balance`, + // `get_base_reward_per_increment`) with `process_sync_aggregate` + // itself, not the arithmetic under test. + let total_active_increments = + get_total_active_balance(&state).unwrap() / preset::EFFECTIVE_BALANCE_INCREMENT; + let total_base_rewards = + get_base_reward_per_increment(&state).unwrap() * total_active_increments; + let max_participant_rewards = total_base_rewards * constants::SYNC_REWARD_WEIGHT + / constants::WEIGHT_DENOMINATOR + / preset::SLOTS_PER_EPOCH; + let participant_reward = max_participant_rewards / preset::SYNC_COMMITTEE_SIZE as u64; + let proposer_reward = participant_reward * constants::PROPOSER_WEIGHT + / (constants::WEIGHT_DENOMINATOR - constants::PROPOSER_WEIGHT); + + let filler_seats = (preset::SYNC_COMMITTEE_SIZE - 4) as u64; + for index in 0..5u64 { + let mut expected = balances_before[index as usize] as i128; + if index < 4 { + expected += participant_reward as i128; + } else { + expected -= (participant_reward * filler_seats) as i128; + } + if index == proposer_index { + expected += (proposer_reward * 4) as i128; + } + assert_eq!( + state.balance(index).unwrap() as i128, + expected, + "validator {index}'s balance did not match its expected reward or penalty" + ); + } + } + + #[test] + fn process_sync_aggregate_rejects_a_non_infinity_signature_for_zero_participants() { + let (mut state, secret_keys) = altair_state_with_keypairs(1); + let filler_pubkey = BlsPubkey(secret_keys[0].sk_to_pk().to_bytes()); + let pubkeys_vec = vec![filler_pubkey; preset::SYNC_COMMITTEE_SIZE]; + let aggregate_pubkey = bls::eth_aggregate_pubkeys(&pubkeys_vec).unwrap(); + let pubkeys = pubkeys_vec + .try_into() + .expect("built at exactly SYNC_COMMITTEE_SIZE"); + + { + let (current_sync_committee, _) = state.sync_committees_mut().unwrap(); + *current_sync_committee = SyncCommittee { + pubkeys, + aggregate_pubkey, + }; + } + + // Every bit left at zero: nobody participated, so + // `eth_fast_aggregate_verify` demands the point-at-infinity signature + // and nothing else, matching the specification's convention for an + // aggregate over an empty key set. + let sync_committee_bits = altair::SyncCommitteeBits::default(); + + let wrong = altair::SyncAggregate { + sync_committee_bits: sync_committee_bits.clone(), + sync_committee_signature: BlsSignature::default(), + }; + assert!( + process_sync_aggregate(&mut state.clone(), &wrong).is_err(), + "the all-zero signature is not the point at infinity, so this must be rejected" + ); + + let correct = altair::SyncAggregate { + sync_committee_bits, + sync_committee_signature: g2_point_at_infinity(), + }; + let balance_before = state.balance(0).unwrap(); + process_sync_aggregate(&mut state, &correct).unwrap(); + + // The sole validator holds every seat and none of them participated, + // so it is penalized once per seat and the proposer (itself, the only + // validator in this registry) earns nothing extra. + let total_active_increments = + get_total_active_balance(&state).unwrap() / preset::EFFECTIVE_BALANCE_INCREMENT; + let total_base_rewards = + get_base_reward_per_increment(&state).unwrap() * total_active_increments; + let max_participant_rewards = total_base_rewards * constants::SYNC_REWARD_WEIGHT + / constants::WEIGHT_DENOMINATOR + / preset::SLOTS_PER_EPOCH; + let participant_reward = max_participant_rewards / preset::SYNC_COMMITTEE_SIZE as u64; + let expected_penalty = participant_reward * preset::SYNC_COMMITTEE_SIZE as u64; + + assert_eq!( + state.balance(0).unwrap(), + balance_before.saturating_sub(expected_penalty) + ); + } +} diff --git a/crates/blockchain/state_transition/src/beacon/stf/bellatrix.rs b/crates/blockchain/state_transition/src/beacon/stf/bellatrix.rs new file mode 100644 index 000000000..ddb320905 --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/stf/bellatrix.rs @@ -0,0 +1,488 @@ +//! Bellatrix's block processing: the Merge. +//! +//! Before this fork, the beacon chain and the execution chain ran two separate +//! consensus mechanisms: proof-of-work miners built the execution chain on +//! their own schedule, joined to the beacon chain only by the deposit +//! contract. Bellatrix retires the execution chain's own consensus outright: +//! from here on a beacon block carries its execution block whole, as an +//! [`bellatrix::ExecutionPayload`], and [`process_execution_payload`] is the +//! one new step [`process_block`] adds to validate it and hand it to +//! [`super::ExecutionEngine`]'s stand-in for a real execution client. +//! +//! [`BeaconState`] keeps only [`bellatrix::ExecutionPayloadHeader`], never the +//! whole payload. Everything a later block needs from the one before it is +//! enough to check continuity, that the new payload's `parent_hash` chains to +//! the stored header's own `block_hash`, and [`process_execution_payload`] +//! does exactly that without ever asking the execution layer what came +//! before. Keeping every transaction, in every state, forever, would cost far +//! more than that one check needs, so the header substitutes +//! `transactions_root` for `transactions`, the same substitution +//! [`crate::beacon::containers::shared::BeaconBlockHeader`] already makes for a +//! beacon block's own body. +//! +//! [`is_merge_transition_complete`], [`is_merge_transition_block`], and +//! [`is_execution_enabled`] exist only for the fork in which the transition +//! itself can happen. Once a chain's first real payload lands, +//! `is_merge_transition_complete` answers the same way forever, and capella's +//! own specification drops the whole family of checks rather than keep +//! evaluating a question every later block already answers identically. See +//! [`bellatrix_state_ref`]'s documentation for what that implies about how +//! far this file's own state projection needs to reach. + +use crate::beacon::config::Config; +use crate::beacon::constants; +use crate::beacon::containers::{BeaconState, bellatrix}; +use crate::beacon::error::{Error, Result, verify}; +use crate::beacon::helpers::accessors::{CommitteeCache, get_current_epoch, get_randao_mix}; +use crate::beacon::preset; +use crate::beacon::primitives::{ + Bytes32, ExecutionAddress, ExecutionBlockHash, HashTreeRoot as _, Root, Slot, Uint256, +}; + +use super::ExecutionEngine; + +// --------------------------------------------------------------------------- +// Block processing +// --------------------------------------------------------------------------- + +/// Bellatrix's block processing: altair's steps, with the execution payload +/// inserted between the header and the RANDAO reveal. +/// +/// That insertion point is the one place this fork's own order matters, and +/// the specification says so explicitly: [`process_execution_payload`] must +/// run before [`super::block::process_randao`], because the payload's +/// `prev_randao` has to match the *previous* block's mix, the very one +/// [`super::block::process_randao`] is about to overwrite with this block's +/// own reveal. Running them in the other order would check `prev_randao` +/// against a mix this same block already replaced, which no honest proposer +/// could ever satisfy. +/// +/// The payload step is conditional on [`is_execution_enabled`]: bellatrix is +/// the one fork where a block might still legitimately carry no real payload, +/// if this chain's merge transition has not happened yet. Every later fork +/// drops the condition outright, since by then it is always true. +pub fn process_block( + state: &mut BeaconState, + block: &bellatrix::BeaconBlock, + config: &Config, + engine: &ExecutionEngine, + committees: &CommitteeCache, +) -> Result<()> { + super::block::process_block_header( + state, + block.slot, + block.proposer_index, + block.parent_root, + block.body.hash_tree_root(), + )?; + if is_execution_enabled(state, &block.body.execution_payload)? { + process_execution_payload(state, &block.body.execution_payload, config, engine)?; + } + super::block::process_randao(state, &block.body.randao_reveal)?; + super::block::process_eth1_data(state, &block.body.eth1_data)?; + super::operations::process_operations( + state, + &block.body.proposer_slashings, + &block.body.attester_slashings, + &block.body.attestations, + &block.body.deposits, + &block.body.voluntary_exits, + config, + committees, + )?; + super::altair::process_sync_aggregate(state, &block.body.sync_aggregate)?; + Ok(()) +} + +/// Validates this slot's execution payload against the state and the +/// stand-in execution engine, then caches its header. +/// +/// Takes `payload` alone rather than a whole body, the same way every shared +/// step in [`super::block`] takes only the fields it reads; see +/// [`crate::beacon::stf`]'s module documentation for why no shared body type exists to +/// pass instead. Takes `config`, which the specification's own +/// `process_execution_payload(state, body, execution_engine)` does not: the +/// specification reaches `SECONDS_PER_SLOT` from global scope the way every +/// specification function reaches every constant, but that value is a +/// network's own [`Config::seconds_per_slot`] here, not a preset, so +/// [`compute_timestamp_at_slot`], the one thing this calls that actually needs +/// it, has to be handed one, and so must this. +/// +/// Collapses the specification's `verify_and_notify_new_payload` (itself +/// `is_valid_block_hash` and `notify_new_payload`, both calls to a real +/// execution client) into reading [`ExecutionEngine::execution_valid`] +/// straight off the stand-in `engine`; see that type's own documentation for +/// why nothing here can call out to a real one. +pub fn process_execution_payload( + state: &mut BeaconState, + payload: &bellatrix::ExecutionPayload, + config: &Config, + engine: &ExecutionEngine, +) -> Result<()> { + if is_merge_transition_complete(state)? { + let bellatrix_ref = bellatrix_state_ref(state, "process_execution_payload")?; + verify( + payload.parent_hash == bellatrix_ref.latest_execution_payload_header.block_hash, + "payload.parent_hash == state.latest_execution_payload_header.block_hash", + )?; + } + verify( + payload.prev_randao == get_randao_mix(state, get_current_epoch(state)), + "payload.prev_randao == get_randao_mix(state, get_current_epoch(state))", + )?; + verify( + payload.timestamp == compute_timestamp_at_slot(state, state.slot(), config), + "payload.timestamp == compute_timestamp_at_slot(state, state.slot, config)", + )?; + verify( + engine.execution_valid, + "verify_and_notify_new_payload(NewPayloadRequest(execution_payload=payload))", + )?; + + let header = bellatrix::ExecutionPayloadHeader { + parent_hash: payload.parent_hash, + fee_recipient: payload.fee_recipient, + state_root: payload.state_root, + receipts_root: payload.receipts_root, + logs_bloom: payload.logs_bloom.clone(), + prev_randao: payload.prev_randao, + block_number: payload.block_number, + gas_limit: payload.gas_limit, + gas_used: payload.gas_used, + timestamp: payload.timestamp, + extra_data: payload.extra_data.clone(), + base_fee_per_gas: payload.base_fee_per_gas, + block_hash: payload.block_hash, + // The header substitutes a root for the whole transaction list; see + // this module's own documentation for why the state keeps only that + // much. + transactions_root: payload.transactions.hash_tree_root(), + }; + bellatrix_state(state, "process_execution_payload")?.latest_execution_payload_header = header; + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Merge-transition predicates +// --------------------------------------------------------------------------- + +/// Whether this chain's merge transition has already happened: whether some +/// earlier block already set `latest_execution_payload_header` to anything +/// other than the all-default value every pre-merge state carries. +/// +/// Once true, stays true forever: nothing ever resets +/// `latest_execution_payload_header` back to its default. Capella's own +/// specification relies on exactly that monotonicity to drop this whole +/// question; see [`bellatrix_state_ref`]'s documentation for what that means +/// for this file's own state projection. +pub fn is_merge_transition_complete(state: &BeaconState) -> Result { + let bellatrix_ref = bellatrix_state_ref(state, "is_merge_transition_complete")?; + Ok(bellatrix_ref.latest_execution_payload_header != default_execution_payload_header()?) +} + +/// Whether `payload` is the one block that carries this chain's transition: +/// the merge has not completed yet, and this payload is not the empty +/// placeholder either. +/// +/// A block proposed before the transition still has to put *something* in its +/// `execution_payload` field, since bellatrix's `BeaconBlockBody` carries one +/// unconditionally rather than making it optional; the specification's own +/// convention is that such a block carries the all-default +/// [`bellatrix::ExecutionPayload`], which this treats as "no real payload +/// yet" rather than as a payload to validate. +pub fn is_merge_transition_block( + state: &BeaconState, + payload: &bellatrix::ExecutionPayload, +) -> Result { + Ok(!is_merge_transition_complete(state)? && *payload != default_execution_payload()?) +} + +/// Whether [`process_execution_payload`] should run at all for this block: +/// either the transition already happened, or this block is the one that +/// makes it happen. +/// +/// The only way this is false is a pre-merge block carrying no real payload; +/// every later fork drops this check because that possibility itself stops +/// existing once a chain's transition is behind it. +pub fn is_execution_enabled( + state: &BeaconState, + payload: &bellatrix::ExecutionPayload, +) -> Result { + Ok(is_merge_transition_block(state, payload)? || is_merge_transition_complete(state)?) +} + +// --------------------------------------------------------------------------- +// Time +// --------------------------------------------------------------------------- + +/// The wall-clock Unix time a slot's block is due, from the chain's genesis +/// time and [`Config::seconds_per_slot`]. +/// +/// Named `compute_timestamp_at_slot` here rather than the specification's own +/// `compute_time_at_slot`; [`Config::seconds_per_slot`]'s own doc comment +/// already anticipates the specification's name, so the two disagree, and +/// whoever next touches either should settle on one name in both places. +/// +/// The specification flags this arithmetic as "unsafe with respect to +/// overflows and underflows", a warning aimed at Python's own +/// arbitrary-precision integers never actually needing it. This function +/// cannot return an error (its caller, [`process_execution_payload`], only +/// ever compares the result against a value the block itself supplies, so +/// there is nothing to reject the block *for* here), so an overflow +/// saturates rather than wraps: wrapping could land on a small value that +/// looks like a plausible, if wrong, timestamp, where saturating cannot. +pub fn compute_timestamp_at_slot(state: &BeaconState, slot: Slot, config: &Config) -> u64 { + // `GENESIS_SLOT` is `Slot`'s zero point, so subtracting it from any slot + // can never underflow. + let slots_since_genesis = slot - constants::GENESIS_SLOT; + slots_since_genesis + .saturating_mul(config.seconds_per_slot) + .saturating_add(state.genesis_time()) +} + +// --------------------------------------------------------------------------- +// Fork projection +// --------------------------------------------------------------------------- + +/// The bellatrix state, mutably, or an error naming the function that needs +/// one. +/// +/// Scoped to `BeaconState::Bellatrix` alone, not to every fork from bellatrix +/// through fulu. That narrower scope is deliberate. Every caller in this file +/// is reached only through [`process_block`], which +/// [`super::block::process_block`] dispatches to precisely when `state` is +/// already `BeaconState::Bellatrix` (checked once, before any per-fork +/// dispatch, by [`super::state_transition`]), and nothing outside this file +/// ever calls back into it for a later fork's own state. Capella's +/// specification is explicit about why that is safe: its own `process_block` +/// drops the `is_execution_enabled` call, and its own +/// `process_execution_payload` drops the `is_merge_transition_complete` +/// check, both marked "Removed" rather than "Modified", because once a +/// chain's transition is behind it, every later block answers both questions +/// the same way regardless of anything this projection could tell it. Each +/// fork from capella on keeps its own `latest_execution_payload_header`, of +/// its own distinct type (withdrawals added at capella, blob fields at deneb, +/// and so on), and will need its own projection of this same shape to reach +/// it, not this one widened to somehow return a different concrete type per +/// caller. +pub(crate) fn bellatrix_state<'a>( + state: &'a mut BeaconState, + function: &'static str, +) -> Result<&'a mut bellatrix::BeaconState> { + match state { + BeaconState::Bellatrix(state) => Ok(state), + other => Err(Error::UnsupportedForFork { + function, + fork: other.fork_name(), + }), + } +} + +/// The bellatrix state, immutably. See [`bellatrix_state`]. +pub(crate) fn bellatrix_state_ref<'a>( + state: &'a BeaconState, + function: &'static str, +) -> Result<&'a bellatrix::BeaconState> { + match state { + BeaconState::Bellatrix(state) => Ok(state), + other => Err(Error::UnsupportedForFork { + function, + fork: other.fork_name(), + }), + } +} + +// --------------------------------------------------------------------------- +// Default values +// --------------------------------------------------------------------------- + +/// The all-default [`bellatrix::ExecutionPayloadHeader`]: the value the +/// specification's bare `ExecutionPayloadHeader()` constructor builds, every +/// field at its type's zero value. +/// +/// Not `#[derive(Default)]`: `logs_bloom` is an [`libssz_types::SszVector`], and +/// libssz gives that type no `Default` impl at all, since, unlike a list, a +/// fixed-length vector has no value that is validly empty. [`crate::beacon::upgrade`] already builds this exact +/// value the same way, for the same reason, when altair's state upgrades into +/// bellatrix's; its helper is private to that module, and this file may only +/// touch its own, so this is a deliberate second copy rather than a shared +/// one. [`is_merge_transition_complete`] is this function's only caller. +fn default_execution_payload_header() -> Result { + Ok(bellatrix::ExecutionPayloadHeader { + parent_hash: ExecutionBlockHash::ZERO, + fee_recipient: ExecutionAddress::ZERO, + state_root: Bytes32::ZERO, + receipts_root: Bytes32::ZERO, + logs_bloom: bellatrix::LogsBloom::try_from(vec![0u8; preset::BYTES_PER_LOGS_BLOOM])?, + prev_randao: Bytes32::ZERO, + block_number: 0, + gas_limit: 0, + gas_used: 0, + timestamp: 0, + extra_data: bellatrix::ExtraData::default(), + base_fee_per_gas: Uint256::ZERO, + block_hash: ExecutionBlockHash::ZERO, + transactions_root: Root::ZERO, + }) +} + +/// The all-default [`bellatrix::ExecutionPayload`]: the value the +/// specification's bare `ExecutionPayload()` constructor builds. See +/// [`default_execution_payload_header`] for why this is built field by field +/// rather than derived. [`is_merge_transition_block`] is this function's only +/// caller. +fn default_execution_payload() -> Result { + Ok(bellatrix::ExecutionPayload { + parent_hash: ExecutionBlockHash::ZERO, + fee_recipient: ExecutionAddress::ZERO, + state_root: Bytes32::ZERO, + receipts_root: Bytes32::ZERO, + logs_bloom: bellatrix::LogsBloom::try_from(vec![0u8; preset::BYTES_PER_LOGS_BLOOM])?, + prev_randao: Bytes32::ZERO, + block_number: 0, + gas_limit: 0, + gas_used: 0, + timestamp: 0, + extra_data: bellatrix::ExtraData::default(), + base_fee_per_gas: Uint256::ZERO, + block_hash: ExecutionBlockHash::ZERO, + transactions: Default::default(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::beacon::fork::ForkName; + + /// A bellatrix state with `count` fully active, full-balance validators, + /// one epoch in (so the block-root history window already has entries), + /// and `header` as its `latest_execution_payload_header`. + /// + /// A thin wrapper around the shared fork-parameterised builder, which has + /// no header to take as a parameter, so this overrides the placeholder it + /// builds with the one every real caller here actually wants under test. + /// See [`crate::beacon::helpers::test_state::with_validators_at`] for the + /// construction this and every other fork's test module used to + /// duplicate. + fn bellatrix_state_with_validators( + count: usize, + header: bellatrix::ExecutionPayloadHeader, + ) -> BeaconState { + let mut state = + crate::beacon::helpers::test_state::with_validators_at(ForkName::Bellatrix, count); + if let BeaconState::Bellatrix(inner) = &mut state { + inner.latest_execution_payload_header = header; + } + state + } + + /// A payload that is not the all-default placeholder: distinct from + /// [`default_execution_payload`] in exactly one field, which is enough + /// for every predicate under test here. + fn non_default_payload() -> bellatrix::ExecutionPayload { + let mut payload = default_execution_payload().unwrap(); + payload.parent_hash = ExecutionBlockHash::repeat_byte(0xab); + payload + } + + // The three states worth distinguishing: a chain that has not started its + // merge transition and whose block carries no real payload either, a + // chain mid-transition whose block is the transition block itself, and a + // chain whose transition is already behind it. + + #[test] + fn pre_merge_state_with_no_real_payload_leaves_execution_disabled() { + let header = default_execution_payload_header().unwrap(); + let state = bellatrix_state_with_validators(4, header); + let payload = default_execution_payload().unwrap(); + + assert!(!is_merge_transition_complete(&state).unwrap()); + assert!(!is_merge_transition_block(&state, &payload).unwrap()); + assert!(!is_execution_enabled(&state, &payload).unwrap()); + } + + #[test] + fn pre_merge_state_with_a_real_payload_is_the_transition_block() { + let header = default_execution_payload_header().unwrap(); + let state = bellatrix_state_with_validators(4, header); + let payload = non_default_payload(); + + assert!(!is_merge_transition_complete(&state).unwrap()); + assert!(is_merge_transition_block(&state, &payload).unwrap()); + assert!( + is_execution_enabled(&state, &payload).unwrap(), + "the transition block itself must still run process_execution_payload" + ); + } + + #[test] + fn post_merge_state_has_execution_enabled_regardless_of_the_payload() { + let mut header = default_execution_payload_header().unwrap(); + header.block_hash = ExecutionBlockHash::repeat_byte(0xcd); + let state = bellatrix_state_with_validators(4, header); + + assert!(is_merge_transition_complete(&state).unwrap()); + + // Once the transition is behind a chain, `is_merge_transition_block` + // is false no matter what the payload looks like: the first half of + // its own condition already failed, so the payload's own shape + // cannot rescue it. + let empty_payload = default_execution_payload().unwrap(); + assert!(!is_merge_transition_block(&state, &empty_payload).unwrap()); + assert!(is_execution_enabled(&state, &empty_payload).unwrap()); + + let real_payload = non_default_payload(); + assert!(!is_merge_transition_block(&state, &real_payload).unwrap()); + assert!(is_execution_enabled(&state, &real_payload).unwrap()); + } + + #[test] + fn compute_timestamp_at_slot_matches_genesis_time_plus_slot_seconds() { + let header = default_execution_payload_header().unwrap(); + let state = bellatrix_state_with_validators(4, header); + let config = Config::minimal(); + + let expected = state.genesis_time() + state.slot() * config.seconds_per_slot; + assert_eq!( + compute_timestamp_at_slot(&state, state.slot(), &config), + expected + ); + } + + #[test] + fn process_execution_payload_rejects_an_invalid_engine_but_accepts_a_valid_one() { + let header = default_execution_payload_header().unwrap(); + let mut state = bellatrix_state_with_validators(4, header); + let config = Config::minimal(); + + let mut payload = non_default_payload(); + payload.prev_randao = get_randao_mix(&state, get_current_epoch(&state)); + payload.timestamp = compute_timestamp_at_slot(&state, state.slot(), &config); + + assert!( + process_execution_payload( + &mut state.clone(), + &payload, + &config, + &ExecutionEngine::invalid() + ) + .is_err(), + "an execution client that rejects the payload must fail the block" + ); + + process_execution_payload(&mut state, &payload, &config, &ExecutionEngine::valid()) + .unwrap(); + + assert!(is_merge_transition_complete(&state).unwrap()); + let stored_header = &bellatrix_state_ref(&state, "test assertion") + .unwrap() + .latest_execution_payload_header; + assert_eq!(stored_header.parent_hash, payload.parent_hash); + assert_eq!( + stored_header.transactions_root, + payload.transactions.hash_tree_root() + ); + } +} diff --git a/crates/blockchain/state_transition/src/beacon/stf/block.rs b/crates/blockchain/state_transition/src/beacon/stf/block.rs new file mode 100644 index 000000000..234d1cef3 --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/stf/block.rs @@ -0,0 +1,321 @@ +//! Block processing: the header, RANDAO, and eth1-vote steps every fork shares, +//! plus the per-fork drivers that run them (and each fork's own operations) in +//! the specification's order. +//! +//! Corresponds to the specification's "Block processing" section (`## +//! Beacon chain state transition function` > `### Block processing`), redefined +//! once per fork here the same way the specification itself redefines +//! `process_block` per fork rather than parameterising a single definition. +//! [`process_block`] is the dispatcher [`super::state_transition`] calls, and +//! [`process_block_phase0`] and [`process_block_altair`] are the two drivers it +//! dispatches to that are actually implemented; see [`super::bellatrix`] and its +//! siblings for the rest, and [`super`]'s module documentation for why none of +//! this needs a shared body type to do it. +//! +//! [`process_block_header`], [`process_randao`], and [`process_eth1_data`] take +//! the fields they read directly rather than a whole body, which is what lets +//! every driver, present and future, call the identical function regardless of +//! what else that fork's body carries. + +use crate::beacon::bls; +use crate::beacon::config::Config; +use crate::beacon::constants; +use crate::beacon::containers::{self, BeaconBlockHeader, BeaconState, Eth1Data, altair, phase0}; +use crate::beacon::error::{Result, verify}; +use crate::beacon::hash::hash; +use crate::beacon::helpers::accessors::{ + CommitteeCache, get_beacon_proposer_index, get_current_epoch, get_domain, get_randao_mix, +}; +use crate::beacon::helpers::math::xor; +use crate::beacon::helpers::misc::compute_signing_root; +use crate::beacon::preset; +use crate::beacon::primitives::{BlsSignature, HashTreeRoot as _, Root, Slot, ValidatorIndex}; + +use super::{ExecutionEngine, bellatrix, capella, deneb, electra, fulu}; + +/// Dispatches on the block's fork and runs that fork's own block processing. +/// +/// Matches [`containers::SignedBeaconBlock`]'s variant once, then hands the +/// unwrapped, fork-specific `BeaconBlock` to the function written for it. Called +/// after [`super::process_slots`] has already advanced the state to the block's +/// slot, so every arm validates and mutates a state already positioned at +/// `signed_block`'s slot. +pub fn process_block( + state: &mut BeaconState, + signed_block: &containers::SignedBeaconBlock, + config: &Config, + engine: &ExecutionEngine, + committees: &CommitteeCache, +) -> Result<()> { + match signed_block { + containers::SignedBeaconBlock::Phase0(signed) => { + process_block_phase0(state, &signed.message, config, committees) + } + containers::SignedBeaconBlock::Altair(signed) => { + process_block_altair(state, &signed.message, config, committees) + } + containers::SignedBeaconBlock::Bellatrix(signed) => { + bellatrix::process_block(state, &signed.message, config, engine, committees) + } + containers::SignedBeaconBlock::Capella(signed) => { + capella::process_block(state, &signed.message, config, engine, committees) + } + containers::SignedBeaconBlock::Deneb(signed) => { + deneb::process_block(state, &signed.message, config, engine, committees) + } + containers::SignedBeaconBlock::Electra(signed) => { + electra::process_block(state, &signed.message, config, engine, committees) + } + containers::SignedBeaconBlock::Fulu(signed) => { + fulu::process_block(state, &signed.message, config, engine, committees) + } + containers::SignedBeaconBlock::Lean(_) => { + crate::beacon::lean_block_unreachable("process_block") + } + } +} + +/// Phase0's block processing: the header, the RANDAO reveal, the eth1 vote, +/// then every operation, in the specification's order. +pub fn process_block_phase0( + state: &mut BeaconState, + block: &phase0::BeaconBlock, + config: &Config, + committees: &CommitteeCache, +) -> Result<()> { + process_block_header( + state, + block.slot, + block.proposer_index, + block.parent_root, + block.body.hash_tree_root(), + )?; + process_randao(state, &block.body.randao_reveal)?; + process_eth1_data(state, &block.body.eth1_data)?; + crate::beacon::stf::operations::process_operations( + state, + &block.body.proposer_slashings, + &block.body.attester_slashings, + &block.body.attestations, + &block.body.deposits, + &block.body.voluntary_exits, + config, + committees, + )?; + Ok(()) +} + +/// Altair's block processing: phase0's steps, plus the sync aggregate. +/// +/// The sync aggregate runs last, after every operation, matching the +/// specification's own `process_block`, which appends +/// `process_sync_aggregate(state, block.body.sync_aggregate)` to phase0's list +/// rather than interleaving it earlier. +pub fn process_block_altair( + state: &mut BeaconState, + block: &altair::BeaconBlock, + config: &Config, + committees: &CommitteeCache, +) -> Result<()> { + process_block_header( + state, + block.slot, + block.proposer_index, + block.parent_root, + block.body.hash_tree_root(), + )?; + process_randao(state, &block.body.randao_reveal)?; + process_eth1_data(state, &block.body.eth1_data)?; + crate::beacon::stf::operations::process_operations( + state, + &block.body.proposer_slashings, + &block.body.attester_slashings, + &block.body.attestations, + &block.body.deposits, + &block.body.voluntary_exits, + config, + committees, + )?; + super::altair::process_sync_aggregate(state, &block.body.sync_aggregate)?; + Ok(()) +} + +/// Validates a block's header against the state and records it as the state's +/// `latest_block_header`. +/// +/// Checks, in order: the slot matches the state's current slot, the slot is +/// strictly after the previous header's, the proposer index is the one the +/// shuffling computes for this slot, the parent root matches the previous +/// header's own root, and the proposer has not been slashed. The slashed check +/// runs last because the specification itself runs it after the header has +/// already been overwritten, which is one more reason an invalid block cannot +/// be retried against the same state: by the time this returns an error, the +/// header it rejected the block for is already gone. +/// +/// Takes `body_root` rather than a body to hash itself, unlike every other +/// piece a driver hands this module's shared steps: a body's shape is +/// fork-specific, so only the caller, which already knows which fork it is +/// calling from, can compute `hash_tree_root()` on it. This function makes no +/// assertion about `body_root` beyond using it verbatim as the header's own +/// field; the caller must pass `block.body.hash_tree_root()`, and a wrong value +/// here would corrupt the header exactly as a wrong value from the +/// specification's own `hash_tree_root(block.body)` would. +pub fn process_block_header( + state: &mut BeaconState, + slot: Slot, + proposer_index: ValidatorIndex, + parent_root: Root, + body_root: Root, +) -> Result<()> { + verify(slot == state.slot(), "block.slot == state.slot")?; + verify( + slot > state.latest_block_header().slot, + "block.slot > state.latest_block_header.slot", + )?; + verify( + proposer_index == get_beacon_proposer_index(state)?, + "block.proposer_index == get_beacon_proposer_index(state)", + )?; + verify( + parent_root == state.latest_block_header().hash_tree_root(), + "block.parent_root == hash_tree_root(state.latest_block_header)", + )?; + + *state.latest_block_header_mut() = BeaconBlockHeader { + slot, + proposer_index, + parent_root, + // Left zero for the reason given on `super::process_slot`'s doc + // comment: a block cannot commit to the root of the state it produces. + state_root: Root::ZERO, + body_root, + }; + + verify( + !state.validator(proposer_index)?.slashed, + "proposer is not slashed", + )?; + Ok(()) +} + +/// Verifies the proposer's RANDAO reveal and mixes it into the current epoch's +/// randao mix. +/// +/// The reveal signs the epoch rather than the slot, so the same signature would +/// verify for every slot of the epoch: the mix it feeds is per-epoch, not +/// per-slot, and a skipped slot leaves nothing for the next proposer to reveal +/// differently. +pub fn process_randao(state: &mut BeaconState, randao_reveal: &BlsSignature) -> Result<()> { + let epoch = get_current_epoch(state); + let proposer_index = get_beacon_proposer_index(state)?; + let domain = get_domain(state, constants::DOMAIN_RANDAO, None); + let signing_root = compute_signing_root(epoch.hash_tree_root(), domain); + + let proposer = state.validator(proposer_index)?; + verify( + bls::verify(&proposer.pubkey, signing_root, randao_reveal), + "RANDAO reveal signature", + )?; + + let mix = xor(get_randao_mix(state, epoch), hash(&randao_reveal.0)); + let position = epoch as usize % preset::EPOCHS_PER_HISTORICAL_VECTOR; + state.randao_mixes_mut()[position] = mix; + Ok(()) +} + +/// Records the block's eth1 vote, adopting it once it holds a majority within +/// the voting period. +/// +/// The voting period is `EPOCHS_PER_ETH1_VOTING_PERIOD * SLOTS_PER_EPOCH`, the +/// specification's own expression for it. Both are preset values rather than +/// per-chain configuration, which is why this function needs no `Config`. The +/// count used for the majority check includes the vote just appended, so a +/// single block can supply the deciding vote. +pub fn process_eth1_data(state: &mut BeaconState, eth1_data: &Eth1Data) -> Result<()> { + state.eth1_data_votes_mut().push(eth1_data.clone())?; + + let matching_votes = state + .eth1_data_votes() + .iter() + .filter(|vote| *vote == eth1_data) + .count() as u64; + let voting_period_slots = preset::EPOCHS_PER_ETH1_VOTING_PERIOD * preset::SLOTS_PER_EPOCH; + if matching_votes * 2 > voting_period_slots { + *state.eth1_data_mut() = eth1_data.clone(); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::beacon::primitives::Bytes32; + + /// An otherwise-empty block body, since the header, RANDAO, and eth1 checks + /// below never look past its `hash_tree_root`. + fn empty_body() -> phase0::BeaconBlockBody { + phase0::BeaconBlockBody { + randao_reveal: BlsSignature::default(), + eth1_data: Eth1Data::default(), + graffiti: Bytes32::ZERO, + proposer_slashings: Default::default(), + attester_slashings: Default::default(), + attestations: Default::default(), + deposits: Default::default(), + voluntary_exits: Default::default(), + } + } + + #[test] + fn a_mismatched_parent_root_is_rejected() { + let mut state = crate::beacon::helpers::test_state::with_validators(8); + let proposer_index = get_beacon_proposer_index(&state).unwrap(); + + let block = phase0::BeaconBlock { + slot: state.slot(), + proposer_index, + // Deliberately wrong: the real parent root is + // `state.latest_block_header().hash_tree_root()`. + parent_root: Root::repeat_byte(0xab), + state_root: Root::ZERO, + body: empty_body(), + }; + + assert!( + process_block_header( + &mut state, + block.slot, + block.proposer_index, + block.parent_root, + block.body.hash_tree_root(), + ) + .is_err() + ); + } + + #[test] + fn a_slashed_proposer_is_rejected() { + let mut state = crate::beacon::helpers::test_state::with_validators(8); + let proposer_index = get_beacon_proposer_index(&state).unwrap(); + state.validator_mut(proposer_index).unwrap().slashed = true; + + let block = phase0::BeaconBlock { + slot: state.slot(), + proposer_index, + parent_root: state.latest_block_header().hash_tree_root(), + state_root: Root::ZERO, + body: empty_body(), + }; + + assert!( + process_block_header( + &mut state, + block.slot, + block.proposer_index, + block.parent_root, + block.body.hash_tree_root(), + ) + .is_err() + ); + } +} diff --git a/crates/blockchain/state_transition/src/beacon/stf/capella.rs b/crates/blockchain/state_transition/src/beacon/stf/capella.rs new file mode 100644 index 000000000..b56639a98 --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/stf/capella.rs @@ -0,0 +1,826 @@ +//! Capella's block processing: withdrawals. +//! +//! Before this fork a validator's balance could shrink, through penalties or +//! slashing, but it could never leave the consensus layer: there was nowhere +//! for it to go, since withdrawal credentials named only a BLS public key, +//! which the execution layer has no way to pay out to. Capella gives every +//! validator a way to get its balance out without ever submitting anything: +//! [`process_withdrawals`] sweeps a bounded slice of the validator registry +//! on every single block, pays out anyone it finds fully or partially +//! withdrawable, and [`BeaconState::next_withdrawal_index`] / +//! [`BeaconState::next_withdrawal_validator_index`] (reached here through +//! [`capella::BeaconState`]'s own fields) are the cursor that makes each +//! block's share of that sweep bounded regardless of how large the registry +//! grows. [`get_expected_withdrawals`] is the sweep itself; +//! [`process_bls_to_execution_change`] is the one new operation, a +//! validator's one-time upgrade from a raw BLS withdrawal credential to an +//! execution address, which is what makes it eligible for a payout in the +//! first place. +//! +//! # Why the sweep runs before the operations, not after +//! +//! [`process_block`]'s order, transcribed from the specification's own +//! `process_block`, is header, withdrawals, execution payload, RANDAO, eth1 +//! vote, operations, sync aggregate. `bls_to_execution_changes` is one of the +//! operations, processed by [`process_operations`] near the end of that list, +//! well after [`process_withdrawals`] already ran. That placement is load +//! bearing, not incidental: it means a validator that upgrades its withdrawal +//! credentials in this very block is not swept by this same block, since the +//! sweep already read (and committed to) the old credentials before the +//! upgrade was even processed. The earliest a freshly upgraded validator can +//! be paid out is the next block's sweep. +//! +//! # Execution payload processing +//! +//! Capella's [`process_execution_payload`] is bellatrix's, minus the +//! [`super::bellatrix::is_merge_transition_complete`] check the specification +//! explicitly marks "Removed in Capella": every capella state's transition is +//! already behind it, so the check is redundant rather than differently +//! evaluated. Everything else bellatrix's version does, including +//! [`super::bellatrix::compute_timestamp_at_slot`] for the timestamp check, +//! is reused rather than copied; only the header-building step is written +//! fresh here, since capella's own [`capella::ExecutionPayloadHeader`] adds +//! `withdrawals_root` and is a distinct type from bellatrix's. + +use crate::beacon::bls; +use crate::beacon::config::Config; +use crate::beacon::constants; +use crate::beacon::containers::shared::{ + Deposit, ProposerSlashing, SignedVoluntaryExit, Validator, +}; +use crate::beacon::containers::{BeaconState, capella, phase0}; +use crate::beacon::error::{Error, Result, verify}; +use crate::beacon::hash::hash; +use crate::beacon::helpers::accessors::{CommitteeCache, get_current_epoch, get_randao_mix}; +use crate::beacon::helpers::capella::{ + is_fully_withdrawable_validator, is_partially_withdrawable_validator, +}; +use crate::beacon::helpers::misc::{compute_domain, compute_signing_root}; +use crate::beacon::helpers::mutators::decrease_balance; +use crate::beacon::preset; +use crate::beacon::primitives::{ExecutionAddress, H256, HashTreeRoot as _}; + +use super::ExecutionEngine; + +// --------------------------------------------------------------------------- +// Block processing +// --------------------------------------------------------------------------- + +/// Capella's block processing: the withdrawal sweep and the execution payload +/// step ahead of everything altair and bellatrix already run, in the +/// specification's own order. +/// +/// See this module's own documentation for why [`process_withdrawals`] runs +/// before [`process_operations`], and so before this same block's own +/// `bls_to_execution_changes` are processed. +pub fn process_block( + state: &mut BeaconState, + block: &capella::BeaconBlock, + config: &Config, + engine: &ExecutionEngine, + committees: &CommitteeCache, +) -> Result<()> { + super::block::process_block_header( + state, + block.slot, + block.proposer_index, + block.parent_root, + block.body.hash_tree_root(), + )?; + process_withdrawals(state, &block.body.execution_payload)?; + process_execution_payload(state, &block.body.execution_payload, config, engine)?; + super::block::process_randao(state, &block.body.randao_reveal)?; + super::block::process_eth1_data(state, &block.body.eth1_data)?; + process_operations( + state, + &block.body.proposer_slashings, + &block.body.attester_slashings, + &block.body.attestations, + &block.body.deposits, + &block.body.voluntary_exits, + &block.body.bls_to_execution_changes, + config, + committees, + )?; + super::altair::process_sync_aggregate(state, &block.body.sync_aggregate)?; + Ok(()) +} + +// --------------------------------------------------------------------------- +// Withdrawals +// --------------------------------------------------------------------------- + +/// The execution address a validator's payout is sent to: the low bytes of +/// its eth1 withdrawal credentials, the same bytes +/// [`process_bls_to_execution_change`] writes when a validator upgrades into +/// this form. +fn withdrawal_address(validator: &Validator) -> ExecutionAddress { + ExecutionAddress::from_slice(&validator.withdrawal_credentials.0[12..]) +} + +/// The withdrawals this block's sweep owes, without applying them. +/// +/// A bounded walk of the validator registry starting at +/// [`capella::BeaconState::next_withdrawal_validator_index`], not a scan of +/// the whole thing: `bound` caps how many validators one call ever visits at +/// [`preset::MAX_VALIDATORS_PER_WITHDRAWALS_SWEEP`], and the loop also stops +/// the moment it has collected [`preset::MAX_WITHDRAWALS_PER_PAYLOAD`] +/// withdrawals, whichever comes first. Both are what keep a single block's +/// share of the sweep bounded regardless of how large the registry grows: +/// without them, a registry of unbounded size would make one block's +/// state-transition cost unbounded too. +/// +/// The index advances past `validator_count` by wrapping with `%`, so the +/// sweep revisits validator zero right after the last one rather than +/// stopping at the end of the registry; [`process_withdrawals`] is what +/// persists the cursor this function starts from and leaves behind. +pub fn get_expected_withdrawals(state: &BeaconState) -> Result> { + let epoch = get_current_epoch(state); + // Through the fork-generic cursor accessor rather than this module's + // projection to `capella::BeaconState`. Deneb reuses this function unchanged + // (its own `process_withdrawals` calls straight into it), and a projection + // to capella's concrete state rejects a deneb state outright, so reading the + // cursor that way made every deneb block carrying a withdrawal fail at + // runtime. + let (mut withdrawal_index, mut validator_index) = state.withdrawal_cursor()?; + + let validator_count = state.validators().len() as u64; + let bound = validator_count.min(preset::MAX_VALIDATORS_PER_WITHDRAWALS_SWEEP); + + let mut withdrawals = Vec::new(); + for _ in 0..bound { + let validator = state.validator(validator_index)?; + let balance = state.balance(validator_index)?; + + if is_fully_withdrawable_validator(validator, balance, epoch) { + withdrawals.push(capella::Withdrawal { + index: withdrawal_index, + validator_index, + address: withdrawal_address(validator), + amount: balance, + }); + withdrawal_index = withdrawal_index + .checked_add(1) + .ok_or(Error::ArithmeticOverflow("withdrawal_index + 1"))?; + } else if is_partially_withdrawable_validator(validator, balance) { + // `is_partially_withdrawable_validator` already requires `balance + // > MAX_EFFECTIVE_BALANCE`, so this cannot underflow; checked + // anyway, since the cost of checking is free and nothing here + // should ever rely on a predicate elsewhere staying exactly this + // strict. + let amount = balance + .checked_sub(preset::MAX_EFFECTIVE_BALANCE) + .ok_or(Error::ArithmeticOverflow("balance - MAX_EFFECTIVE_BALANCE"))?; + withdrawals.push(capella::Withdrawal { + index: withdrawal_index, + validator_index, + address: withdrawal_address(validator), + amount, + }); + withdrawal_index = withdrawal_index + .checked_add(1) + .ok_or(Error::ArithmeticOverflow("withdrawal_index + 1"))?; + } + + // First early stop: a full payload's worth of withdrawals, regardless + // of how much of the sweep bound is left to visit. + if withdrawals.len() == preset::MAX_WITHDRAWALS_PER_PAYLOAD { + break; + } + validator_index = validator_index + .checked_add(1) + .ok_or(Error::ArithmeticOverflow("validator_index + 1"))? + % validator_count; + } + // Second early stop: the loop above never runs more than `bound` + // iterations, so a registry larger than + // `MAX_VALIDATORS_PER_WITHDRAWALS_SWEEP` with nothing withdrawable in this + // round's window returns an empty list here rather than scanning every + // validator in the registry. + + Ok(withdrawals) +} + +/// Applies this block's withdrawal sweep: checks the block's declared +/// `payload.withdrawals` against what the sweep actually owes, pays each one +/// out, and advances the sweep's cursor for next time. +/// +/// The cursor update has two cases, and they diverge in a way that is easy to +/// miss reading only the "happy path": when the sweep filled a whole payload +/// (`expected_withdrawals.len() == MAX_WITHDRAWALS_PER_PAYLOAD`), +/// `next_withdrawal_validator_index` resumes right after the last validator +/// actually paid. Otherwise the sweep ran its entire bound without filling +/// the payload, and the cursor instead advances by the *constant* +/// `MAX_VALIDATORS_PER_WITHDRAWALS_SWEEP`, added to the cursor's value from +/// before this call, not to wherever [`get_expected_withdrawals`]'s loop +/// happened to land. On a registry smaller than that constant, those are +/// different validators: the loop itself only ever visits +/// `min(validator_count, MAX_VALIDATORS_PER_WITHDRAWALS_SWEEP)` indices and +/// wraps `validator_count`-periodically, while this update wraps the larger, +/// unclamped constant, so the next sweep can start from a position the +/// previous one never actually reached. +pub fn process_withdrawals( + state: &mut BeaconState, + payload: &capella::ExecutionPayload, +) -> Result<()> { + let expected_withdrawals = get_expected_withdrawals(state)?; + verify( + payload.withdrawals.to_vec() == expected_withdrawals, + "payload.withdrawals == expected_withdrawals", + )?; + + for withdrawal in &expected_withdrawals { + decrease_balance(state, withdrawal.validator_index, withdrawal.amount)?; + } + + // Update the next withdrawal index if this block contained withdrawals. + if let Some(latest_withdrawal) = expected_withdrawals.last() { + capella_state(state, "process_withdrawals")?.next_withdrawal_index = latest_withdrawal + .index + .checked_add(1) + .ok_or(Error::ArithmeticOverflow("latest_withdrawal.index + 1"))?; + } + + // Update the next validator index to start the next withdrawal sweep. See + // this function's own documentation for why the two branches below do not + // agree on where "next" is once the registry is smaller than + // MAX_VALIDATORS_PER_WITHDRAWALS_SWEEP. + let validator_count = state.validators().len() as u64; + let next_validator_index = if expected_withdrawals.len() == preset::MAX_WITHDRAWALS_PER_PAYLOAD + { + // A full payload: the next sweep resumes right after the last + // validator this block actually paid. + let latest_withdrawal = expected_withdrawals + .last() + .expect("MAX_WITHDRAWALS_PER_PAYLOAD is never zero, so a full payload is non-empty"); + latest_withdrawal + .validator_index + .checked_add(1) + .ok_or(Error::ArithmeticOverflow( + "latest_withdrawal.validator_index + 1", + ))? + % validator_count + } else { + // Not a full payload: advance the sweep by its own maximum length, + // from the cursor as it stood before this call, not from wherever the + // (already-exhausted) sweep loop ended up. + let current_cursor = + capella_state_ref(state, "process_withdrawals")?.next_withdrawal_validator_index; + current_cursor + .checked_add(preset::MAX_VALIDATORS_PER_WITHDRAWALS_SWEEP) + .ok_or(Error::ArithmeticOverflow( + "next_withdrawal_validator_index + MAX_VALIDATORS_PER_WITHDRAWALS_SWEEP", + ))? + % validator_count + }; + capella_state(state, "process_withdrawals")?.next_withdrawal_validator_index = + next_validator_index; + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Execution payload +// --------------------------------------------------------------------------- + +/// Validates this slot's execution payload and caches its header. +/// +/// Bellatrix's [`super::bellatrix::process_execution_payload`] with the +/// `is_merge_transition_complete` check the specification marks "Removed in +/// Capella" dropped, and reusing bellatrix's own +/// [`super::bellatrix::compute_timestamp_at_slot`] for the timestamp check +/// rather than a second copy of it. Takes `config` for the same reason +/// bellatrix's version does: the specification's `compute_time_at_slot` +/// reaches `SECONDS_PER_SLOT` from global scope, but this module keeps that +/// value as [`Config::seconds_per_slot`], a runtime value rather than a +/// preset, so anything that calls through to it needs one. +pub fn process_execution_payload( + state: &mut BeaconState, + payload: &capella::ExecutionPayload, + config: &Config, + engine: &ExecutionEngine, +) -> Result<()> { + verify( + payload.parent_hash + == capella_state_ref(state, "process_execution_payload")? + .latest_execution_payload_header + .block_hash, + "payload.parent_hash == state.latest_execution_payload_header.block_hash", + )?; + verify( + payload.prev_randao == get_randao_mix(state, get_current_epoch(state)), + "payload.prev_randao == get_randao_mix(state, get_current_epoch(state))", + )?; + verify( + payload.timestamp + == super::bellatrix::compute_timestamp_at_slot(state, state.slot(), config), + "payload.timestamp == compute_time_at_slot(state, state.slot)", + )?; + verify( + engine.execution_valid, + "verify_and_notify_new_payload(NewPayloadRequest(execution_payload=payload))", + )?; + + let header = capella::ExecutionPayloadHeader { + parent_hash: payload.parent_hash, + fee_recipient: payload.fee_recipient, + state_root: payload.state_root, + receipts_root: payload.receipts_root, + logs_bloom: payload.logs_bloom.clone(), + prev_randao: payload.prev_randao, + block_number: payload.block_number, + gas_limit: payload.gas_limit, + gas_used: payload.gas_used, + timestamp: payload.timestamp, + extra_data: payload.extra_data.clone(), + base_fee_per_gas: payload.base_fee_per_gas, + block_hash: payload.block_hash, + transactions_root: payload.transactions.hash_tree_root(), + // [New in Capella] + withdrawals_root: payload.withdrawals.hash_tree_root(), + }; + capella_state(state, "process_execution_payload")?.latest_execution_payload_header = header; + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Operations +// --------------------------------------------------------------------------- + +/// Runs every operation in a block, in the specification's order: phase0's +/// five lists, unchanged, plus this fork's own `bls_to_execution_changes` +/// appended after them. +/// +/// Delegates the first five lists to [`super::operations::process_operations`] +/// wholesale (deposit-count check included) rather than re-running each +/// per-operation function here: that function already calls the identical +/// functions in the identical order for every fork through deneb, and capella +/// changes none of them, so there is nothing for this to do differently +/// beyond adding the one new loop. +/// +/// Eight parameters, one past clippy's default limit, because this mirrors +/// the specification's own `process_operations(state, body)` unpacked into +/// the fields it reads rather than a whole body; see [`crate::beacon::stf`]'s module +/// documentation for why that unpacking is the point rather than an accident. +#[allow(clippy::too_many_arguments)] +pub fn process_operations( + state: &mut BeaconState, + proposer_slashings: &[ProposerSlashing], + attester_slashings: &[phase0::AttesterSlashing], + attestations: &[phase0::Attestation], + deposits: &[Deposit], + voluntary_exits: &[SignedVoluntaryExit], + bls_to_execution_changes: &[capella::SignedBLSToExecutionChange], + config: &Config, + committees: &CommitteeCache, +) -> Result<()> { + super::operations::process_operations( + state, + proposer_slashings, + attester_slashings, + attestations, + deposits, + voluntary_exits, + config, + committees, + )?; + for signed_change in bls_to_execution_changes { + process_bls_to_execution_change(state, signed_change, config)?; + } + Ok(()) +} + +/// Upgrades a validator's withdrawal credentials from a raw BLS public key +/// hash to an execution address, the one operation that makes a validator +/// eligible for [`get_expected_withdrawals`]'s sweep at all. +/// +/// Checked, in order: the credential really is the BLS-prefixed form (an +/// eth1-form credential has nothing left to upgrade), the credential's tail +/// really is the hash of the pubkey the request claims to be upgrading from +/// (proving the request is not renaming someone else's validator), and the +/// signature over the request verifies against that same pubkey. +/// +/// The signature is checked against +/// [`constants::DOMAIN_BLS_TO_EXECUTION_CHANGE`] combined with +/// [`Config::genesis_fork_version`], not the state's current fork version the +/// way [`crate::beacon::helpers::accessors::get_domain`] would compute it. That looks +/// like a bug, since every other signed message in this module signs under +/// whichever fork was active when the message was produced, but the +/// specification calls it out explicitly ("Fork-agnostic domain since address +/// changes are valid across forks") and means it: the validator holding the +/// BLS credential being replaced may not have been online, or even +/// instantiated, since genesis, so pinning the domain to a version that +/// cannot itself be superseded is what keeps this specific signature valid no +/// matter how many later forks have happened by the time it is actually +/// submitted. +pub fn process_bls_to_execution_change( + state: &mut BeaconState, + signed_change: &capella::SignedBLSToExecutionChange, + config: &Config, +) -> Result<()> { + let change = &signed_change.message; + + let validator = state.validator(change.validator_index)?; + verify( + validator.withdrawal_credentials.0[0] == constants::BLS_WITHDRAWAL_PREFIX, + "validator.withdrawal_credentials[:1] == BLS_WITHDRAWAL_PREFIX", + )?; + let hashed_pubkey = hash(&change.from_bls_pubkey.0); + verify( + validator.withdrawal_credentials.0[1..] == hashed_pubkey.0[1..], + "validator.withdrawal_credentials[1:] == hash(address_change.from_bls_pubkey)[1:]", + )?; + + let domain = compute_domain( + constants::DOMAIN_BLS_TO_EXECUTION_CHANGE, + config.genesis_fork_version, + state.genesis_validators_root(), + ); + let signing_root = compute_signing_root(change.hash_tree_root(), domain); + verify( + bls::verify( + &change.from_bls_pubkey, + signing_root, + &signed_change.signature, + ), + "bls.Verify(address_change.from_bls_pubkey, signing_root, signed_address_change.signature)", + )?; + + let mut credentials = [0u8; 32]; + credentials[0] = constants::ETH1_ADDRESS_WITHDRAWAL_PREFIX; + credentials[12..].copy_from_slice(&change.to_execution_address.0); + state + .validator_mut(change.validator_index)? + .withdrawal_credentials = H256(credentials); + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Fork projection +// --------------------------------------------------------------------------- + +/// The capella state, mutably, or an error naming the function that needs +/// one. +/// +/// Scoped to `BeaconState::Capella` alone, not to every later fork that keeps +/// the same withdrawal-cursor fields. Every caller in this file is reached +/// only through [`process_block`], which [`super::block::process_block`] +/// dispatches to precisely when `state` is already `BeaconState::Capella`, so +/// nothing here ever needs to read a deneb, electra, or fulu state. See +/// [`super::bellatrix::bellatrix_state`]'s own documentation for the identical +/// reasoning one fork earlier: each later fork's own block-processing module +/// will need its own projection of this same shape to reach its own +/// `latest_execution_payload_header` (a distinct type per fork from bellatrix +/// on) and its own withdrawal cursor, not this one widened to somehow return +/// a different concrete type per caller. +fn capella_state<'a>( + state: &'a mut BeaconState, + function: &'static str, +) -> Result<&'a mut capella::BeaconState> { + match state { + BeaconState::Capella(state) => Ok(state), + other => Err(Error::UnsupportedForFork { + function, + fork: other.fork_name(), + }), + } +} + +/// The capella state, immutably. See [`capella_state`]. +fn capella_state_ref<'a>( + state: &'a BeaconState, + function: &'static str, +) -> Result<&'a capella::BeaconState> { + match state { + BeaconState::Capella(state) => Ok(state), + other => Err(Error::UnsupportedForFork { + function, + fork: other.fork_name(), + }), + } +} + +#[cfg(test)] +mod tests { + use blst::min_pk::SecretKey; + + use super::*; + use crate::beacon::fork::ForkName; + use crate::beacon::primitives::{ + BlsPubkey, BlsSignature, ExecutionBlockHash, Gwei, Root, Uint256, ValidatorIndex, + }; + use libssz_types::SszVector; + + /// A capella state with `count` fully active, full-balance validators, one + /// epoch in (so the block-root history window already has entries). + /// + /// Every validator's pubkey is left at its all-zero default rather than a + /// real curve point: nothing under test here (the withdrawal sweep, its + /// cursor arithmetic, or the BLS-to-execution-change operation) ever + /// verifies a signature against a validator's own `pubkey`, so paying for + /// real key generation on every validator, which matters once a test + /// needs a registry larger than `MAX_VALIDATORS_PER_WITHDRAWALS_SWEEP`, + /// would buy nothing. + /// + /// A thin wrapper around the shared fork-parameterised builder: see + /// [`crate::beacon::helpers::test_state::with_validators_at`] for the construction + /// this and every other fork's test module used to duplicate. + fn capella_state_with_validators(count: usize) -> BeaconState { + crate::beacon::helpers::test_state::with_validators_at(ForkName::Capella, count) + } + + /// Marks validator `index` fully withdrawable: an eth1 credential, a + /// withdrawable epoch already past, and a positive balance. + fn make_fully_withdrawable(state: &mut BeaconState, index: ValidatorIndex, balance: Gwei) { + { + let validator = state.validator_mut(index).unwrap(); + validator.withdrawal_credentials.0[0] = constants::ETH1_ADDRESS_WITHDRAWAL_PREFIX; + validator.withdrawable_epoch = 0; + } + state.balances_mut()[index as usize] = balance; + } + + fn empty_execution_payload() -> capella::ExecutionPayload { + capella::ExecutionPayload { + parent_hash: ExecutionBlockHash::ZERO, + fee_recipient: ExecutionAddress::ZERO, + state_root: Root::ZERO, + receipts_root: Root::ZERO, + logs_bloom: SszVector::try_from(vec![0u8; preset::BYTES_PER_LOGS_BLOOM]) + .expect("built at exactly BYTES_PER_LOGS_BLOOM"), + prev_randao: Root::ZERO, + block_number: 0, + gas_limit: 0, + gas_used: 0, + timestamp: 0, + extra_data: Default::default(), + base_fee_per_gas: Uint256::ZERO, + block_hash: ExecutionBlockHash::ZERO, + transactions: Default::default(), + withdrawals: Default::default(), + } + } + + // ----------------------------------------------------------------------- + // get_expected_withdrawals + // ----------------------------------------------------------------------- + + #[test] + fn the_sweep_wraps_around_past_the_end_of_the_registry() { + let mut state = capella_state_with_validators(3); + for index in 0..3u64 { + make_fully_withdrawable(&mut state, index, preset::MAX_EFFECTIVE_BALANCE); + } + // Starting the cursor at the last validator forces a bound of three + // (or more) to wrap back to index 0 rather than running off the end + // of the registry. + capella_state(&mut state, "test setup") + .unwrap() + .next_withdrawal_validator_index = 2; + + let withdrawals = get_expected_withdrawals(&state).unwrap(); + let visited: Vec = withdrawals.iter().map(|w| w.validator_index).collect(); + assert_eq!( + visited, + vec![2, 0, 1], + "the sweep must wrap through index 0 rather than stop or error at the end" + ); + } + + #[test] + fn the_sweep_stops_once_a_full_payload_is_collected() { + let count = preset::MAX_WITHDRAWALS_PER_PAYLOAD + 5; + let mut state = capella_state_with_validators(count); + for index in 0..count as u64 { + make_fully_withdrawable(&mut state, index, preset::MAX_EFFECTIVE_BALANCE); + } + + let withdrawals = get_expected_withdrawals(&state).unwrap(); + assert_eq!( + withdrawals.len(), + preset::MAX_WITHDRAWALS_PER_PAYLOAD, + "the sweep must stop the moment a full payload is collected, even though \ + every remaining validator in the bound is also withdrawable" + ); + } + + #[test] + fn the_sweep_never_visits_past_its_own_bound() { + // A registry one validator larger than the sweep bound, with only the + // validator just past the bound withdrawable: if the sweep reached + // it, the bound would not be doing anything. Every validator here + // keeps its all-default (non-real) pubkey, which is what makes a + // registry this large cheap to build. + let bound = preset::MAX_VALIDATORS_PER_WITHDRAWALS_SWEEP as usize; + let count = bound + 1; + let mut state = capella_state_with_validators(count); + make_fully_withdrawable( + &mut state, + bound as ValidatorIndex, + preset::MAX_EFFECTIVE_BALANCE, + ); + + let withdrawals = get_expected_withdrawals(&state).unwrap(); + assert!( + withdrawals.is_empty(), + "the sweep must not visit a validator past MAX_VALIDATORS_PER_WITHDRAWALS_SWEEP" + ); + } + + // ----------------------------------------------------------------------- + // process_withdrawals + // ----------------------------------------------------------------------- + + #[test] + fn a_full_payload_resumes_the_cursor_right_after_the_last_withdrawal_paid() { + let count = preset::MAX_WITHDRAWALS_PER_PAYLOAD + 2; + let mut state = capella_state_with_validators(count); + for index in 0..count as u64 { + make_fully_withdrawable(&mut state, index, preset::MAX_EFFECTIVE_BALANCE); + } + + let expected = get_expected_withdrawals(&state).unwrap(); + assert_eq!(expected.len(), preset::MAX_WITHDRAWALS_PER_PAYLOAD); + let last = expected.last().unwrap().clone(); + + let payload = capella::ExecutionPayload { + withdrawals: expected.try_into().unwrap(), + ..empty_execution_payload() + }; + process_withdrawals(&mut state, &payload).unwrap(); + + let inner = capella_state_ref(&state, "test assertion").unwrap(); + assert_eq!(inner.next_withdrawal_index, last.index + 1); + assert_eq!( + inner.next_withdrawal_validator_index, + (last.validator_index + 1) % count as u64 + ); + } + + #[test] + fn a_partial_sweep_advances_the_cursor_by_the_bound_from_before_the_call() { + // Nobody in this registry is withdrawable, so the sweep runs its + // whole bound and returns nothing. The cursor must still move: by + // MAX_VALIDATORS_PER_WITHDRAWALS_SWEEP from wherever it stood before + // this call, not by however many validators the (empty) sweep loop + // actually visited. + let count = 5; + let mut state = capella_state_with_validators(count); + capella_state(&mut state, "test setup") + .unwrap() + .next_withdrawal_validator_index = 2; + + let expected = get_expected_withdrawals(&state).unwrap(); + assert!( + expected.is_empty(), + "nobody in this registry is withdrawable" + ); + + let payload = capella::ExecutionPayload { + withdrawals: Default::default(), + ..empty_execution_payload() + }; + process_withdrawals(&mut state, &payload).unwrap(); + + let inner = capella_state_ref(&state, "test assertion").unwrap(); + let expected_next = (2 + preset::MAX_VALIDATORS_PER_WITHDRAWALS_SWEEP) % count as u64; + assert_eq!(inner.next_withdrawal_validator_index, expected_next); + } + + #[test] + fn a_block_declaring_the_wrong_withdrawals_is_rejected() { + let mut state = capella_state_with_validators(3); + make_fully_withdrawable(&mut state, 0, preset::MAX_EFFECTIVE_BALANCE); + + let payload = capella::ExecutionPayload { + // The sweep owes one withdrawal; declaring none must fail. + withdrawals: Default::default(), + ..empty_execution_payload() + }; + assert!(process_withdrawals(&mut state, &payload).is_err()); + } + + // ----------------------------------------------------------------------- + // process_bls_to_execution_change + // ----------------------------------------------------------------------- + + /// `specs/altair/bls.md`'s BLS proof-of-possession domain separation tag, + /// the same one every other real-signature test in this module signs + /// under. + const DST: &[u8] = b"BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_"; + + #[test] + fn a_credential_change_verifies_under_the_genesis_fork_version_not_the_current_one() { + let config = Config::mainnet(); + let mut state = capella_state_with_validators(2); + // A capella state's own fork version is not the genesis one: proves + // the signature is checked against `config.genesis_fork_version` + // specifically, not `state.fork().current_version`, which would also + // be available here and would silently pass a same-fork test without + // proving anything about which version is actually used. + state.fork_mut().current_version = config.capella_fork_version; + state.fork_mut().epoch = config.capella_fork_epoch; + assert_ne!(config.capella_fork_version, config.genesis_fork_version); + + let secret_key = SecretKey::key_gen(&[3u8; 32], &[]).expect("32 bytes of key material"); + let from_pubkey = BlsPubkey(secret_key.sk_to_pk().to_bytes()); + let hashed = hash(&from_pubkey.0); + { + let validator = state.validator_mut(0).unwrap(); + validator.withdrawal_credentials.0[0] = constants::BLS_WITHDRAWAL_PREFIX; + validator.withdrawal_credentials.0[1..].copy_from_slice(&hashed.0[1..]); + } + + let message = capella::BLSToExecutionChange { + validator_index: 0, + from_bls_pubkey: from_pubkey, + to_execution_address: ExecutionAddress::repeat_byte(0xab), + }; + let genesis_domain = compute_domain( + constants::DOMAIN_BLS_TO_EXECUTION_CHANGE, + config.genesis_fork_version, + state.genesis_validators_root(), + ); + let signing_root = compute_signing_root(message.hash_tree_root(), genesis_domain); + let signature = BlsSignature( + secret_key + .sign(signing_root.as_slice(), DST, &[]) + .to_bytes(), + ); + let signed_change = capella::SignedBLSToExecutionChange { message, signature }; + + process_bls_to_execution_change(&mut state, &signed_change, &config).unwrap(); + + let validator = state.validator(0).unwrap(); + assert_eq!( + validator.withdrawal_credentials.0[0], + constants::ETH1_ADDRESS_WITHDRAWAL_PREFIX + ); + assert_eq!( + &validator.withdrawal_credentials.0[12..], + ExecutionAddress::repeat_byte(0xab).as_slice() + ); + } + + #[test] + fn a_signature_made_under_the_current_fork_version_is_rejected() { + let config = Config::mainnet(); + let mut state = capella_state_with_validators(2); + state.fork_mut().current_version = config.capella_fork_version; + state.fork_mut().epoch = config.capella_fork_epoch; + + let secret_key = SecretKey::key_gen(&[4u8; 32], &[]).expect("32 bytes of key material"); + let from_pubkey = BlsPubkey(secret_key.sk_to_pk().to_bytes()); + let hashed = hash(&from_pubkey.0); + { + let validator = state.validator_mut(0).unwrap(); + validator.withdrawal_credentials.0[0] = constants::BLS_WITHDRAWAL_PREFIX; + validator.withdrawal_credentials.0[1..].copy_from_slice(&hashed.0[1..]); + } + + let message = capella::BLSToExecutionChange { + validator_index: 0, + from_bls_pubkey: from_pubkey, + to_execution_address: ExecutionAddress::repeat_byte(0xab), + }; + // Signed under the state's current (capella) fork version, which + // process_bls_to_execution_change must not accept. + let wrong_domain = compute_domain( + constants::DOMAIN_BLS_TO_EXECUTION_CHANGE, + config.capella_fork_version, + state.genesis_validators_root(), + ); + let signing_root = compute_signing_root(message.hash_tree_root(), wrong_domain); + let signature = BlsSignature( + secret_key + .sign(signing_root.as_slice(), DST, &[]) + .to_bytes(), + ); + let signed_change = capella::SignedBLSToExecutionChange { message, signature }; + + assert!(process_bls_to_execution_change(&mut state, &signed_change, &config).is_err()); + } + + #[test] + fn a_credential_not_in_the_bls_form_is_rejected() { + let config = Config::mainnet(); + let mut state = capella_state_with_validators(1); + state.validator_mut(0).unwrap().withdrawal_credentials.0[0] = + constants::ETH1_ADDRESS_WITHDRAWAL_PREFIX; + + let secret_key = SecretKey::key_gen(&[5u8; 32], &[]).expect("32 bytes of key material"); + let message = capella::BLSToExecutionChange { + validator_index: 0, + from_bls_pubkey: BlsPubkey(secret_key.sk_to_pk().to_bytes()), + to_execution_address: ExecutionAddress::repeat_byte(0xab), + }; + let signed_change = capella::SignedBLSToExecutionChange { + message, + signature: BlsSignature::default(), + }; + + assert!(process_bls_to_execution_change(&mut state, &signed_change, &config).is_err()); + } +} diff --git a/crates/blockchain/state_transition/src/beacon/stf/deneb.rs b/crates/blockchain/state_transition/src/beacon/stf/deneb.rs new file mode 100644 index 000000000..528892b21 --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/stf/deneb.rs @@ -0,0 +1,756 @@ +//! Deneb-specific block processing. +//! +//! Deneb's headline change is data blobs (EIP-4844): temporary storage for +//! rollup data that the beacon chain commits to but never itself processes. +//! A blob is far larger than everything else a block carries, and consensus +//! never reads its contents, only its existence, so the specification never +//! puts one in the block. Instead a block commits only to a KZG commitment +//! per blob (`blob_kzg_commitments`, appended to `BeaconBlockBody`), and each +//! blob is propagated separately as a [`crate::beacon::containers::deneb::BlobSidecar`] +//! over its own gossip subnet. [`kzg_commitment_to_versioned_hash`] is the +//! bridge between the two worlds: it turns a commitment into the same +//! versioned hash form the execution payload's blob-carrying transactions +//! reference, which is what lets [`process_execution_payload`] check that a +//! block's commitments and its payload's transactions agree on which blobs +//! this block actually depends on, without the beacon chain ever holding a +//! blob itself. +//! +//! Deneb's block itself is capella's `process_block` unchanged in structure +//! (header, withdrawals, execution payload, RANDAO, eth1 vote, operations, +//! sync aggregate); see [`process_block`] for exactly which steps are +//! capella's own and which are this module's. Three of those steps change: +//! [`process_attestation`] widens its inclusion-slot check and its +//! timely-target reward condition for EIP-7045, [`process_execution_payload`] +//! checks the block's blob commitments against the execution engine and +//! caches two new blob-gas fields for EIP-4844, and [`process_voluntary_exit`] +//! signs under a fixed fork version for EIP-7044. + +use crate::beacon::bls; +use crate::beacon::config::Config; +use crate::beacon::constants::{self, FAR_FUTURE_EPOCH}; +use crate::beacon::containers::capella::SignedBLSToExecutionChange; +use crate::beacon::containers::shared::{ + AttestationData, Deposit, ProposerSlashing, SignedVoluntaryExit, +}; +use crate::beacon::containers::{BeaconState, deneb, phase0}; +use crate::beacon::error::{Error, Result, verify}; +use crate::beacon::hash::hash; +use crate::beacon::helpers::accessors::{ + CommitteeCache, CommitteeCacheExt, get_beacon_proposer_index, get_block_root, + get_block_root_at_slot, get_current_epoch, get_previous_epoch, get_randao_mix, +}; +use crate::beacon::helpers::altair::{add_flag, get_base_reward_per_increment, has_flag}; +use crate::beacon::helpers::attestation::{get_indexed_attestation, is_valid_indexed_attestation}; +use crate::beacon::helpers::math::integer_squareroot; +use crate::beacon::helpers::misc::{compute_domain, compute_epoch_at_slot, compute_signing_root}; +use crate::beacon::helpers::mutators::{ + decrease_balance, increase_balance, initiate_validator_exit, +}; +use crate::beacon::helpers::predicates::is_active_validator; +use crate::beacon::preset; +use crate::beacon::primitives::{ + Bytes32, Gwei, H256, HashTreeRoot as _, KzgCommitment, ParticipationFlags, ValidatorIndex, +}; + +use super::ExecutionEngine; + +// --------------------------------------------------------------------------- +// Block processing +// --------------------------------------------------------------------------- + +/// Deneb's block processing: capella's steps, unchanged in order. +/// +/// The specification never lists a "Modified `process_block`" for deneb: the +/// only per-step notes it adds are to [`process_attestation`], +/// [`process_execution_payload`], and [`process_voluntary_exit`], so this +/// mirrors capella's own driver line for line rather than something this +/// module invents. That is also why this dispatches to [`process_operations`] +/// below rather than to [`super::operations::process_operations`] the way +/// bellatrix and altair do: deneb's own attestation and voluntary-exit rules +/// have to reach the block's operations somewhere, and +/// [`super::operations::process_operations`] has no fork check of its own to +/// hang them on. See [`process_operations`]'s own documentation for why that +/// is a full copy of the loop rather than a smaller patch. +pub fn process_block( + state: &mut BeaconState, + block: &deneb::BeaconBlock, + config: &Config, + engine: &ExecutionEngine, + committees: &CommitteeCache, +) -> Result<()> { + super::block::process_block_header( + state, + block.slot, + block.proposer_index, + block.parent_root, + block.body.hash_tree_root(), + )?; + process_withdrawals(state, &block.body.execution_payload)?; + process_execution_payload( + state, + &block.body.execution_payload, + &block.body.blob_kzg_commitments, + config, + engine, + )?; + super::block::process_randao(state, &block.body.randao_reveal)?; + super::block::process_eth1_data(state, &block.body.eth1_data)?; + process_operations( + state, + &block.body.proposer_slashings, + &block.body.attester_slashings, + &block.body.attestations, + &block.body.deposits, + &block.body.voluntary_exits, + &block.body.bls_to_execution_changes, + config, + committees, + )?; + super::altair::process_sync_aggregate(state, &block.body.sync_aggregate)?; + Ok(()) +} + +/// Runs every operation in a deneb block, in the specification's order. +/// +/// Capella's own body adds exactly one list to phase0's five +/// (`bls_to_execution_changes`), and deneb adds none, so the operations this +/// loops over are identical to capella's. What is not identical is which +/// per-operation function two of those loops call: attestations go to this +/// module's own [`process_attestation`] rather than altair's, and voluntary +/// exits go to this module's own [`process_voluntary_exit`] rather than +/// phase0's, since [`super::operations::process_operations`] (which every +/// earlier fork's driver shares) hard-codes the other two. Rather than teach +/// that shared function a fork check for two operations it does not own the +/// rules for, this copies its loop structure wholesale, the same way the +/// specification itself redefines `process_operations` per fork whenever a +/// body's shape or a called function changes; see this module's own +/// documentation for the alternative (a fork check inside the shared +/// function) and why it was not the one picked. +/// +/// The deposit count check is copied unchanged from +/// [`super::operations::process_operations`]: nothing about deposits changed +/// between phase0 and deneb. +/// +/// Six lists, one per parameter, is one past clippy's default limit; capella's +/// own `process_operations` carries the identical six and the identical +/// allowance, since a block's operations really do not compress into fewer +/// arguments without inventing a body type this module deliberately does not +/// have (see [`crate::beacon::stf`]'s module documentation). +#[allow(clippy::too_many_arguments)] +fn process_operations( + state: &mut BeaconState, + proposer_slashings: &[ProposerSlashing], + attester_slashings: &[phase0::AttesterSlashing], + attestations: &[phase0::Attestation], + deposits: &[Deposit], + voluntary_exits: &[SignedVoluntaryExit], + bls_to_execution_changes: &[SignedBLSToExecutionChange], + config: &Config, + committees: &CommitteeCache, +) -> Result<()> { + let outstanding = state + .eth1_data() + .deposit_count + .checked_sub(state.eth1_deposit_index()) + .ok_or(Error::ArithmeticOverflow( + "eth1_data.deposit_count - eth1_deposit_index", + ))?; + verify( + deposits.len() as u64 == outstanding.min(preset::MAX_DEPOSITS as u64), + "len(body.deposits) == min(MAX_DEPOSITS, eth1_data.deposit_count - eth1_deposit_index)", + )?; + + for proposer_slashing in proposer_slashings { + super::operations::process_proposer_slashing(state, proposer_slashing, config)?; + } + for attester_slashing in attester_slashings { + super::operations::process_attester_slashing(state, attester_slashing, config)?; + } + for attestation in attestations { + process_attestation(state, attestation, committees)?; + } + for deposit in deposits { + super::operations::process_deposit(state, deposit, config)?; + } + for voluntary_exit in voluntary_exits { + process_voluntary_exit(state, voluntary_exit, config)?; + } + for signed_change in bls_to_execution_changes { + super::capella::process_bls_to_execution_change(state, signed_change, config)?; + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Withdrawals +// --------------------------------------------------------------------------- + +/// Applies this slot's withdrawal sweep against a deneb execution payload. +/// +/// [`super::capella::get_expected_withdrawals`] is reused unchanged: it +/// depends only on `state`, not on any payload type, since which validators +/// are due a withdrawal has nothing to do with which fork's execution payload +/// carries the result. This function itself cannot be capella's own, despite +/// applying an identical rule, because `payload.withdrawals` has to be +/// compared against a `payload` typed [`deneb::ExecutionPayload`], and that +/// is a different Rust type from `capella::ExecutionPayload` even though +/// both alias the same [`crate::beacon::containers::capella::Withdrawal`] element +/// type for the list itself. +/// +/// Public, like capella's and electra's counterparts, because the `operations` +/// fixture suite drives each fork's withdrawal step directly rather than through +/// `process_block`. +pub fn process_withdrawals( + state: &mut BeaconState, + payload: &deneb::ExecutionPayload, +) -> Result<()> { + let expected_withdrawals = super::capella::get_expected_withdrawals(state)?; + verify( + payload.withdrawals.to_vec() == expected_withdrawals, + "payload.withdrawals == expected_withdrawals", + )?; + + for withdrawal in &expected_withdrawals { + decrease_balance(state, withdrawal.validator_index, withdrawal.amount)?; + } + + if let Some(latest_withdrawal) = expected_withdrawals.last() { + deneb_state(state, "process_withdrawals")?.next_withdrawal_index = latest_withdrawal + .index + .checked_add(1) + .ok_or(Error::ArithmeticOverflow("latest_withdrawal.index + 1"))?; + } + + let validator_count = state.validators().len() as ValidatorIndex; + let next_validator_index = if expected_withdrawals.len() == preset::MAX_WITHDRAWALS_PER_PAYLOAD + { + let latest_withdrawal = expected_withdrawals + .last() + .expect("MAX_WITHDRAWALS_PER_PAYLOAD is never zero, so a full payload is non-empty"); + latest_withdrawal + .validator_index + .checked_add(1) + .ok_or(Error::ArithmeticOverflow( + "latest_withdrawal.validator_index + 1", + ))? + % validator_count + } else { + let current_cursor = + deneb_state_ref(state, "process_withdrawals")?.next_withdrawal_validator_index; + current_cursor + .checked_add(preset::MAX_VALIDATORS_PER_WITHDRAWALS_SWEEP) + .ok_or(Error::ArithmeticOverflow( + "next_withdrawal_validator_index + MAX_VALIDATORS_PER_WITHDRAWALS_SWEEP", + ))? + % validator_count + }; + deneb_state(state, "process_withdrawals")?.next_withdrawal_validator_index = + next_validator_index; + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Attestations +// --------------------------------------------------------------------------- + +/// Scores an attestation and pays its including proposer, widening altair's +/// rule for EIP-7045. +/// +/// Shares its validation prologue with +/// [`crate::beacon::helpers::altair::get_attestation_participation_flag_indices`]'s +/// caller in altair (target epoch, committee shape, signature), with one +/// prologue check dropped: altair also demands `state.slot <= data.slot + +/// SLOTS_PER_EPOCH`, an upper bound on how late an attestation may still be +/// included. FFG justification itself has no such bound; a source vote +/// either extends the justified chain or it does not, however late it +/// lands. Deneb drops the bound to match, and widens +/// [`attestation_participation_flag_indices`] the same way: the timely-target +/// flag no longer checks `inclusion_delay` at all, so a late-but-correct +/// target vote is not paid for its correctness one moment and then refused +/// the reward for the exact same vote the moment altair's old window closed. +/// +/// Structured as the same two-phase read-then-write split altair's version +/// uses, and for the identical reason: deciding which flags this attestation +/// newly grants only ever reads `state`, while flipping them needs `&mut +/// state`, and the two cannot interleave in one pass without conflicting +/// borrows (see altair's `process_attestation` for the borrow-checker +/// argument in full). +pub fn process_attestation( + state: &mut BeaconState, + attestation: &phase0::Attestation, + committees: &CommitteeCache, +) -> Result<()> { + let data = attestation.data; + let current_epoch = get_current_epoch(state); + let previous_epoch = get_previous_epoch(state); + + verify( + data.target.epoch == previous_epoch || data.target.epoch == current_epoch, + "data.target.epoch in (get_previous_epoch(state), get_current_epoch(state))", + )?; + verify( + data.target.epoch == compute_epoch_at_slot(data.slot), + "data.target.epoch == compute_epoch_at_slot(data.slot)", + )?; + + // [EIP-7045]: no upper bound on `state.slot` here, unlike altair; see this + // function's own documentation for why. `data.slot` still comes straight + // off the wire, so the lower bound's own addition is still checked. + let min_slot = data + .slot + .checked_add(preset::MIN_ATTESTATION_INCLUSION_DELAY) + .ok_or(Error::ArithmeticOverflow( + "data.slot + MIN_ATTESTATION_INCLUSION_DELAY", + ))?; + verify( + min_slot <= state.slot(), + "data.slot + MIN_ATTESTATION_INCLUSION_DELAY <= state.slot", + )?; + // The committee count read off the shared shuffling rather than through + // `get_committee_count_per_slot`, which would scan the whole registry per + // attestation for the same value. + let epoch_committees = committees.committees(state, data.target.epoch); + verify( + data.index < epoch_committees.committees_per_slot(), + "data.index < get_committee_count_per_slot(state, data.target.epoch)", + )?; + + let committee_len = epoch_committees.committee(data.slot, data.index)?.len(); + verify( + attestation.aggregation_bits.len() == committee_len, + "len(attestation.aggregation_bits) == len(committee)", + )?; + + // Safe: `min_slot <= state.slot()` above and `min_slot >= data.slot` (the + // inclusion delay is non-negative), so `data.slot <= state.slot()`. + let inclusion_delay = state.slot() - data.slot; + let participation_flag_indices = + attestation_participation_flag_indices(state, &data, inclusion_delay)?; + + let indexed_attestation = get_indexed_attestation(state, attestation, committees)?; + verify( + is_valid_indexed_attestation(state, &indexed_attestation), + "is_valid_indexed_attestation(state, get_indexed_attestation(state, attestation))", + )?; + + // Read phase: for every attester, decide which flags this attestation + // newly satisfies and add up the proposer's reward for granting them. + // + // `indexed_attestation`'s indices rather than a second + // `get_attesting_indices` call, for the reason given on the same line in + // `electra::process_attestation`. + let attesting_indices = indexed_attestation.attesting_indices.to_vec(); + let current_epoch_target = data.target.epoch == current_epoch; + // Through the fork-generic accessor, not a projection to a concrete + // `altair::BeaconState`. The participation lists are unchanged from altair + // through fulu, so a projection matching only `BeaconState::Altair` rejects + // every deneb state, which is the only kind this function is ever called + // with: it made deneb's attestation processing fail outright. + let (previous_participation, current_participation, _) = state.altair_validator_lists()?; + let epoch_participation = if current_epoch_target { + current_participation + } else { + previous_participation + }; + + // Hoisted: see the comment on the same line in `electra::process_attestation`. + let base_reward_per_increment = get_base_reward_per_increment(state)?; + + let mut proposer_reward_numerator: Gwei = 0; + let mut updates: Vec<(ValidatorIndex, ParticipationFlags)> = Vec::new(); + for index in attesting_indices { + let current_flags = + epoch_participation + .get(index as usize) + .copied() + .ok_or(Error::IndexOutOfBounds { + index: index as usize, + len: epoch_participation.len(), + })?; + + let mut new_flags: ParticipationFlags = 0; + for &flag_index in &participation_flag_indices { + if has_flag(current_flags, flag_index) { + continue; + } + new_flags = add_flag(new_flags, flag_index); + let weight = constants::PARTICIPATION_FLAG_WEIGHTS[flag_index]; + // `get_base_reward(state, index)` inlined against the hoisted + // per-increment value, in the helper's own order of operations so + // the result is bit-identical. See `electra::process_attestation` + // for why the hoist is not a tidy-up but the difference between + // importing a block at mainnet scale and not. + let increments = + state.validator(index)?.effective_balance / preset::EFFECTIVE_BALANCE_INCREMENT; + let reward = (increments * base_reward_per_increment) + .checked_mul(weight) + .ok_or(Error::ArithmeticOverflow( + "get_base_reward(state, index) * weight", + ))?; + proposer_reward_numerator = proposer_reward_numerator + .checked_add(reward) + .ok_or(Error::ArithmeticOverflow("proposer_reward_numerator"))?; + } + if new_flags != 0 { + updates.push((index, new_flags)); + } + } + + // Write phase: apply exactly the flags the read phase decided on. + let (previous_participation, current_participation, _) = state.altair_validator_lists_mut()?; + let epoch_participation = if current_epoch_target { + current_participation + } else { + previous_participation + }; + let epoch_participation_len = epoch_participation.len(); + for (index, new_flags) in updates { + let flags = epoch_participation + .get_mut(index as usize) + .ok_or(Error::IndexOutOfBounds { + index: index as usize, + len: epoch_participation_len, + })?; + *flags |= new_flags; + } + + const NON_PROPOSER_WEIGHT: u64 = constants::WEIGHT_DENOMINATOR - constants::PROPOSER_WEIGHT; + const PROPOSER_REWARD_DENOMINATOR: u64 = + NON_PROPOSER_WEIGHT * constants::WEIGHT_DENOMINATOR / constants::PROPOSER_WEIGHT; + let proposer_reward = proposer_reward_numerator / PROPOSER_REWARD_DENOMINATOR; + let proposer_index = get_beacon_proposer_index(state)?; + increase_balance(state, proposer_index, proposer_reward)?; + + Ok(()) +} + +/// Which of the three participation flags an attestation with `data`, +/// included after `inclusion_delay` slots, satisfies. +/// +/// Deneb's version of +/// [`crate::beacon::helpers::altair::get_attestation_participation_flag_indices`]: +/// EIP-7045 grants the timely-target flag to every correctly-targeted +/// attestation regardless of `inclusion_delay`, rather than only within +/// altair's one-epoch reward window. The source and head conditions are +/// altair's, unchanged. See [`process_attestation`]'s own documentation for +/// why the two forks diverge here at all. +fn attestation_participation_flag_indices( + state: &BeaconState, + data: &AttestationData, + inclusion_delay: u64, +) -> Result> { + let justified_checkpoint = if data.target.epoch == get_current_epoch(state) { + state.current_justified_checkpoint() + } else { + state.previous_justified_checkpoint() + }; + let is_matching_source = data.source == justified_checkpoint; + + let target_root = get_block_root(state, data.target.epoch)?; + let target_root_matches = data.target.root == target_root; + let is_matching_target = is_matching_source && target_root_matches; + + let head_root = get_block_root_at_slot(state, data.slot)?; + let head_root_matches = data.beacon_block_root == head_root; + let is_matching_head = is_matching_target && head_root_matches; + + verify(is_matching_source, "is_matching_source")?; + + let mut participation_flag_indices = Vec::new(); + if is_matching_source && inclusion_delay <= integer_squareroot(preset::SLOTS_PER_EPOCH) { + participation_flag_indices.push(constants::TIMELY_SOURCE_FLAG_INDEX); + } + // [EIP-7045]: no `inclusion_delay` bound, unlike altair's + // `inclusion_delay <= SLOTS_PER_EPOCH`. + if is_matching_target { + participation_flag_indices.push(constants::TIMELY_TARGET_FLAG_INDEX); + } + if is_matching_head && inclusion_delay == preset::MIN_ATTESTATION_INCLUSION_DELAY { + participation_flag_indices.push(constants::TIMELY_HEAD_FLAG_INDEX); + } + + Ok(participation_flag_indices) +} + +// --------------------------------------------------------------------------- +// Execution payload +// --------------------------------------------------------------------------- + +/// Validates this slot's execution payload and its blob commitments, then +/// caches the payload's header alongside deneb's two new blob-gas fields. +/// +/// Takes `config`, which the specification's own three-argument +/// `process_execution_payload(state, body, execution_engine)` does not: +/// [`super::bellatrix::compute_timestamp_at_slot`] needs one, for the same +/// reason bellatrix's own version of this function does, and this module +/// systematically hands every function needing a configuration value one +/// explicitly rather than reaching for a global. +/// +/// Also takes `blob_kzg_commitments` on its own, separate from `payload`, +/// rather than a whole body, matching how every shared step in +/// [`super::block`] takes only the fields it reads (see [`crate::beacon::stf`]'s +/// module documentation for why no shared body type exists to pass instead): +/// the commitments live on the block body, not the payload, and this is the +/// one step that needs them, since [`kzg_commitment_to_versioned_hash`] turns +/// each into the same versioned-hash form a blob-carrying transaction inside +/// `payload.transactions` commits to, which is exactly what the +/// specification's `NewPayloadRequest.versioned_hashes` reconciles against +/// `is_valid_versioned_hashes`. +/// +/// That reconciliation itself is not checked here, for the same reason +/// [`super::bellatrix::process_execution_payload`] cannot check +/// `payload.block_hash` against a transaction's own contents either: this +/// crate's `Transaction` is opaque bytes, never decoded, so nothing here can +/// extract a transaction's claimed versioned hashes to compare. It collapses, +/// like the rest of `verify_and_notify_new_payload`, into +/// [`ExecutionEngine::execution_valid`]; the versioned hashes are still +/// computed unconditionally, both so [`kzg_commitment_to_versioned_hash`] is +/// exercised the way the specification's own data flow exercises it, and so a +/// future engine model with something real to check against has the value +/// ready to hand it. +/// +/// What this function can and does check on its own, without any engine at +/// all, is the commitment count: [`Config::max_blobs_per_block_deneb`] is +/// this fork's fixed cap, configuration rather than a preset because +/// electra raises it and fulu's blob schedule (EIP-7892) can raise it again +/// without a further hard fork; see [`Config::max_blobs_per_block`]'s own +/// documentation for why deneb reads the fixed field directly instead of +/// going through that schedule-aware lookup. +/// +/// No `is_merge_transition_complete` check, unlike bellatrix's version: +/// capella's specification already removes it on the grounds that a chain +/// still pre-merge by capella cannot exist, and deneb inherits that removal +/// unchanged. +pub fn process_execution_payload( + state: &mut BeaconState, + payload: &deneb::ExecutionPayload, + blob_kzg_commitments: &[KzgCommitment], + config: &Config, + engine: &ExecutionEngine, +) -> Result<()> { + let deneb_ref = deneb_state_ref(state, "process_execution_payload")?; + verify( + payload.parent_hash == deneb_ref.latest_execution_payload_header.block_hash, + "payload.parent_hash == state.latest_execution_payload_header.block_hash", + )?; + verify( + payload.prev_randao == get_randao_mix(state, get_current_epoch(state)), + "payload.prev_randao == get_randao_mix(state, get_current_epoch(state))", + )?; + verify( + payload.timestamp + == super::bellatrix::compute_timestamp_at_slot(state, state.slot(), config), + "payload.timestamp == compute_time_at_slot(state, state.slot)", + )?; + verify( + blob_kzg_commitments.len() as u64 <= config.max_blobs_per_block_deneb, + "len(body.blob_kzg_commitments) <= MAX_BLOBS_PER_BLOCK", + )?; + + // See this function's own documentation for why this is computed but not + // itself checked against anything. + let _versioned_hashes: Vec = blob_kzg_commitments + .iter() + .map(kzg_commitment_to_versioned_hash) + .collect(); + + verify( + engine.execution_valid, + "verify_and_notify_new_payload(NewPayloadRequest(execution_payload=payload, \ + versioned_hashes=versioned_hashes, \ + parent_beacon_block_root=state.latest_block_header.parent_root))", + )?; + + let header = deneb::ExecutionPayloadHeader { + parent_hash: payload.parent_hash, + fee_recipient: payload.fee_recipient, + state_root: payload.state_root, + receipts_root: payload.receipts_root, + logs_bloom: payload.logs_bloom.clone(), + prev_randao: payload.prev_randao, + block_number: payload.block_number, + gas_limit: payload.gas_limit, + gas_used: payload.gas_used, + timestamp: payload.timestamp, + extra_data: payload.extra_data.clone(), + base_fee_per_gas: payload.base_fee_per_gas, + block_hash: payload.block_hash, + // The header substitutes a root for each bulky list; see + // `super::bellatrix`'s own documentation for why the state keeps only + // that much. + transactions_root: payload.transactions.hash_tree_root(), + withdrawals_root: payload.withdrawals.hash_tree_root(), + blob_gas_used: payload.blob_gas_used, + excess_blob_gas: payload.excess_blob_gas, + }; + deneb_state(state, "process_execution_payload")?.latest_execution_payload_header = header; + + Ok(()) +} + +/// A blob's versioned hash: the form its KZG commitment takes wherever a +/// blob-carrying transaction references it, so [`process_execution_payload`] +/// can reconcile the two without decoding a transaction itself. +/// +/// Not a bare hash of the commitment: overwriting the hash's own first byte +/// with [`constants::VERSIONED_HASH_VERSION_KZG`] is what lets the execution +/// layer's transaction format tell a KZG-backed versioned hash apart from a +/// different kind of hash-derived identifier it might introduce later under a +/// different version byte, without either layer needing to know which +/// commitment scheme produced any given one. +pub fn kzg_commitment_to_versioned_hash(commitment: &KzgCommitment) -> Bytes32 { + let digest = hash(&commitment.0); + let mut versioned_hash = digest.0; + versioned_hash[0] = constants::VERSIONED_HASH_VERSION_KZG; + H256(versioned_hash) +} + +// --------------------------------------------------------------------------- +// Voluntary exits +// --------------------------------------------------------------------------- + +/// Starts a validator's voluntary exit, signed under a fixed fork version for +/// EIP-7044. +/// +/// Every check but the last is phase0's, unchanged: still active, not already +/// exiting, past its own requested epoch, and past `SHARD_COMMITTEE_PERIOD` +/// since activation. What changes is the domain the signature is checked +/// against. Every other signed message in this module calls +/// [`crate::beacon::helpers::accessors::get_domain`], which signs under whichever +/// fork version is active at the message's own epoch, so the same message +/// signed just before and just after a fork boundary produces two different, +/// mutually invalid signatures. A voluntary exit is deliberately signed far +/// in advance of the epoch it takes effect at (a validator may queue its exit +/// long before it is eligible to leave), so binding it to "whichever fork is +/// current" means a signature made under, say, capella would stop verifying +/// the moment the chain forked into deneb, silently expiring a validator's +/// already-signed intent to leave through no fault of its own. Fixing the +/// domain to [`Config::capella_fork_version`] instead, forever, regardless of +/// which fork actually processes the exit, is what makes a signed exit +/// perpetually valid: the same signature verifies whether it is processed the +/// moment it is signed or years and several forks later. +/// +/// `CAPELLA_FORK_VERSION` specifically, not deneb's own, because EIP-7044 +/// shipped at deneb but pins the version one fork earlier: capella is the +/// latest fork every already-signed exit on a live network could have been +/// signed under, so pinning there (rather than to deneb's own version, which +/// no exit signed before deneb activated could have used) is what keeps +/// every exit signed before this rule existed valid under it too. +pub fn process_voluntary_exit( + state: &mut BeaconState, + signed_voluntary_exit: &SignedVoluntaryExit, + config: &Config, +) -> Result<()> { + let voluntary_exit = signed_voluntary_exit.message; + let current_epoch = get_current_epoch(state); + let validator = state.validator(voluntary_exit.validator_index)?; + + verify( + is_active_validator(validator, current_epoch), + "is_active_validator(validator, get_current_epoch(state))", + )?; + verify( + validator.exit_epoch == FAR_FUTURE_EPOCH, + "validator.exit_epoch == FAR_FUTURE_EPOCH", + )?; + verify( + current_epoch >= voluntary_exit.epoch, + "get_current_epoch(state) >= voluntary_exit.epoch", + )?; + let eligible_epoch = validator + .activation_epoch + .checked_add(config.shard_committee_period) + .ok_or(Error::ArithmeticOverflow( + "validator.activation_epoch + SHARD_COMMITTEE_PERIOD", + ))?; + verify( + current_epoch >= eligible_epoch, + "get_current_epoch(state) >= validator.activation_epoch + SHARD_COMMITTEE_PERIOD", + )?; + + // [EIP-7044]: a fixed fork version rather than `get_domain`'s + // current-epoch lookup; see this function's own documentation for why. + let domain = compute_domain( + constants::DOMAIN_VOLUNTARY_EXIT, + config.capella_fork_version, + state.genesis_validators_root(), + ); + let signing_root = compute_signing_root(voluntary_exit.hash_tree_root(), domain); + verify( + bls::verify( + &validator.pubkey, + signing_root, + &signed_voluntary_exit.signature, + ), + "bls.Verify(validator.pubkey, signing_root, signed_voluntary_exit.signature)", + )?; + + initiate_validator_exit(state, voluntary_exit.validator_index, config)?; + Ok(()) +} + +// --------------------------------------------------------------------------- +// Fork projection +// --------------------------------------------------------------------------- + +/// The deneb state, mutably, or an error naming the function that needs one. +/// +/// Scoped to `BeaconState::Deneb` alone, the same deliberately narrow scope +/// [`super::bellatrix::bellatrix_state`] documents for itself: every caller +/// here is reached only through [`process_block`], which +/// [`super::block::process_block`] dispatches to precisely when `state` is +/// already `BeaconState::Deneb`. +fn deneb_state<'a>( + state: &'a mut BeaconState, + function: &'static str, +) -> Result<&'a mut deneb::BeaconState> { + match state { + BeaconState::Deneb(state) => Ok(state), + other => Err(Error::UnsupportedForFork { + function, + fork: other.fork_name(), + }), + } +} + +/// The deneb state, immutably. See [`deneb_state`]. +fn deneb_state_ref<'a>( + state: &'a BeaconState, + function: &'static str, +) -> Result<&'a deneb::BeaconState> { + match state { + BeaconState::Deneb(state) => Ok(state), + other => Err(Error::UnsupportedForFork { + function, + fork: other.fork_name(), + }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn kzg_commitment_to_versioned_hash_overwrites_only_the_first_byte() { + let commitment = KzgCommitment([7; 48]); + let digest = hash(&commitment.0); + + let versioned_hash = kzg_commitment_to_versioned_hash(&commitment); + + assert_eq!(versioned_hash.0[0], constants::VERSIONED_HASH_VERSION_KZG); + assert_eq!(&versioned_hash.0[1..], &digest.0[1..]); + } + + #[test] + fn kzg_commitment_to_versioned_hash_is_sensitive_to_the_whole_commitment() { + let a = KzgCommitment([1; 48]); + let b = KzgCommitment([2; 48]); + assert_ne!( + kzg_commitment_to_versioned_hash(&a), + kzg_commitment_to_versioned_hash(&b) + ); + } +} diff --git a/crates/blockchain/state_transition/src/beacon/stf/electra.rs b/crates/blockchain/state_transition/src/beacon/stf/electra.rs new file mode 100644 index 000000000..b7152b369 --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/stf/electra.rs @@ -0,0 +1,2326 @@ +//! Electra's block processing. +//! +//! Two changes, both from EIP-7251 and its neighbours, drive nearly +//! everything below. +//! +//! **A validator's effective balance is no longer pinned to one value.** +//! Through deneb, every validator's ceiling was the same fixed +//! `MAX_EFFECTIVE_BALANCE`, so rate-limiting how many validators could enter +//! or leave the registry in one epoch also rate-limited how much stake moved. +//! Once a validator can hold up to [`preset::MAX_EFFECTIVE_BALANCE_ELECTRA`] +//! by upgrading to a compounding withdrawal credential, a headcount no longer +//! bounds a balance: one large validator's deposit, exit, or consolidation +//! could move as much stake in a single slot as thousands of ordinary ones +//! used to, together. So deposits, exits, and consolidations all move from +//! "applied immediately, rate-limited by counting validators" to "queued in +//! the state (`pending_deposits`, `pending_partial_withdrawals`, +//! `pending_consolidations`) and drained a bounded *balance* at a time" (the +//! draining itself is epoch processing, `crates/blockchain/state_transition/src/beacon/stf/epoch/electra.rs`, +//! not this file; this file only ever appends to those queues or reads them). +//! [`crate::beacon::helpers::electra`] carries the balance-churn accounting this +//! forces; see its own module doc for the full account. +//! +//! **The execution layer can request a deposit, withdrawal, or consolidation +//! directly.** EIP-6110, EIP-7002, and EIP-7251 let the execution layer +//! append a [`electra::DepositRequest`], [`electra::WithdrawalRequest`], or +//! [`electra::ConsolidationRequest`] to its block, bundled into +//! [`electra::ExecutionRequests`] and carried on +//! [`electra::BeaconBlockBody::execution_requests`]. Consensus cannot reject +//! a whole block over a request the execution layer itself already committed +//! to accepting (unlike every other operation here, which a proposer chose +//! to include and so can be held to a strict standard), so +//! [`process_withdrawal_request`] and [`process_consolidation_request`] both +//! validate by *silently doing nothing* on most invalid input rather than by +//! rejecting the block; see their own documentation for exactly which +//! conditions do which. +//! +//! EIP-7549 (committee-indexed attestations) and EIP-7691 (more blobs) are +//! the other two forces at work, each touching one area: attestations now +//! read their covered committees from [`electra::Attestation::committee_bits`] +//! rather than a single `data.index` (see [`process_attestation`]), and the +//! blob commitment limit becomes a network configuration value +//! ([`Config::max_blobs_per_block_electra`]) rather than deneb's fixed preset +//! (see [`process_execution_payload`]). + +use std::collections::HashSet; + +use libssz::SszEncode as _; + +use crate::beacon::bls; +use crate::beacon::config::Config; +use crate::beacon::constants::{self, FAR_FUTURE_EPOCH}; +use crate::beacon::containers::shared::{ + AttestationData, Deposit, DepositMessage, EpochParticipation, InactivityScores, + SignedVoluntaryExit, Validator, +}; +use crate::beacon::containers::{BeaconState, capella, deneb, electra, fulu}; +use crate::beacon::error::{Error, Result, verify}; +use crate::beacon::helpers::accessors::{ + CommitteeCache, CommitteeCacheExt, get_beacon_proposer_index, get_block_root, + get_block_root_at_slot, get_current_epoch, get_previous_epoch, get_randao_mix, +}; +use crate::beacon::helpers::altair::{add_flag, get_base_reward_per_increment, has_flag}; +use crate::beacon::helpers::electra::{ + compute_exit_epoch_and_update_churn, electra_state, get_committee_indices, + get_consolidation_churn_limit, get_indexed_attestation, get_max_effective_balance, + get_pending_balance_to_withdraw, has_compounding_withdrawal_credential, + has_eth1_withdrawal_credential, has_execution_withdrawal_credential, + initiate_validator_exit as electra_initiate_validator_exit, is_fully_withdrawable_validator, + is_partially_withdrawable_validator, is_valid_indexed_attestation, +}; +use crate::beacon::helpers::math::integer_squareroot; +use crate::beacon::helpers::misc::{ + compute_deposit_domain, compute_domain, compute_epoch_at_slot, compute_signing_root, + is_valid_merkle_branch, +}; +use crate::beacon::helpers::mutators::{decrease_balance, increase_balance, slash_validator}; +use crate::beacon::helpers::predicates::{ + is_active_validator, is_slashable_attestation_data, is_slashable_validator, +}; +use crate::beacon::preset; +use crate::beacon::primitives::{ + BlsPubkey, BlsSignature, Bytes32, ExecutionAddress, Gwei, HashTreeRoot as _, + ParticipationFlags, ValidatorIndex, WithdrawalIndex, +}; + +use super::ExecutionEngine; + +// --------------------------------------------------------------------------- +// Local state projection +// --------------------------------------------------------------------------- +// +// `crate::beacon::helpers::electra::{electra_state, electra_state_ref}` already +// project a `BeaconState` down to electra's (or fulu's) concrete struct, but +// only far enough to reach the balance-churn fields their own module needs +// (`pending_deposits` and the four churn cursors; see that module's doc). This +// file's block-processing steps also need `latest_execution_payload_header`, +// the withdrawal-sweep cursor (`next_withdrawal_index`, +// `next_withdrawal_validator_index`), `pending_partial_withdrawals` and +// `pending_consolidations` mutably, `deposit_requests_start_index`, and +// (introduced at altair, not electra) `previous_epoch_participation`, +// `current_epoch_participation`, and `inactivity_scores`. None of those are +// fork-invariant (`crate::beacon::containers::mod`'s `shared_state_accessors!` macro +// does not cover any of them), and reaching them would mean adding methods to +// `helpers::electra`'s projection, which is outside this file's ownership. +// This is a second, narrower projection kept local to this module instead, +// for exactly the fields block processing (as opposed to balance-churn +// accounting) needs. +// +// Scoped to `BeaconState::Electra` *and* `BeaconState::Fulu`, the same way +// `helpers::electra`'s own projection is, rather than to `Electra` alone the +// way `crate::beacon::stf::{bellatrix,capella,deneb}`'s own per-fork projections are +// scoped to exactly one variant. Those files are narrower because nothing +// outside them ever calls back into their own fork's state: bellatrix's +// projection is reached only through bellatrix's own `process_block`, which +// only ever runs against a `BeaconState::Bellatrix`. Fulu is different: +// nothing in its own specification touches any of the fields this projection +// reaches (its own changes are `proposer_lookahead` and the blob schedule), +// so whoever writes `crate::beacon::stf::fulu` is expected to reuse this file's +// functions wholesale, on a `BeaconState::Fulu`, the identical reasoning +// `crate::beacon::helpers::electra`'s own module doc gives for widening its +// projection the same way. +struct BlockRef<'a> { + inner: BlockRefInner<'a>, +} + +enum BlockRefInner<'a> { + Electra(&'a electra::BeaconState), + Fulu(&'a fulu::BeaconState), +} + +impl<'a> BlockRef<'a> { + fn latest_execution_payload_header(&self) -> &'a deneb::ExecutionPayloadHeader { + match self.inner { + BlockRefInner::Electra(state) => &state.latest_execution_payload_header, + BlockRefInner::Fulu(state) => &state.latest_execution_payload_header, + } + } + + fn next_withdrawal_index(&self) -> WithdrawalIndex { + match self.inner { + BlockRefInner::Electra(state) => state.next_withdrawal_index, + BlockRefInner::Fulu(state) => state.next_withdrawal_index, + } + } + + fn next_withdrawal_validator_index(&self) -> ValidatorIndex { + match self.inner { + BlockRefInner::Electra(state) => state.next_withdrawal_validator_index, + BlockRefInner::Fulu(state) => state.next_withdrawal_validator_index, + } + } + + fn deposit_requests_start_index(&self) -> u64 { + match self.inner { + BlockRefInner::Electra(state) => state.deposit_requests_start_index, + BlockRefInner::Fulu(state) => state.deposit_requests_start_index, + } + } +} + +/// The electra-or-fulu state, immutably, projected far enough for this +/// module's own steps. See this section's own documentation. +fn block_ref<'a>(state: &'a BeaconState, function: &'static str) -> Result> { + match state { + BeaconState::Electra(inner) => Ok(BlockRef { + inner: BlockRefInner::Electra(inner), + }), + BeaconState::Fulu(inner) => Ok(BlockRef { + inner: BlockRefInner::Fulu(inner), + }), + other => Err(Error::UnsupportedForFork { + function, + fork: other.fork_name(), + }), + } +} + +enum BlockMut<'a> { + Electra(&'a mut electra::BeaconState), + Fulu(&'a mut fulu::BeaconState), +} + +impl<'a> BlockMut<'a> { + fn latest_execution_payload_header_mut(&mut self) -> &mut deneb::ExecutionPayloadHeader { + match self { + BlockMut::Electra(state) => &mut state.latest_execution_payload_header, + BlockMut::Fulu(state) => &mut state.latest_execution_payload_header, + } + } + + fn next_withdrawal_index_mut(&mut self) -> &mut WithdrawalIndex { + match self { + BlockMut::Electra(state) => &mut state.next_withdrawal_index, + BlockMut::Fulu(state) => &mut state.next_withdrawal_index, + } + } + + fn next_withdrawal_validator_index_mut(&mut self) -> &mut ValidatorIndex { + match self { + BlockMut::Electra(state) => &mut state.next_withdrawal_validator_index, + BlockMut::Fulu(state) => &mut state.next_withdrawal_validator_index, + } + } + + fn pending_partial_withdrawals_mut(&mut self) -> &mut electra::PendingPartialWithdrawals { + match self { + BlockMut::Electra(state) => &mut state.pending_partial_withdrawals, + BlockMut::Fulu(state) => &mut state.pending_partial_withdrawals, + } + } + + fn pending_consolidations_mut(&mut self) -> &mut electra::PendingConsolidations { + match self { + BlockMut::Electra(state) => &mut state.pending_consolidations, + BlockMut::Fulu(state) => &mut state.pending_consolidations, + } + } + + fn deposit_requests_start_index_mut(&mut self) -> &mut u64 { + match self { + BlockMut::Electra(state) => &mut state.deposit_requests_start_index, + BlockMut::Fulu(state) => &mut state.deposit_requests_start_index, + } + } + + /// `previous_epoch_participation` or `current_epoch_participation`, + /// whichever `current` selects. + fn epoch_participation_mut(&mut self, current: bool) -> &mut EpochParticipation { + match (self, current) { + (BlockMut::Electra(state), true) => &mut state.current_epoch_participation, + (BlockMut::Electra(state), false) => &mut state.previous_epoch_participation, + (BlockMut::Fulu(state), true) => &mut state.current_epoch_participation, + (BlockMut::Fulu(state), false) => &mut state.previous_epoch_participation, + } + } + + fn inactivity_scores_mut(&mut self) -> &mut InactivityScores { + match self { + BlockMut::Electra(state) => &mut state.inactivity_scores, + BlockMut::Fulu(state) => &mut state.inactivity_scores, + } + } +} + +/// The electra-or-fulu state, mutably. See [`block_ref`]. +fn block_mut<'a>(state: &'a mut BeaconState, function: &'static str) -> Result> { + match state { + BeaconState::Electra(inner) => Ok(BlockMut::Electra(inner)), + BeaconState::Fulu(inner) => Ok(BlockMut::Fulu(inner)), + other => Err(Error::UnsupportedForFork { + function, + fork: other.fork_name(), + }), + } +} + +/// `previous_epoch_participation` or `current_epoch_participation`, +/// immutably, whichever `current` selects. A companion to +/// [`BlockMut::epoch_participation_mut`] rather than a method on [`BlockRef`]: +/// only [`process_attestation`]'s read phase needs this, and it needs it +/// before deciding whether anything requires the mutable projection at all. +fn epoch_participation<'a>( + state: &'a BeaconState, + current: bool, + function: &'static str, +) -> Result<&'a EpochParticipation> { + match state { + BeaconState::Electra(inner) if current => Ok(&inner.current_epoch_participation), + BeaconState::Electra(inner) => Ok(&inner.previous_epoch_participation), + BeaconState::Fulu(inner) if current => Ok(&inner.current_epoch_participation), + BeaconState::Fulu(inner) => Ok(&inner.previous_epoch_participation), + other => Err(Error::UnsupportedForFork { + function, + fork: other.fork_name(), + }), + } +} + +// --------------------------------------------------------------------------- +// Deposits +// --------------------------------------------------------------------------- + +/// Builds the registry entry a new deposit creates. +/// +/// Modified from phase0's version (`crate::beacon::stf::operations::get_validator_from_deposit`) +/// to cap the effective balance at [`get_max_effective_balance`] rather than +/// the fixed `MAX_EFFECTIVE_BALANCE`: a depositor who supplies a compounding +/// (`0x02`-prefixed) withdrawal credential from the start is entitled to +/// activate at the higher ceiling immediately, not just after a later +/// [`crate::beacon::helpers::electra::switch_to_compounding_validator`] call. +/// +/// Subtracting the remainder can never underflow: a modulus is always at +/// most the value it divides. +pub fn get_validator_from_deposit( + pubkey: BlsPubkey, + withdrawal_credentials: Bytes32, + amount: Gwei, +) -> Validator { + let validator = Validator { + pubkey, + withdrawal_credentials, + effective_balance: 0, + slashed: false, + activation_eligibility_epoch: FAR_FUTURE_EPOCH, + activation_epoch: FAR_FUTURE_EPOCH, + exit_epoch: FAR_FUTURE_EPOCH, + withdrawable_epoch: FAR_FUTURE_EPOCH, + }; + + let max_effective_balance = get_max_effective_balance(&validator); + Validator { + effective_balance: (amount - amount % preset::EFFECTIVE_BALANCE_INCREMENT) + .min(max_effective_balance), + ..validator + } +} + +/// Appends a brand-new validator and its starting balance. +/// +/// Modified from phase0's version (`crate::beacon::stf::operations::add_validator_to_registry`) +/// in two ways. It uses this module's own [`get_validator_from_deposit`] +/// rather than phase0's, for the reason that function's own doc gives. And it +/// also pushes to `previous_epoch_participation`, `current_epoch_participation`, +/// and `inactivity_scores`, altair-era fields phase0's own version never had +/// to grow; every one of those lists is positionally parallel to `validators` +/// and must be grown together, so a new entry with nothing to say about any +/// of the three still needs a zero placeholder in each. +pub fn add_validator_to_registry( + state: &mut BeaconState, + pubkey: BlsPubkey, + withdrawal_credentials: Bytes32, + amount: Gwei, +) -> Result<()> { + state.validators_mut().push(get_validator_from_deposit( + pubkey, + withdrawal_credentials, + amount, + ))?; + state.balances_mut().push(amount)?; + + let mut fields = block_mut(state, "add_validator_to_registry")?; + fields.epoch_participation_mut(true).push(0)?; + fields.epoch_participation_mut(false).push(0)?; + fields.inactivity_scores_mut().push(0)?; + Ok(()) +} + +/// Whether `signature` is a valid proof of possession over a deposit for +/// `pubkey`, `withdrawal_credentials`, and `amount`. +/// +/// New in electra only in the sense that the specification factors it out of +/// `apply_deposit` into its own named function; the check itself (a +/// fork-agnostic domain, since a depositor cannot know which fork or chain +/// will eventually accept its deposit) is exactly what phase0's own +/// `apply_deposit` (`crate::beacon::stf::operations::apply_deposit`) already inlines. +pub fn is_valid_deposit_signature( + pubkey: BlsPubkey, + withdrawal_credentials: Bytes32, + amount: Gwei, + signature: &BlsSignature, + config: &Config, +) -> bool { + let deposit_message = DepositMessage { + pubkey, + withdrawal_credentials, + amount, + }; + // A deposit is signed by a depositor who has no way to know which fork, + // or even which chain, will eventually accept it; see this function's own + // documentation. + let domain = compute_deposit_domain(config.genesis_fork_version); + let signing_root = compute_signing_root(deposit_message.hash_tree_root(), domain); + bls::verify(&pubkey, signing_root, signature) +} + +/// Registers a new validator (if the signature checks out) and queues the +/// deposit's amount as a [`electra::PendingDeposit`], for the epoch boundary +/// to credit. +/// +/// This is the single biggest behavioral change EIP-7251 makes to deposit +/// handling: phase0's `apply_deposit` (`crate::beacon::stf::operations::apply_deposit`) +/// credits a deposit's amount to a balance the moment it is processed. From +/// electra on, *no* deposit does that directly, existing validator or brand +/// new one alike; every deposit becomes a queue entry, and only +/// `process_pending_deposits` (epoch processing, not this file) ever +/// increases a balance because of one. That indirection is what lets the +/// epoch boundary rate-limit how much stake activates per epoch by *balance* +/// rather than by counting deposits. +/// +/// A new pubkey with an invalid signature is not queued at all: the +/// specification's own control flow only reaches the "append pending +/// deposit" step by falling through the `if pubkey not in validator_pubkeys` +/// block (when the pubkey already exists) or by successfully registering a +/// new validator inside it; a failed signature check on a new pubkey takes +/// neither path; it returns immediately with no side effect, mirroring +/// phase0's own "an invalid signature just means the deposit is not +/// credited" rule (see `crate::beacon::stf::operations::apply_deposit`'s own +/// documentation) one level further up, before there is even a queue entry +/// to add. +pub fn apply_deposit( + state: &mut BeaconState, + pubkey: BlsPubkey, + withdrawal_credentials: Bytes32, + amount: Gwei, + signature: &BlsSignature, + config: &Config, +) -> Result<()> { + let already_registered = state.validators().iter().any(|v| v.pubkey == pubkey); + if !already_registered { + if is_valid_deposit_signature(pubkey, withdrawal_credentials, amount, signature, config) { + // The registry entry starts at a zero balance; see this + // function's own documentation for why even a first-time + // deposit's amount is queued rather than credited directly. + add_validator_to_registry(state, pubkey, withdrawal_credentials, 0)?; + } else { + return Ok(()); + } + } + + let deposit = electra::PendingDeposit { + pubkey, + withdrawal_credentials, + amount, + signature: *signature, + slot: constants::GENESIS_SLOT, + }; + electra_state(state, "apply_deposit")? + .pending_deposits_mut() + .push(deposit)?; + Ok(()) +} + +/// Verifies a deposit's merkle proof, then applies it. +/// +/// Unchanged from phase0's version (`crate::beacon::stf::operations::process_deposit`) +/// beyond calling this module's own [`apply_deposit`]: the merkle-proof check +/// and the unconditional index advance are identical, and that unconditional +/// advance matters for the same reason phase0's own documentation gives, that +/// the index tracks how many deposits have been *consumed*, not how many +/// produced a validator. +pub fn process_deposit(state: &mut BeaconState, deposit: &Deposit, config: &Config) -> Result<()> { + verify( + is_valid_merkle_branch( + deposit.data.hash_tree_root(), + &deposit.proof, + (constants::DEPOSIT_CONTRACT_TREE_DEPTH + 1) as u64, + state.eth1_deposit_index(), + state.eth1_data().deposit_root, + ), + "is_valid_merkle_branch(hash_tree_root(deposit.data), deposit.proof, DEPOSIT_CONTRACT_TREE_DEPTH + 1, state.eth1_deposit_index, state.eth1_data.deposit_root)", + )?; + + *state.eth1_deposit_index_mut() += 1; + + apply_deposit( + state, + deposit.data.pubkey, + deposit.data.withdrawal_credentials, + deposit.data.amount, + &deposit.data.signature, + config, + ) +} + +// --------------------------------------------------------------------------- +// Voluntary exits +// --------------------------------------------------------------------------- + +/// Starts a validator's voluntary exit. +/// +/// Every check but the last two is phase0's, unchanged: still active, not +/// already exiting, past its own requested epoch, and past +/// `SHARD_COMMITTEE_PERIOD` since activation. The two additions are both +/// EIP-7251's: the validator must have no partial withdrawal already queued +/// (exiting out from under one would leave nothing to pay it from, so +/// [`get_pending_balance_to_withdraw`] must read zero first), and the exit +/// queue epoch itself is computed through +/// [`crate::beacon::helpers::electra::initiate_validator_exit`] rather than +/// phase0's (`crate::beacon::helpers::mutators::initiate_validator_exit`), the same +/// churn-by-balance swap described in this module's own documentation. +/// +/// Signs under a fixed fork version, [`Config::capella_fork_version`], for +/// EIP-7044, unchanged from deneb's own version +/// (`crate::beacon::stf::deneb::process_voluntary_exit`); see that function's own +/// documentation for why the domain is pinned rather than read off the +/// state's current fork. Not delegated to deneb's version despite that +/// overlap, because deneb's calls phase0's `initiate_validator_exit`, which +/// this fork can no longer use; see this function's own documentation above. +pub fn process_voluntary_exit( + state: &mut BeaconState, + signed_voluntary_exit: &SignedVoluntaryExit, + config: &Config, +) -> Result<()> { + let voluntary_exit = signed_voluntary_exit.message; + let current_epoch = get_current_epoch(state); + let validator = state.validator(voluntary_exit.validator_index)?; + + verify( + is_active_validator(validator, current_epoch), + "is_active_validator(validator, get_current_epoch(state))", + )?; + verify( + validator.exit_epoch == FAR_FUTURE_EPOCH, + "validator.exit_epoch == FAR_FUTURE_EPOCH", + )?; + verify( + current_epoch >= voluntary_exit.epoch, + "get_current_epoch(state) >= voluntary_exit.epoch", + )?; + let eligible_epoch = validator + .activation_epoch + .checked_add(config.shard_committee_period) + .ok_or(Error::ArithmeticOverflow( + "validator.activation_epoch + SHARD_COMMITTEE_PERIOD", + ))?; + verify( + current_epoch >= eligible_epoch, + "get_current_epoch(state) >= validator.activation_epoch + SHARD_COMMITTEE_PERIOD", + )?; + // [New in Electra:EIP7251] + verify( + get_pending_balance_to_withdraw(state, voluntary_exit.validator_index)? == 0, + "get_pending_balance_to_withdraw(state, voluntary_exit.validator_index) == 0", + )?; + + let domain = compute_domain( + constants::DOMAIN_VOLUNTARY_EXIT, + config.capella_fork_version, + state.genesis_validators_root(), + ); + let signing_root = compute_signing_root(voluntary_exit.hash_tree_root(), domain); + verify( + bls::verify( + &validator.pubkey, + signing_root, + &signed_voluntary_exit.signature, + ), + "bls.Verify(validator.pubkey, signing_root, signed_voluntary_exit.signature)", + )?; + + // [Modified in Electra:EIP7251] + electra_initiate_validator_exit(state, voluntary_exit.validator_index, config)?; + Ok(()) +} + +// --------------------------------------------------------------------------- +// BLS-to-execution changes +// --------------------------------------------------------------------------- + +/// Upgrades a validator's withdrawal credentials from a raw BLS public key +/// hash to an execution address. +/// +/// Not modified by electra: delegates to capella's own implementation +/// (`crate::beacon::stf::capella::process_bls_to_execution_change`) wholesale rather +/// than copying its body. That function needs nothing capella-specific +/// (every field it reads or writes, `validators`, `genesis_validators_root`, +/// is fork-invariant), so this file is required to expose the name at all +/// (see this module's own task list), but not a second copy of the logic to +/// keep in sync with capella's. +pub fn process_bls_to_execution_change( + state: &mut BeaconState, + signed_change: &capella::SignedBLSToExecutionChange, + config: &Config, +) -> Result<()> { + super::capella::process_bls_to_execution_change(state, signed_change, config) +} + +// --------------------------------------------------------------------------- +// Attester slashings +// --------------------------------------------------------------------------- + +/// Slashes every slashable validator in the overlap of two conflicting +/// attestations' attesting sets. +/// +/// Not modified by electra beyond the types it operates on: the +/// specification's own table of contents lists no "Modified +/// `process_attester_slashing`", only the `AttesterSlashing` and +/// `IndexedAttestation` containers it reads. This is phase0's version +/// (`crate::beacon::stf::operations::process_attester_slashing`) transcribed against +/// [`electra::AttesterSlashing`] and [`crate::beacon::helpers::electra::is_valid_indexed_attestation`] +/// instead, the same reason that helper exists as its own copy (see its own +/// documentation): a different concrete `IndexedAttestation` type, not +/// different logic. +pub fn process_attester_slashing( + state: &mut BeaconState, + attester_slashing: &electra::AttesterSlashing, + config: &Config, +) -> Result<()> { + let attestation_1 = &attester_slashing.attestation_1; + let attestation_2 = &attester_slashing.attestation_2; + + verify( + is_slashable_attestation_data(&attestation_1.data, &attestation_2.data), + "is_slashable_attestation_data(attestation_1.data, attestation_2.data)", + )?; + verify( + is_valid_indexed_attestation(state, attestation_1), + "is_valid_indexed_attestation(state, attestation_1)", + )?; + verify( + is_valid_indexed_attestation(state, attestation_2), + "is_valid_indexed_attestation(state, attestation_2)", + )?; + + let current_epoch = get_current_epoch(state); + // `is_valid_indexed_attestation` already required both index lists to be + // sorted and unique; see `crate::beacon::stf::operations::process_attester_slashing` + // for why walking `attestation_1`'s list in order while filtering by + // membership in `attestation_2`'s set yields the intersection already + // sorted. + let indices_2: HashSet = + attestation_2.attesting_indices.iter().copied().collect(); + + let mut slashed_any = false; + for &index in attestation_1.attesting_indices.iter() { + if !indices_2.contains(&index) { + continue; + } + if is_slashable_validator(state.validator(index)?, current_epoch) { + slash_validator(state, index, None, config)?; + slashed_any = true; + } + } + verify( + slashed_any, + "at least one validator in the intersection of the two attesting index sets was slashed", + )?; + Ok(()) +} + +// --------------------------------------------------------------------------- +// Attestations +// --------------------------------------------------------------------------- + +/// Scores an [`electra::Attestation`] against the three timeliness conditions +/// and pays the including proposer, reading the committees it covers from +/// `committee_bits` rather than a single `data.index` (EIP-7549). +/// +/// Shares altair's and deneb's overall shape (validate, then score, then pay +/// the proposer for newly-granted flags) but the validation prologue is +/// electra's own: `data.index` must be zero, since `committee_bits` is now +/// the only source of which committees this attestation covers, and each +/// named committee must contribute at least one attester +/// (`committee_attesters` non-empty), which is what stops a proposer padding +/// `committee_bits` with a committee nobody in it actually attested to. +/// [`crate::beacon::helpers::electra::get_attesting_indices`] (used below, in the +/// read phase) already does this same per-committee offset walk to build the +/// attester set; this prologue re-walks it only far enough to check the +/// "non-empty" and "total length matches" assertions the specification makes +/// before that set is ever computed, since neither assertion is something +/// [`crate::beacon::helpers::electra::get_attesting_indices`] itself checks. +/// +/// Structured as the same two-phase read-then-write split altair's and +/// deneb's versions use, and for the identical borrow-checker reason: see +/// `crate::beacon::stf::altair::process_attestation`'s own documentation. +pub fn process_attestation( + state: &mut BeaconState, + attestation: &electra::Attestation, + committees: &CommitteeCache, +) -> Result<()> { + let data = attestation.data; + let current_epoch = get_current_epoch(state); + let previous_epoch = get_previous_epoch(state); + + verify( + data.target.epoch == previous_epoch || data.target.epoch == current_epoch, + "data.target.epoch in (get_previous_epoch(state), get_current_epoch(state))", + )?; + verify( + data.target.epoch == compute_epoch_at_slot(data.slot), + "data.target.epoch == compute_epoch_at_slot(data.slot)", + )?; + // [EIP-7045, inherited from deneb]: no upper bound on `state.slot`, unlike + // altair's; see `crate::beacon::stf::deneb::process_attestation`'s own + // documentation for why FFG justification has no such bound to begin + // with. `data.slot` still comes straight off the wire, so the lower + // bound's own addition is still checked. + let min_slot = data + .slot + .checked_add(preset::MIN_ATTESTATION_INCLUSION_DELAY) + .ok_or(Error::ArithmeticOverflow( + "data.slot + MIN_ATTESTATION_INCLUSION_DELAY", + ))?; + verify( + min_slot <= state.slot(), + "data.slot + MIN_ATTESTATION_INCLUSION_DELAY <= state.slot", + )?; + + // [Modified in Electra:EIP7549] + verify(data.index == 0, "data.index == 0")?; + let committee_indices = get_committee_indices(&attestation.committee_bits); + // One `EpochCommittees` for the whole loop below, rather than a fresh + // `get_committee_count_per_slot` and `get_beacon_committee` call (each an + // unconditional `O(registry size)` active-set scan) per named committee: + // `committee_bits` can name up to `MAX_COMMITTEES_PER_SLOT` committees in + // a single attestation, all drawn from this same `(state, + // data.target.epoch)` pair. Taken from `committees` rather than built + // here, so this shares the shuffling with `get_indexed_attestation`'s own + // walk below and with fork choice's replay of this same attestation. + let epoch_committees = committees.committees(state, data.target.epoch); + let mut committee_offset = 0usize; + for committee_index in committee_indices { + verify( + committee_index < epoch_committees.committees_per_slot(), + "committee_index < get_committee_count_per_slot(state, data.target.epoch)", + )?; + let committee = epoch_committees.committee(data.slot, committee_index)?; + let committee_has_an_attester = (0..committee.len()).any(|position| { + attestation + .aggregation_bits + .get(committee_offset + position) + .unwrap_or(false) + }); + verify(committee_has_an_attester, "len(committee_attesters) > 0")?; + committee_offset += committee.len(); + } + verify( + attestation.aggregation_bits.len() == committee_offset, + "len(attestation.aggregation_bits) == committee_offset", + )?; + + // Safe: `min_slot <= state.slot()` above and `min_slot >= data.slot` (the + // inclusion delay is non-negative), so `data.slot <= state.slot()`. + let inclusion_delay = state.slot() - data.slot; + let participation_flag_indices = + attestation_participation_flag_indices(state, &data, inclusion_delay)?; + + let indexed_attestation = get_indexed_attestation(state, attestation, committees)?; + verify( + is_valid_indexed_attestation(state, &indexed_attestation), + "is_valid_indexed_attestation(state, get_indexed_attestation(state, attestation))", + )?; + + // Read phase: for every attester, decide which flags this attestation + // newly satisfies and add up the proposer's reward for granting them. + // + // Not a second `get_attesting_indices(state, attestation)` call: that + // would recompute exactly what `get_indexed_attestation` above already + // did (walking every named committee and sorting the result again) to + // fill `indexed_attestation.attesting_indices`, for the same attestation + // against the same unmutated `state`. `AttestingIndices` derefs to + // `&[ValidatorIndex]`, already sorted by `get_attesting_indices`, so this + // is the identical value the second call would have produced. + let attesting_indices = indexed_attestation.attesting_indices.to_vec(); + let current_epoch_target = data.target.epoch == current_epoch; + let participation = epoch_participation(state, current_epoch_target, "process_attestation")?; + + // Hoisted out of the loop below, where the specification writes + // `get_base_reward(state, index)` per attester per flag. That helper is + // `increments * get_base_reward_per_increment(state)`, and the second + // factor is `get_total_active_balance`, which scans the whole validator + // registry and allocates a `Vec` of the active set on every call, with no + // cache anywhere beneath it. + // + // The read phase does not mutate `state`, so the value is constant across + // the whole loop and this is the same arithmetic in a different order. At + // mainnet's ~1M validators it is also the difference between importing a + // block and not: an Electra block carries up to `MAX_ATTESTATIONS_ELECTRA` + // aggregates covering thousands of attesters each, so the unhoisted form + // ran tens of thousands of million-element scans per block and never + // finished one. That was measured on a live mainnet run: the chain actor + // sat at 98% CPU inside `get_active_validator_indices`, reached through + // exactly this line, and the node's clock stopped advancing. + let base_reward_per_increment = get_base_reward_per_increment(state)?; + + let mut proposer_reward_numerator: Gwei = 0; + let mut updates: Vec<(ValidatorIndex, ParticipationFlags)> = Vec::new(); + for index in attesting_indices { + let current_flags = + participation + .get(index as usize) + .copied() + .ok_or(Error::IndexOutOfBounds { + index: index as usize, + len: participation.len(), + })?; + + let mut new_flags: ParticipationFlags = 0; + for &flag_index in &participation_flag_indices { + if has_flag(current_flags, flag_index) { + continue; + } + new_flags = add_flag(new_flags, flag_index); + let weight = constants::PARTICIPATION_FLAG_WEIGHTS[flag_index]; + // `get_base_reward(state, index)` inlined against the hoisted + // per-increment value above, keeping the helper's own order of + // operations so the result is bit-identical. + let increments = + state.validator(index)?.effective_balance / preset::EFFECTIVE_BALANCE_INCREMENT; + let reward = (increments * base_reward_per_increment) + .checked_mul(weight) + .ok_or(Error::ArithmeticOverflow( + "get_base_reward(state, index) * weight", + ))?; + proposer_reward_numerator = proposer_reward_numerator + .checked_add(reward) + .ok_or(Error::ArithmeticOverflow("proposer_reward_numerator"))?; + } + if new_flags != 0 { + updates.push((index, new_flags)); + } + } + + // Write phase: apply exactly the flags the read phase decided on. Nothing + // from here on reads `state` any further, so this is free to take the + // mutable projection the read phase could not. + { + let mut fields = block_mut(state, "process_attestation")?; + let participation_mut = fields.epoch_participation_mut(current_epoch_target); + let participation_len = participation_mut.len(); + for (index, new_flags) in updates { + let flags = + participation_mut + .get_mut(index as usize) + .ok_or(Error::IndexOutOfBounds { + index: index as usize, + len: participation_len, + })?; + *flags |= new_flags; + } + } + + const NON_PROPOSER_WEIGHT: u64 = constants::WEIGHT_DENOMINATOR - constants::PROPOSER_WEIGHT; + const PROPOSER_REWARD_DENOMINATOR: u64 = + NON_PROPOSER_WEIGHT * constants::WEIGHT_DENOMINATOR / constants::PROPOSER_WEIGHT; + let proposer_reward = proposer_reward_numerator / PROPOSER_REWARD_DENOMINATOR; + let proposer_index = get_beacon_proposer_index(state)?; + increase_balance(state, proposer_index, proposer_reward)?; + + Ok(()) +} + +/// Which of the three participation flags an attestation with `data`, +/// included after `inclusion_delay` slots, satisfies. +/// +/// Electra changes nothing here itself. This is deneb's EIP-7045 version, +/// duplicated rather than called: `crate::beacon::stf::deneb`'s own function of this +/// name is private to that module (not `pub`), so it is not visible from +/// here even though both files are in the same crate, and this module has no +/// shared location for a function altair introduces and deneb later modifies +/// (`crate::beacon::helpers::altair::altair_state`'s own projection, which a shared +/// version would sit behind, is scoped to `BeaconState::Altair` alone; see +/// its own doc). Whoever next touches either copy should consider hoisting +/// one, in `crate::beacon::helpers::altair` or wherever else both `stf::deneb` and +/// `stf::electra` (and, presumably, every later fork) can reach it. +fn attestation_participation_flag_indices( + state: &BeaconState, + data: &AttestationData, + inclusion_delay: u64, +) -> Result> { + let justified_checkpoint = if data.target.epoch == get_current_epoch(state) { + state.current_justified_checkpoint() + } else { + state.previous_justified_checkpoint() + }; + let is_matching_source = data.source == justified_checkpoint; + + let target_root = get_block_root(state, data.target.epoch)?; + let target_root_matches = data.target.root == target_root; + let is_matching_target = is_matching_source && target_root_matches; + + let head_root = get_block_root_at_slot(state, data.slot)?; + let head_root_matches = data.beacon_block_root == head_root; + let is_matching_head = is_matching_target && head_root_matches; + + verify(is_matching_source, "is_matching_source")?; + + let mut participation_flag_indices = Vec::new(); + if is_matching_source && inclusion_delay <= integer_squareroot(preset::SLOTS_PER_EPOCH) { + participation_flag_indices.push(constants::TIMELY_SOURCE_FLAG_INDEX); + } + // [EIP-7045]: no `inclusion_delay` bound, unlike altair's own + // `inclusion_delay <= SLOTS_PER_EPOCH`. + if is_matching_target { + participation_flag_indices.push(constants::TIMELY_TARGET_FLAG_INDEX); + } + if is_matching_head && inclusion_delay == preset::MIN_ATTESTATION_INCLUSION_DELAY { + participation_flag_indices.push(constants::TIMELY_HEAD_FLAG_INDEX); + } + + Ok(participation_flag_indices) +} + +// --------------------------------------------------------------------------- +// Withdrawals +// --------------------------------------------------------------------------- + +/// The execution address a validator's payout is sent to: the low bytes of +/// its withdrawal credentials, present regardless of whether those +/// credentials are eth1 or compounding (both are execution-form; see +/// `crate::beacon::helpers::electra::has_execution_withdrawal_credential`). +/// +/// A duplicate of capella's own private `withdrawal_address` +/// (`crate::beacon::stf::capella`): that function is not `pub`, so it is not visible +/// here even within the same crate. +fn withdrawal_address(validator: &Validator) -> ExecutionAddress { + ExecutionAddress::from_slice(&validator.withdrawal_credentials.0[12..]) +} + +/// The withdrawals this block's sweep owes, without applying them, and how +/// many entries of `pending_partial_withdrawals` it consumed while deciding +/// that. +/// +/// Two sweeps, in the specification's order, both drawing from the same +/// `withdrawals` list and the same `withdrawal_index` cursor so a partial +/// withdrawal already collected by the first sweep is visible to the +/// second's own `total_withdrawn` accounting for the same validator. +/// +/// The first sweep drains [`electra::PendingPartialWithdrawal`]s +/// (EIP-7251's own queue, absent before this fork): each entry is either +/// paid (if the validator is still active, sitting on enough effective and +/// excess balance) or simply dropped, but either way it counts toward +/// `processed_partial_withdrawals_count`, which is the second return value +/// [`process_withdrawals`] needs to know how much of the queue to drop +/// afterward: a withdrawal request can be consumed by this sweep without +/// ever producing an actual [`capella::Withdrawal`], and the queue still has +/// to advance past it regardless. +/// +/// The second sweep is capella's own registry walk +/// (`crate::beacon::stf::capella::get_expected_withdrawals`), unchanged in shape, +/// except that both withdrawability predicates are electra's +/// ([`is_fully_withdrawable_validator`], [`is_partially_withdrawable_validator`]) +/// and a partial withdrawal's amount is capped against +/// [`get_max_effective_balance`] (a validator's own ceiling) rather than the +/// single fixed `MAX_EFFECTIVE_BALANCE`. +pub fn get_expected_withdrawals(state: &BeaconState) -> Result<(Vec, usize)> { + let epoch = get_current_epoch(state); + let fields = block_ref(state, "get_expected_withdrawals")?; + let mut withdrawal_index = fields.next_withdrawal_index(); + let mut validator_index = fields.next_withdrawal_validator_index(); + + let mut withdrawals: Vec = Vec::new(); + let mut processed_partial_withdrawals_count: usize = 0; + + // [New in Electra:EIP7251]: consume pending partial withdrawals. + let electra_ref = + crate::beacon::helpers::electra::electra_state_ref(state, "get_expected_withdrawals")?; + let pending_partial_withdrawals = electra_ref.pending_partial_withdrawals(); + for withdrawal in pending_partial_withdrawals.iter() { + if withdrawal.withdrawable_epoch > epoch + || withdrawals.len() == preset::MAX_PENDING_PARTIALS_PER_WITHDRAWALS_SWEEP as usize + { + break; + } + + let validator = state.validator(withdrawal.validator_index)?; + let has_sufficient_effective_balance = + validator.effective_balance >= preset::MIN_ACTIVATION_BALANCE; + let total_withdrawn: Gwei = withdrawals + .iter() + .filter(|paid| paid.validator_index == withdrawal.validator_index) + .fold(0, |total, paid| total.saturating_add(paid.amount)); + let balance = state + .balance(withdrawal.validator_index)? + .checked_sub(total_withdrawn) + .ok_or(Error::ArithmeticOverflow( + "state.balances[withdrawal.validator_index] - total_withdrawn", + ))?; + let has_excess_balance = balance > preset::MIN_ACTIVATION_BALANCE; + + if validator.exit_epoch == FAR_FUTURE_EPOCH + && has_sufficient_effective_balance + && has_excess_balance + { + let withdrawable_balance = + (balance - preset::MIN_ACTIVATION_BALANCE).min(withdrawal.amount); + withdrawals.push(capella::Withdrawal { + index: withdrawal_index, + validator_index: withdrawal.validator_index, + address: withdrawal_address(validator), + amount: withdrawable_balance, + }); + withdrawal_index = withdrawal_index + .checked_add(1) + .ok_or(Error::ArithmeticOverflow("withdrawal_index + 1"))?; + } + + // Regardless of whether a withdrawal was actually produced above, + // this queue entry is consumed either way. + processed_partial_withdrawals_count += 1; + } + + // Sweep for the rest, the same bounded registry walk capella's own + // `get_expected_withdrawals` runs; see this function's own documentation + // for what electra changes about it. + let validator_count = state.validators().len() as u64; + let bound = validator_count.min(preset::MAX_VALIDATORS_PER_WITHDRAWALS_SWEEP); + for _ in 0..bound { + let validator = state.validator(validator_index)?; + let total_withdrawn: Gwei = withdrawals + .iter() + .filter(|paid| paid.validator_index == validator_index) + .fold(0, |total, paid| total.saturating_add(paid.amount)); + let balance = state + .balance(validator_index)? + .checked_sub(total_withdrawn) + .ok_or(Error::ArithmeticOverflow( + "state.balances[validator_index] - total_withdrawn", + ))?; + + if is_fully_withdrawable_validator(validator, balance, epoch) { + withdrawals.push(capella::Withdrawal { + index: withdrawal_index, + validator_index, + address: withdrawal_address(validator), + amount: balance, + }); + withdrawal_index = withdrawal_index + .checked_add(1) + .ok_or(Error::ArithmeticOverflow("withdrawal_index + 1"))?; + } else if is_partially_withdrawable_validator(validator, balance) { + // [Modified in Electra:EIP7251]: capped against this validator's + // own ceiling, not the single fixed `MAX_EFFECTIVE_BALANCE`. + let amount = balance + .checked_sub(get_max_effective_balance(validator)) + .ok_or(Error::ArithmeticOverflow( + "balance - get_max_effective_balance(validator)", + ))?; + withdrawals.push(capella::Withdrawal { + index: withdrawal_index, + validator_index, + address: withdrawal_address(validator), + amount, + }); + withdrawal_index = withdrawal_index + .checked_add(1) + .ok_or(Error::ArithmeticOverflow("withdrawal_index + 1"))?; + } + + if withdrawals.len() == preset::MAX_WITHDRAWALS_PER_PAYLOAD { + break; + } + validator_index = validator_index + .checked_add(1) + .ok_or(Error::ArithmeticOverflow("validator_index + 1"))? + % validator_count; + } + + Ok((withdrawals, processed_partial_withdrawals_count)) +} + +/// Applies this block's withdrawal sweep: checks the block's declared +/// `payload.withdrawals` against what the sweep actually owes, pays each one +/// out, drops however much of `pending_partial_withdrawals` +/// [`get_expected_withdrawals`] consumed, and advances the sweep's cursor for +/// next time. +/// +/// The cursor-update logic (the two-branch split between a full payload and +/// a partial one) is unchanged from capella's own version +/// (`crate::beacon::stf::capella::process_withdrawals`); see that function's own +/// documentation for why the two branches disagree about where "next" is +/// once the registry is smaller than `MAX_VALIDATORS_PER_WITHDRAWALS_SWEEP`. +/// What electra adds is the `pending_partial_withdrawals` drop, which has no +/// capella analogue since that queue does not exist before this fork. +pub fn process_withdrawals( + state: &mut BeaconState, + payload: &deneb::ExecutionPayload, +) -> Result<()> { + let (expected_withdrawals, processed_partial_withdrawals_count) = + get_expected_withdrawals(state)?; + verify( + payload.withdrawals.to_vec() == expected_withdrawals, + "payload.withdrawals == expected_withdrawals", + )?; + + for withdrawal in &expected_withdrawals { + decrease_balance(state, withdrawal.validator_index, withdrawal.amount)?; + } + + // [New in Electra:EIP7251]: drop exactly as many pending partial + // withdrawals as get_expected_withdrawals actually consumed, whether or + // not each one produced a real payout. + { + let mut fields = block_mut(state, "process_withdrawals")?; + let mut owned = std::mem::take(fields.pending_partial_withdrawals_mut()).into_inner(); + // Safe: `processed_partial_withdrawals_count` only ever counts + // iterations of this same list's own loop in `get_expected_withdrawals`, + // so it can never exceed the list's length. + let remaining = owned.split_off(processed_partial_withdrawals_count); + *fields.pending_partial_withdrawals_mut() = remaining.try_into()?; + } + + if let Some(latest_withdrawal) = expected_withdrawals.last() { + *block_mut(state, "process_withdrawals")?.next_withdrawal_index_mut() = latest_withdrawal + .index + .checked_add(1) + .ok_or(Error::ArithmeticOverflow("latest_withdrawal.index + 1"))?; + } + + let validator_count = state.validators().len() as u64; + let next_validator_index = if expected_withdrawals.len() == preset::MAX_WITHDRAWALS_PER_PAYLOAD + { + let latest_withdrawal = expected_withdrawals + .last() + .expect("MAX_WITHDRAWALS_PER_PAYLOAD is never zero, so a full payload is non-empty"); + latest_withdrawal + .validator_index + .checked_add(1) + .ok_or(Error::ArithmeticOverflow( + "latest_withdrawal.validator_index + 1", + ))? + % validator_count + } else { + let current_cursor = + block_ref(state, "process_withdrawals")?.next_withdrawal_validator_index(); + current_cursor + .checked_add(preset::MAX_VALIDATORS_PER_WITHDRAWALS_SWEEP) + .ok_or(Error::ArithmeticOverflow( + "next_withdrawal_validator_index + MAX_VALIDATORS_PER_WITHDRAWALS_SWEEP", + ))? + % validator_count + }; + *block_mut(state, "process_withdrawals")?.next_withdrawal_validator_index_mut() = + next_validator_index; + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Execution-layer-triggered requests +// --------------------------------------------------------------------------- + +/// Runs every execution-layer-triggered request in `requests`, in the +/// specification's order: deposits, then withdrawals, then consolidations. +/// +/// The one loop [`process_operations`] itself does not inline, since +/// [`electra::ExecutionRequests`] bundles all three lists together on +/// [`electra::BeaconBlockBody::execution_requests`] rather than carrying them +/// as three separate body fields the way every other operation list is +/// carried. +pub fn process_execution_requests( + state: &mut BeaconState, + requests: &electra::ExecutionRequests, + config: &Config, +) -> Result<()> { + for deposit in requests.deposits.iter() { + process_deposit_request(state, deposit)?; + } + for withdrawal in requests.withdrawals.iter() { + process_withdrawal_request(state, withdrawal, config)?; + } + for consolidation in requests.consolidations.iter() { + process_consolidation_request(state, consolidation, config)?; + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Deposit requests +// --------------------------------------------------------------------------- + +/// Records an execution-layer-triggered deposit (EIP-6110) as a +/// [`electra::PendingDeposit`], the same queue-then-drain destination every +/// other deposit source ([`apply_deposit`]) feeds. +/// +/// The first request ever seen fixes `deposit_requests_start_index`, the +/// point past which [`process_operations`]'s own deposit-count check trusts +/// the execution layer's request log rather than the old `Eth1Data` vote +/// count; see that check's own documentation. No signature check happens +/// here, unlike [`apply_deposit`]'s: a request already arrived bundled with +/// the execution payload the block itself commits to, so there is no +/// separate proof-of-possession step to gate registering it on, the same way +/// [`process_withdrawal_request`] and [`process_consolidation_request`] +/// trust the execution layer's own request rather than re-deriving a +/// signature for it. +pub fn process_deposit_request( + state: &mut BeaconState, + request: &electra::DepositRequest, +) -> Result<()> { + { + let mut fields = block_mut(state, "process_deposit_request")?; + if *fields.deposit_requests_start_index_mut() + == constants::UNSET_DEPOSIT_REQUESTS_START_INDEX + { + *fields.deposit_requests_start_index_mut() = request.index; + } + } + + let deposit = electra::PendingDeposit { + pubkey: request.pubkey, + withdrawal_credentials: request.withdrawal_credentials, + amount: request.amount, + signature: request.signature, + slot: state.slot(), + }; + electra_state(state, "process_deposit_request")? + .pending_deposits_mut() + .push(deposit)?; + Ok(()) +} + +// --------------------------------------------------------------------------- +// Execution-layer withdrawal requests +// --------------------------------------------------------------------------- + +/// Honors (or quietly drops) an execution-layer-triggered exit or partial +/// withdrawal (EIP-7002, EIP-7251). +/// +/// Every early return below is the specification's own plain `return`, never +/// an `assert`, and that distinction is load bearing: none of these +/// conditions makes the wrapping *block* invalid. The execution layer +/// already committed to this request by including it in the payload the +/// block's own hash covers; consensus choosing not to act on a stale, +/// malformed, or already-superseded one is a decision about the request, +/// not a verdict on the block that carried it. Getting this backwards, an +/// `?` where the specification has a `return`, would reject an otherwise +/// perfectly valid block over a request that simply no longer applies (a +/// validator that exited on its own between the request being queued on the +/// execution side and this block including it, say). +/// +/// In order: the pending-partial-withdrawals queue must have room (unless +/// this is a full exit, which does not use that queue), the requested pubkey +/// must resolve to a real validator, that validator's withdrawal credentials +/// must actually name `source_address` (so a request cannot be honored +/// against a validator it was never authorized to touch), the validator must +/// be active, not already exiting, and past `SHARD_COMMITTEE_PERIOD` since +/// activation. A full exit request (`amount == FULL_EXIT_REQUEST_AMOUNT`) +/// then either exits the validator or does nothing, unconditionally, +/// regardless of which; a partial withdrawal request additionally requires a +/// compounding credential and genuine excess balance before it queues +/// anything. +/// +/// The one piece of arithmetic that *does* reject the block on failure is +/// `validator.activation_epoch + SHARD_COMMITTEE_PERIOD`: an overflow there +/// means `activation_epoch` itself is corrupt state, not that this request is +/// stale, so it is checked and propagated with `?` rather than folded into +/// the silent-return chain above it. +pub fn process_withdrawal_request( + state: &mut BeaconState, + request: &electra::WithdrawalRequest, + config: &Config, +) -> Result<()> { + let amount = request.amount; + let is_full_exit_request = amount == constants::FULL_EXIT_REQUEST_AMOUNT; + + let pending_partial_withdrawals_len = block_mut(state, "process_withdrawal_request")? + .pending_partial_withdrawals_mut() + .len(); + if pending_partial_withdrawals_len == preset::PENDING_PARTIAL_WITHDRAWALS_LIMIT + && !is_full_exit_request + { + return Ok(()); + } + + let Some(index) = state + .validators() + .iter() + .position(|validator| validator.pubkey == request.validator_pubkey) + .map(|index| index as ValidatorIndex) + else { + return Ok(()); + }; + let validator = state.validator(index)?; + + let has_correct_credential = has_execution_withdrawal_credential(validator); + let is_correct_source_address = withdrawal_address(validator) == request.source_address; + if !(has_correct_credential && is_correct_source_address) { + return Ok(()); + } + if !is_active_validator(validator, get_current_epoch(state)) { + return Ok(()); + } + if validator.exit_epoch != FAR_FUTURE_EPOCH { + return Ok(()); + } + // See this function's own documentation for why this one step is + // checked with `?` rather than folded into the silent-return chain. + let eligible_epoch = validator + .activation_epoch + .checked_add(config.shard_committee_period) + .ok_or(Error::ArithmeticOverflow( + "validator.activation_epoch + SHARD_COMMITTEE_PERIOD", + ))?; + if get_current_epoch(state) < eligible_epoch { + return Ok(()); + } + + let pending_balance_to_withdraw = get_pending_balance_to_withdraw(state, index)?; + + if is_full_exit_request { + // Only exit the validator if it has no pending withdrawal in the + // queue; either way, a full exit request never falls through to the + // partial-withdrawal logic below. + if pending_balance_to_withdraw == 0 { + electra_initiate_validator_exit(state, index, config)?; + } + return Ok(()); + } + + let validator = state.validator(index)?; + let has_sufficient_effective_balance = + validator.effective_balance >= preset::MIN_ACTIVATION_BALANCE; + let has_excess_balance = state.balance(index)? + > preset::MIN_ACTIVATION_BALANCE.saturating_add(pending_balance_to_withdraw); + + // Only a compounding validator can take a *partial* withdrawal through + // this path; a non-compounding one's only route out is the full exit + // above. Falling through this `if` with nothing queued (rather than an + // `else` branch that errors) is itself the specification's own behavior: + // a partial request that does not qualify is simply not honored. + if has_compounding_withdrawal_credential(validator) + && has_sufficient_effective_balance + && has_excess_balance + { + let to_withdraw = state + .balance(index)? + .checked_sub(preset::MIN_ACTIVATION_BALANCE) + .and_then(|value| value.checked_sub(pending_balance_to_withdraw)) + .ok_or(Error::ArithmeticOverflow( + "state.balances[index] - MIN_ACTIVATION_BALANCE - pending_balance_to_withdraw", + ))? + .min(amount); + let exit_queue_epoch = compute_exit_epoch_and_update_churn(state, to_withdraw, config)?; + let withdrawable_epoch = exit_queue_epoch + .checked_add(config.min_validator_withdrawability_delay) + .ok_or(Error::ArithmeticOverflow( + "exit_queue_epoch + MIN_VALIDATOR_WITHDRAWABILITY_DELAY", + ))?; + let withdrawal = electra::PendingPartialWithdrawal { + validator_index: index, + amount: to_withdraw, + withdrawable_epoch, + }; + block_mut(state, "process_withdrawal_request")? + .pending_partial_withdrawals_mut() + .push(withdrawal)?; + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Execution-layer consolidation requests +// --------------------------------------------------------------------------- + +/// Whether `request` is really a request to switch its source (and only +/// source) validator from an eth1 to a compounding withdrawal credential, +/// disguised as a self-consolidation. +/// +/// A validator that already holds an eth1 credential has no other way to +/// become compounding: [`crate::beacon::helpers::electra::switch_to_compounding_validator`] +/// is only ever reachable through [`process_consolidation_request`] noticing +/// this pattern (source and target are the same validator) first. Returns +/// `Result` only for symmetry with this module's other state-reading +/// functions; nothing inside can actually fail, since every condition here +/// is itself one of the specification's own `return False` guards rather +/// than an assertion. +pub fn is_valid_switch_to_compounding_request( + state: &BeaconState, + request: &electra::ConsolidationRequest, +) -> Result { + if request.source_pubkey != request.target_pubkey { + return Ok(false); + } + + let Some(source_validator) = state + .validators() + .iter() + .find(|validator| validator.pubkey == request.source_pubkey) + else { + return Ok(false); + }; + + if withdrawal_address(source_validator) != request.source_address { + return Ok(false); + } + if !has_eth1_withdrawal_credential(source_validator) { + return Ok(false); + } + if !is_active_validator(source_validator, get_current_epoch(state)) { + return Ok(false); + } + if source_validator.exit_epoch != FAR_FUTURE_EPOCH { + return Ok(false); + } + + Ok(true) +} + +/// Honors (or quietly drops) an execution-layer-triggered consolidation +/// (EIP-7251): either a switch-to-compounding request in disguise (see +/// [`is_valid_switch_to_compounding_request`]), or a real merge of one +/// validator's balance into another's. +/// +/// Every early return here, in both branches, is the specification's own +/// plain `return`, not an `assert`; see [`process_withdrawal_request`]'s own +/// documentation for why that distinction matters and must not be turned +/// into a rejected block by an incautious `?`. The one exception, again as +/// in that function, is `source_validator.activation_epoch + +/// SHARD_COMMITTEE_PERIOD`: an overflow there is checked and propagated, +/// since it would mean corrupt state rather than a stale request. +pub fn process_consolidation_request( + state: &mut BeaconState, + request: &electra::ConsolidationRequest, + config: &Config, +) -> Result<()> { + if is_valid_switch_to_compounding_request(state, request)? { + // Already known to resolve, by the check just above; state has not + // been mutated since. + let source_index = state + .validators() + .iter() + .position(|validator| validator.pubkey == request.source_pubkey) + .map(|index| index as ValidatorIndex) + .ok_or(Error::SpecAssert( + "is_valid_switch_to_compounding_request already resolved consolidation_request.source_pubkey", + ))?; + crate::beacon::helpers::electra::switch_to_compounding_validator(state, source_index)?; + return Ok(()); + } + + // Guards against using a self-consolidation to trigger an exit through + // the branch below, once the switch-to-compounding branch above has + // already ruled out (for some other reason) treating it as a genuine + // credential upgrade. + if request.source_pubkey == request.target_pubkey { + return Ok(()); + } + let pending_consolidations_len = block_mut(state, "process_consolidation_request")? + .pending_consolidations_mut() + .len(); + if pending_consolidations_len == preset::PENDING_CONSOLIDATIONS_LIMIT { + return Ok(()); + } + if get_consolidation_churn_limit(state, config)? <= preset::MIN_ACTIVATION_BALANCE { + return Ok(()); + } + + let Some(source_index) = state + .validators() + .iter() + .position(|validator| validator.pubkey == request.source_pubkey) + .map(|index| index as ValidatorIndex) + else { + return Ok(()); + }; + let Some(target_index) = state + .validators() + .iter() + .position(|validator| validator.pubkey == request.target_pubkey) + .map(|index| index as ValidatorIndex) + else { + return Ok(()); + }; + + let source_validator = state.validator(source_index)?; + let has_correct_credential = has_execution_withdrawal_credential(source_validator); + let is_correct_source_address = withdrawal_address(source_validator) == request.source_address; + if !(has_correct_credential && is_correct_source_address) { + return Ok(()); + } + + let target_validator = state.validator(target_index)?; + if !has_compounding_withdrawal_credential(target_validator) { + return Ok(()); + } + + let current_epoch = get_current_epoch(state); + if !is_active_validator(source_validator, current_epoch) { + return Ok(()); + } + if !is_active_validator(target_validator, current_epoch) { + return Ok(()); + } + if source_validator.exit_epoch != FAR_FUTURE_EPOCH { + return Ok(()); + } + if target_validator.exit_epoch != FAR_FUTURE_EPOCH { + return Ok(()); + } + // See this function's own documentation for why this one step is + // checked with `?` rather than folded into the silent-return chain. + let eligible_epoch = source_validator + .activation_epoch + .checked_add(config.shard_committee_period) + .ok_or(Error::ArithmeticOverflow( + "source_validator.activation_epoch + SHARD_COMMITTEE_PERIOD", + ))?; + if current_epoch < eligible_epoch { + return Ok(()); + } + if get_pending_balance_to_withdraw(state, source_index)? > 0 { + return Ok(()); + } + + let source_effective_balance = source_validator.effective_balance; + let exit_epoch = crate::beacon::helpers::electra::compute_consolidation_epoch_and_update_churn( + state, + source_effective_balance, + config, + )?; + let withdrawable_epoch = exit_epoch + .checked_add(config.min_validator_withdrawability_delay) + .ok_or(Error::ArithmeticOverflow( + "exit_epoch + MIN_VALIDATOR_WITHDRAWABILITY_DELAY", + ))?; + + { + let source_validator = state.validator_mut(source_index)?; + source_validator.exit_epoch = exit_epoch; + source_validator.withdrawable_epoch = withdrawable_epoch; + } + + let consolidation = electra::PendingConsolidation { + source_index, + target_index, + }; + block_mut(state, "process_consolidation_request")? + .pending_consolidations_mut() + .push(consolidation)?; + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Execution payload +// --------------------------------------------------------------------------- + +/// Validates this slot's execution payload and its blob commitments, then +/// caches the payload's header. +/// +/// Structurally deneb's own [`super::deneb::process_execution_payload`] +/// (parent-hash continuity, `prev_randao`, timestamp, a blob-commitment +/// count check, the collapsed engine check, then caching a +/// [`deneb::ExecutionPayloadHeader`], the same header type electra keeps +/// unchanged from deneb), but not literally callable as that function: its +/// commitment-count check reads [`Config::max_blobs_per_block_deneb`], while +/// electra's own limit, [`Config::max_blobs_per_block_electra`], is a +/// *different* configuration field (`MAX_BLOBS_PER_BLOCK_ELECTRA` is listed +/// under electra's own "Configuration" table, not carried over from deneb's +/// "Preset" one), and `crate::beacon::stf::deneb`'s own state projection +/// (`deneb_state`/`deneb_state_ref`) is deliberately scoped to +/// `BeaconState::Deneb` alone, so it could not read an electra state's +/// header even if the constant matched. Every other step is transcribed +/// rather than restructured, including not computing anything from +/// `body.execution_requests`: see [`super::deneb::process_execution_payload`]'s +/// own documentation for why the versioned-hashes list (and, from electra +/// on, the execution-requests list [`get_execution_requests_list`] builds) +/// is dead weight in this module specifically, since [`ExecutionEngine`] +/// collapses the whole `verify_and_notify_new_payload` interface to one +/// boolean and never inspects either. +pub fn process_execution_payload( + state: &mut BeaconState, + body: &electra::BeaconBlockBody, + config: &Config, + engine: &ExecutionEngine, +) -> Result<()> { + let payload = &body.execution_payload; + + let expected_parent_hash = block_ref(state, "process_execution_payload")? + .latest_execution_payload_header() + .block_hash; + verify( + payload.parent_hash == expected_parent_hash, + "payload.parent_hash == state.latest_execution_payload_header.block_hash", + )?; + verify( + payload.prev_randao == get_randao_mix(state, get_current_epoch(state)), + "payload.prev_randao == get_randao_mix(state, get_current_epoch(state))", + )?; + verify( + payload.timestamp + == super::bellatrix::compute_timestamp_at_slot(state, state.slot(), config), + "payload.timestamp == compute_time_at_slot(state, state.slot)", + )?; + // [Modified in Electra:EIP7691]: see this function's own documentation + // for why `Config::max_blobs_per_block_electra` rather than + // `Config::max_blobs_per_block_deneb`. + verify( + body.blob_kzg_commitments.len() as u64 <= config.max_blobs_per_block_electra, + "len(body.blob_kzg_commitments) <= MAX_BLOBS_PER_BLOCK_ELECTRA", + )?; + + // See this function's own documentation for why this is computed but + // not itself checked against anything. + let _versioned_hashes: Vec = body + .blob_kzg_commitments + .iter() + .map(super::deneb::kzg_commitment_to_versioned_hash) + .collect(); + + verify( + engine.execution_valid, + "verify_and_notify_new_payload(NewPayloadRequest(execution_payload=payload, \ + versioned_hashes=versioned_hashes, \ + parent_beacon_block_root=state.latest_block_header.parent_root, \ + execution_requests=body.execution_requests))", + )?; + + let header = deneb::ExecutionPayloadHeader { + parent_hash: payload.parent_hash, + fee_recipient: payload.fee_recipient, + state_root: payload.state_root, + receipts_root: payload.receipts_root, + logs_bloom: payload.logs_bloom.clone(), + prev_randao: payload.prev_randao, + block_number: payload.block_number, + gas_limit: payload.gas_limit, + gas_used: payload.gas_used, + timestamp: payload.timestamp, + extra_data: payload.extra_data.clone(), + base_fee_per_gas: payload.base_fee_per_gas, + block_hash: payload.block_hash, + transactions_root: payload.transactions.hash_tree_root(), + withdrawals_root: payload.withdrawals.hash_tree_root(), + blob_gas_used: payload.blob_gas_used, + excess_blob_gas: payload.excess_blob_gas, + }; + *block_mut(state, "process_execution_payload")?.latest_execution_payload_header_mut() = header; + + Ok(()) +} + +/// The EIP-7685 encoding of a block's execution requests. +/// +/// Each non-empty list becomes its request type byte followed by that list's own +/// SSZ serialization, ordered by type byte ascending. An empty list is excluded +/// outright rather than encoded as a bare type byte, which is what the +/// specification means by "Elements with empty `request_data` MUST be excluded", +/// and what `engine_newPayloadV4` rejects as invalid params if you get it wrong. +/// +/// This is the fourth parameter of `engine_newPayloadV4`, and it also feeds the +/// execution client's own block-hash validation: prague folds a commitment over +/// this list into the payload's `block_hash`, so a list assembled in the wrong +/// order makes a perfectly good block come back `INVALID`. +/// +/// Nothing inside this module calls it. [`ExecutionEngine`] collapses the whole +/// `verify_and_notify_new_payload` interface to one boolean and never inspects a +/// request list; the caller that needs one is the engine client, which assembles +/// its `newPayload` request in `ethlambda-blockchain`. +pub fn get_execution_requests_list(requests: &electra::ExecutionRequests) -> Vec> { + let mut list = Vec::new(); + + let mut push = |request_type: u8, is_empty: bool, encoded: Vec| { + if is_empty { + return; + } + let mut element = Vec::with_capacity(1 + encoded.len()); + element.push(request_type); + element.extend_from_slice(&encoded); + list.push(element); + }; + + push( + constants::DEPOSIT_REQUEST_TYPE, + requests.deposits.is_empty(), + requests.deposits.to_ssz(), + ); + push( + constants::WITHDRAWAL_REQUEST_TYPE, + requests.withdrawals.is_empty(), + requests.withdrawals.to_ssz(), + ); + push( + constants::CONSOLIDATION_REQUEST_TYPE, + requests.consolidations.is_empty(), + requests.consolidations.to_ssz(), + ); + + list +} + +// --------------------------------------------------------------------------- +// Operations +// --------------------------------------------------------------------------- + +/// Runs every operation in an electra block, in the specification's order. +/// +/// Takes `body: &electra::BeaconBlockBody` rather than each operation list +/// as its own parameter, unlike `crate::beacon::stf::operations::process_operations` +/// (six lists) and even `crate::beacon::stf::capella::process_operations` and +/// `crate::beacon::stf::deneb::process_operations` (seven, one past clippy's +/// default limit, already tolerated there with an `#[allow]`). Electra's own +/// body carries eight lists relevant here: phase0's five, capella's +/// `bls_to_execution_changes`, and (bundled into one field, +/// [`electra::ExecutionRequests`]) the three new execution-layer-triggered +/// request kinds. Eight separate parameters plus `config` would be two past +/// that same limit for no real benefit, since every call site already holds +/// a whole `electra::BeaconBlockBody` (this function's only caller, +/// [`process_block`], unpacks nothing else from it either); see this module's +/// module documentation (`crate::beacon::stf`) for why a shared body abstraction is +/// usually the wrong tool, and why taking the *whole*, already fork-specific +/// body here is the exception that doc anticipates rather than a +/// contradiction of it: nothing generic dispatches to this function the way +/// `crate::beacon::stf::block::process_block_header` is shared across every fork, so +/// there is no caller anywhere that would have to learn electra's body shape +/// just to call this. +/// +/// The deposit-count check is electra's own (EIP-6110): once +/// `deposit_requests_start_index` is set, it (not `eth1_data.deposit_count` +/// alone) caps how many `Eth1Data`-sourced deposits a block may still carry, +/// which is what lets the old deposit-contract path wind down cleanly as the +/// execution-layer request path (`process_deposit_request`) takes over. +/// `eth1_deposit_index_limit - state.eth1_deposit_index` cannot underflow: +/// the surrounding `if` already established `state.eth1_deposit_index < +/// eth1_deposit_index_limit`. +pub fn process_operations( + state: &mut BeaconState, + body: &electra::BeaconBlockBody, + config: &Config, + committees: &CommitteeCache, +) -> Result<()> { + let deposit_requests_start_index = + block_ref(state, "process_operations")?.deposit_requests_start_index(); + let eth1_deposit_index_limit = state + .eth1_data() + .deposit_count + .min(deposit_requests_start_index); + if state.eth1_deposit_index() < eth1_deposit_index_limit { + let outstanding = eth1_deposit_index_limit - state.eth1_deposit_index(); + verify( + body.deposits.len() as u64 == outstanding.min(preset::MAX_DEPOSITS as u64), + "len(body.deposits) == min(MAX_DEPOSITS, eth1_deposit_index_limit - state.eth1_deposit_index)", + )?; + } else { + verify(body.deposits.is_empty(), "len(body.deposits) == 0")?; + } + + for proposer_slashing in body.proposer_slashings.iter() { + super::operations::process_proposer_slashing(state, proposer_slashing, config)?; + } + for attester_slashing in body.attester_slashings.iter() { + process_attester_slashing(state, attester_slashing, config)?; + } + // [Modified in Electra:EIP7549] + for attestation in body.attestations.iter() { + process_attestation(state, attestation, committees)?; + } + for deposit in body.deposits.iter() { + process_deposit(state, deposit, config)?; + } + // [Modified in Electra:EIP7251] + for voluntary_exit in body.voluntary_exits.iter() { + process_voluntary_exit(state, voluntary_exit, config)?; + } + for signed_change in body.bls_to_execution_changes.iter() { + process_bls_to_execution_change(state, signed_change, config)?; + } + // [New in Electra:EIP6110:EIP7002:EIP7251] + process_execution_requests(state, &body.execution_requests, config)?; + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Block processing +// --------------------------------------------------------------------------- + +/// Electra's block processing. +/// +/// The specification's own order: header, withdrawals, execution payload, +/// RANDAO, eth1 vote, operations, sync aggregate; unchanged from capella and +/// deneb (see `crate::beacon::stf::capella::process_block`'s own documentation for +/// why the sweep runs ahead of `bls_to_execution_changes`, one of this same +/// block's own operations). [`super::block::process_block_header`], +/// [`super::block::process_randao`], and [`super::block::process_eth1_data`] +/// are reused unchanged: none of the three reads anything beyond the +/// fork-invariant fields every block shares, which is the whole point of +/// them taking those fields directly rather than a whole body (see +/// `crate::beacon::stf`'s module documentation). [`super::altair::process_sync_aggregate`] +/// is reused the same way, for the same reason. +pub fn process_block( + state: &mut BeaconState, + block: &electra::BeaconBlock, + config: &Config, + engine: &ExecutionEngine, + committees: &CommitteeCache, +) -> Result<()> { + super::block::process_block_header( + state, + block.slot, + block.proposer_index, + block.parent_root, + block.body.hash_tree_root(), + )?; + process_withdrawals(state, &block.body.execution_payload)?; + process_execution_payload(state, &block.body, config, engine)?; + super::block::process_randao(state, &block.body.randao_reveal)?; + super::block::process_eth1_data(state, &block.body.eth1_data)?; + process_operations(state, &block.body, config, committees)?; + super::altair::process_sync_aggregate(state, &block.body.sync_aggregate)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::beacon::fork::ForkName; + + /// An electra state with `count` fully active validators, each with + /// [`preset::MIN_ACTIVATION_BALANCE`] and an eth1 withdrawal credential, + /// positioned many epochs in (see the `SHARD_COMMITTEE_PERIOD` note + /// below) so the previous epoch and the block-root history window both + /// have entries. + /// + /// Built on the shared fork-parameterised builder (see + /// [`crate::beacon::helpers::test_state::with_validators_at`]) and then given the + /// two overrides this module's own tests need beyond that default: every + /// validator here gets a real, distinguishable eth1 credential + /// (`ETH1_ADDRESS_WITHDRAWAL_PREFIX` followed by the validator's own + /// index, so [`source_address`] can build a request that matches it), + /// where the shared builder leaves every validator's credentials at + /// their default (all-zero, BLS-form) value; and this state sits far + /// more than `SHARD_COMMITTEE_PERIOD` epochs past genesis, since + /// [`process_withdrawal_request`] and [`process_consolidation_request`] + /// both gate on a validator having been active that long, where the + /// shared builder's own default callers never need to clear that bar. + fn electra_state_with_validators(count: usize) -> BeaconState { + let mut state = + crate::beacon::helpers::test_state::with_validators_at(ForkName::Electra, count); + + // Comfortably past `SHARD_COMMITTEE_PERIOD` for both mainnet (256 + // epochs) and minimal (64 epochs) configs, so every validator + // (active since genesis by the shared builder's own construction) is + // already eligible for an exit or a consolidation by the time a test + // runs one against it. + const EPOCHS_PAST_GENESIS: u64 = 300; + *state.slot_mut() = EPOCHS_PAST_GENESIS * preset::SLOTS_PER_EPOCH; + + for index in 0..count as ValidatorIndex { + let validator = state.validator_mut(index).unwrap(); + // Distinct per validator: the shared builder's all-zero pubkey + // would otherwise make every validator indistinguishable to a + // pubkey lookup, which is exactly how the request functions + // under test resolve + // `validator_pubkey`/`source_pubkey`/`target_pubkey` into an + // index. + validator.pubkey = BlsPubkey([index as u8; 48]); + validator.withdrawal_credentials.0[0] = constants::ETH1_ADDRESS_WITHDRAWAL_PREFIX; + validator.withdrawal_credentials.0[31] = index as u8; + } + state + } + + /// The execution address [`electra_state_with_validators`] derived + /// validator `index`'s withdrawal credentials from, i.e. the + /// `source_address` a request against that validator must carry to pass + /// [`has_execution_withdrawal_credential`]'s address check. + fn source_address(index: ValidatorIndex) -> ExecutionAddress { + let mut bytes = [0u8; 20]; + bytes[19] = index as u8; + ExecutionAddress::from_slice(&bytes) + } + + // -- process_withdrawal_request ----------------------------------------- + + #[test] + fn a_full_exit_request_for_an_unknown_pubkey_is_silently_dropped() { + let mut state = electra_state_with_validators(4); + let config = Config::mainnet(); + let before = state.clone(); + + let request = electra::WithdrawalRequest { + source_address: source_address(0), + validator_pubkey: BlsPubkey([0xff; 48]), + amount: constants::FULL_EXIT_REQUEST_AMOUNT, + }; + process_withdrawal_request(&mut state, &request, &config) + .expect("an unresolved pubkey is dropped silently, not rejected"); + assert_eq!( + state, before, + "no validator should have been touched for a pubkey nobody holds" + ); + } + + #[test] + fn a_request_with_the_wrong_source_address_is_silently_dropped() { + let mut state = electra_state_with_validators(4); + let config = Config::mainnet(); + let pubkey = state.validator(0).unwrap().pubkey; + let before = state.clone(); + + let request = electra::WithdrawalRequest { + // Validator 0's real credentials point at `source_address(0)`, + // not this one. + source_address: source_address(1), + validator_pubkey: pubkey, + amount: constants::FULL_EXIT_REQUEST_AMOUNT, + }; + process_withdrawal_request(&mut state, &request, &config) + .expect("a mismatched source address is dropped silently, not rejected"); + assert_eq!(state, before, "no validator should have been touched"); + } + + #[test] + fn a_full_exit_request_exits_a_matching_active_validator() { + let mut state = electra_state_with_validators(4); + let config = Config::mainnet(); + let pubkey = state.validator(0).unwrap().pubkey; + + let request = electra::WithdrawalRequest { + source_address: source_address(0), + validator_pubkey: pubkey, + amount: constants::FULL_EXIT_REQUEST_AMOUNT, + }; + process_withdrawal_request(&mut state, &request, &config).unwrap(); + + assert_ne!( + state.validator(0).unwrap().exit_epoch, + FAR_FUTURE_EPOCH, + "a full exit request against a validator with nothing pending must exit it" + ); + } + + /// Gated to `preset-minimal` only: mainnet's own + /// `PENDING_PARTIAL_WITHDRAWALS_LIMIT` is large enough (a fraction over + /// a hundred million) that actually building a queue at that literal + /// size would allocate several gigabytes and take seconds, for a check + /// that adds nothing over exercising the identical code path at + /// minimal's much smaller limit. This still runs, just not under the + /// default `cargo test` invocation this module's own task list uses, + /// which is why `preset::MIN_ACTIVATION_BALANCE` and friends throughout + /// this file are written against whichever preset is active rather than + /// hardcoded, the same as everywhere else in this module. + #[test] + #[cfg(feature = "preset-minimal")] + fn a_full_queue_silently_drops_a_partial_request_but_not_a_full_exit() { + let mut state = electra_state_with_validators(2); + let config = Config::mainnet(); + + // Fill the pending-partial-withdrawals queue to its literal limit; + // only practical because this test is gated to the minimal preset, + // whose PENDING_PARTIAL_WITHDRAWALS_LIMIT is small. See this + // function's own documentation. + { + let mut fields = block_mut(&mut state, "test setup").unwrap(); + let queue = fields.pending_partial_withdrawals_mut(); + for _ in 0..preset::PENDING_PARTIAL_WITHDRAWALS_LIMIT { + queue + .push(electra::PendingPartialWithdrawal { + validator_index: 0, + amount: 1, + withdrawable_epoch: 0, + }) + .unwrap(); + } + } + let pubkey = state.validator(1).unwrap().pubkey; + let before_full_queue_len = block_mut(&mut state, "test assertion") + .unwrap() + .pending_partial_withdrawals_mut() + .len(); + assert_eq!( + before_full_queue_len, + preset::PENDING_PARTIAL_WITHDRAWALS_LIMIT + ); + + // A partial request against the full queue: silently dropped. + let partial_request = electra::WithdrawalRequest { + source_address: source_address(1), + validator_pubkey: pubkey, + amount: 1, + }; + process_withdrawal_request(&mut state, &partial_request, &config) + .expect("a full queue drops a partial request silently, not with an error"); + assert_eq!( + block_mut(&mut state, "test assertion") + .unwrap() + .pending_partial_withdrawals_mut() + .len(), + preset::PENDING_PARTIAL_WITHDRAWALS_LIMIT, + "the queue must not have grown" + ); + + // A full exit request against the same validator: not gated by the + // queue at all, since it never uses it. + let full_exit_request = electra::WithdrawalRequest { + source_address: source_address(1), + validator_pubkey: pubkey, + amount: constants::FULL_EXIT_REQUEST_AMOUNT, + }; + process_withdrawal_request(&mut state, &full_exit_request, &config).unwrap(); + assert_ne!( + state.validator(1).unwrap().exit_epoch, + FAR_FUTURE_EPOCH, + "a full exit request must not be blocked by a full partial-withdrawal queue" + ); + } + + #[test] + fn a_full_exit_request_ignores_the_partial_withdrawals_queue_entirely() { + // The cross-preset counterpart of the `preset-minimal`-only test + // above: rather than filling the queue to its literal (and, under + // mainnet, impractically large) limit, this only has to show that + // `is_full_exit_request`'s branch never even reads + // `pending_partial_withdrawals_len`, by pushing one placeholder + // entry and confirming the exit still succeeds. + let mut state = electra_state_with_validators(2); + let config = Config::mainnet(); + block_mut(&mut state, "test setup") + .unwrap() + .pending_partial_withdrawals_mut() + .push(electra::PendingPartialWithdrawal { + validator_index: 0, + amount: 1, + withdrawable_epoch: 0, + }) + .unwrap(); + let pubkey = state.validator(1).unwrap().pubkey; + + let full_exit_request = electra::WithdrawalRequest { + source_address: source_address(1), + validator_pubkey: pubkey, + amount: constants::FULL_EXIT_REQUEST_AMOUNT, + }; + process_withdrawal_request(&mut state, &full_exit_request, &config).unwrap(); + assert_ne!( + state.validator(1).unwrap().exit_epoch, + FAR_FUTURE_EPOCH, + "a full exit request must not consult the partial-withdrawals queue length at all" + ); + } + + #[test] + fn a_compounding_validator_with_excess_balance_gets_a_partial_withdrawal_queued() { + let mut state = electra_state_with_validators(2); + let config = Config::mainnet(); + { + let validator = state.validator_mut(0).unwrap(); + validator.withdrawal_credentials.0[0] = constants::COMPOUNDING_WITHDRAWAL_PREFIX; + validator.effective_balance = preset::MIN_ACTIVATION_BALANCE; + } + state.balances_mut()[0] = preset::MIN_ACTIVATION_BALANCE + 1_000_000_000; + let pubkey = state.validator(0).unwrap().pubkey; + + let request = electra::WithdrawalRequest { + source_address: source_address(0), + validator_pubkey: pubkey, + amount: 500_000_000, + }; + process_withdrawal_request(&mut state, &request, &config).unwrap(); + + let queued = + crate::beacon::helpers::electra::get_pending_balance_to_withdraw(&state, 0).unwrap(); + assert_eq!( + queued, 500_000_000, + "the request's own amount, capped at the validator's actual excess, must be queued" + ); + } + + #[test] + fn a_noncompounding_validator_gets_no_partial_withdrawal_queued() { + // Validator 0 keeps its eth1 (not compounding) credential from + // `electra_state_with_validators`: only a compounding validator may + // take a partial withdrawal through this path (a non-compounding one + // must use a full exit instead), so this must fall through and queue + // nothing even with genuine excess balance. + let mut state = electra_state_with_validators(2); + let config = Config::mainnet(); + state.balances_mut()[0] = preset::MIN_ACTIVATION_BALANCE + 1_000_000_000; + let pubkey = state.validator(0).unwrap().pubkey; + + let request = electra::WithdrawalRequest { + source_address: source_address(0), + validator_pubkey: pubkey, + amount: 500_000_000, + }; + process_withdrawal_request(&mut state, &request, &config).unwrap(); + + let queued = + crate::beacon::helpers::electra::get_pending_balance_to_withdraw(&state, 0).unwrap(); + assert_eq!( + queued, 0, + "a non-compounding validator has no partial-withdrawal route" + ); + } + + // -- process_consolidation_request -------------------------------------- + + #[test] + fn a_self_consolidation_switches_the_validator_to_compounding() { + let mut state = electra_state_with_validators(2); + let config = Config::mainnet(); + let pubkey = state.validator(0).unwrap().pubkey; + + let request = electra::ConsolidationRequest { + source_address: source_address(0), + source_pubkey: pubkey, + target_pubkey: pubkey, + }; + process_consolidation_request(&mut state, &request, &config).unwrap(); + + assert!(has_compounding_withdrawal_credential( + state.validator(0).unwrap() + )); + assert!( + block_mut(&mut state, "test assertion") + .unwrap() + .pending_consolidations_mut() + .is_empty(), + "a switch-to-compounding request is not a real consolidation and must not queue one" + ); + } + + #[test] + fn a_self_consolidation_with_the_wrong_source_address_is_silently_dropped() { + let mut state = electra_state_with_validators(2); + let config = Config::mainnet(); + let pubkey = state.validator(0).unwrap().pubkey; + let before = state.clone(); + + let request = electra::ConsolidationRequest { + // Does not match validator 0's real credentials, so this is + // neither a valid switch-to-compounding request nor (since + // source == target) a real consolidation. + source_address: source_address(1), + source_pubkey: pubkey, + target_pubkey: pubkey, + }; + process_consolidation_request(&mut state, &request, &config) + .expect("neither branch applies, so this must be a silent no-op, not an error"); + assert_eq!(state, before, "no validator should have been touched"); + } + + #[test] + fn a_full_pending_consolidations_queue_silently_drops_a_real_consolidation() { + let mut state = electra_state_with_validators(2); + let config = Config::mainnet(); + { + let mut fields = block_mut(&mut state, "test setup").unwrap(); + let queue = fields.pending_consolidations_mut(); + for _ in 0..preset::PENDING_CONSOLIDATIONS_LIMIT { + queue + .push(electra::PendingConsolidation { + source_index: 0, + target_index: 0, + }) + .unwrap(); + } + } + { + let validator = state.validator_mut(1).unwrap(); + validator.withdrawal_credentials.0[0] = constants::COMPOUNDING_WITHDRAWAL_PREFIX; + } + let source_pubkey = state.validator(0).unwrap().pubkey; + let target_pubkey = state.validator(1).unwrap().pubkey; + let before_len = block_mut(&mut state, "test assertion") + .unwrap() + .pending_consolidations_mut() + .len(); + + let request = electra::ConsolidationRequest { + source_address: source_address(0), + source_pubkey, + target_pubkey, + }; + process_consolidation_request(&mut state, &request, &config) + .expect("a full queue drops the request silently, not with an error"); + + assert_eq!( + block_mut(&mut state, "test assertion") + .unwrap() + .pending_consolidations_mut() + .len(), + before_len, + "the queue must not have grown past its limit" + ); + assert_eq!( + state.validator(0).unwrap().exit_epoch, + FAR_FUTURE_EPOCH, + "the source validator must not have been exited either" + ); + } + + /// Uses 200 validators and [`Config::minimal`], not the two-validator + /// state (and `Config::mainnet`) this module's other request tests use. + /// `get_consolidation_churn_limit` splits `get_balance_churn_limit` + /// (proportional to total active balance) between activations/exits and + /// consolidations, capping the former at + /// `Config::max_per_epoch_activation_exit_churn_limit`; with only two + /// validators' worth of active balance, that cap consumes the *entire* + /// churn budget under either config, leaving a genuine consolidation + /// nothing to spend and this same silent-return chain drops it just as + /// the full-queue case does. Mainnet's own `Config::churn_limit_quotient` + /// (in the tens of thousands) would need on the order of half a million + /// validators' worth of active balance before any is left over for + /// consolidations; minimal's much smaller quotient reaches that same + /// point around 200, which is what this test actually builds. + #[test] + fn a_valid_consolidation_request_queues_a_pending_consolidation_and_exits_the_source() { + let mut state = electra_state_with_validators(200); + let config = Config::minimal(); + { + let validator = state.validator_mut(1).unwrap(); + validator.withdrawal_credentials.0[0] = constants::COMPOUNDING_WITHDRAWAL_PREFIX; + } + let source_pubkey = state.validator(0).unwrap().pubkey; + let target_pubkey = state.validator(1).unwrap().pubkey; + + let request = electra::ConsolidationRequest { + source_address: source_address(0), + source_pubkey, + target_pubkey, + }; + process_consolidation_request(&mut state, &request, &config).unwrap(); + + assert_ne!( + state.validator(0).unwrap().exit_epoch, + FAR_FUTURE_EPOCH, + "a genuine consolidation must start the source validator's exit" + ); + let queue_len = block_mut(&mut state, "test assertion") + .unwrap() + .pending_consolidations_mut() + .len(); + assert_eq!(queue_len, 1, "the consolidation must have been queued"); + } + + // -- apply_deposit / process_deposit ------------------------------------- + + #[test] + fn a_new_validators_deposit_is_queued_rather_than_credited_directly() { + let mut state = electra_state_with_validators(1); + let config = Config::mainnet(); + let count_before = state.validators().len(); + + let pubkey = BlsPubkey([9; 48]); + let signature = BlsSignature::default(); + // An invalid signature: `apply_deposit` must still register the + // validator, since `is_valid_deposit_signature` is checked + // separately and this test is not exercising it. Use a signature + // that happens to be valid would require real key generation for no + // benefit here. + let withdrawal_credentials = Bytes32::ZERO; + + apply_deposit( + &mut state, + pubkey, + withdrawal_credentials, + 32_000_000_000, + &signature, + &config, + ) + .unwrap(); + + // An invalid signature means the deposit is not credited to a new + // validator at all: see `apply_deposit`'s own documentation. + assert_eq!(state.validators().len(), count_before); + assert!( + crate::beacon::helpers::electra::electra_state_ref(&state, "test assertion") + .unwrap() + .pending_partial_withdrawals() + .is_empty(), + "unrelated to this test, sanity check only" + ); + } + + #[test] + fn get_expected_withdrawals_reports_how_many_pending_partial_withdrawals_it_consumed() { + let mut state = electra_state_with_validators(2); + // Two pending partial withdrawals, both already due: the sweep must + // report having consumed both, regardless of whether either produced + // an actual `Withdrawal`. + { + let mut fields = block_mut(&mut state, "test setup").unwrap(); + let queue = fields.pending_partial_withdrawals_mut(); + queue + .push(electra::PendingPartialWithdrawal { + validator_index: 0, + amount: 1, + withdrawable_epoch: 0, + }) + .unwrap(); + queue + .push(electra::PendingPartialWithdrawal { + validator_index: 1, + amount: 1, + withdrawable_epoch: 0, + }) + .unwrap(); + } + // Neither validator has excess balance above MIN_ACTIVATION_BALANCE, + // so neither pending withdrawal actually produces a `Withdrawal`; + // the count returned is still 2. + let (withdrawals, processed_partial_withdrawals_count) = + get_expected_withdrawals(&state).unwrap(); + assert!(withdrawals.is_empty()); + assert_eq!(processed_partial_withdrawals_count, 2); + } + + #[test] + fn an_empty_requests_container_yields_an_empty_list() { + let requests = electra::ExecutionRequests { + deposits: Default::default(), + withdrawals: Default::default(), + consolidations: Default::default(), + }; + assert!(get_execution_requests_list(&requests).is_empty()); + } + + #[test] + fn each_present_kind_is_prefixed_with_its_type_byte_and_ordered() { + let mut requests = electra::ExecutionRequests { + deposits: Default::default(), + withdrawals: Default::default(), + consolidations: Default::default(), + }; + requests + .withdrawals + .push(electra::WithdrawalRequest { + source_address: ExecutionAddress::ZERO, + validator_pubkey: BlsPubkey::default(), + amount: 0, + }) + .expect("one withdrawal request fits"); + requests + .consolidations + .push(electra::ConsolidationRequest { + source_address: ExecutionAddress::ZERO, + source_pubkey: BlsPubkey::default(), + target_pubkey: BlsPubkey::default(), + }) + .expect("one consolidation request fits"); + + let list = get_execution_requests_list(&requests); + + // Deposits are empty, so they are excluded entirely rather than + // appearing as a bare type byte. + assert_eq!(list.len(), 2); + assert_eq!(list[0][0], constants::WITHDRAWAL_REQUEST_TYPE); + assert_eq!(list[1][0], constants::CONSOLIDATION_REQUEST_TYPE); + // Each element is longer than its type byte alone. + assert!(list[0].len() > 1); + assert!(list[1].len() > 1); + } +} diff --git a/crates/blockchain/state_transition/src/beacon/stf/epoch/altair.rs b/crates/blockchain/state_transition/src/beacon/stf/epoch/altair.rs new file mode 100644 index 000000000..1b2f07320 --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/stf/epoch/altair.rs @@ -0,0 +1,498 @@ +//! Altair-specific epoch processing. +//! +//! Altair keeps every phase0 step that does not touch attestation accounting +//! unchanged (registry updates, slashings, the four resets, the historical +//! roots roll-up) and replaces the rest: justification now reads participation +//! flags instead of `PendingAttestation`s, rewards score those same flags +//! instead of replaying attestations, and three steps are new outright: +//! [`process_inactivity_updates`] (the running per-validator score the leak +//! penalty scales from), [`process_participation_flag_updates`] (the rotation +//! that gives the reward accounting its two-epoch window), and +//! [`process_sync_committee_updates`] (rotating in the next sync committee at +//! each period boundary). [`process_epoch`] below is the driver, transcribed +//! from the specification's own list in the order it gives them; that order is +//! load-bearing in the same two places [`super`]'s phase0 driver documents, +//! plus one more altair adds: [`process_inactivity_updates`] must run before +//! [`process_rewards_and_penalties`], since the inactivity penalty scales by +//! the score the former just updated, not by whatever it held a step earlier. + +use crate::beacon::config::Config; +use crate::beacon::constants; +use crate::beacon::containers::BeaconState; +use crate::beacon::error::{Error, Result}; +use crate::beacon::helpers::accessors::{ + get_current_epoch, get_previous_epoch, get_total_active_balance, get_total_balance, +}; +use crate::beacon::helpers::altair::{ + get_flag_index_deltas, get_inactivity_penalty_deltas, get_next_sync_committee, + get_unslashed_participating_indices, +}; +use crate::beacon::helpers::finality::{get_eligible_validator_indices, is_in_inactivity_leak}; +use crate::beacon::helpers::math::saturating_sub; +use crate::beacon::helpers::mutators::{decrease_balance, increase_balance}; +use crate::beacon::preset; +use crate::beacon::primitives::ValidatorIndex; + +use super::justification::weigh_justification_and_finalization; + +/// Runs every altair epoch-boundary step, in the specification's order. +/// +/// Delegates every step the specification does not modify in altair +/// (registry updates, slashings, the four resets, the historical roots +/// update) to the fork-shared functions in [`super`], and substitutes +/// altair's own version of the rest. +pub fn process_epoch(state: &mut BeaconState, config: &Config) -> Result<()> { + process_justification_and_finalization(state)?; + process_inactivity_updates(state, config)?; + process_rewards_and_penalties(state, config)?; + super::registry::process_registry_updates(state, config)?; + super::registry::process_slashings(state, config)?; + super::process_eth1_data_reset(state)?; + super::process_effective_balance_updates(state)?; + super::process_slashings_reset(state)?; + super::process_randao_mixes_reset(state)?; + super::process_historical_roots_update(state)?; + process_participation_flag_updates(state)?; + process_sync_committee_updates(state)?; + Ok(()) +} + +/// Updates justification and finality from how much active balance cast a +/// timely, correct target vote for the previous and current epoch. +/// +/// The only difference from phase0's version: the two target balances come +/// from [`get_unslashed_participating_indices`] over the epoch's participation +/// flags rather than from matching stored `PendingAttestation`s against the +/// block root history. Once those two balances are in hand, the actual +/// bitfield and finality bookkeeping is identical, so this hands off to +/// [`weigh_justification_and_finalization`], the same function phase0 calls. +pub fn process_justification_and_finalization(state: &mut BeaconState) -> Result<()> { + // Initial FFG checkpoint values have a `0x00` stub for `root`. Skip FFG + // updates in the first two epochs to avoid corner cases that might result + // in modifying this stub. + if get_current_epoch(state) <= constants::GENESIS_EPOCH + 1 { + return Ok(()); + } + + let previous_indices = get_unslashed_participating_indices( + state, + constants::TIMELY_TARGET_FLAG_INDEX, + get_previous_epoch(state), + )?; + let current_indices = get_unslashed_participating_indices( + state, + constants::TIMELY_TARGET_FLAG_INDEX, + get_current_epoch(state), + )?; + let total_active_balance = get_total_active_balance(state)?; + let previous_target_balance = get_total_balance(state, &previous_indices)?; + let current_target_balance = get_total_balance(state, ¤t_indices)?; + weigh_justification_and_finalization( + state, + total_active_balance, + previous_target_balance, + current_target_balance, + ) +} + +/// Updates every eligible validator's inactivity score from its previous +/// epoch's timely-target participation. +/// +/// The score is a validator's own running memory of missed target votes: it +/// rises by [`Config::inactivity_score_bias`] for an epoch it misses, and +/// falls back down (by one for participating, and by +/// [`Config::inactivity_score_recovery_rate`] more whenever the chain is not +/// leaking) the moment it returns. That per-validator memory is what makes +/// [`crate::beacon::helpers::altair::get_inactivity_penalty_deltas`]'s leak penalty +/// proportional to an individual's own record of absence rather than to a +/// single chain-wide severity shared by everyone: two validators who have been +/// offline for different lengths of time pay different penalties even if the +/// leak itself is the same age for both. +/// +/// Skipped at the genesis epoch, since the score update reads the previous +/// epoch's participation and genesis has none. +pub fn process_inactivity_updates(state: &mut BeaconState, config: &Config) -> Result<()> { + if get_current_epoch(state) == constants::GENESIS_EPOCH { + return Ok(()); + } + + // Every read below needs `&BeaconState`, so they all run before this takes + // the mutable borrow `inactivity_scores` requires: `altair_validator_lists_mut` + // borrows the whole state, and there is no way to hold that mutably while + // also calling `get_eligible_validator_indices`, `get_unslashed_participating_indices`, + // or `is_in_inactivity_leak`, each of which needs its own `&BeaconState`. + // `process_effective_balance_updates` in the parent module resolves the + // identical conflict the same way: decide everything in one pass over + // immutable state, then apply it in a second pass over a mutable borrow. + let eligible_indices = get_eligible_validator_indices(state); + let previous_epoch = get_previous_epoch(state); + let participating_indices = get_unslashed_participating_indices( + state, + constants::TIMELY_TARGET_FLAG_INDEX, + previous_epoch, + )?; + let leaking = is_in_inactivity_leak(state); + + let (_, _, inactivity_scores) = state.altair_validator_lists_mut()?; + let score_count = inactivity_scores.len(); + for index in eligible_indices { + let score = inactivity_scores + .get_mut(index as usize) + .ok_or(Error::IndexOutOfBounds { + index: index as usize, + len: score_count, + })?; + + // `participating_indices` is ascending and duplicate-free (see + // `get_unslashed_participating_indices`), so membership is a binary + // search rather than a linear scan. + if participating_indices.binary_search(&index).is_ok() { + // `x -= min(1, x)`, written with `saturating_sub` so a + // already-zero score cannot underflow. + *score = saturating_sub(*score, 1); + } else { + // The specification treats a `uint64` overflow here as an invalid + // state rather than a wrapped one, so this is checked rather than + // left to release-mode wrapping. + *score = score.checked_add(config.inactivity_score_bias).ok_or( + Error::ArithmeticOverflow("inactivity_scores[index] + INACTIVITY_SCORE_BIAS"), + )?; + } + + if !leaking { + *score = saturating_sub(*score, config.inactivity_score_recovery_rate); + } + } + + Ok(()) +} + +/// Applies the epoch's flag-index and inactivity deltas to every validator's +/// balance. +/// +/// Altair's version of this step: phase0 sums four attestation-shaped delta +/// functions (source, target, head, inclusion delay) plus one inactivity +/// penalty; altair instead sums one delta per participation flag (weighted by +/// [`constants::PARTICIPATION_FLAG_WEIGHTS`]) plus the same kind of inactivity +/// penalty, computed from [`crate::beacon::helpers::altair::get_inactivity_penalty_deltas`] +/// against the scores [`process_inactivity_updates`] just brought up to date. +/// Applying rewards and penalties as two separate passes (through +/// [`increase_balance`] and [`decrease_balance`], not one netted delta) is +/// unchanged from phase0, and for the same reason: [`decrease_balance`] floors +/// at zero, so netting first would let a reward mask a penalty that should +/// have driven a low balance all the way down. +/// +/// Skipped entirely at the genesis epoch: rewards pay for participation +/// recorded during the previous epoch, and genesis has none. +pub fn process_rewards_and_penalties(state: &mut BeaconState, config: &Config) -> Result<()> { + if get_current_epoch(state) == constants::GENESIS_EPOCH { + return Ok(()); + } + + let mut deltas = Vec::with_capacity(constants::PARTICIPATION_FLAG_WEIGHTS.len() + 1); + for flag_index in 0..constants::PARTICIPATION_FLAG_WEIGHTS.len() { + deltas.push(get_flag_index_deltas(state, flag_index)?); + } + deltas.push(get_inactivity_penalty_deltas(state, config)?); + + let validator_count = state.validators().len() as ValidatorIndex; + for (rewards, penalties) in deltas { + for index in 0..validator_count { + increase_balance(state, index, rewards[index as usize])?; + decrease_balance(state, index, penalties[index as usize])?; + } + } + Ok(()) +} + +/// Rotates the current epoch's participation flags into the previous slot and +/// installs a fresh, all-zero current list. +/// +/// This is what gives the reward accounting a two-epoch window without +/// storing whole attestations the way phase0 does: a flag set at processing +/// time survives exactly one more epoch boundary (as +/// `previous_epoch_participation`, read by the next epoch's rewards and +/// justification) and then this drops it, so the state never holds more than +/// two epochs of participation regardless of how long the chain runs. +/// +/// The fresh current list is sized to the validator registry, not left empty: +/// every validator (including one that just activated) needs a flag slot from +/// the moment it can be attested for, and `process_attestation` (not +/// implemented in this file) indexes into it directly rather than appending. +pub fn process_participation_flag_updates(state: &mut BeaconState) -> Result<()> { + let validator_count = state.validators().len(); + let (previous_epoch_participation, current_epoch_participation, _) = + state.altair_validator_lists_mut()?; + + *previous_epoch_participation = core::mem::take(current_epoch_participation); + *current_epoch_participation = vec![0; validator_count].try_into()?; + + Ok(()) +} + +/// Rotates in the next sync committee at each sync committee period boundary. +/// +/// Only fires once every [`preset::EPOCHS_PER_SYNC_COMMITTEE_PERIOD`] epochs; +/// every other epoch this is a no-op, since `current_sync_committee` and +/// `next_sync_committee` are otherwise left exactly as they were. Computing +/// the new `next_sync_committee` before touching either field (rather than +/// computing it after `current_sync_committee` has already been overwritten) +/// matters because [`get_next_sync_committee`] reads the validator registry, +/// not either committee field, so the order is safe either way for +/// correctness; it is still done first here so the fallible call happens +/// before any mutation, leaving the state untouched if it errors. +pub fn process_sync_committee_updates(state: &mut BeaconState) -> Result<()> { + let next_epoch = get_current_epoch(state) + 1; + if next_epoch.is_multiple_of(preset::EPOCHS_PER_SYNC_COMMITTEE_PERIOD) { + let next_committee = get_next_sync_committee(state)?; + let (current_sync_committee, next_sync_committee) = state.sync_committees_mut()?; + *current_sync_committee = core::mem::replace(next_sync_committee, next_committee); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::beacon::fork::ForkName; + use crate::beacon::helpers::altair::add_flag; + use crate::beacon::primitives::BlsPubkey; + + /// A deterministic but genuinely valid BLS public key for validator + /// `index`. + /// + /// [`process_sync_committee_updates`] aggregates every validator's pubkey + /// through [`get_next_sync_committee`], and a zero pubkey is not a curve + /// point, so an all-default validator would make that aggregation fail + /// rather than exercise the rotation this module is testing. + fn pubkey_for(index: usize) -> BlsPubkey { + let mut ikm = [0u8; 32]; + ikm[..8].copy_from_slice(&(index as u64 + 1).to_le_bytes()); + let secret = blst::min_pk::SecretKey::key_gen(&ikm, &[]) + .expect("32 bytes of input material is enough for key generation"); + BlsPubkey(secret.sk_to_pk().to_bytes()) + } + + /// An altair state with `count` fully active, full-balance validators, + /// positioned one epoch in, the same way + /// `crate::beacon::helpers::test_state::with_validators` positions its phase0 + /// state. + /// + /// Built on the shared fork-parameterised builder (see + /// [`crate::beacon::helpers::test_state::with_validators_at`]) and then given + /// real, distinguishable BLS pubkeys: [`process_sync_committee_updates`] + /// aggregates every validator's pubkey through + /// [`get_next_sync_committee`], and the shared builder's all-default + /// pubkey is not a curve point, which would make that aggregation fail + /// rather than exercise the rotation this module is testing. + fn altair_state_with_validators(count: usize) -> BeaconState { + let mut state = + crate::beacon::helpers::test_state::with_validators_at(ForkName::Altair, count); + for index in 0..count as ValidatorIndex { + state.validator_mut(index).unwrap().pubkey = pubkey_for(index as usize); + } + state + } + + // ----------------------------------------------------------------------- + // process_justification_and_finalization + // ----------------------------------------------------------------------- + + #[test] + fn justification_near_genesis_is_a_no_op() { + // Positioned at current epoch 1, still within the `GENESIS_EPOCH + 1` + // guard. + let mut state = altair_state_with_validators(4); + let before = state.clone(); + + process_justification_and_finalization(&mut state).unwrap(); + + assert_eq!(state.justification_bits(), before.justification_bits()); + assert_eq!( + state.current_justified_checkpoint(), + before.current_justified_checkpoint() + ); + } + + #[test] + fn justification_reads_target_balance_from_participation_flags() { + let mut state = altair_state_with_validators(4); + *state.slot_mut() = preset::SLOTS_PER_EPOCH * 2; // current epoch 2, previous epoch 1 + + // Every validator cast a timely, correct target vote last epoch, but + // none has yet this epoch: only the previous epoch should justify. + { + let (previous_epoch_participation, _, _) = state.altair_validator_lists_mut().unwrap(); + for flags in previous_epoch_participation.iter_mut() { + *flags = add_flag(*flags, constants::TIMELY_TARGET_FLAG_INDEX); + } + } + + process_justification_and_finalization(&mut state).unwrap(); + + let bits = state.justification_bits(); + assert_eq!(bits.get(0), Some(false), "current epoch did not justify"); + assert_eq!(bits.get(1), Some(true), "previous epoch justified"); + assert_eq!(state.current_justified_checkpoint().epoch, 1); + } + + // ----------------------------------------------------------------------- + // process_inactivity_updates + // ----------------------------------------------------------------------- + + #[test] + fn inactivity_updates_are_a_no_op_at_genesis() { + let mut state = altair_state_with_validators(4); + *state.slot_mut() = 0; + let (_, _, scores) = state.altair_validator_lists().unwrap(); + let before = scores.clone(); + + process_inactivity_updates(&mut state, &Config::mainnet()).unwrap(); + + let (_, _, scores) = state.altair_validator_lists().unwrap(); + assert_eq!(*scores, before); + } + + #[test] + fn inactivity_updates_diverge_by_participation_during_a_leak() { + let config = Config::mainnet(); + let mut state = altair_state_with_validators(4); + // Push the previous epoch far enough past the (still-genesis) + // finalized checkpoint to be a leak, matching how + // `rewards.rs`'s `finality_stalled_past_the_threshold_is_a_leak` test + // forces the same condition. + *state.slot_mut() = + preset::SLOTS_PER_EPOCH * (preset::MIN_EPOCHS_TO_INACTIVITY_PENALTY + 10); + + { + let (previous_epoch_participation, _, inactivity_scores) = + state.altair_validator_lists_mut().unwrap(); + inactivity_scores[0] = 10; + inactivity_scores[1] = 10; + // Validator 0 participated last epoch; validator 1 did not. + previous_epoch_participation[0] = add_flag(0, constants::TIMELY_TARGET_FLAG_INDEX); + } + + process_inactivity_updates(&mut state, &config).unwrap(); + + let (_, _, scores) = state.altair_validator_lists().unwrap(); + // Participating: score falls by one; during a leak nothing recovers + // it further. + assert_eq!(scores[0], 9); + // Not participating: score rises by the configured bias. + assert_eq!(scores[1], 10 + config.inactivity_score_bias); + } + + #[test] + fn inactivity_updates_recover_fully_outside_a_leak() { + let config = Config::mainnet(); + let mut state = altair_state_with_validators(4); + // The default position (current epoch 1, finalized checkpoint at + // epoch 0) is not a leak: `get_finality_delay` is zero. + + { + let (previous_epoch_participation, _, inactivity_scores) = + state.altair_validator_lists_mut().unwrap(); + inactivity_scores[0] = 5; + previous_epoch_participation[0] = add_flag(0, constants::TIMELY_TARGET_FLAG_INDEX); + } + + process_inactivity_updates(&mut state, &config).unwrap(); + + // One point off for participating, then the whole remainder recovers + // because the recovery rate outpaces a score this small. + let (_, _, scores) = state.altair_validator_lists().unwrap(); + assert_eq!(scores[0], 0); + } + + // ----------------------------------------------------------------------- + // process_rewards_and_penalties + // ----------------------------------------------------------------------- + + #[test] + fn rewards_and_penalties_are_a_no_op_at_genesis() { + let config = Config::mainnet(); + let mut state = altair_state_with_validators(4); + *state.slot_mut() = 0; + let balances_before = state.balances().clone(); + + process_rewards_and_penalties(&mut state, &config).unwrap(); + + assert_eq!( + state.balances(), + &balances_before, + "the genesis epoch has no previous epoch to reward" + ); + } + + // ----------------------------------------------------------------------- + // process_participation_flag_updates + // ----------------------------------------------------------------------- + + #[test] + fn participation_flag_updates_rotate_current_into_previous_and_reset_current() { + let mut state = altair_state_with_validators(4); + let current_before = { + let (_, current_epoch_participation, _) = state.altair_validator_lists_mut().unwrap(); + current_epoch_participation[0] = add_flag(0, constants::TIMELY_SOURCE_FLAG_INDEX); + current_epoch_participation[1] = add_flag(0, constants::TIMELY_HEAD_FLAG_INDEX); + current_epoch_participation.to_vec() + }; + + process_participation_flag_updates(&mut state).unwrap(); + + let (previous_epoch_participation, current_epoch_participation, _) = + state.altair_validator_lists().unwrap(); + assert_eq!(previous_epoch_participation.to_vec(), current_before); + assert_eq!(current_epoch_participation.len(), 4); + assert!( + current_epoch_participation.iter().all(|&flags| flags == 0), + "the fresh current list must start all-zero" + ); + } + + // ----------------------------------------------------------------------- + // process_sync_committee_updates + // ----------------------------------------------------------------------- + + #[test] + fn sync_committee_updates_are_a_no_op_off_the_period_boundary() { + // Current epoch 1: `next_epoch` (2) is not a multiple of + // `EPOCHS_PER_SYNC_COMMITTEE_PERIOD` on either preset. + let mut state = altair_state_with_validators(4); + let (before_current, before_next) = { + let (current, next) = state.sync_committees().unwrap(); + (current.clone(), next.clone()) + }; + + process_sync_committee_updates(&mut state).unwrap(); + + let (current, next) = state.sync_committees().unwrap(); + assert_eq!(*current, before_current); + assert_eq!(*next, before_next); + } + + #[test] + fn sync_committee_updates_rotate_at_the_period_boundary() { + let mut state = altair_state_with_validators(4); + // One epoch before a period boundary, so `next_epoch` lands exactly on + // it. + *state.slot_mut() = + preset::SLOTS_PER_EPOCH * (preset::EPOCHS_PER_SYNC_COMMITTEE_PERIOD - 1); + + let old_next = state.sync_committees().unwrap().1.clone(); + let expected_next = get_next_sync_committee(&state).unwrap(); + + process_sync_committee_updates(&mut state).unwrap(); + + let (current, next) = state.sync_committees().unwrap(); + assert_eq!( + *current, old_next, + "the old next committee takes over as current" + ); + assert_eq!( + *next, expected_next, + "a freshly drawn committee takes the next slot" + ); + } +} diff --git a/crates/blockchain/state_transition/src/beacon/stf/epoch/capella.rs b/crates/blockchain/state_transition/src/beacon/stf/epoch/capella.rs new file mode 100644 index 000000000..6a93c68b7 --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/stf/epoch/capella.rs @@ -0,0 +1,223 @@ +//! Capella-specific epoch processing. +//! +//! Capella's own `process_epoch` is otherwise altair's step list with one +//! swap: `process_historical_roots_update` is replaced by +//! [`process_historical_summaries_update`], since `historical_roots` is +//! frozen as of this fork (`state.historical_roots` keeps whatever bellatrix +//! left it at and is never appended to again) and `historical_summaries` +//! accumulates new history in its place. [`process_epoch`] below is +//! transcribed from the specification's own list, in the order it gives +//! them, which is the same order [`super::altair::process_epoch`] uses apart +//! from that one substitution; see [`super`]'s and `super::altair`'s own +//! documentation for why that order matters everywhere it is not just this +//! one swap. +//! +//! Deneb reuses this driver unchanged: its own "Epoch processing" section in +//! the specification says nothing at all, meaning deneb's `process_epoch` is +//! whatever the previous fork (capella) already defined. [`super::process_epoch`]'s +//! dispatcher already routes `ForkName::Deneb` here for exactly that reason, +//! so [`process_epoch`] and [`process_historical_summaries_update`] both +//! accept a deneb state as readily as a capella one rather than gating on +//! `ForkName::Capella` specifically; see [`historical_summaries_mut`] for +//! where that acceptance actually lives. + +use crate::beacon::config::Config; +use crate::beacon::containers::{BeaconState, HistoricalSummaries, HistoricalSummary}; +use crate::beacon::error::{Error, Result}; +use crate::beacon::helpers::accessors::get_current_epoch; +use crate::beacon::preset; +use crate::beacon::primitives::{Epoch, HashTreeRoot as _}; + +/// Capella's epoch-boundary driver, in the specification's order. +/// +/// Every step but one is altair's own, called through `super::altair` and +/// `super::registry` exactly as [`super::altair::process_epoch`] itself calls +/// them; the one exception is [`process_historical_summaries_update`] in +/// place of `super::process_historical_roots_update`. +pub fn process_epoch(state: &mut BeaconState, config: &Config) -> Result<()> { + super::altair::process_justification_and_finalization(state)?; + super::altair::process_inactivity_updates(state, config)?; + super::altair::process_rewards_and_penalties(state, config)?; + super::registry::process_registry_updates(state, config)?; + super::registry::process_slashings(state, config)?; + super::process_eth1_data_reset(state)?; + super::process_effective_balance_updates(state)?; + super::process_slashings_reset(state)?; + super::process_randao_mixes_reset(state)?; + // [Modified in Capella] + process_historical_summaries_update(state)?; + super::altair::process_participation_flag_updates(state)?; + super::altair::process_sync_committee_updates(state)?; + Ok(()) +} + +/// Folds the block and state root vectors into one [`HistoricalSummary`] when +/// they are about to wrap, capella's replacement for +/// `super::process_historical_roots_update`. +/// +/// [`HistoricalSummary`]'s two fields are `hash_tree_root(state.block_roots)` +/// and `hash_tree_root(state.state_roots)` taken directly, not the root of a +/// combined [`crate::beacon::containers::HistoricalBatch`] wrapping both: the +/// specification's own note that the two containers are hash-tree-root +/// compatible is what makes those the same two child roots either way, so a +/// verifier holding only one of the two forms can still check a historical +/// proof against either. +pub fn process_historical_summaries_update(state: &mut BeaconState) -> Result<()> { + let next_epoch = get_current_epoch(state) + 1; + let epochs_per_historical_root = + (preset::SLOTS_PER_HISTORICAL_ROOT / preset::SLOTS_PER_EPOCH as usize) as Epoch; + if next_epoch.is_multiple_of(epochs_per_historical_root) { + let summary = HistoricalSummary { + block_summary_root: state.block_roots().hash_tree_root(), + state_summary_root: state.state_roots().hash_tree_root(), + }; + historical_summaries_mut(state, "process_historical_summaries_update")?.push(summary)?; + } + Ok(()) +} + +/// The `historical_summaries` list, mutably, for any fork that carries it, or +/// an error naming the function that needs one. +/// +/// `historical_summaries` enters the state at capella and every later fork +/// keeps the identical field: deneb reuses this whole driver unchanged (see +/// this module's own documentation), and electra and fulu each fold this same +/// step into their own, larger driver rather than redefining it, since +/// neither fork's specification says anything about historical summaries at +/// all. Matching every variant that actually has the field, rather than only +/// [`BeaconState::Capella`], is what lets each of those reuse this function +/// instead of a copy of it. +fn historical_summaries_mut<'a>( + state: &'a mut BeaconState, + function: &'static str, +) -> Result<&'a mut HistoricalSummaries> { + match state { + BeaconState::Capella(state) => Ok(&mut state.historical_summaries), + BeaconState::Deneb(state) => Ok(&mut state.historical_summaries), + BeaconState::Electra(state) => Ok(&mut state.historical_summaries), + BeaconState::Fulu(state) => Ok(&mut state.historical_summaries), + other => Err(Error::UnsupportedForFork { + function, + fork: other.fork_name(), + }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::beacon::fork::ForkName; + + /// A capella state with `count` fully active, full-balance validators, + /// positioned one epoch in the same way + /// `crate::beacon::helpers::test_state::with_validators` positions its phase0 + /// state. + /// + /// A thin wrapper around the shared fork-parameterised builder: see + /// [`crate::beacon::helpers::test_state::with_validators_at`] for the construction + /// this and every other fork's test module used to duplicate. + fn capella_state_with_validators(count: usize) -> BeaconState { + crate::beacon::helpers::test_state::with_validators_at(ForkName::Capella, count) + } + + #[test] + fn historical_summaries_update_is_a_no_op_off_the_boundary() { + // Current epoch 1: `next_epoch` (2) is not a multiple of + // `SLOTS_PER_HISTORICAL_ROOT / SLOTS_PER_EPOCH` on either preset. + let mut state = capella_state_with_validators(4); + process_historical_summaries_update(&mut state).unwrap(); + + let inner = historical_summaries_mut(&mut state, "test assertion").unwrap(); + assert!(inner.is_empty()); + } + + #[test] + fn historical_summaries_update_appends_at_the_boundary_instead_of_historical_roots() { + let epochs_per_historical_root = + preset::SLOTS_PER_HISTORICAL_ROOT as u64 / preset::SLOTS_PER_EPOCH; + let mut state = capella_state_with_validators(4); + // One epoch before the boundary, so `next_epoch` lands exactly on it. + *state.slot_mut() = preset::SLOTS_PER_EPOCH * (epochs_per_historical_root - 1); + + let expected_block_summary_root = state.block_roots().hash_tree_root(); + let expected_state_summary_root = state.state_roots().hash_tree_root(); + + process_historical_summaries_update(&mut state).unwrap(); + + assert!( + state.historical_roots().is_empty(), + "capella must never append to historical_roots: it is frozen as of this fork" + ); + let summaries = historical_summaries_mut(&mut state, "test assertion").unwrap(); + assert_eq!(summaries.len(), 1); + assert_eq!(summaries[0].block_summary_root, expected_block_summary_root); + assert_eq!(summaries[0].state_summary_root, expected_state_summary_root); + } + + #[test] + fn historical_summaries_update_accepts_a_deneb_state_too() { + // `process_epoch` is dispatched for deneb states through this exact + // driver (`ForkName::Deneb => capella::process_epoch`), so the + // historical-summaries step it calls must not reject one. + let capella_state = capella_state_with_validators(2); + let deneb_state = if let BeaconState::Capella(inner) = capella_state { + crate::beacon::containers::deneb::BeaconState { + genesis_time: inner.genesis_time, + genesis_validators_root: inner.genesis_validators_root, + slot: preset::SLOTS_PER_HISTORICAL_ROOT as u64 - preset::SLOTS_PER_EPOCH, + fork: inner.fork, + latest_block_header: inner.latest_block_header, + block_roots: inner.block_roots, + state_roots: inner.state_roots, + historical_roots: inner.historical_roots, + eth1_data: inner.eth1_data, + eth1_data_votes: inner.eth1_data_votes, + eth1_deposit_index: inner.eth1_deposit_index, + validators: inner.validators, + balances: inner.balances, + randao_mixes: inner.randao_mixes, + slashings: inner.slashings, + previous_epoch_participation: inner.previous_epoch_participation, + current_epoch_participation: inner.current_epoch_participation, + justification_bits: inner.justification_bits, + previous_justified_checkpoint: inner.previous_justified_checkpoint, + current_justified_checkpoint: inner.current_justified_checkpoint, + finalized_checkpoint: inner.finalized_checkpoint, + inactivity_scores: inner.inactivity_scores, + current_sync_committee: inner.current_sync_committee, + next_sync_committee: inner.next_sync_committee, + latest_execution_payload_header: + crate::beacon::containers::deneb::ExecutionPayloadHeader { + parent_hash: inner.latest_execution_payload_header.parent_hash, + fee_recipient: inner.latest_execution_payload_header.fee_recipient, + state_root: inner.latest_execution_payload_header.state_root, + receipts_root: inner.latest_execution_payload_header.receipts_root, + logs_bloom: inner.latest_execution_payload_header.logs_bloom, + prev_randao: inner.latest_execution_payload_header.prev_randao, + block_number: inner.latest_execution_payload_header.block_number, + gas_limit: inner.latest_execution_payload_header.gas_limit, + gas_used: inner.latest_execution_payload_header.gas_used, + timestamp: inner.latest_execution_payload_header.timestamp, + extra_data: inner.latest_execution_payload_header.extra_data, + base_fee_per_gas: inner.latest_execution_payload_header.base_fee_per_gas, + block_hash: inner.latest_execution_payload_header.block_hash, + transactions_root: inner.latest_execution_payload_header.transactions_root, + withdrawals_root: inner.latest_execution_payload_header.withdrawals_root, + blob_gas_used: 0, + excess_blob_gas: 0, + }, + next_withdrawal_index: inner.next_withdrawal_index, + next_withdrawal_validator_index: inner.next_withdrawal_validator_index, + historical_summaries: inner.historical_summaries, + } + } else { + unreachable!() + }; + let mut state = BeaconState::Deneb(deneb_state); + + process_historical_summaries_update(&mut state).unwrap(); + + let summaries = historical_summaries_mut(&mut state, "test assertion").unwrap(); + assert_eq!(summaries.len(), 1); + } +} diff --git a/crates/blockchain/state_transition/src/beacon/stf/epoch/electra.rs b/crates/blockchain/state_transition/src/beacon/stf/epoch/electra.rs new file mode 100644 index 000000000..2f6bf9fcf --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/stf/epoch/electra.rs @@ -0,0 +1,1254 @@ +//! Electra-specific epoch processing. +//! +//! Every earlier fork credits a deposit's balance the moment block processing +//! sees it, because every validator's effective balance was capped at the +//! same fixed value, so the only thing worth rate-limiting was how many +//! validators could activate in one epoch. EIP-7251 breaks that: a validator +//! with a compounding withdrawal credential can hold up to +//! [`preset::MAX_EFFECTIVE_BALANCE_ELECTRA`], dozens of times the old ceiling +//! ([`crate::beacon::helpers::electra::get_max_effective_balance`]), so a single +//! deposit or consolidation can now move as much voting weight as the old +//! count-based churn limit needed a whole epoch's worth of validators to +//! admit. Crediting it immediately would let that happen in one slot instead. +//! So electra queues both kinds of balance movement and drains each a +//! bounded amount per epoch instead of applying either at block-processing +//! time: [`process_pending_deposits`] rate-limits new stake by +//! [`crate::beacon::helpers::electra::get_activation_exit_churn_limit`]'s Gwei +//! budget, and [`process_pending_consolidations`] moves balance between +//! already-active validators, which is why the second one needs no churn +//! budget of its own (see its own doc for why). +//! +//! [`process_epoch`] is transcribed from the specification's own list, in the +//! order it gives them. Registry updates, slashings, and effective-balance +//! updates are electra's own rewrites ([`process_registry_updates`], +//! [`process_slashings`], and [`process_effective_balance_updates`]); the two +//! pending-queue steps are new outright; everything else is unmodified and +//! reused through `super::altair`, `super::registry`, `super::capella`, and +//! `super`. `process_slashings` looks, at a glance, like the same +//! "swap-the-constant" shape altair and bellatrix each use for this same +//! function (see `super::registry::process_slashings`'s own doc), but EIP-7251 +//! restructures the division itself rather than only raising a multiplier, so +//! that shared copy stops being correct here; see [`process_slashings`]'s own +//! doc for the arithmetic and a worked example of the two diverging. + +use crate::beacon::bls; +use crate::beacon::config::Config; +use crate::beacon::constants::{self, FAR_FUTURE_EPOCH}; +use crate::beacon::containers::shared::{DepositMessage, Validator}; +use crate::beacon::containers::{BeaconState, electra, fulu}; +use crate::beacon::error::{Error, Result}; +use crate::beacon::helpers::accessors::{get_current_epoch, get_total_active_balance}; +use crate::beacon::helpers::electra::{ + get_activation_exit_churn_limit, get_max_effective_balance, initiate_validator_exit, + is_eligible_for_activation_queue, +}; +use crate::beacon::helpers::misc::{ + compute_activation_exit_epoch, compute_deposit_domain, compute_signing_root, + compute_start_slot_at_epoch, +}; +use crate::beacon::helpers::mutators::{decrease_balance, increase_balance}; +use crate::beacon::helpers::predicates::{is_active_validator, is_eligible_for_activation}; +use crate::beacon::preset; +use crate::beacon::primitives::{ + BlsPubkey, BlsSignature, Bytes32, Epoch, Gwei, HashTreeRoot as _, ValidatorIndex, +}; + +/// Electra's epoch-boundary driver, in the specification's order. +/// +/// Every step but five is reused unchanged, called through `super::altair`, +/// `super::registry`, `super::capella`, and `super` exactly as +/// [`super::capella::process_epoch`] itself calls them. The five exceptions: +/// [`process_registry_updates`], [`process_slashings`], and +/// [`process_effective_balance_updates`] replace the shared versions in +/// place, and [`process_pending_deposits`] and [`process_pending_consolidations`] +/// are inserted right after `process_eth1_data_reset`, exactly where the +/// specification's own listing puts them (before the effective-balance +/// update, so a deposit credited this epoch is already reflected when +/// effective balances round toward it). +pub fn process_epoch(state: &mut BeaconState, config: &Config) -> Result<()> { + super::altair::process_justification_and_finalization(state)?; + super::altair::process_inactivity_updates(state, config)?; + super::altair::process_rewards_and_penalties(state, config)?; + // [Modified in Electra:EIP7251] + process_registry_updates(state, config)?; + // [Modified in Electra:EIP7251]: electra's own copy; see this module's + // own doc and `process_slashings`'s own doc for why `super::registry`'s + // copy is no longer correct from here on. + process_slashings(state, config)?; + super::process_eth1_data_reset(state)?; + // [New in Electra:EIP7251] + process_pending_deposits(state, config)?; + // [New in Electra:EIP7251] + process_pending_consolidations(state, config)?; + // [Modified in Electra:EIP7251] + process_effective_balance_updates(state)?; + super::process_slashings_reset(state)?; + super::process_randao_mixes_reset(state)?; + super::capella::process_historical_summaries_update(state)?; + super::altair::process_participation_flag_updates(state)?; + super::altair::process_sync_committee_updates(state)?; + Ok(()) +} + +// --------------------------------------------------------------------------- +// Registry updates +// --------------------------------------------------------------------------- + +/// A validator's outcome for one run of [`process_registry_updates`]: at most +/// one of the specification's `if`/`elif`/`elif` branches applies. +enum RegistryAction { + QueueForActivation, + Eject, + Activate, +} + +/// Moves validators between activation-queue eligibility, ejection, and +/// activation, replacing `super::registry::process_registry_updates` for this +/// fork. +/// +/// Rewritten as a single pass over the registry rather than phase0's two +/// passes (eligibility-and-ejection, then a sorted, churn-limited activation +/// queue): EIP-7251 moves the very thing that used to bound activation, a +/// per-epoch validator headcount, onto [`process_pending_deposits`]'s Gwei +/// budget instead, so nothing here needs to rate-limit how many validators +/// activate in one epoch. Every validator whose eligibility is already +/// finalized simply activates, in registry order, at the same +/// `compute_activation_exit_epoch(current_epoch)` the specification computes +/// once up front, which is the detail that most differs from phase0 and is +/// worth transcribing precisely rather than assuming. +/// +/// Decided in one immutable pass and applied in a second, the same shape +/// `crate::beacon::stf::epoch::process_effective_balance_updates` uses: `state` is an +/// enum over per-fork structs, so nothing can hold `validators` mutably while +/// also reading it to decide the next validator's action. None of the three +/// checks reads anything a *different* validator's mutation could have +/// changed, so the two passes are safe to split. The one exception is +/// [`initiate_validator_exit`]'s own churn cursor, which must still advance in +/// ascending validator-index order for the ejected balance total to match the +/// specification's single loop exactly; applying every decided action in +/// registry order (rather than, say, ejections first) is what preserves that. +pub fn process_registry_updates(state: &mut BeaconState, config: &Config) -> Result<()> { + let current_epoch = get_current_epoch(state); + let activation_epoch = compute_activation_exit_epoch(current_epoch); + let finalized_epoch = state.finalized_checkpoint().epoch; + + let actions: Vec> = state + .validators() + .iter() + .map(|validator| { + if is_eligible_for_activation_queue(validator) { + Some(RegistryAction::QueueForActivation) + } else if is_active_validator(validator, current_epoch) + && validator.effective_balance <= config.ejection_balance + { + Some(RegistryAction::Eject) + } else if is_eligible_for_activation(validator, finalized_epoch) { + Some(RegistryAction::Activate) + } else { + None + } + }) + .collect(); + + for (index, action) in actions.into_iter().enumerate() { + let index = index as ValidatorIndex; + match action { + Some(RegistryAction::QueueForActivation) => { + state.validator_mut(index)?.activation_eligibility_epoch = current_epoch + 1; + } + Some(RegistryAction::Eject) => initiate_validator_exit(state, index, config)?, + Some(RegistryAction::Activate) => { + state.validator_mut(index)?.activation_epoch = activation_epoch; + } + None => {} + } + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Slashings +// --------------------------------------------------------------------------- + +/// Applies the deferred part of every slashing whose penalty falls due this +/// epoch, replacing `super::registry::process_slashings` for this fork. +/// +/// The same three passes as that version: sum `state.slashings`, scale and cap +/// the sum against the total active balance, then apply a per-validator +/// penalty to whoever's withdrawable epoch arrives this epoch. What EIP-7251 +/// changes is which side of a division the rounding happens on. That version +/// computes, per validator, `effective_balance_increments * +/// adjusted_total_slashing_balance`, divides by the raw `total_balance`, and +/// only then multiplies back up by `increment`. This version instead divides +/// `adjusted_total_slashing_balance` by `total_balance / increment` once, up +/// front, and multiplies that one shared quotient by each validator's own +/// `effective_balance_increments`. +/// +/// The two orders are not algebraically equivalent under integer division. +/// Multiplying by a validator's own increment count before dividing, the way +/// the pre-electra version does, needs the *product* to reach `total_balance` +/// before any penalty shows up at all; dividing first only needs the slashed +/// amount itself to reach `total_balance / increment`, a number smaller by a +/// factor of `EFFECTIVE_BALANCE_INCREMENT`. A validator whose slashed balance +/// is not the dominant contributor to the epoch's total can therefore round +/// all the way down to no penalty under the old order while still receiving a +/// real one under this one; see this function's own tests for a worked +/// example. The specification also frames the restructuring as an overflow +/// guard: the pre-electra product, `effective_balance_increments * +/// adjusted_total_slashing_balance`, risks overflowing a `uint64` once a +/// compounding validator's ceiling +/// ([`preset::MAX_EFFECTIVE_BALANCE_ELECTRA`]) is dozens of times larger than +/// the old, fixed one, so dividing the shared quotient down to size before +/// that multiplication, rather than after, is not merely a rounding +/// preference. +/// +/// The proportional multiplier itself is unchanged from bellatrix: electra +/// never redefines it, so reading it by fork through +/// [`preset::retuned::proportional_slashing_multiplier`] reaches the same +/// constant that function already selects for this fork; only the arithmetic +/// downstream of it moves. +/// +/// Takes `config` only to match [`process_epoch`]'s pipeline, the same reason +/// `super::registry::process_slashings` does; the specification's own version +/// takes no configuration either. +pub fn process_slashings(state: &mut BeaconState, _config: &Config) -> Result<()> { + let epoch = get_current_epoch(state); + let total_balance = get_total_active_balance(state)?; + + let mut slashed_sum: Gwei = 0; + for &slashing in state.slashings().iter() { + slashed_sum = slashed_sum + .checked_add(slashing) + .ok_or(Error::ArithmeticOverflow("summing the slashings vector"))?; + } + let multiplier = preset::retuned::proportional_slashing_multiplier(state.fork_name()); + let scaled_slashings = slashed_sum + .checked_mul(multiplier) + .ok_or(Error::ArithmeticOverflow( + "scaling the summed slashings by the proportional multiplier", + ))?; + let adjusted_total_slashing_balance = scaled_slashings.min(total_balance); + + let increment = preset::EFFECTIVE_BALANCE_INCREMENT; + // `total_balance` (`get_total_active_balance`) sums every active + // validator's own increment-quantized effective balance and is floored at + // one whole increment, so it is always an exact multiple of `increment`: + // this can never divide by zero, and no remainder is lost to carry + // forward. + let total_increments = total_balance / increment; + let penalty_per_effective_balance_increment = + adjusted_total_slashing_balance / total_increments; + + let withdrawable_offset = (preset::EPOCHS_PER_SLASHINGS_VECTOR / 2) as Epoch; + + // Collecting the penalties before applying any of them keeps this pass + // reading a stable registry, the same reason + // `super::registry::process_slashings` does. + let mut penalties = Vec::new(); + for (index, validator) in state.validators().iter().enumerate() { + if validator.slashed && epoch + withdrawable_offset == validator.withdrawable_epoch { + let effective_balance_increments = validator.effective_balance / increment; + let penalty = penalty_per_effective_balance_increment + .checked_mul(effective_balance_increments) + .ok_or(Error::ArithmeticOverflow( + "penalty_per_effective_balance_increment * effective_balance_increments", + ))?; + penalties.push((index as ValidatorIndex, penalty)); + } + } + + for (index, penalty) in penalties { + decrease_balance(state, index, penalty)?; + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Pending deposits +// --------------------------------------------------------------------------- + +/// Drains a balance-churn-limited amount of [`electra::PendingDeposit`]s into +/// the validator registry. +/// +/// New in electra; see this module's own doc for why crediting a deposit +/// immediately, the way every earlier fork's `process_deposit` does, is no +/// longer safe once a validator's ceiling can be far above +/// [`preset::MIN_ACTIVATION_BALANCE`]. `crate::beacon::stf::operations::process_deposit` +/// and `apply_deposit` (block processing's own deposit path, EIP-6110, a file +/// this task does not own) queue a deposit's amount rather than crediting it +/// directly; this is what actually applies it, once there is room in this +/// epoch's [`get_activation_exit_churn_limit`] budget. +/// +/// The queue is drained strictly from the front. Every entry reached advances +/// `next_deposit_index`, meaning it leaves its place at the front of the +/// queue, except one case: a deposit that would push this epoch's processed +/// total over budget stops the whole pass rather than being skipped over, so +/// a small deposit further back in the queue can never jump ahead of a large +/// one still waiting for room. Three outcomes decide what happens to an entry +/// that clears the ordering gates (the eth1-bridge-ahead-of-requests check, +/// the finality check, and the per-epoch processing cap): +/// +/// - A deposit naming a validator that is already past its withdrawable +/// epoch can never usefully activate that balance, so it is credited +/// immediately without touching the churn budget at all. +/// - A deposit naming a validator that has started exiting, but is not yet +/// withdrawable, is **postponed**: moved out of its place in the queue and +/// appended to `deposits_to_postpone`, to be retried once the validator's +/// status resolves one way or the other. This is the branch worth reading +/// twice: dropping it here instead, the way an ordinary rejected operation +/// would be dropped, is silent stake loss, since nothing else in the state +/// transition ever revisits a discarded deposit. +/// - Anything else (an unknown pubkey, or a validator that is neither exited +/// nor withdrawn) consumes churn and is credited, unless doing so would +/// exceed the budget, in which case processing stops for this epoch as +/// described above. +pub fn process_pending_deposits(state: &mut BeaconState, config: &Config) -> Result<()> { + let next_epoch = get_current_epoch(state) + 1; + let churn_limit = get_activation_exit_churn_limit(state, config)?; + let finalized_slot = compute_start_slot_at_epoch(state.finalized_checkpoint().epoch); + let eth1_deposit_index = state.eth1_deposit_index(); + + // `pending_queue_fields` borrows the whole state, so everything read + // through it below has to finish before the loop's own, ordinary + // mutable borrows of `state` begin. Taking `pending_deposits` by value + // here, rather than iterating it in place, is what frees `state` for + // those: once the queue is a plain `Vec` of its own, reading + // `state.validators()` and crediting balances through `state` cannot + // conflict with walking the deposits that drive those reads and writes. + let (deposit_requests_start_index, available_for_processing, deposits) = { + let mut fields = pending_queue_fields(state, "process_pending_deposits")?; + let deposit_requests_start_index = fields.deposit_requests_start_index(); + let available_for_processing = fields + .deposit_balance_to_consume() + .checked_add(churn_limit) + .ok_or(Error::ArithmeticOverflow( + "deposit_balance_to_consume + get_activation_exit_churn_limit", + ))?; + let deposits: Vec = + core::mem::take(fields.pending_deposits_mut()).into_inner(); + ( + deposit_requests_start_index, + available_for_processing, + deposits, + ) + }; + + let mut processed_amount: Gwei = 0; + let mut next_deposit_index = 0usize; + let mut deposits_to_postpone: Vec = Vec::new(); + let mut is_churn_limit_reached = false; + + for deposit in &deposits { + // Eth1 bridge deposits (`slot == GENESIS_SLOT`) must all be applied + // before the first deposit *request* is: the two sources are ordered + // relative to each other only by this check, since a request's own + // slot says nothing about where it falls in the bridge's queue. + if deposit.slot > constants::GENESIS_SLOT + && eth1_deposit_index < deposit_requests_start_index + { + break; + } + + // A deposit whose queue position could still be reorged out must + // wait: crediting it now and reverting later is not an option, since + // nothing else in the state transition undoes a balance change. + if deposit.slot > finalized_slot { + break; + } + + if next_deposit_index >= preset::MAX_PENDING_DEPOSITS_PER_EPOCH as usize { + break; + } + + let (is_validator_exited, is_validator_withdrawn) = match state + .validators() + .iter() + .position(|validator| validator.pubkey == deposit.pubkey) + { + Some(index) => { + let validator = &state.validators()[index]; + ( + validator.exit_epoch < FAR_FUTURE_EPOCH, + validator.withdrawable_epoch < next_epoch, + ) + } + None => (false, false), + }; + + if is_validator_withdrawn { + apply_pending_deposit(state, deposit, config)?; + } else if is_validator_exited { + deposits_to_postpone.push(deposit.clone()); + } else { + match processed_amount.checked_add(deposit.amount) { + Some(sum) if sum <= available_for_processing => { + processed_amount = sum; + apply_pending_deposit(state, deposit, config)?; + } + // Either the sum overflowed (certainly too much) or it fit in + // a `u64` but still exceeded the budget: both mean this + // epoch's processing stops here. + _ => { + is_churn_limit_reached = true; + break; + } + } + } + + next_deposit_index += 1; + } + + let remaining: Vec = deposits + .into_iter() + .skip(next_deposit_index) + .chain(deposits_to_postpone) + .collect(); + + // Leftover churn is only worth remembering when it was actually the + // reason processing stopped: if the queue simply ran out, or one of the + // ordering gates stopped it first, next epoch's budget starts fresh + // rather than inheriting room this epoch never even tried to spend. + let deposit_balance_to_consume = if is_churn_limit_reached { + available_for_processing + .checked_sub(processed_amount) + .ok_or(Error::ArithmeticOverflow( + "available_for_processing - processed_amount", + ))? + } else { + 0 + }; + + let mut fields = pending_queue_fields(state, "process_pending_deposits")?; + *fields.pending_deposits_mut() = electra::PendingDeposits::try_from(remaining)?; + *fields.deposit_balance_to_consume_mut() = deposit_balance_to_consume; + + Ok(()) +} + +/// Credits one dequeued [`electra::PendingDeposit`]: as a balance top-up if +/// its pubkey already has a registry entry, or as a brand-new validator +/// (subject to [`is_valid_deposit_signature`]) if it does not. +/// +/// The specification's own `apply_pending_deposit`. Not a call into +/// `crate::beacon::stf::operations::apply_deposit`: that function answers the +/// equivalent question for block processing's own, differently-shaped +/// deposit path (a file this task does not own), and, as read while writing +/// this, still builds a new validator phase0's way; see +/// [`add_validator_from_pending_deposit`]'s doc for exactly how reusing it +/// would go wrong here. +fn apply_pending_deposit( + state: &mut BeaconState, + deposit: &electra::PendingDeposit, + config: &Config, +) -> Result<()> { + let existing_index = state + .validators() + .iter() + .position(|validator| validator.pubkey == deposit.pubkey); + + match existing_index { + Some(index) => increase_balance(state, index as ValidatorIndex, deposit.amount), + None if is_valid_deposit_signature( + deposit.pubkey, + deposit.withdrawal_credentials, + deposit.amount, + deposit.signature, + config, + ) => + { + add_validator_from_pending_deposit( + state, + deposit.pubkey, + deposit.withdrawal_credentials, + deposit.amount, + ) + } + // An invalid signature is not an error: the deposit is simply never + // credited to a new validator, the same tolerance + // `crate::beacon::stf::operations::apply_deposit` documents for its own, + // block-processing deposit path. + None => Ok(()), + } +} + +/// Whether `signature` is a valid proof of possession over (`pubkey`, +/// `withdrawal_credentials`, `amount`). +/// +/// The specification's own domain, `compute_domain(DOMAIN_DEPOSIT)` with no +/// fork version or validators root supplied, defaults to the genesis fork +/// version and an all-zero validators root: exactly what +/// [`compute_deposit_domain`] already computes for phase0's own deposit path, +/// so this reuses it rather than re-deriving the same default by hand. A +/// deposit is signed by a depositor with no way to know which fork, or even +/// which chain, will eventually accept it, which is why this is the one +/// signature check in the whole state transition that does not commit to a +/// specific fork version or genesis validators root the way every other one +/// does. +fn is_valid_deposit_signature( + pubkey: BlsPubkey, + withdrawal_credentials: Bytes32, + amount: Gwei, + signature: BlsSignature, + config: &Config, +) -> bool { + let deposit_message = DepositMessage { + pubkey, + withdrawal_credentials, + amount, + }; + let domain = compute_deposit_domain(config.genesis_fork_version); + let signing_root = compute_signing_root(deposit_message.hash_tree_root(), domain); + bls::verify(&pubkey, signing_root, &signature) +} + +/// Builds and appends a brand-new validator for a pending deposit whose +/// pubkey has no existing registry entry. +/// +/// A second copy of the specification's electra-modified +/// `add_validator_to_registry`, not a call into +/// `crate::beacon::stf::operations::add_validator_to_registry`: that copy belongs to +/// block processing's own deposit path (a file this task does not own) and, +/// as read while writing this, still caps a new validator's effective +/// balance at the phase0 `MAX_EFFECTIVE_BALANCE` rather than +/// [`get_max_effective_balance`]'s per-validator ceiling, and never extends +/// `previous_epoch_participation`, `current_epoch_participation`, or +/// `inactivity_scores` (fields phase0 does not have). Reusing it here would +/// activate a compounding validator at the wrong ceiling and desync those +/// three lists from the registry the moment a pending deposit creates a +/// validator, since nothing would ever grow them back into with the +/// registry's own length again. +fn add_validator_from_pending_deposit( + state: &mut BeaconState, + pubkey: BlsPubkey, + withdrawal_credentials: Bytes32, + amount: Gwei, +) -> Result<()> { + let mut validator = Validator { + pubkey, + withdrawal_credentials, + activation_eligibility_epoch: FAR_FUTURE_EPOCH, + activation_epoch: FAR_FUTURE_EPOCH, + exit_epoch: FAR_FUTURE_EPOCH, + withdrawable_epoch: FAR_FUTURE_EPOCH, + ..Default::default() + }; + // `get_max_effective_balance` only reads `withdrawal_credentials`, which + // is already set above, so the ceiling is correct even though + // `effective_balance` itself is still the zero `Default` placeholder. + // Subtracting the remainder can never underflow, since a modulus is + // always at most the value it divides. + let max_effective_balance = get_max_effective_balance(&validator); + validator.effective_balance = + (amount - amount % preset::EFFECTIVE_BALANCE_INCREMENT).min(max_effective_balance); + + state.validators_mut().push(validator)?; + state.balances_mut().push(amount)?; + pending_queue_fields(state, "add_validator_from_pending_deposit")? + .push_empty_participation_and_inactivity() +} + +// --------------------------------------------------------------------------- +// Pending consolidations +// --------------------------------------------------------------------------- + +/// Applies queued validator consolidations (EIP-7251), moving each eligible +/// source's balance to its target. +/// +/// New in electra. Unlike [`process_pending_deposits`], nothing here is +/// balance-churn-limited: a consolidation moves stake between two validators +/// already counted in the active balance rather than admitting new stake, so +/// it cannot grow the total the way a deposit can, and there is nothing left +/// for a churn budget to protect against. `config` is threaded through only +/// so this matches the signature every other step of [`process_epoch`]'s +/// pipeline is called with, the same reason +/// `super::registry::process_slashings` takes one it never reads. +/// +/// The queue is drained strictly from the front, but its two stopping +/// conditions behave differently. A slashed source is dropped from the queue +/// outright: its balance is already earmarked for the slashing penalty +/// instead of its intended target, and no later epoch changes that, so +/// nothing is gained by keeping the entry around. A source that is not yet +/// withdrawable simply stops the pass: that entry, and everything queued +/// behind it, is left in place (not postponed to the back, unlike +/// [`process_pending_deposits`]'s exited-validator case) to be retried once +/// it clears. +pub fn process_pending_consolidations(state: &mut BeaconState, _config: &Config) -> Result<()> { + let next_epoch = get_current_epoch(state) + 1; + + let consolidations: Vec = { + let mut fields = pending_queue_fields(state, "process_pending_consolidations")?; + core::mem::take(fields.pending_consolidations_mut()).into_inner() + }; + + let mut next_pending_consolidation = 0usize; + for consolidation in &consolidations { + let (slashed, withdrawable_epoch, effective_balance) = { + let source = state.validator(consolidation.source_index)?; + ( + source.slashed, + source.withdrawable_epoch, + source.effective_balance, + ) + }; + + if slashed { + next_pending_consolidation += 1; + continue; + } + if withdrawable_epoch > next_epoch { + break; + } + + let source_effective_balance = state + .balance(consolidation.source_index)? + .min(effective_balance); + decrease_balance(state, consolidation.source_index, source_effective_balance)?; + increase_balance(state, consolidation.target_index, source_effective_balance)?; + next_pending_consolidation += 1; + } + + let remaining: Vec = consolidations + .into_iter() + .skip(next_pending_consolidation) + .collect(); + let mut fields = pending_queue_fields(state, "process_pending_consolidations")?; + *fields.pending_consolidations_mut() = electra::PendingConsolidations::try_from(remaining)?; + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Effective balance updates +// --------------------------------------------------------------------------- + +/// Moves each validator's effective balance toward its actual balance, +/// replacing `super::process_effective_balance_updates` for this fork. +/// +/// The only change from that version: the ceiling each validator rounds +/// toward is [`get_max_effective_balance`], read per validator, rather than +/// the single `MAX_EFFECTIVE_BALANCE` every validator shared before +/// EIP-7251, since a compounding validator's ceiling can be far higher. The +/// hysteresis arithmetic itself is copied unchanged from that version, down +/// to computing `HYSTERESIS_INCREMENT` by dividing first and only then +/// multiplying it up to each threshold: multiplying before dividing is +/// algebraically equivalent but rounds differently, and only the +/// specification's own order reproduces its integer rounding. +pub fn process_effective_balance_updates(state: &mut BeaconState) -> Result<()> { + const HYSTERESIS_INCREMENT: Gwei = + preset::EFFECTIVE_BALANCE_INCREMENT / preset::HYSTERESIS_QUOTIENT; + const DOWNWARD_THRESHOLD: Gwei = HYSTERESIS_INCREMENT * preset::HYSTERESIS_DOWNWARD_MULTIPLIER; + const UPWARD_THRESHOLD: Gwei = HYSTERESIS_INCREMENT * preset::HYSTERESIS_UPWARD_MULTIPLIER; + + // Two passes for the same reason `super::process_effective_balance_updates` + // needs them: `state` is an enum over per-fork structs, so there is no + // way to hold `validators` mutably while also reading `balances`, or + // (here) while calling `get_max_effective_balance` on the validator + // being decided on. + let mut updates = Vec::new(); + for (index, validator) in state.validators().iter().enumerate() { + let balance = state.balances()[index]; + if balance + DOWNWARD_THRESHOLD < validator.effective_balance + || validator.effective_balance + UPWARD_THRESHOLD < balance + { + let max_effective_balance = get_max_effective_balance(validator); + let effective = (balance - balance % preset::EFFECTIVE_BALANCE_INCREMENT) + .min(max_effective_balance); + updates.push((index, effective)); + } + } + + let validators = state.validators_mut(); + for (index, effective) in updates { + validators[index].effective_balance = effective; + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Fork projection +// --------------------------------------------------------------------------- + +/// Fields [`process_pending_deposits`] and [`process_pending_consolidations`] +/// need that [`crate::beacon::helpers::electra::electra_state`] does not expose: that +/// projection only covers what its own module's functions need (the exit and +/// consolidation churn cursors, and `pending_deposits_mut` for +/// `queue_excess_active_balance` and its neighbors). This crate keeps one +/// file per fork's own state-transition concerns, so a second, file-local +/// projection lives here rather than as an addition to that module, even +/// though its shape, an electra-or-fulu match, is identical: fulu keeps every +/// field this covers unchanged (see `crate::beacon::helpers::electra`'s own module +/// doc for why that module's projection accepts fulu too). +enum PendingQueueFields<'a> { + Electra(&'a mut electra::BeaconState), + Fulu(&'a mut fulu::BeaconState), +} + +impl<'a> PendingQueueFields<'a> { + /// The execution-layer deposit request index at which the state switched + /// from crediting deposits off `Eth1Data` votes to crediting them off + /// `DepositRequest`s directly, read by [`process_pending_deposits`] to + /// know whether any eth1-bridge deposit is still outstanding. + fn deposit_requests_start_index(&self) -> u64 { + match self { + PendingQueueFields::Electra(state) => state.deposit_requests_start_index, + PendingQueueFields::Fulu(state) => state.deposit_requests_start_index, + } + } + + /// How much of this epoch's deposit balance churn limit remains unused. + fn deposit_balance_to_consume(&self) -> Gwei { + match self { + PendingQueueFields::Electra(state) => state.deposit_balance_to_consume, + PendingQueueFields::Fulu(state) => state.deposit_balance_to_consume, + } + } + + fn deposit_balance_to_consume_mut(&mut self) -> &mut Gwei { + match self { + PendingQueueFields::Electra(state) => &mut state.deposit_balance_to_consume, + PendingQueueFields::Fulu(state) => &mut state.deposit_balance_to_consume, + } + } + + /// Deposits known but not yet credited to the validator registry. + fn pending_deposits_mut(&mut self) -> &mut electra::PendingDeposits { + match self { + PendingQueueFields::Electra(state) => &mut state.pending_deposits, + PendingQueueFields::Fulu(state) => &mut state.pending_deposits, + } + } + + /// Consolidations known but not yet applied. + fn pending_consolidations_mut(&mut self) -> &mut electra::PendingConsolidations { + match self { + PendingQueueFields::Electra(state) => &mut state.pending_consolidations, + PendingQueueFields::Fulu(state) => &mut state.pending_consolidations, + } + } + + /// Extends `previous_epoch_participation`, `current_epoch_participation`, + /// and `inactivity_scores` by one all-zero entry each, keeping them + /// exactly as long as the registry after + /// [`add_validator_from_pending_deposit`] appends a validator. + fn push_empty_participation_and_inactivity(&mut self) -> Result<()> { + match self { + PendingQueueFields::Electra(state) => { + state.previous_epoch_participation.push(0)?; + state.current_epoch_participation.push(0)?; + state.inactivity_scores.push(0)?; + } + PendingQueueFields::Fulu(state) => { + state.previous_epoch_participation.push(0)?; + state.current_epoch_participation.push(0)?; + state.inactivity_scores.push(0)?; + } + } + Ok(()) + } +} + +/// The electra-or-fulu state, mutably, through [`PendingQueueFields`]. See +/// its own doc for why this is a second projection rather than a call into +/// [`crate::beacon::helpers::electra::electra_state`]. +fn pending_queue_fields<'a>( + state: &'a mut BeaconState, + function: &'static str, +) -> Result> { + match state { + BeaconState::Electra(state) => Ok(PendingQueueFields::Electra(state)), + BeaconState::Fulu(state) => Ok(PendingQueueFields::Fulu(state)), + other => Err(Error::UnsupportedForFork { + function, + fork: other.fork_name(), + }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use blst::min_pk::SecretKey; + + use crate::beacon::fork::ForkName; + + /// An electra state with `count` fully active, full-balance validators, + /// positioned one epoch in, the same way + /// `crate::beacon::helpers::test_state::with_validators` positions its phase0 + /// state. + /// + /// A thin wrapper around the shared fork-parameterised builder: see + /// [`crate::beacon::helpers::test_state::with_validators_at`] for the construction + /// this and every other fork's test module used to duplicate. + fn electra_state_with_validators(count: usize) -> BeaconState { + crate::beacon::helpers::test_state::with_validators_at(ForkName::Electra, count) + } + + // ----------------------------------------------------------------------- + // process_pending_deposits + // ----------------------------------------------------------------------- + + #[test] + fn a_pending_deposit_for_an_already_withdrawn_validator_bypasses_churn() { + let config = Config::mainnet(); + let mut state = electra_state_with_validators(2); + let next_epoch = get_current_epoch(&state) + 1; + + let (pubkey, withdrawal_credentials) = { + let validator = state.validator_mut(0).unwrap(); + validator.exit_epoch = 0; + validator.withdrawable_epoch = 0; + (validator.pubkey, validator.withdrawal_credentials) + }; + assert!(state.validator(0).unwrap().withdrawable_epoch < next_epoch); + + let churn_limit = get_activation_exit_churn_limit(&state, &config).unwrap(); + // Deliberately larger than the whole churn budget: a withdrawn + // validator's deposit must go through regardless of budget. + let deposit_amount = churn_limit + preset::EFFECTIVE_BALANCE_INCREMENT; + let deposit = electra::PendingDeposit { + pubkey, + withdrawal_credentials, + amount: deposit_amount, + signature: BlsSignature::default(), + slot: constants::GENESIS_SLOT, + }; + pending_queue_fields(&mut state, "test setup") + .unwrap() + .pending_deposits_mut() + .push(deposit) + .unwrap(); + + let balance_before = state.balance(0).unwrap(); + process_pending_deposits(&mut state, &config).unwrap(); + + assert_eq!(state.balance(0).unwrap(), balance_before + deposit_amount); + let mut fields = pending_queue_fields(&mut state, "test assertion").unwrap(); + assert_eq!(fields.deposit_balance_to_consume(), 0); + assert!(fields.pending_deposits_mut().is_empty()); + } + + #[test] + fn a_pending_deposit_for_an_exited_but_not_yet_withdrawn_validator_is_postponed_not_dropped() { + let config = Config::mainnet(); + let mut state = electra_state_with_validators(2); + let next_epoch = get_current_epoch(&state) + 1; + + let (pubkey, withdrawal_credentials) = { + let validator = state.validator_mut(0).unwrap(); + validator.exit_epoch = 0; + // Still far from withdrawable: `next_epoch` must not exceed this. + validator.withdrawable_epoch = FAR_FUTURE_EPOCH; + (validator.pubkey, validator.withdrawal_credentials) + }; + assert!(state.validator(0).unwrap().withdrawable_epoch >= next_epoch); + + let deposit = electra::PendingDeposit { + pubkey, + withdrawal_credentials, + amount: preset::EFFECTIVE_BALANCE_INCREMENT, + signature: BlsSignature::default(), + slot: constants::GENESIS_SLOT, + }; + pending_queue_fields(&mut state, "test setup") + .unwrap() + .pending_deposits_mut() + .push(deposit.clone()) + .unwrap(); + + let balance_before = state.balance(0).unwrap(); + process_pending_deposits(&mut state, &config).unwrap(); + + // Not applied... + assert_eq!(state.balance(0).unwrap(), balance_before); + // ...and not lost: it comes back out the other end of the queue. + let mut fields = pending_queue_fields(&mut state, "test assertion").unwrap(); + let remaining = fields.pending_deposits_mut(); + assert_eq!(remaining.len(), 1); + assert_eq!(remaining[0], deposit); + } + + #[test] + fn pending_deposits_stop_at_the_churn_limit_and_the_rest_stay_queued_in_place() { + let config = Config::mainnet(); + let mut state = electra_state_with_validators(2); + let churn_limit = get_activation_exit_churn_limit(&state, &config).unwrap(); + + let (pubkey_0, credentials_0) = { + let v = state.validator(0).unwrap(); + (v.pubkey, v.withdrawal_credentials) + }; + let (pubkey_1, credentials_1) = { + let v = state.validator(1).unwrap(); + (v.pubkey, v.withdrawal_credentials) + }; + + let first_amount = churn_limit / 2; + let second_amount = churn_limit; + let first = electra::PendingDeposit { + pubkey: pubkey_0, + withdrawal_credentials: credentials_0, + amount: first_amount, + signature: BlsSignature::default(), + slot: constants::GENESIS_SLOT, + }; + let second = electra::PendingDeposit { + pubkey: pubkey_1, + withdrawal_credentials: credentials_1, + amount: second_amount, + signature: BlsSignature::default(), + slot: constants::GENESIS_SLOT, + }; + { + let mut fields = pending_queue_fields(&mut state, "test setup").unwrap(); + fields.pending_deposits_mut().push(first).unwrap(); + fields.pending_deposits_mut().push(second.clone()).unwrap(); + } + + let balance_0_before = state.balance(0).unwrap(); + let balance_1_before = state.balance(1).unwrap(); + process_pending_deposits(&mut state, &config).unwrap(); + + assert_eq!(state.balance(0).unwrap(), balance_0_before + first_amount); + assert_eq!( + state.balance(1).unwrap(), + balance_1_before, + "over budget: must not apply" + ); + + let mut fields = pending_queue_fields(&mut state, "test assertion").unwrap(); + assert_eq!( + fields.deposit_balance_to_consume(), + churn_limit - first_amount + ); + let remaining = fields.pending_deposits_mut(); + assert_eq!(remaining.len(), 1); + assert_eq!(remaining[0], second); + } + + #[test] + fn a_pending_deposit_for_an_unseen_pubkey_activates_a_new_validator_at_its_own_ceiling() { + let config = Config::mainnet(); + let mut state = electra_state_with_validators(1); + + let secret_key = SecretKey::key_gen(&[9u8; 32], &[]).expect("32 bytes of key material"); + let pubkey = BlsPubkey(secret_key.sk_to_pk().to_bytes()); + let mut withdrawal_credentials = Bytes32::ZERO; + withdrawal_credentials.0[0] = constants::COMPOUNDING_WITHDRAWAL_PREFIX; + let amount = preset::MIN_ACTIVATION_BALANCE + preset::EFFECTIVE_BALANCE_INCREMENT; + + let deposit_message = DepositMessage { + pubkey, + withdrawal_credentials, + amount, + }; + let domain = compute_deposit_domain(config.genesis_fork_version); + let signing_root = compute_signing_root(deposit_message.hash_tree_root(), domain); + // The same domain separation tag `crate::beacon::bls` signs and verifies + // under, redefined here because that module keeps it private; see + // `crate::beacon::stf::operations::tests`'s own attester-slashing test for + // the same pattern applied to a different domain. + const DST: &[u8] = b"BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_"; + let signature = BlsSignature( + secret_key + .sign(signing_root.as_slice(), DST, &[]) + .to_bytes(), + ); + + let deposit = electra::PendingDeposit { + pubkey, + withdrawal_credentials, + amount, + signature, + slot: constants::GENESIS_SLOT, + }; + pending_queue_fields(&mut state, "test setup") + .unwrap() + .pending_deposits_mut() + .push(deposit) + .unwrap(); + + let validators_before = state.validators().len(); + process_pending_deposits(&mut state, &config).unwrap(); + + assert_eq!(state.validators().len(), validators_before + 1); + let new_index = validators_before as ValidatorIndex; + let new_validator = state.validator(new_index).unwrap(); + assert_eq!(new_validator.pubkey, pubkey); + // A compounding credential from birth, so the deposit's whole amount + // (comfortably under `MAX_EFFECTIVE_BALANCE_ELECTRA`) becomes + // effective balance rather than being capped at the non-compounding + // `MIN_ACTIVATION_BALANCE` ceiling. + assert_eq!(new_validator.effective_balance, amount); + assert_eq!(state.balance(new_index).unwrap(), amount); + + // The participation and inactivity lists must stay exactly as long + // as the registry, or a later epoch's indexing into them for this + // validator panics or errors. + match &state { + BeaconState::Electra(inner) => { + assert_eq!( + inner.previous_epoch_participation.len(), + state.validators().len() + ); + assert_eq!( + inner.current_epoch_participation.len(), + state.validators().len() + ); + assert_eq!(inner.inactivity_scores.len(), state.validators().len()); + } + _ => unreachable!("electra_state_with_validators always builds an Electra state"), + } + } + + #[test] + fn a_pending_deposit_with_an_invalid_signature_is_dropped_not_added() { + let config = Config::mainnet(); + let mut state = electra_state_with_validators(1); + + let deposit = electra::PendingDeposit { + pubkey: BlsPubkey::default(), + withdrawal_credentials: Bytes32::ZERO, + amount: preset::MIN_ACTIVATION_BALANCE, + signature: BlsSignature::default(), + slot: constants::GENESIS_SLOT, + }; + pending_queue_fields(&mut state, "test setup") + .unwrap() + .pending_deposits_mut() + .push(deposit) + .unwrap(); + + let validators_before = state.validators().len(); + process_pending_deposits(&mut state, &config).unwrap(); + + assert_eq!( + state.validators().len(), + validators_before, + "an invalid signature must not create a validator" + ); + let mut fields = pending_queue_fields(&mut state, "test assertion").unwrap(); + assert!( + fields.pending_deposits_mut().is_empty(), + "the entry is still consumed from the queue, just never credited" + ); + } + + // ----------------------------------------------------------------------- + // process_pending_consolidations + // ----------------------------------------------------------------------- + + #[test] + fn a_pending_consolidation_from_a_slashed_source_is_dropped_from_the_queue() { + let config = Config::mainnet(); + let mut state = electra_state_with_validators(2); + state.validator_mut(0).unwrap().slashed = true; + + let consolidation = electra::PendingConsolidation { + source_index: 0, + target_index: 1, + }; + pending_queue_fields(&mut state, "test setup") + .unwrap() + .pending_consolidations_mut() + .push(consolidation) + .unwrap(); + + let source_balance_before = state.balance(0).unwrap(); + let target_balance_before = state.balance(1).unwrap(); + process_pending_consolidations(&mut state, &config).unwrap(); + + assert_eq!(state.balance(0).unwrap(), source_balance_before); + assert_eq!(state.balance(1).unwrap(), target_balance_before); + let mut fields = pending_queue_fields(&mut state, "test assertion").unwrap(); + assert!(fields.pending_consolidations_mut().is_empty()); + } + + #[test] + fn a_pending_consolidation_not_yet_withdrawable_stays_queued_in_place() { + let config = Config::mainnet(); + let mut state = electra_state_with_validators(2); + let next_epoch = get_current_epoch(&state) + 1; + state.validator_mut(0).unwrap().withdrawable_epoch = next_epoch + 1; + + let consolidation = electra::PendingConsolidation { + source_index: 0, + target_index: 1, + }; + pending_queue_fields(&mut state, "test setup") + .unwrap() + .pending_consolidations_mut() + .push(consolidation.clone()) + .unwrap(); + + process_pending_consolidations(&mut state, &config).unwrap(); + + let mut fields = pending_queue_fields(&mut state, "test assertion").unwrap(); + let remaining = fields.pending_consolidations_mut(); + assert_eq!(remaining.len(), 1); + assert_eq!(remaining[0], consolidation); + } + + #[test] + fn an_eligible_pending_consolidation_moves_the_source_balance_to_the_target() { + let config = Config::mainnet(); + let mut state = electra_state_with_validators(2); + state.validator_mut(0).unwrap().withdrawable_epoch = 0; + + let consolidation = electra::PendingConsolidation { + source_index: 0, + target_index: 1, + }; + pending_queue_fields(&mut state, "test setup") + .unwrap() + .pending_consolidations_mut() + .push(consolidation) + .unwrap(); + + let source_effective_balance = state.validator(0).unwrap().effective_balance; + let source_balance_before = state.balance(0).unwrap(); + let target_balance_before = state.balance(1).unwrap(); + + process_pending_consolidations(&mut state, &config).unwrap(); + + let moved = source_balance_before.min(source_effective_balance); + assert_eq!(state.balance(0).unwrap(), source_balance_before - moved); + assert_eq!(state.balance(1).unwrap(), target_balance_before + moved); + let mut fields = pending_queue_fields(&mut state, "test assertion").unwrap(); + assert!(fields.pending_consolidations_mut().is_empty()); + } + + // ----------------------------------------------------------------------- + // process_registry_updates + // ----------------------------------------------------------------------- + + #[test] + fn every_finalized_eligible_validator_activates_the_same_epoch_uncapped_by_headcount() { + let config = Config::mainnet(); + // More candidates than phase0's old count-based churn limit would + // ever admit in one epoch, to make the absence of that cap here + // unmistakable. + let count = config.min_per_epoch_churn_limit as usize + 4; + let mut state = electra_state_with_validators(count); + + for index in 0..count as ValidatorIndex { + let validator = state.validator_mut(index).unwrap(); + validator.activation_eligibility_epoch = 0; // already finalized + validator.activation_epoch = FAR_FUTURE_EPOCH; // not yet activated + } + + process_registry_updates(&mut state, &config).unwrap(); + + for index in 0..count as ValidatorIndex { + assert_ne!( + state.validator(index).unwrap().activation_epoch, + FAR_FUTURE_EPOCH, + "validator {index} should have activated: electra rate-limits admission by \ + balance, not by a per-epoch headcount", + ); + } + } + + // ----------------------------------------------------------------------- + // process_effective_balance_updates + // ----------------------------------------------------------------------- + + #[test] + fn effective_balance_updates_cap_a_compounding_validator_higher_than_a_regular_one() { + let mut state = electra_state_with_validators(2); + state.validator_mut(1).unwrap().withdrawal_credentials.0[0] = + constants::COMPOUNDING_WITHDRAWAL_PREFIX; + + // Push both balances far above either ceiling, so the update fires + // and both validators are actually capped, not merely nudged. + let huge = preset::MAX_EFFECTIVE_BALANCE_ELECTRA + preset::EFFECTIVE_BALANCE_INCREMENT; + state.balances_mut()[0] = huge; + state.balances_mut()[1] = huge; + + process_effective_balance_updates(&mut state).unwrap(); + + assert_eq!( + state.validator(0).unwrap().effective_balance, + preset::MIN_ACTIVATION_BALANCE, + "a non-compounding validator is still capped at MIN_ACTIVATION_BALANCE" + ); + assert_eq!( + state.validator(1).unwrap().effective_balance, + preset::MAX_EFFECTIVE_BALANCE_ELECTRA, + "a compounding validator's ceiling is MAX_EFFECTIVE_BALANCE_ELECTRA instead" + ); + } + + // ----------------------------------------------------------------------- + // process_slashings + // ----------------------------------------------------------------------- + + /// A slashing outside the withdrawable window must leave the balance + /// untouched, the same guard `super::registry::process_slashings` has. + #[test] + fn a_slashing_outside_the_withdrawable_window_is_untouched() { + let config = Config::mainnet(); + let mut state = electra_state_with_validators(4); + let balance_before = state.balance(1).unwrap(); + + state.validator_mut(1).unwrap().slashed = true; + state.slashings_mut()[0] = preset::MAX_EFFECTIVE_BALANCE_ELECTRA; + + process_slashings(&mut state, &config).unwrap(); + + assert_eq!(state.balance(1).unwrap(), balance_before); + } + + /// A worked example of the divergence [`process_slashings`]'s own doc + /// describes. A slashed sum equal to `total_increments` (the total active + /// balance's own increment count) is dwarfed by `total_balance` itself, + /// which is `total_increments` whole copies of `EFFECTIVE_BALANCE_INCREMENT`, + /// but it is exactly enough for electra's own division, + /// `adjusted_total_slashing_balance / total_increments`, to clear its + /// floor: the two cancel to precisely `multiplier`, with nothing left + /// over. `super::registry::process_slashings` divides by the raw + /// `total_balance` instead of `total_increments`, a divisor larger by a + /// factor of `EFFECTIVE_BALANCE_INCREMENT`, so the identical input never + /// clears *that* floor and rounds down to no penalty at all. + /// + /// This is not a contrived corner case: it is the ordinary shape of a + /// single validator's slashing measured against a whole active set's + /// balance, which is exactly why reusing the shared, pre-electra copy for + /// this fork would silently drop real penalties rather than merely + /// rounding them differently. + #[test] + fn electras_division_order_still_penalizes_where_the_shared_copy_rounds_to_zero() { + let config = Config::mainnet(); + let mut state = electra_state_with_validators(4); + let epoch = get_current_epoch(&state); + + let total_balance = get_total_active_balance(&state).unwrap(); + let total_increments = total_balance / preset::EFFECTIVE_BALANCE_INCREMENT; + let multiplier = preset::retuned::proportional_slashing_multiplier(state.fork_name()); + + let validator = state.validator_mut(1).unwrap(); + validator.slashed = true; + validator.withdrawable_epoch = epoch + (preset::EPOCHS_PER_SLASHINGS_VECTOR / 2) as Epoch; + let effective_balance_increments = + validator.effective_balance / preset::EFFECTIVE_BALANCE_INCREMENT; + + state.slashings_mut()[0] = total_increments; + + let mut shared_copy_state = state.clone(); + process_slashings(&mut state, &config).unwrap(); + super::super::registry::process_slashings(&mut shared_copy_state, &config).unwrap(); + + let expected_penalty = multiplier * effective_balance_increments; + assert_eq!( + preset::MIN_ACTIVATION_BALANCE - state.balance(1).unwrap(), + expected_penalty, + "electra's own division order lands exactly multiplier * effective_balance_increments" + ); + assert_eq!( + shared_copy_state.balance(1).unwrap(), + preset::MIN_ACTIVATION_BALANCE, + "the shared, pre-electra copy rounds the exact same inputs down to no penalty at all" + ); + } +} diff --git a/crates/blockchain/state_transition/src/beacon/stf/epoch/fulu.rs b/crates/blockchain/state_transition/src/beacon/stf/epoch/fulu.rs new file mode 100644 index 000000000..0975158e9 --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/stf/epoch/fulu.rs @@ -0,0 +1,180 @@ +//! Fulu-specific epoch processing. +//! +//! Electra's whole step list carries over unchanged: fulu's "Epoch processing" +//! section (`beacon-chain.md`) redefines `process_epoch` only to append one new +//! step, [`process_proposer_lookahead`] (EIP-7917), after electra's last one. +//! Nothing about how any existing step behaves changes; the state simply grows +//! one more piece of bookkeeping for [`process_proposer_lookahead`] to +//! maintain. +//! +//! [`process_proposer_lookahead`] keeps `BeaconState::proposer_lookahead` +//! (`crate::beacon::containers::fulu::BeaconState`) a fixed-length rolling window: it +//! drops the epoch that just ended (the window's oldest slice) and appends the +//! one epoch further out than the window already reached, so the window keeps +//! covering exactly the current epoch through `MIN_SEED_LOOKAHEAD` epochs +//! beyond it, the same span +//! [`crate::beacon::helpers::fulu::initialize_proposer_lookahead`] fills from scratch +//! at genesis and at the fulu upgrade. See that function's module docs for why +//! a seed, and therefore a proposer, is only ever knowable that far ahead and +//! no further. +//! +//! # Why this step runs last +//! +//! The newly-visible epoch's proposers come from +//! [`crate::beacon::helpers::fulu::get_beacon_proposer_indices`], which weighs +//! [`crate::beacon::helpers::accessors::get_active_validator_indices`] and each +//! validator's `effective_balance`, both of which earlier steps in this same +//! epoch's processing change: [`super::registry::process_registry_updates`] +//! moves validators into or out of the active set, and +//! [`super::process_effective_balance_updates`] moves balances toward their +//! post-epoch values. Running [`process_proposer_lookahead`] after every such +//! step, rather than before, is what lets the newly-appended epoch's proposers +//! reflect this epoch's final registry state rather than a stale one; the +//! specification gets that simply by placing the step last, and this driver +//! does the same. +//! +//! The randao mix the new epoch's seed reads is not why the step sits where it +//! does. [`crate::beacon::helpers::accessors::get_seed`]'s lookback means the +//! newly-visible epoch's seed is drawn from the *current*, outgoing epoch's +//! mix, and that mix was already fixed by the last block processed in this +//! epoch, well before epoch processing starts. [`super::process_randao_mixes_reset`] +//! only ever writes the *next* epoch's slot, so running this step before or +//! after that reset would not change which mix the new epoch's seed reads; +//! only the registry and balance state matters for the ordering here. + +use crate::beacon::config::Config; +use crate::beacon::containers::BeaconState; +use crate::beacon::error::Result; +use crate::beacon::helpers::accessors::get_current_epoch; +use crate::beacon::helpers::fulu::{fulu_state, get_beacon_proposer_indices}; +use crate::beacon::preset; + +use super::electra; + +/// Fulu's epoch-boundary driver: electra's, unchanged, with +/// [`process_proposer_lookahead`] appended at the end. +pub fn process_epoch(state: &mut BeaconState, config: &Config) -> Result<()> { + electra::process_epoch(state, config)?; + process_proposer_lookahead(state) +} + +/// Shifts `proposer_lookahead` forward by one epoch. +/// +/// Drops the window's first `SLOTS_PER_EPOCH` entries (the epoch that just +/// ended) and appends `SLOTS_PER_EPOCH` more for the epoch that becomes +/// computable now that this epoch's registry and balance updates have run: see +/// the module docs for why appending happens last rather than first. +/// +/// The specification writes this as two in-place slice assignments on +/// `state.proposer_lookahead`. This instead builds the whole new window as a +/// plain `Vec` and assigns it back in one piece, since `SszVector` has no +/// `Default` to grow into and its `IndexMut` only ever addresses a window that +/// already exists at its full length; building the replacement value +/// explicitly, at exactly [`preset::PROPOSER_LOOKAHEAD_LENGTH`], sidesteps +/// needing one. +pub fn process_proposer_lookahead(state: &mut BeaconState) -> Result<()> { + // The seed for this epoch is only just now fixed, per the module docs, so + // this is the earliest moment its proposers could have been computed. + let new_epoch = get_current_epoch(state) + preset::MIN_SEED_LOOKAHEAD + 1; + let new_epoch_proposers = get_beacon_proposer_indices(state, new_epoch)?; + + let fulu_state = fulu_state(state, "process_proposer_lookahead")?; + let slots_per_epoch = preset::SLOTS_PER_EPOCH as usize; + + let mut window = Vec::with_capacity(preset::PROPOSER_LOOKAHEAD_LENGTH); + window.extend_from_slice(&fulu_state.proposer_lookahead[slots_per_epoch..]); + window.extend(new_epoch_proposers); + + fulu_state.proposer_lookahead = window.try_into().expect( + "dropping SLOTS_PER_EPOCH entries and appending SLOTS_PER_EPOCH more preserves \ + PROPOSER_LOOKAHEAD_LENGTH", + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::beacon::fork::ForkName; + use crate::beacon::helpers::fulu::initialize_proposer_lookahead; + + /// A fulu state with `count` fully active, full-balance validators and a + /// `proposer_lookahead` filled by + /// [`crate::beacon::helpers::fulu::initialize_proposer_lookahead`], one epoch past + /// genesis. + /// + /// The shared builder leaves `proposer_lookahead` zeroed, since that + /// field is fulu-specific and most of this module's per-fork test states + /// never touch it; this module's own tests are exactly the ones that do, + /// so this runs the real computation on top before handing the state + /// back, the same override + /// `crate::beacon::helpers::fulu::tests::fulu_state_with_validators` applies. + fn fulu_state_with_validators(count: usize) -> BeaconState { + let mut state = + crate::beacon::helpers::test_state::with_validators_at(ForkName::Fulu, count); + let lookahead = initialize_proposer_lookahead(&state).unwrap(); + if let BeaconState::Fulu(fulu_state) = &mut state { + fulu_state.proposer_lookahead = lookahead.try_into().expect( + "initialize_proposer_lookahead returns exactly PROPOSER_LOOKAHEAD_LENGTH indices", + ); + } + state + } + + /// Reads out `proposer_lookahead` for assertions, without exposing the + /// fork-specific projection to every test. + fn lookahead_of(state: &BeaconState) -> Vec { + match state { + BeaconState::Fulu(state) => state.proposer_lookahead.to_vec(), + _ => unreachable!("test states here are always fulu"), + } + } + + #[test] + fn process_proposer_lookahead_preserves_the_carried_over_slice() { + let mut state = fulu_state_with_validators(32); + let before = lookahead_of(&state); + + process_proposer_lookahead(&mut state).unwrap(); + + let after = lookahead_of(&state); + let slots_per_epoch = preset::SLOTS_PER_EPOCH as usize; + // Everything but the oldest and newest epoch's worth of entries must + // carry over unchanged, just shifted down by one epoch's length. + assert_eq!( + after[..after.len() - slots_per_epoch], + before[slots_per_epoch..] + ); + } + + #[test] + fn process_proposer_lookahead_appends_the_newly_computable_epoch() { + let state = fulu_state_with_validators(32); + let current_epoch = get_current_epoch(&state); + let new_epoch = current_epoch + preset::MIN_SEED_LOOKAHEAD + 1; + let expected = get_beacon_proposer_indices(&state, new_epoch).unwrap(); + + let mut state = state; + process_proposer_lookahead(&mut state).unwrap(); + + let after = lookahead_of(&state); + let slots_per_epoch = preset::SLOTS_PER_EPOCH as usize; + assert_eq!(after[after.len() - slots_per_epoch..], expected[..]); + } + + #[test] + fn process_proposer_lookahead_keeps_the_window_at_its_fixed_length() { + let mut state = fulu_state_with_validators(32); + process_proposer_lookahead(&mut state).unwrap(); + assert_eq!( + lookahead_of(&state).len(), + preset::PROPOSER_LOOKAHEAD_LENGTH + ); + } + + #[test] + fn process_proposer_lookahead_rejects_a_state_older_than_fulu() { + let mut phase0_state = crate::beacon::helpers::test_state::with_validators(4); + assert!(process_proposer_lookahead(&mut phase0_state).is_err()); + } +} diff --git a/crates/blockchain/state_transition/src/beacon/stf/epoch/justification.rs b/crates/blockchain/state_transition/src/beacon/stf/epoch/justification.rs new file mode 100644 index 000000000..ecbadf293 --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/stf/epoch/justification.rs @@ -0,0 +1,253 @@ +//! Justification and finalization. +//! +//! Every epoch, the state re-weighs how much of the active balance attested to +//! the previous and current epoch's checkpoint and folds the result into +//! `justification_bits`, a four-epoch sliding window recording which of the +//! last four epochs reached the two-thirds threshold. Finalization then checks +//! that window for one of two shapes: two consecutively-justified epochs, or +//! three, with the extra epoch of slack existing so a single epoch that narrowly +//! misses justification does not also cost the chain finality for the epoch +//! before it. Each shape is checked from both the previous and the current +//! epoch's justified checkpoint, which is why there are four rules rather than +//! two. + +use crate::beacon::config::Config; +use crate::beacon::constants; +use crate::beacon::containers::{BeaconState, Checkpoint}; +use crate::beacon::error::{Error, Result}; +use crate::beacon::helpers::accessors::{ + get_block_root, get_current_epoch, get_previous_epoch, get_total_active_balance, +}; +use crate::beacon::primitives::Gwei; + +use super::{JUSTIFICATION_BITS, get_attesting_balance, get_matching_target_attestations}; + +/// Updates justification and finality from the attestations the previous and +/// current epoch collected. +/// +/// `config` is unused: this step needs no configuration value, but takes one +/// anyway so every step in [`super::process_epoch`]'s pipeline shares a call +/// shape. +pub fn process_justification_and_finalization( + state: &mut BeaconState, + _config: &Config, +) -> Result<()> { + // Initial FFG checkpoint values have a `0x00` stub for `root`. + // Skip FFG updates in the first two epochs to avoid corner cases that might + // result in modifying this stub. + if get_current_epoch(state) <= constants::GENESIS_EPOCH + 1 { + return Ok(()); + } + + let previous_attestations = get_matching_target_attestations(state, get_previous_epoch(state))?; + let current_attestations = get_matching_target_attestations(state, get_current_epoch(state))?; + let total_active_balance = get_total_active_balance(state)?; + let previous_target_balance = get_attesting_balance(state, &previous_attestations)?; + let current_target_balance = get_attesting_balance(state, ¤t_attestations)?; + weigh_justification_and_finalization( + state, + total_active_balance, + previous_target_balance, + current_target_balance, + ) +} + +/// Advances the justification bitfield and applies the four finalization rules +/// against it. +/// +/// The target balances are passed in rather than recomputed here so this can be +/// exercised (and reasoned about) independently of attestation matching, which +/// is what [`process_justification_and_finalization`] uses it for. +pub fn weigh_justification_and_finalization( + state: &mut BeaconState, + total_active_balance: Gwei, + previous_epoch_target_balance: Gwei, + current_epoch_target_balance: Gwei, +) -> Result<()> { + let previous_epoch = get_previous_epoch(state); + let current_epoch = get_current_epoch(state); + let old_previous_justified_checkpoint = state.previous_justified_checkpoint(); + let old_current_justified_checkpoint = state.current_justified_checkpoint(); + + // Process justifications + *state.previous_justified_checkpoint_mut() = state.current_justified_checkpoint(); + + // Age the bitfield by one epoch before folding in this epoch's result. Index + // 0 always names the epoch just processed, so ageing moves every bit toward + // a HIGHER index (older epochs); the oldest bit falls off the top and is + // lost. Reading index `i - 1` before writing index `i`, from the top down, + // reproduces the spec's simultaneous slice assignment without a temporary + // copy. + for i in (1..JUSTIFICATION_BITS).rev() { + let older = state + .justification_bits() + .get(i - 1) + .expect("index is within JUSTIFICATION_BITS_LENGTH"); + state + .justification_bits_mut() + .set(i, older) + .expect("index is within JUSTIFICATION_BITS_LENGTH"); + } + state + .justification_bits_mut() + .set(0, false) + .expect("index is within JUSTIFICATION_BITS_LENGTH"); + + if meets_justification_threshold(previous_epoch_target_balance, total_active_balance)? { + *state.current_justified_checkpoint_mut() = Checkpoint { + epoch: previous_epoch, + root: get_block_root(state, previous_epoch)?, + }; + state + .justification_bits_mut() + .set(1, true) + .expect("index is within JUSTIFICATION_BITS_LENGTH"); + } + if meets_justification_threshold(current_epoch_target_balance, total_active_balance)? { + *state.current_justified_checkpoint_mut() = Checkpoint { + epoch: current_epoch, + root: get_block_root(state, current_epoch)?, + }; + state + .justification_bits_mut() + .set(0, true) + .expect("index is within JUSTIFICATION_BITS_LENGTH"); + } + + // Process finalizations + // + // Read every bit the four rules need before touching `finalized_checkpoint`, + // since holding a `&JustificationBits` borrow across those writes would + // conflict with the `&mut BeaconState` each rule needs. + let (justifies_1_2_3, justifies_1_2, justifies_0_1_2, justifies_0_1) = { + let bits = state.justification_bits(); + let bit = |i: usize| { + bits.get(i) + .expect("index is within JUSTIFICATION_BITS_LENGTH") + }; + ( + bit(1) && bit(2) && bit(3), + bit(1) && bit(2), + bit(0) && bit(1) && bit(2), + bit(0) && bit(1), + ) + }; + + // The 2nd/3rd/4th most recent epochs are justified, the 2nd using the 4th as source + if justifies_1_2_3 && old_previous_justified_checkpoint.epoch + 3 == current_epoch { + *state.finalized_checkpoint_mut() = old_previous_justified_checkpoint; + } + // The 2nd/3rd most recent epochs are justified, the 2nd using the 3rd as source + if justifies_1_2 && old_previous_justified_checkpoint.epoch + 2 == current_epoch { + *state.finalized_checkpoint_mut() = old_previous_justified_checkpoint; + } + // The 1st/2nd/3rd most recent epochs are justified, the 1st using the 3rd as source + if justifies_0_1_2 && old_current_justified_checkpoint.epoch + 2 == current_epoch { + *state.finalized_checkpoint_mut() = old_current_justified_checkpoint; + } + // The 1st/2nd most recent epochs are justified, the 1st using the 2nd as source + if justifies_0_1 && old_current_justified_checkpoint.epoch + 1 == current_epoch { + *state.finalized_checkpoint_mut() = old_current_justified_checkpoint; + } + + Ok(()) +} + +/// Whether `balance` covers at least two-thirds of `total_active_balance`. +/// +/// Written as `balance * 3 >= total_active_balance * 2` to avoid a division, +/// matching the specification exactly. `total_active_balance` sums the whole +/// validator registry, so its product is checked rather than left to wrap: a +/// wrapped comparison could manufacture or hide a justification that the real +/// balances never earned. +fn meets_justification_threshold(balance: Gwei, total_active_balance: Gwei) -> Result { + let weighed_balance = balance.checked_mul(3).ok_or(Error::ArithmeticOverflow( + "weigh_justification_and_finalization", + ))?; + let weighed_total = total_active_balance + .checked_mul(2) + .ok_or(Error::ArithmeticOverflow( + "weigh_justification_and_finalization", + ))?; + Ok(weighed_balance >= weighed_total) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::beacon::config::Config; + + #[test] + fn near_genesis_process_is_a_no_op() { + // `with_validators` positions the state at slot `SLOTS_PER_EPOCH`, i.e. + // current epoch 1, which is still within the `GENESIS_EPOCH + 1` guard. + let mut state = crate::beacon::helpers::test_state::with_validators(4); + let before = state.clone(); + let config = Config::mainnet(); + + process_justification_and_finalization(&mut state, &config).unwrap(); + + assert_eq!( + state.justification_bits(), + before.justification_bits(), + "the bitfield must be untouched this close to genesis" + ); + assert_eq!( + state.current_justified_checkpoint(), + before.current_justified_checkpoint() + ); + assert_eq!(state.finalized_checkpoint(), before.finalized_checkpoint()); + } + + #[test] + fn only_the_previous_epoch_bit_is_set_when_only_it_justifies() { + // `with_validators` positions the state at current epoch 1, previous + // epoch 0, which is enough to exercise `weigh_justification_and_finalization` + // directly without needing to build real attestations: it takes the + // target balances as arguments. + let mut state = crate::beacon::helpers::test_state::with_validators(4); + let total_active_balance: Gwei = 300; + let previous_epoch_target_balance: Gwei = 200; // exactly two-thirds: justifies + let current_epoch_target_balance: Gwei = 0; // nowhere near: does not justify + + weigh_justification_and_finalization( + &mut state, + total_active_balance, + previous_epoch_target_balance, + current_epoch_target_balance, + ) + .unwrap(); + + let bits = state.justification_bits(); + assert_eq!(bits.get(0), Some(false), "current epoch did not justify"); + assert_eq!(bits.get(1), Some(true), "previous epoch justified"); + assert_eq!(bits.get(2), Some(false)); + assert_eq!(bits.get(3), Some(false)); + + assert_eq!(state.current_justified_checkpoint().epoch, 0); + // Only one epoch justified this round, so none of the four finalization + // rules (each needing two or three consecutive justified epochs) fire. + assert_eq!(state.finalized_checkpoint().epoch, constants::GENESIS_EPOCH); + } + + #[test] + fn a_below_threshold_balance_justifies_neither_epoch() { + let mut state = crate::beacon::helpers::test_state::with_validators(4); + let total_active_balance: Gwei = 300; + + weigh_justification_and_finalization(&mut state, total_active_balance, 199, 199).unwrap(); + + let bits = state.justification_bits(); + assert_eq!(bits.get(0), Some(false)); + assert_eq!(bits.get(1), Some(false)); + } + + #[test] + fn an_overflowing_balance_is_reported_rather_than_wrapped() { + let mut state = crate::beacon::helpers::test_state::with_validators(4); + + let result = weigh_justification_and_finalization(&mut state, Gwei::MAX, Gwei::MAX, 0); + + assert!(matches!(result, Err(Error::ArithmeticOverflow(_)))); + } +} diff --git a/crates/blockchain/state_transition/src/beacon/stf/epoch/mod.rs b/crates/blockchain/state_transition/src/beacon/stf/epoch/mod.rs new file mode 100644 index 000000000..026953803 --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/stf/epoch/mod.rs @@ -0,0 +1,337 @@ +//! Epoch processing. +//! +//! Runs on the last slot of every epoch. Phase0 does the bulk of its accounting +//! here rather than as attestations arrive, because the reward an attestation +//! earns depends on facts that are not settled when it is included: whether its +//! target became the canonical block for the epoch, and whether the chain is +//! finalizing at all. So attestations accumulate in the state and are replayed +//! here. +//! +//! Order matters between these steps, and in two places it is load-bearing: +//! +//! - Justification and finalization runs before rewards, so rewards are computed +//! against the finality state the attestations themselves produced. +//! - Registry updates run before slashings, so a validator that exits this epoch +//! is already exiting when the slashing penalty is scaled. + +pub mod altair; +pub mod capella; +pub mod electra; +pub mod fulu; +pub mod justification; +pub mod registry; +pub mod rewards; + +use crate::beacon::containers::phase0::PendingAttestation; +use crate::beacon::containers::{BeaconState, HistoricalBatch}; +use crate::beacon::error::{Result, verify}; +use crate::beacon::fork::ForkName; +use crate::beacon::helpers::accessors::{ + CommitteeCache, CommitteeCacheExt, get_block_root, get_block_root_at_slot, get_current_epoch, + get_previous_epoch, get_randao_mix, get_total_balance, +}; +use crate::beacon::helpers::misc::compute_epoch_at_slot; +use crate::beacon::lean_state_unreachable; +use crate::beacon::preset; +use crate::beacon::primitives::{Epoch, Gwei, HashTreeRoot as _, ValidatorIndex}; +use crate::beacon::{config::Config, constants}; + +use super::phase0_state; + +/// Phase0's epoch-boundary driver, in the specification's order. +/// +/// Named explicitly, unlike [`altair::process_epoch`] and every later fork's +/// own driver, only because this one predates [`process_epoch`] below: phase0 +/// has no module of its own elsewhere in [`crate::beacon::stf`] the way every later +/// fork does, so its driver stays here rather than moving to one. +pub fn process_epoch_phase0(state: &mut BeaconState, config: &Config) -> Result<()> { + justification::process_justification_and_finalization(state, config)?; + rewards::process_rewards_and_penalties(state, config)?; + registry::process_registry_updates(state, config)?; + registry::process_slashings(state, config)?; + process_eth1_data_reset(state)?; + process_effective_balance_updates(state)?; + process_slashings_reset(state)?; + process_randao_mixes_reset(state)?; + process_historical_roots_update(state)?; + process_participation_record_updates(state)?; + Ok(()) +} + +/// Runs every epoch-boundary step, dispatching on the state's own fork. +/// +/// Phase0 and altair each have a driver of their own already; every later +/// fork either reuses an earlier one unchanged or gets a stub of its own here, +/// to be filled in once that fork's own epoch-processing steps are written. +/// Which is which is settled by `beacon-chain.md`'s "Epoch processing" section +/// for each fork, not assumed: +/// +/// - Bellatrix's section only modifies `get_inactivity_penalty_deltas` and +/// `slash_validator`, two helpers the driver calls into, and never redefines +/// `process_epoch` itself, so this reuses altair's driver unchanged. +/// - Capella's section gives a full, modified `process_epoch` (it swaps +/// `process_historical_roots_update` for `process_historical_summaries_update`), +/// so it gets its own stub. +/// - Deneb's section only modifies `process_registry_updates` (EIP-7514's +/// activation churn limit), a helper the driver already calls uniformly +/// across forks, and never redefines `process_epoch` itself, so this reuses +/// capella's driver. +/// - Electra's and fulu's sections each give a full, modified `process_epoch` +/// (electra adds the pending-deposit and pending-consolidation steps; +/// fulu appends the proposer-lookahead step), so each gets its own stub. +pub fn process_epoch(state: &mut BeaconState, config: &Config) -> Result<()> { + match state.fork_name() { + ForkName::Phase0 => process_epoch_phase0(state, config), + ForkName::Altair => altair::process_epoch(state, config), + ForkName::Bellatrix => altair::process_epoch(state, config), + ForkName::Capella => capella::process_epoch(state, config), + ForkName::Deneb => capella::process_epoch(state, config), + ForkName::Electra => electra::process_epoch(state, config), + ForkName::Fulu => fulu::process_epoch(state, config), + ForkName::Lean => lean_state_unreachable("process_epoch"), + } +} + +/// Updates justification and finality, dispatching on the state's own fork. +/// +/// Every fork's driver already reaches its own version of this step directly, +/// so this dispatcher exists for the one caller that runs the step *outside* a +/// driver: [`crate::beacon::fork_choice::compute_pulled_up_tip`], which advances a +/// throwaway copy of a block's post-state just far enough to learn what +/// justification and finality that chain is heading towards. Fork choice has no +/// business knowing which fork's rules to reach for, so it asks here. +/// +/// Altair rewrote the step to read participation flags instead of replaying +/// stored attestations, and no later fork changes it again, so altair's version +/// serves everything from altair on. +pub fn process_justification_and_finalization( + state: &mut BeaconState, + config: &Config, +) -> Result<()> { + match state.fork_name() { + ForkName::Phase0 => justification::process_justification_and_finalization(state, config), + _ => altair::process_justification_and_finalization(state), + } +} + +// --------------------------------------------------------------------------- +// Attestation matching +// --------------------------------------------------------------------------- + +/// The attestations the state retained for `epoch`. +/// +/// Only the current and previous epoch are available, since those are the only +/// two the state keeps. +pub fn get_matching_source_attestations( + state: &BeaconState, + epoch: Epoch, +) -> Result> { + let current = get_current_epoch(state); + verify( + epoch == current || epoch == get_previous_epoch(state), + "attestations are only retained for the current and previous epoch", + )?; + + let state = super::phase0_state_ref(state, "get_matching_source_attestations")?; + let attestations = if epoch == current { + &state.current_epoch_attestations + } else { + &state.previous_epoch_attestations + }; + Ok(attestations.to_vec()) +} + +/// Those whose target is the epoch's canonical block, meaning the attester agreed +/// with this chain about what the epoch's checkpoint is. +pub fn get_matching_target_attestations( + state: &BeaconState, + epoch: Epoch, +) -> Result> { + let source = get_matching_source_attestations(state, epoch)?; + + // The early return is load-bearing, not an optimization. The specification + // writes this as a list comprehension, so `get_block_root` is evaluated per + // element and never at all when there are no attestations. It has its own + // range assertion, which fails for the epoch a state sits at the very start + // of, so hoisting the call out of the loop would reject states the + // specification accepts. + if source.is_empty() { + return Ok(Vec::new()); + } + + let block_root = get_block_root(state, epoch)?; + Ok(source + .into_iter() + .filter(|attestation| attestation.data.target.root == block_root) + .collect()) +} + +/// Those that also agreed about the head block at their own slot, which is the +/// strictest of the three and the only one that depends on the attester having +/// been up to date at the time. +pub fn get_matching_head_attestations( + state: &BeaconState, + epoch: Epoch, +) -> Result> { + let mut matching = Vec::new(); + for attestation in get_matching_target_attestations(state, epoch)? { + let root = get_block_root_at_slot(state, attestation.data.slot)?; + if attestation.data.beacon_block_root == root { + matching.push(attestation); + } + } + Ok(matching) +} + +/// The union of the attesters in `attestations`, minus those since slashed. +/// +/// Sorted and deduplicated, since callers use it both as a set and to index the +/// registry in order. +/// +/// Every attestation's committee comes out of one [`CommitteeCache`] held for +/// this call, so a pending-attestation list, which belongs to a single epoch, +/// costs one shuffling however many attestations it holds. A caller asking +/// about several lists (or single attestations) of one epoch in turn should +/// hold its own cache across them and call `unslashed_attesting_indices` +/// instead. +pub fn get_unslashed_attesting_indices( + state: &BeaconState, + attestations: &[PendingAttestation], +) -> Result> { + unslashed_attesting_indices(state, attestations, &CommitteeCache::default()) +} + +/// [`get_unslashed_attesting_indices`], drawing committees from a cache the +/// caller holds across calls. +pub(crate) fn unslashed_attesting_indices( + state: &BeaconState, + attestations: &[PendingAttestation], + committees: &CommitteeCache, +) -> Result> { + let mut indices = Vec::new(); + for attestation in attestations { + let slot = attestation.data.slot; + let epoch_committees = committees.committees(state, compute_epoch_at_slot(slot)); + let committee = epoch_committees.committee(slot, attestation.data.index)?; + for (position, index) in committee.iter().enumerate() { + if attestation.aggregation_bits.get(position).unwrap_or(false) { + indices.push(*index); + } + } + } + + indices.sort_unstable(); + indices.dedup(); + indices.retain(|index| { + state + .validator(*index) + .is_ok_and(|validator| !validator.slashed) + }); + Ok(indices) +} + +/// The combined effective balance of the unslashed attesters in `attestations`. +pub fn get_attesting_balance( + state: &BeaconState, + attestations: &[PendingAttestation], +) -> Result { + let indices = get_unslashed_attesting_indices(state, attestations)?; + get_total_balance(state, &indices) +} + +// --------------------------------------------------------------------------- +// Resets and rotations +// --------------------------------------------------------------------------- + +/// Clears the eth1 vote tally at the end of each voting period. +pub fn process_eth1_data_reset(state: &mut BeaconState) -> Result<()> { + let next_epoch = get_current_epoch(state) + 1; + if next_epoch.is_multiple_of(preset::EPOCHS_PER_ETH1_VOTING_PERIOD) { + *state.eth1_data_votes_mut() = Default::default(); + } + Ok(()) +} + +/// Moves each validator's effective balance toward its actual balance. +/// +/// The two thresholds are hysteresis: a balance has to move meaningfully past the +/// boundary before the effective balance follows it. Without that, a validator +/// hovering at an increment boundary would change effective balance every epoch, +/// and since effective balance feeds the shuffling seed's weighting and every +/// reward, that would churn far more than it measures. +pub fn process_effective_balance_updates(state: &mut BeaconState) -> Result<()> { + const HYSTERESIS_INCREMENT: Gwei = + preset::EFFECTIVE_BALANCE_INCREMENT / preset::HYSTERESIS_QUOTIENT; + const DOWNWARD_THRESHOLD: Gwei = HYSTERESIS_INCREMENT * preset::HYSTERESIS_DOWNWARD_MULTIPLIER; + const UPWARD_THRESHOLD: Gwei = HYSTERESIS_INCREMENT * preset::HYSTERESIS_UPWARD_MULTIPLIER; + + // Decided in one pass and applied in another. The state is an enum over + // per-fork structs, so the accessors hand out a borrow of the whole state + // rather than of one field, and there is no way to hold `validators` mutably + // while reading `balances`. Collecting the decisions first keeps this + // fork-independent, which matters because every fork runs this step + // unchanged. + let mut updates = Vec::new(); + for (index, validator) in state.validators().iter().enumerate() { + let balance = state.balances()[index]; + if balance + DOWNWARD_THRESHOLD < validator.effective_balance + || validator.effective_balance + UPWARD_THRESHOLD < balance + { + let effective = (balance - balance % preset::EFFECTIVE_BALANCE_INCREMENT) + .min(preset::MAX_EFFECTIVE_BALANCE); + updates.push((index, effective)); + } + } + + let validators = state.validators_mut(); + for (index, effective) in updates { + validators[index].effective_balance = effective; + } + Ok(()) +} + +/// Zeroes the slot the slashings ring buffer is about to reuse. +pub fn process_slashings_reset(state: &mut BeaconState) -> Result<()> { + let next_epoch = get_current_epoch(state) + 1; + let position = next_epoch as usize % preset::EPOCHS_PER_SLASHINGS_VECTOR; + state.slashings_mut()[position] = 0; + Ok(()) +} + +/// Seeds the next epoch's randao slot with the current epoch's mix. +pub fn process_randao_mixes_reset(state: &mut BeaconState) -> Result<()> { + let current_epoch = get_current_epoch(state); + let mix = get_randao_mix(state, current_epoch); + let position = (current_epoch + 1) as usize % preset::EPOCHS_PER_HISTORICAL_VECTOR; + state.randao_mixes_mut()[position] = mix; + Ok(()) +} + +/// Folds the block and state root vectors into one historical root when they are +/// about to wrap. +/// +/// This is what keeps history provable after the ring buffers overwrite it: the +/// roots themselves are dropped, but a commitment to them is kept forever. +pub fn process_historical_roots_update(state: &mut BeaconState) -> Result<()> { + let next_epoch = get_current_epoch(state) + 1; + let epochs_per_historical_root = + (preset::SLOTS_PER_HISTORICAL_ROOT / preset::SLOTS_PER_EPOCH as usize) as Epoch; + if next_epoch.is_multiple_of(epochs_per_historical_root) { + let batch = HistoricalBatch { + block_roots: state.block_roots().clone(), + state_roots: state.state_roots().clone(), + }; + state.historical_roots_mut().push(batch.hash_tree_root())?; + } + Ok(()) +} + +/// Rotates the retained attestations, discarding those two epochs old. +pub fn process_participation_record_updates(state: &mut BeaconState) -> Result<()> { + let state = phase0_state(state, "process_participation_record_updates")?; + state.previous_epoch_attestations = core::mem::take(&mut state.current_epoch_attestations); + Ok(()) +} + +/// The number of epochs of attestation history the justification bitfield holds. +pub(crate) const JUSTIFICATION_BITS: usize = constants::JUSTIFICATION_BITS_LENGTH; diff --git a/crates/blockchain/state_transition/src/beacon/stf/epoch/registry.rs b/crates/blockchain/state_transition/src/beacon/stf/epoch/registry.rs new file mode 100644 index 000000000..5c8f30dc2 --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/stf/epoch/registry.rs @@ -0,0 +1,386 @@ +//! Registry updates and slashings. +//! +//! Both functions walk the whole validator registry once per epoch: the first +//! moves validators between activation and exit states, the second applies the +//! deferred part of a slashing penalty. + +use crate::beacon::config::Config; +use crate::beacon::containers::BeaconState; +use crate::beacon::error::{Error, Result}; +use crate::beacon::fork::ForkName; +use crate::beacon::helpers::accessors::{ + get_current_epoch, get_total_active_balance, get_validator_churn_limit, +}; +use crate::beacon::helpers::misc::compute_activation_exit_epoch; +use crate::beacon::helpers::mutators::{decrease_balance, initiate_validator_exit}; +use crate::beacon::helpers::predicates::{ + is_active_validator, is_eligible_for_activation, is_eligible_for_activation_queue, +}; +use crate::beacon::lean_state_unreachable; +use crate::beacon::preset; +use crate::beacon::primitives::{Epoch, Gwei, ValidatorIndex}; + +/// The cap on how many validators may newly activate this epoch. +/// +/// Phase0 through capella cap activations only indirectly, as part of the +/// combined activation/exit churn limit ([`get_validator_churn_limit`]). +/// Deneb adds a second, independent ceiling on activations specifically +/// (EIP-7514, `MAX_PER_EPOCH_ACTIVATION_CHURN_LIMIT` in [`Config`]), so that a +/// single epoch's whole churn budget cannot be spent activating new +/// validators and leave none of it for the exits already in flight. +/// +/// Electra replaces [`process_registry_updates`] wholesale with a +/// balance-denominated version (`get_balance_churn_limit`), which belongs to +/// whichever module implements electra rather than here, and the +/// specification never revives a validator-count churn limit for fulu after +/// that. Calling this for electra or fulu would silently apply deneb's rule +/// where the specification no longer has one at all, so both are refused +/// outright rather than guessed at; see [`process_registry_updates`]'s own +/// documentation for exactly which forks that leaves this serving. +fn activation_churn_limit(state: &BeaconState, config: &Config) -> Result { + let churn_limit = get_validator_churn_limit(state, config); + match state.fork_name() { + ForkName::Phase0 | ForkName::Altair | ForkName::Bellatrix | ForkName::Capella => { + Ok(churn_limit) + } + ForkName::Deneb => Ok(config.max_per_epoch_activation_churn_limit.min(churn_limit)), + fork @ (ForkName::Electra | ForkName::Fulu) => Err(Error::UnsupportedForFork { + function: "process_registry_updates", + fork, + }), + ForkName::Lean => lean_state_unreachable("activation_churn_limit"), + } +} + +/// Moves validators between activation and exit states. +/// +/// Three passes, and the order between them matters. The first marks +/// validators newly eligible for the activation queue and starts exiting +/// anyone whose balance has fallen to the ejection floor; both checks read the +/// registry as it stood at the start of the epoch, before either pass changes +/// anything. The second builds the activation queue from eligibility as it +/// stands after that pass, so a validator ejected and a validator freshly +/// eligible in the same epoch are both accounted for before anyone activates. +/// +/// One copy of this function serves phase0 through deneb. Deneb's own change +/// (EIP-7514) is confined to which cap the third pass dequeues against, so it +/// is selected by fork through [`activation_churn_limit`] rather than +/// duplicating the three passes around it, the same reuse-by-value pattern +/// [`super::process_slashings`] already uses for its own per-fork multiplier. +/// Electra redefines every pass, not just the cap, so it is a wholly separate +/// function the crate's electra module owns, and fulu never revives a +/// validator-count rule after that; [`activation_churn_limit`] refuses to run +/// for either, so this function does too, rather than letting an electra or +/// fulu state silently activate validators under deneb's superseded rule. +pub fn process_registry_updates(state: &mut BeaconState, config: &Config) -> Result<()> { + let current_epoch = get_current_epoch(state); + let validator_count = state.validators().len() as ValidatorIndex; + + // `initiate_validator_exit` itself scans every validator's exit epoch to + // find the queue's current tail, which needs `state` uncommitted to any + // other borrow. Collecting the indices first, rather than calling it from + // inside a loop that also holds a reference into the registry, is what + // keeps that scan free to run. + let mut to_eject = Vec::new(); + for index in 0..validator_count { + if is_eligible_for_activation_queue(state.validator(index)?) { + state.validator_mut(index)?.activation_eligibility_epoch = current_epoch + 1; + } + + let validator = state.validator(index)?; + if is_active_validator(validator, current_epoch) + && validator.effective_balance <= config.ejection_balance + { + to_eject.push(index); + } + } + for index in to_eject { + initiate_validator_exit(state, index, config)?; + } + + // Queue validators eligible for activation and not yet dequeued. The sort + // key pairs the eligibility epoch with the validator index so that two + // validators becoming eligible in the same epoch still activate in the + // same order on every client, rather than in whatever order the registry + // happens to store them. + let finalized_epoch = state.finalized_checkpoint().epoch; + let mut activation_queue: Vec = (0..validator_count) + .filter(|&index| { + is_eligible_for_activation( + state + .validator(index) + .expect("index is within the registry"), + finalized_epoch, + ) + }) + .collect(); + activation_queue.sort_by_key(|&index| { + let validator = state + .validator(index) + .expect("index is within the registry"); + (validator.activation_eligibility_epoch, index) + }); + + // Dequeue validators for activation up to the churn limit. + let churn_limit = activation_churn_limit(state, config)? as usize; + let activation_epoch = compute_activation_exit_epoch(current_epoch); + for &index in activation_queue.iter().take(churn_limit) { + state.validator_mut(index)?.activation_epoch = activation_epoch; + } + + Ok(()) +} + +/// Applies the deferred part of every slashing whose penalty falls due this +/// epoch. +/// +/// `slash_validator` only takes an immediate cut of the slashed validator's +/// balance; the rest is scaled by how much of the whole active balance was +/// slashed in the surrounding window and applied here, `EPOCHS_PER_SLASHINGS_VECTOR` +/// divided by two after the offence. Scaling by the fraction of the registry +/// slashed together is what makes a coordinated attack cost far more per +/// validator than an isolated slashable mistake. +/// +/// Takes `config` only to match the signature every other step of +/// [`super::process_epoch`]'s pipeline is called with; the specification's +/// version of this function takes no configuration, since the multiplier and +/// the slashings window are both presets, not chain configuration. +/// +/// One copy of this function serves phase0 through deneb. Altair and +/// bellatrix each raise the proportional multiplier and nothing else, which +/// the specification expresses by redefining the whole function around a new +/// constant; here the value is selected by fork through [`preset::retuned`] +/// instead, the same reuse-by-value pattern [`process_registry_updates`] uses +/// for its own deneb-only change. +/// +/// Electra (EIP-7251) is not another multiplier swap: it restructures the +/// division itself, so this copy stops being correct there. Where this +/// function divides `effective_balance_increments * adjusted_total_slashing_balance` +/// by the raw `total_balance` and multiplies the result back up by +/// `increment` at the end, electra's version divides +/// `adjusted_total_slashing_balance` by `total_balance / increment` once, up +/// front, and multiplies that shared quotient by each validator's own +/// `effective_balance_increments` instead. The two orders are not equivalent +/// under integer division, and electra's own copy in +/// [`super::electra::process_slashings`] is where that difference is worked +/// through with a concrete example; fulu never mentions this function again, +/// so it reuses electra's copy the same way it reuses everything else electra +/// last redefined. +pub fn process_slashings(state: &mut BeaconState, _config: &Config) -> Result<()> { + let epoch = get_current_epoch(state); + let total_balance = get_total_active_balance(state)?; + + let mut slashed_sum: Gwei = 0; + for &slashing in state.slashings().iter() { + slashed_sum = slashed_sum + .checked_add(slashing) + .ok_or(Error::ArithmeticOverflow("summing the slashings vector"))?; + } + let multiplier = preset::retuned::proportional_slashing_multiplier(state.fork_name()); + let scaled_slashings = slashed_sum + .checked_mul(multiplier) + .ok_or(Error::ArithmeticOverflow( + "scaling the summed slashings by the proportional multiplier", + ))?; + let adjusted_total_slashing_balance = scaled_slashings.min(total_balance); + + let withdrawable_offset = (preset::EPOCHS_PER_SLASHINGS_VECTOR / 2) as Epoch; + + // Collecting the penalties before applying any of them keeps this pass + // reading a stable registry: `decrease_balance` only touches the balances + // vector, not the validator being read here, but every other mutator in + // this module needs the same shape, so this one follows suit. + let mut penalties = Vec::new(); + for (index, validator) in state.validators().iter().enumerate() { + if validator.slashed && epoch + withdrawable_offset == validator.withdrawable_epoch { + // Factored out from the penalty numerator to avoid a `uint64` + // overflow, exactly as the specification does; multiplying before + // dividing (rather than the algebraically equivalent other order) + // is what reproduces the specification's integer rounding. + let increment = preset::EFFECTIVE_BALANCE_INCREMENT; + let penalty_numerator = (validator.effective_balance / increment) + .checked_mul(adjusted_total_slashing_balance) + .ok_or(Error::ArithmeticOverflow( + "computing a validator's slashing penalty numerator", + ))?; + let penalty = penalty_numerator / total_balance * increment; + penalties.push((index as ValidatorIndex, penalty)); + } + } + + for (index, penalty) in penalties { + decrease_balance(state, index, penalty)?; + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::beacon::constants::FAR_FUTURE_EPOCH; + + /// A deneb state with `count` fully active, full-balance validators, + /// positioned one epoch in, the same shape + /// [`crate::beacon::helpers::test_state::with_validators`] builds for phase0. + /// + /// A thin wrapper around the shared fork-parameterised builder: see + /// [`crate::beacon::helpers::test_state::with_validators_at`] for the construction + /// this and every other fork's test module used to duplicate. + fn deneb_state_with_validators(count: usize) -> BeaconState { + crate::beacon::helpers::test_state::with_validators_at(ForkName::Deneb, count) + } + + /// Deneb's EIP-7514 cap must bind even when the plain validator-count + /// churn limit alone would let more validators through: enough of these + /// validators are already active that [`get_validator_churn_limit`] + /// exceeds [`Config::max_per_epoch_activation_churn_limit`], so a state + /// still running phase0's rule would activate more of the queued + /// validators below than deneb's is allowed to. + #[test] + fn deneb_caps_activation_churn_below_the_validator_count_limit() { + let config = Config::minimal(); + let active_count = 200; + let queued_count = 10; + let mut state = deneb_state_with_validators(active_count + queued_count); + + // Everyone from `active_count` on is eligible for activation but not + // yet active; everyone before them already is, active from genesis + // by `deneb_state_with_validators`'s own construction. + for index in active_count..(active_count + queued_count) { + let validator = state.validator_mut(index as ValidatorIndex).unwrap(); + validator.activation_epoch = FAR_FUTURE_EPOCH; + validator.activation_eligibility_epoch = 0; + } + + let churn_limit = get_validator_churn_limit(&state, &config); + assert!( + churn_limit > config.max_per_epoch_activation_churn_limit, + "the active set must be large enough that the plain churn limit \ + alone exceeds deneb's cap, or this test is not exercising it" + ); + assert_eq!( + activation_churn_limit(&state, &config).unwrap(), + config.max_per_epoch_activation_churn_limit, + "the smaller of the two limits must win" + ); + + process_registry_updates(&mut state, &config).unwrap(); + + let activated = (active_count..(active_count + queued_count)) + .filter(|&index| { + state + .validator(index as ValidatorIndex) + .unwrap() + .activation_epoch + != FAR_FUTURE_EPOCH + }) + .count(); + assert_eq!( + activated, config.max_per_epoch_activation_churn_limit as usize, + "only the deneb cap's worth of queued validators should have activated this epoch" + ); + } + + /// Validators becoming eligible for activation in the same epoch must + /// dequeue in index order, since every client has to agree on which ones + /// take the churn limit's few slots. + #[test] + fn activation_ordering_breaks_ties_by_validator_index() { + let config = Config::mainnet(); + // None of these validators are active yet, so the churn limit is the + // configured floor rather than a share of the active set. A few more + // candidates than that floor leaves some outside the queue. + let count = config.min_per_epoch_churn_limit as usize + 2; + let mut state = crate::beacon::helpers::test_state::with_validators(count); + + // Every validator becomes eligible for activation in the same epoch, + // but the churn limit does not admit all of them. + for index in 0..count as ValidatorIndex { + let validator = state.validator_mut(index).unwrap(); + validator.activation_eligibility_epoch = 0; + validator.activation_epoch = FAR_FUTURE_EPOCH; + } + let churn_limit = get_validator_churn_limit(&state, &config); + assert_eq!(churn_limit, config.min_per_epoch_churn_limit); + assert!((churn_limit as usize) < count); + + process_registry_updates(&mut state, &config).unwrap(); + + for index in 0..churn_limit { + assert_ne!( + state.validator(index).unwrap().activation_epoch, + FAR_FUTURE_EPOCH, + "validator {index} is within the churn limit and should have activated", + ); + } + for index in churn_limit..count as ValidatorIndex { + assert_eq!( + state.validator(index).unwrap().activation_epoch, + FAR_FUTURE_EPOCH, + "validator {index} is past the churn limit and should still be queued", + ); + } + } + + /// A validator whose effective balance has fallen to the ejection floor + /// must be moved into the exit queue, even though nothing else about it + /// changed. + #[test] + fn a_validator_at_the_ejection_balance_is_queued_to_exit() { + let config = Config::mainnet(); + let mut state = crate::beacon::helpers::test_state::with_validators(4); + state.validator_mut(2).unwrap().effective_balance = config.ejection_balance; + + process_registry_updates(&mut state, &config).unwrap(); + + assert_ne!(state.validator(2).unwrap().exit_epoch, FAR_FUTURE_EPOCH); + } + + /// A validator just above the ejection floor is untouched. + #[test] + fn a_validator_above_the_ejection_balance_stays_active() { + let config = Config::mainnet(); + let mut state = crate::beacon::helpers::test_state::with_validators(4); + state.validator_mut(2).unwrap().effective_balance = config.ejection_balance + 1; + + process_registry_updates(&mut state, &config).unwrap(); + + assert_eq!(state.validator(2).unwrap().exit_epoch, FAR_FUTURE_EPOCH); + } + + /// A freshly slashed validator is not due a deferred penalty yet: the + /// window has not elapsed, so this epoch must leave its balance alone. + #[test] + fn slashings_outside_the_withdrawable_window_are_untouched() { + let config = Config::mainnet(); + let mut state = crate::beacon::helpers::test_state::with_validators(4); + let balance_before = state.balance(1).unwrap(); + + state.validator_mut(1).unwrap().slashed = true; + state.slashings_mut()[0] = preset::MAX_EFFECTIVE_BALANCE; + + process_slashings(&mut state, &config).unwrap(); + + assert_eq!(state.balance(1).unwrap(), balance_before); + } + + /// Once the withdrawable epoch arrives, the deferred penalty is applied, + /// scaled up by the whole slashed balance recorded in the window. + #[test] + fn a_slashing_due_this_epoch_reduces_the_balance() { + let config = Config::mainnet(); + let mut state = crate::beacon::helpers::test_state::with_validators(4); + let epoch = get_current_epoch(&state); + let balance_before = state.balance(1).unwrap(); + + let validator = state.validator_mut(1).unwrap(); + validator.slashed = true; + validator.withdrawable_epoch = epoch + (preset::EPOCHS_PER_SLASHINGS_VECTOR / 2) as Epoch; + state.slashings_mut()[0] = preset::MAX_EFFECTIVE_BALANCE; + + process_slashings(&mut state, &config).unwrap(); + + assert!(state.balance(1).unwrap() < balance_before); + } +} diff --git a/crates/blockchain/state_transition/src/beacon/stf/epoch/rewards.rs b/crates/blockchain/state_transition/src/beacon/stf/epoch/rewards.rs new file mode 100644 index 000000000..a6bfab32b --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/stf/epoch/rewards.rs @@ -0,0 +1,382 @@ +//! Rewards and penalties. +//! +//! Every accounting pass here reads the attestations the previous epoch's +//! validators cast (buffered as `PendingAttestation`s and matched by slot, +//! target, and head in `super`) and turns them into one delta per validator: a +//! reward for a vote component that matched the canonical chain, a penalty for +//! one that did not, and, while the chain is failing to finalize, an +//! inactivity penalty that grows the longer finality stays stuck. Every reward +//! and penalty is proportional to [`get_base_reward`], itself inversely +//! proportional to the square root of the total active balance, so the +//! aggregate reward rate shrinks as the validator set grows rather than paying +//! a fixed amount per validator regardless of how many there are. +//! +//! [`process_rewards_and_penalties`] applies the results as two separate +//! passes, rewards then penalties, each through [`increase_balance`] and +//! [`decrease_balance`] rather than a single netted delta. That distinction is +//! load-bearing: [`decrease_balance`] floors at zero, and netting the two +//! before applying them would let a reward mask a penalty that should have +//! driven a low balance all the way down. +//! +//! # Why every component-delta function takes `config` +//! +//! None of phase0's own formulas below read a configuration value: every +//! divisor and threshold they use (`BASE_REWARD_FACTOR`, +//! `PROPOSER_REWARD_QUOTIENT`, `INACTIVITY_PENALTY_QUOTIENT`, +//! `MIN_EPOCHS_TO_INACTIVITY_PENALTY`) is a preset, resolved through +//! `crate::beacon::preset` at compile time with no argument needed. [`get_source_deltas`], +//! [`get_target_deltas`], [`get_head_deltas`], [`get_inclusion_delay_deltas`], +//! and [`get_inactivity_penalty_deltas`] still take `config: &Config` (unused +//! here, each parameter named `_config`), for two reasons: the `rewards` +//! fixture runner calls all five through one array and so needs them to share +//! exactly one signature, and altair's rewrite of this module (participation +//! flags instead of matched attestations, and configuration-scoped inactivity +//! score parameters) does need `Config` for the equivalent functions there. +//! Accepting the parameter now, unused, is what keeps this module's call shape +//! stable across that later rewrite. + +use crate::beacon::config::Config; +use crate::beacon::constants; +use crate::beacon::containers::BeaconState; +use crate::beacon::containers::phase0::PendingAttestation; +use crate::beacon::error::{Error, Result}; +use crate::beacon::helpers::accessors::{ + CommitteeCache, get_current_epoch, get_previous_epoch, get_total_active_balance, + get_total_balance, +}; +use crate::beacon::helpers::finality::{ + get_eligible_validator_indices, get_finality_delay, is_in_inactivity_leak, +}; +use crate::beacon::helpers::math::integer_squareroot; +use crate::beacon::helpers::mutators::{decrease_balance, increase_balance}; +use crate::beacon::preset; +use crate::beacon::primitives::{Gwei, ValidatorIndex}; + +use super::{ + get_matching_head_attestations, get_matching_source_attestations, + get_matching_target_attestations, get_unslashed_attesting_indices, unslashed_attesting_indices, +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// The base unit every attestation-component reward and inactivity penalty +/// scales from. +/// +/// Scales with the validator's own effective balance, but divides by the +/// square root of the total active balance rather than the total itself: the +/// aggregate reward budget is meant to grow with the square root of stake, not +/// linearly with it, so adding validators dilutes each one's reward rate. +pub fn get_base_reward(state: &BeaconState, index: ValidatorIndex) -> Result { + let total_balance = get_total_active_balance(state)?; + let effective_balance = state.validator(index)?.effective_balance; + Ok(effective_balance * preset::BASE_REWARD_FACTOR + / integer_squareroot(total_balance) + / constants::BASE_REWARDS_PER_EPOCH) +} + +/// The proposer's cut of an attester's base reward for including its +/// attestation. +pub fn get_proposer_reward(state: &BeaconState, attesting_index: ValidatorIndex) -> Result { + Ok(get_base_reward(state, attesting_index)? / preset::PROPOSER_REWARD_QUOTIENT) +} + +/// Shared accounting for the source, target, and head components of the +/// attestation reward: every eligible validator that cast whichever vote +/// `attestations` names is rewarded, and every eligible validator that did not +/// is penalized one base reward. +/// +/// Takes no `config`, unlike its five callers below, because nothing in the +/// shared formula reads one; see the module documentation for why the callers +/// carry it anyway. +/// +/// During an inactivity leak, a matching validator is paid its full base +/// reward here rather than the balance-weighted share the non-leaking branch +/// computes, because [`get_inactivity_penalty_deltas`] is about to cancel +/// exactly that much back out; paying it in full first is what makes an +/// optimally participating validator's net reward during a leak come out +/// neutral rather than negative. +pub fn get_attestation_component_deltas( + state: &BeaconState, + attestations: &[PendingAttestation], +) -> Result<(Vec, Vec)> { + let mut rewards = vec![0; state.validators().len()]; + let mut penalties = vec![0; state.validators().len()]; + + let total_balance = get_total_active_balance(state)?; + let unslashed_attesting_indices = get_unslashed_attesting_indices(state, attestations)?; + let attesting_balance = get_total_balance(state, &unslashed_attesting_indices)?; + + for index in get_eligible_validator_indices(state) { + if unslashed_attesting_indices.binary_search(&index).is_ok() { + // Factored out of both totals below before the division, to keep + // the numerator and denominator well clear of `u64::MAX` on a + // large validator set. + let increment = preset::EFFECTIVE_BALANCE_INCREMENT; + if is_in_inactivity_leak(state) { + rewards[index as usize] += get_base_reward(state, index)?; + } else { + let reward_numerator = + get_base_reward(state, index)? * (attesting_balance / increment); + rewards[index as usize] += reward_numerator / (total_balance / increment); + } + } else { + penalties[index as usize] += get_base_reward(state, index)?; + } + } + Ok((rewards, penalties)) +} + +// --------------------------------------------------------------------------- +// Components of attestation deltas +// --------------------------------------------------------------------------- + +/// Attester micro-rewards/penalties for the source-vote component. +pub fn get_source_deltas(state: &BeaconState, _config: &Config) -> Result<(Vec, Vec)> { + let matching_source_attestations = + get_matching_source_attestations(state, get_previous_epoch(state))?; + get_attestation_component_deltas(state, &matching_source_attestations) +} + +/// Attester micro-rewards/penalties for the target-vote component. +pub fn get_target_deltas(state: &BeaconState, _config: &Config) -> Result<(Vec, Vec)> { + let matching_target_attestations = + get_matching_target_attestations(state, get_previous_epoch(state))?; + get_attestation_component_deltas(state, &matching_target_attestations) +} + +/// Attester micro-rewards/penalties for the head-vote component. +pub fn get_head_deltas(state: &BeaconState, _config: &Config) -> Result<(Vec, Vec)> { + let matching_head_attestations = + get_matching_head_attestations(state, get_previous_epoch(state))?; + get_attestation_component_deltas(state, &matching_head_attestations) +} + +/// Proposer and inclusion-delay micro-rewards for each validator. +/// +/// Unlike the three components above, there is no penalty side: attesting +/// late only shrinks the reward the attester and its including proposer +/// split, it never costs either of them a balance they would otherwise have +/// kept. +/// +/// An attester that matches more than one retained source attestation (by +/// appearing in more than one of them) is paid only once, through whichever +/// reached the chain with the smallest `inclusion_delay`: the proposer who +/// included that one earns the proposer reward, and the attester's own reward +/// divides by that same delay, so being included again later cannot be used to +/// collect a second reward. +pub fn get_inclusion_delay_deltas( + state: &BeaconState, + _config: &Config, +) -> Result<(Vec, Vec)> { + let mut rewards = vec![0; state.validators().len()]; + + let matching_source_attestations = + get_matching_source_attestations(state, get_previous_epoch(state))?; + + // `PendingAttestation` shares `Attestation`'s `data`/`aggregation_bits` + // shape but is a distinct container type, so membership is checked by + // asking for the unslashed attesters of a single-attestation slice rather + // than by re-deriving the committee lookup directly. Every `index` below + // is already known unslashed (it comes from the union over the same + // attestations), so this agrees with the specification's plain "index in + // get_attesting_indices(state, a)" check. + // + // Computed once per attestation up front rather than once per (attester, + // attestation) pair inside the loop, where the specification writes it: + // the answer does not depend on `index`, and every attestation here + // belongs to the previous epoch, so one cache serves all of them one + // shuffling. + let committees = CommitteeCache::default(); + let attesters_per_attestation = matching_source_attestations + .iter() + .map(|attestation| { + unslashed_attesting_indices(state, std::slice::from_ref(attestation), &committees) + }) + .collect::>>()?; + + for index in unslashed_attesting_indices(state, &matching_source_attestations, &committees)? { + let attestation = matching_source_attestations + .iter() + .zip(&attesters_per_attestation) + .filter(|(_, attesters)| attesters.binary_search(&index).is_ok()) + .map(|(attestation, _)| attestation) + .min_by_key(|attestation| attestation.inclusion_delay) + .ok_or(Error::SpecAssert( + "an unslashed source attester attests in at least one matching source attestation", + ))?; + + let proposer_reward = get_proposer_reward(state, index)?; + rewards[attestation.proposer_index as usize] += proposer_reward; + let max_attester_reward = get_base_reward(state, index)? - proposer_reward; + rewards[index as usize] += max_attester_reward / attestation.inclusion_delay; + } + + // No penalties are associated with inclusion delay. + let penalties = vec![0; state.validators().len()]; + Ok((rewards, penalties)) +} + +/// Inactivity penalties for each validator; phase0 pays no separate inactivity +/// reward, only a penalty, so the reward side of the pair is always zero. +/// +/// Outside a leak this is a no-op: the whole penalty exists to drain balance +/// from validators that are not helping the chain finalize while it is stuck, +/// and there is nothing to drain if it is not stuck. Inside a leak, every +/// eligible validator pays back the base reward +/// [`get_attestation_component_deltas`] already credited it, for a canceling +/// effect on an optimally participating validator, and a validator that also +/// missed the target vote pays an additional penalty that grows with +/// [`get_finality_delay`]: the longer finality stalls, the faster an inactive +/// validator's share of the stake shrinks. +pub fn get_inactivity_penalty_deltas( + state: &BeaconState, + _config: &Config, +) -> Result<(Vec, Vec)> { + let mut penalties = vec![0; state.validators().len()]; + + if is_in_inactivity_leak(state) { + let matching_target_attestations = + get_matching_target_attestations(state, get_previous_epoch(state))?; + let matching_target_attesting_indices = + get_unslashed_attesting_indices(state, &matching_target_attestations)?; + + for index in get_eligible_validator_indices(state) { + let base_reward = get_base_reward(state, index)?; + let proposer_reward = get_proposer_reward(state, index)?; + penalties[index as usize] += + constants::BASE_REWARDS_PER_EPOCH * base_reward - proposer_reward; + + if matching_target_attesting_indices + .binary_search(&index) + .is_err() + { + let effective_balance = state.validator(index)?.effective_balance; + // `get_finality_delay` grows without bound the longer a leak + // lasts, so this product is checked rather than left to wrap: + // the specification treats a `uint64` overflow here as an + // invalid state, not as a penalty that silently wraps small. + let penalty_numerator = effective_balance + .checked_mul(get_finality_delay(state)) + .ok_or(Error::ArithmeticOverflow( + "scaling effective balance by the finality delay for the inactivity penalty", + ))?; + penalties[index as usize] += + penalty_numerator / preset::INACTIVITY_PENALTY_QUOTIENT; + } + } + } + + // No rewards are associated with inactivity penalties. + let rewards = vec![0; state.validators().len()]; + Ok((rewards, penalties)) +} + +// --------------------------------------------------------------------------- +// `get_attestation_deltas` +// --------------------------------------------------------------------------- + +/// The combined attestation reward and penalty for each validator: the sum of +/// the source, target, head, and inclusion-delay rewards, and the sum of the +/// source, target, head, and inactivity penalties. +pub fn get_attestation_deltas( + state: &BeaconState, + config: &Config, +) -> Result<(Vec, Vec)> { + let (source_rewards, source_penalties) = get_source_deltas(state, config)?; + let (target_rewards, target_penalties) = get_target_deltas(state, config)?; + let (head_rewards, head_penalties) = get_head_deltas(state, config)?; + let (inclusion_delay_rewards, _) = get_inclusion_delay_deltas(state, config)?; + let (_, inactivity_penalties) = get_inactivity_penalty_deltas(state, config)?; + + let validator_count = state.validators().len(); + let mut rewards = vec![0; validator_count]; + let mut penalties = vec![0; validator_count]; + for i in 0..validator_count { + rewards[i] = + source_rewards[i] + target_rewards[i] + head_rewards[i] + inclusion_delay_rewards[i]; + penalties[i] = + source_penalties[i] + target_penalties[i] + head_penalties[i] + inactivity_penalties[i]; + } + + Ok((rewards, penalties)) +} + +/// Applies the epoch's attestation rewards and penalties to every validator's +/// balance. +/// +/// Skipped entirely at the genesis epoch: rewards pay for attestations cast in +/// the previous epoch, and genesis has none. Rewards and penalties are two +/// separate passes over [`increase_balance`] and [`decrease_balance`], not one +/// netted delta, because [`decrease_balance`] floors at zero: netting first +/// would let a reward mask a penalty that should have driven a low balance all +/// the way down. +pub fn process_rewards_and_penalties(state: &mut BeaconState, config: &Config) -> Result<()> { + if get_current_epoch(state) == constants::GENESIS_EPOCH { + return Ok(()); + } + + let (rewards, penalties) = get_attestation_deltas(state, config)?; + for index in 0..state.validators().len() as ValidatorIndex { + increase_balance(state, index, rewards[index as usize])?; + decrease_balance(state, index, penalties[index as usize])?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn finality_delay_is_zero_on_a_fresh_state() { + // `with_validators` positions the state at current epoch 1, previous + // epoch 0, with the finalized checkpoint left at its default (epoch + // 0): finality has not fallen behind at all. + let state = crate::beacon::helpers::test_state::with_validators(4); + assert_eq!(get_finality_delay(&state), 0); + } + + #[test] + fn current_finality_is_not_a_leak() { + let state = crate::beacon::helpers::test_state::with_validators(4); + assert!(!is_in_inactivity_leak(&state)); + } + + #[test] + fn finality_stalled_past_the_threshold_is_a_leak() { + let mut state = crate::beacon::helpers::test_state::with_validators(4); + // The finalized checkpoint stays at its default (epoch 0); pushing the + // slot far enough ahead pulls the previous epoch more than + // `MIN_EPOCHS_TO_INACTIVITY_PENALTY` epochs ahead of it. + *state.slot_mut() = + preset::SLOTS_PER_EPOCH * (preset::MIN_EPOCHS_TO_INACTIVITY_PENALTY + 10); + assert!(is_in_inactivity_leak(&state)); + } + + #[test] + fn every_active_validator_is_eligible() { + let state = crate::beacon::helpers::test_state::with_validators(6); + assert_eq!( + get_eligible_validator_indices(&state), + (0..6).collect::>() + ); + } + + #[test] + fn process_rewards_and_penalties_is_a_no_op_at_genesis() { + let config = Config::mainnet(); + let mut state = crate::beacon::helpers::test_state::with_validators(4); + *state.slot_mut() = 0; + let balances_before = state.balances().clone(); + + process_rewards_and_penalties(&mut state, &config).unwrap(); + + assert_eq!( + state.balances(), + &balances_before, + "the genesis epoch has no previous epoch to reward" + ); + } +} diff --git a/crates/blockchain/state_transition/src/beacon/stf/fulu.rs b/crates/blockchain/state_transition/src/beacon/stf/fulu.rs new file mode 100644 index 000000000..9cd8d3267 --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/stf/fulu.rs @@ -0,0 +1,398 @@ +//! Fulu-specific block processing. +//! +//! Fulu changes no field of a block and redefines no step of `process_block` +//! itself: `beacon-chain.md`'s "Block processing" section for this fork +//! contains exactly one item, a modified `process_execution_payload`. So +//! [`process_block`] dispatches on [`electra::BeaconBlock`], the same type +//! fulu's own [`crate::beacon::containers::SignedBeaconBlock::Fulu`] variant wraps, +//! rather than a `fulu::BeaconBlock` that does not exist, and every step +//! but one is [`super::electra`]'s own function, called directly rather than +//! transcribed. +//! +//! The one change is why a block's blob commitment count stops being checked +//! against a single network-wide constant. Through electra, a block could +//! carry at most [`Config::max_blobs_per_block_electra`] blobs, each +//! downloaded and verified whole by every node that wants to check it. Fulu +//! moves to a sampling model instead (`das-core.md`): a blob is +//! erasure-coded into a wide row of columns, and a node gains the same +//! confidence that the data behind a commitment is available by sampling a +//! handful of those columns rather than downloading the blob itself. That +//! changes what the per-block limit is even bounding: no longer "how much +//! data must every node download," but "how many columns exist for the +//! network to sample from," a quantity the network can raise again and +//! again without a further hard fork, since nothing about an individual +//! node's own workload scales with it the way whole-blob downloads used to. +//! EIP-7892's blob schedule ([`Config::blob_schedule`]) is what carries a +//! raised limit into effect at a chosen epoch, and [`process_execution_payload`] +//! is the one place in block processing that reads it, through +//! [`Config::max_blobs_per_block`], in place of electra's fixed field. +//! +//! [`super::block::process_block_header`], [`super::block::process_randao`], +//! and [`super::block::process_eth1_data`] are reused because they always +//! were fork-shared; [`super::electra::process_withdrawals`], +//! [`super::electra::process_operations`], and +//! [`super::altair::process_sync_aggregate`] are reused because fulu's +//! specification never mentions any of the three. [`process_execution_payload`] +//! itself cannot be [`super::electra::process_execution_payload`] called +//! unchanged, for the same reason electra's own version could not be +//! deneb's: it reads a different [`Config`] field for the one check that +//! changed. + +use crate::beacon::config::Config; +use crate::beacon::containers::{BeaconState, deneb, electra}; +use crate::beacon::error::{Result, verify}; +use crate::beacon::helpers::accessors::{CommitteeCache, get_current_epoch, get_randao_mix}; +use crate::beacon::helpers::fulu::{fulu_state, fulu_state_ref}; +use crate::beacon::primitives::{Bytes32, HashTreeRoot as _}; + +use super::ExecutionEngine; + +// --------------------------------------------------------------------------- +// Block processing +// --------------------------------------------------------------------------- + +/// Fulu's block processing: electra's own steps, in electra's own order, +/// with [`process_execution_payload`] standing in for +/// [`super::electra::process_execution_payload`]. See the module docs for +/// why that is the only step this fork's specification asks to change. +pub fn process_block( + state: &mut BeaconState, + block: &electra::BeaconBlock, + config: &Config, + engine: &ExecutionEngine, + committees: &CommitteeCache, +) -> Result<()> { + super::block::process_block_header( + state, + block.slot, + block.proposer_index, + block.parent_root, + block.body.hash_tree_root(), + )?; + super::electra::process_withdrawals(state, &block.body.execution_payload)?; + process_execution_payload(state, &block.body, config, engine)?; + super::block::process_randao(state, &block.body.randao_reveal)?; + super::block::process_eth1_data(state, &block.body.eth1_data)?; + super::electra::process_operations(state, &block.body, config, committees)?; + super::altair::process_sync_aggregate(state, &block.body.sync_aggregate)?; + Ok(()) +} + +// --------------------------------------------------------------------------- +// Execution payload +// --------------------------------------------------------------------------- + +/// Validates this slot's execution payload and its blob commitments, then +/// caches the payload's header. +/// +/// Structurally [`super::electra::process_execution_payload`] (parent-hash +/// continuity, `prev_randao`, timestamp, a blob-commitment count check, the +/// collapsed engine check, then caching a [`deneb::ExecutionPayloadHeader`], +/// the same header type fulu keeps unchanged from deneb through electra), +/// but not literally callable as that function: its commitment-count check +/// reads [`Config::max_blobs_per_block_electra`], a single fixed +/// configuration value, while fulu's own limit comes from +/// [`Config::max_blobs_per_block`], which consults +/// [`Config::blob_schedule`] (EIP-7892) for the epoch the block belongs to +/// and only falls back to that same fixed value once the schedule has +/// nothing to say about it (see that method's own documentation, which +/// mirrors the specification's `get_blob_parameters`). [`super::electra`]'s +/// own state projection reads and writes `latest_execution_payload_header` +/// through functions private to that module, so this instead goes through +/// [`fulu_state_ref`] and [`fulu_state`], the same projection +/// [`crate::beacon::helpers::fulu::get_beacon_proposer_index`] already uses to reach +/// fulu's own state. +/// +/// Every other step is transcribed unchanged, including not computing +/// anything from `body.execution_requests`: see +/// [`super::deneb::process_execution_payload`]'s own documentation for why +/// the versioned-hashes list, and the execution-requests list a real +/// execution client would also need, are dead weight in this module +/// specifically. [`ExecutionEngine`] collapses the whole +/// `verify_and_notify_new_payload` interface to one boolean and never +/// inspects either, but the versioned hashes are still computed +/// unconditionally so that a future engine model with something real to +/// check against has the value ready to hand it. +pub fn process_execution_payload( + state: &mut BeaconState, + body: &electra::BeaconBlockBody, + config: &Config, + engine: &ExecutionEngine, +) -> Result<()> { + let payload = &body.execution_payload; + + let expected_parent_hash = fulu_state_ref(state, "process_execution_payload")? + .latest_execution_payload_header + .block_hash; + verify( + payload.parent_hash == expected_parent_hash, + "payload.parent_hash == state.latest_execution_payload_header.block_hash", + )?; + verify( + payload.prev_randao == get_randao_mix(state, get_current_epoch(state)), + "payload.prev_randao == get_randao_mix(state, get_current_epoch(state))", + )?; + verify( + payload.timestamp + == super::bellatrix::compute_timestamp_at_slot(state, state.slot(), config), + "payload.timestamp == compute_time_at_slot(state, state.slot)", + )?; + // [Modified in Fulu:EIP7892]: the schedule-aware limit rather than + // electra's fixed `max_blobs_per_block_electra`; see this function's own + // documentation for why the two cannot share one check. + verify( + body.blob_kzg_commitments.len() as u64 + <= config.max_blobs_per_block(get_current_epoch(state)), + "len(body.blob_kzg_commitments) <= get_blob_parameters(get_current_epoch(state)).max_blobs_per_block", + )?; + + // See this function's own documentation for why this is computed but + // not itself checked against anything. + let _versioned_hashes: Vec = body + .blob_kzg_commitments + .iter() + .map(super::deneb::kzg_commitment_to_versioned_hash) + .collect(); + + verify( + engine.execution_valid, + "verify_and_notify_new_payload(NewPayloadRequest(execution_payload=payload, \ + versioned_hashes=versioned_hashes, \ + parent_beacon_block_root=state.latest_block_header.parent_root, \ + execution_requests=body.execution_requests))", + )?; + + let header = deneb::ExecutionPayloadHeader { + parent_hash: payload.parent_hash, + fee_recipient: payload.fee_recipient, + state_root: payload.state_root, + receipts_root: payload.receipts_root, + logs_bloom: payload.logs_bloom.clone(), + prev_randao: payload.prev_randao, + block_number: payload.block_number, + gas_limit: payload.gas_limit, + gas_used: payload.gas_used, + timestamp: payload.timestamp, + extra_data: payload.extra_data.clone(), + base_fee_per_gas: payload.base_fee_per_gas, + block_hash: payload.block_hash, + transactions_root: payload.transactions.hash_tree_root(), + withdrawals_root: payload.withdrawals.hash_tree_root(), + blob_gas_used: payload.blob_gas_used, + excess_blob_gas: payload.excess_blob_gas, + }; + fulu_state(state, "process_execution_payload")?.latest_execution_payload_header = header; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::beacon::config::BlobScheduleEntry; + use crate::beacon::containers::bellatrix::LogsBloom; + use crate::beacon::fork::ForkName; + use crate::beacon::helpers::accessors::{get_current_epoch, get_randao_mix}; + use crate::beacon::preset; + use crate::beacon::primitives::{ + ExecutionAddress, ExecutionBlockHash, KzgCommitment, Root, Uint256, + }; + + /// An all-zero execution payload header, standing in for the genesis + /// payload. + fn empty_execution_payload_header() -> deneb::ExecutionPayloadHeader { + deneb::ExecutionPayloadHeader { + parent_hash: ExecutionBlockHash::ZERO, + fee_recipient: ExecutionAddress::ZERO, + state_root: Bytes32::ZERO, + receipts_root: Bytes32::ZERO, + logs_bloom: LogsBloom::try_from(vec![0u8; preset::BYTES_PER_LOGS_BLOOM]) + .expect("built at exactly BYTES_PER_LOGS_BLOOM"), + prev_randao: Bytes32::ZERO, + block_number: 0, + gas_limit: 0, + gas_used: 0, + timestamp: 0, + extra_data: Default::default(), + base_fee_per_gas: Uint256::ZERO, + block_hash: ExecutionBlockHash::ZERO, + transactions_root: Root::ZERO, + withdrawals_root: Root::ZERO, + blob_gas_used: 0, + excess_blob_gas: 0, + } + } + + /// A fulu state with `count` fully active, full-balance validators, one + /// epoch in (so the block-root history window already has entries), and + /// `header` as its `latest_execution_payload_header`. + /// + /// A thin wrapper around the shared fork-parameterised builder, which has + /// no header to take as a parameter, so this overrides the placeholder it + /// builds with the one every real caller here actually wants under test. + /// See [`crate::beacon::helpers::test_state::with_validators_at`] for the + /// construction this and every other fork's test module used to + /// duplicate. + fn fulu_state_with_validators( + count: usize, + header: deneb::ExecutionPayloadHeader, + ) -> BeaconState { + let mut state = + crate::beacon::helpers::test_state::with_validators_at(ForkName::Fulu, count); + if let BeaconState::Fulu(inner) = &mut state { + inner.latest_execution_payload_header = header; + } + state + } + + /// A block body carrying `commitment_count` blob commitments and an + /// otherwise-empty operation list, with a payload that matches `state` + /// and `header` closely enough to pass every check in + /// [`process_execution_payload`] except the one under test. + fn body_with_commitments( + state: &BeaconState, + config: &Config, + header: &deneb::ExecutionPayloadHeader, + commitment_count: usize, + ) -> electra::BeaconBlockBody { + let payload = deneb::ExecutionPayload { + parent_hash: header.block_hash, + fee_recipient: ExecutionAddress::ZERO, + state_root: Bytes32::ZERO, + receipts_root: Bytes32::ZERO, + logs_bloom: LogsBloom::try_from(vec![0u8; preset::BYTES_PER_LOGS_BLOOM]) + .expect("built at exactly BYTES_PER_LOGS_BLOOM"), + prev_randao: get_randao_mix(state, get_current_epoch(state)), + block_number: 0, + gas_limit: 0, + gas_used: 0, + timestamp: crate::beacon::stf::bellatrix::compute_timestamp_at_slot( + state, + state.slot(), + config, + ), + extra_data: Default::default(), + base_fee_per_gas: Uint256::ZERO, + block_hash: ExecutionBlockHash::repeat_byte(0xcd), + transactions: Default::default(), + withdrawals: Default::default(), + blob_gas_used: 0, + excess_blob_gas: 0, + }; + + electra::BeaconBlockBody { + randao_reveal: Default::default(), + eth1_data: Default::default(), + graffiti: Bytes32::ZERO, + proposer_slashings: Default::default(), + attester_slashings: Default::default(), + attestations: Default::default(), + deposits: Default::default(), + voluntary_exits: Default::default(), + sync_aggregate: Default::default(), + execution_payload: payload, + bls_to_execution_changes: Default::default(), + blob_kzg_commitments: vec![KzgCommitment::default(); commitment_count] + .try_into() + .expect("commitment_count stays well within MAX_BLOB_COMMITMENTS_PER_BLOCK here"), + execution_requests: electra::ExecutionRequests { + deposits: Default::default(), + withdrawals: Default::default(), + consolidations: Default::default(), + }, + } + } + + /// The one behavior fulu actually changes: a commitment count above + /// electra's fixed ceiling but within a raised schedule entry is + /// rejected by electra's own function and accepted by fulu's. + /// + /// Calls `crate::beacon::stf::electra::process_execution_payload` directly on + /// the same fulu state, rather than building a separate electra one: + /// that function's internal state projection already accepts a fulu + /// state (see this file's own module docs), so this exercises the real + /// function fulu diverges from instead of a stand-in for it. + #[test] + fn process_execution_payload_uses_the_schedule_aware_limit_electra_does_not() { + let header = empty_execution_payload_header(); + let mut config = Config::minimal(); + let raised_limit = config.max_blobs_per_block_electra + 3; + config.blob_schedule = vec![BlobScheduleEntry { + epoch: 0, + max_blobs_per_block: raised_limit, + }] + .try_into() + .expect("one entry is within the bound"); + + let state = fulu_state_with_validators(4, header.clone()); + let commitment_count = (config.max_blobs_per_block_electra + 1) as usize; + let body = body_with_commitments(&state, &config, &header, commitment_count); + let engine = ExecutionEngine::valid(); + + assert!( + crate::beacon::stf::electra::process_execution_payload( + &mut state.clone(), + &body, + &config, + &engine + ) + .is_err(), + "electra's own fixed ceiling must still reject a count above it" + ); + process_execution_payload(&mut state.clone(), &body, &config, &engine) + .expect("the schedule raised the limit above this count"); + } + + /// The schedule is a ceiling, not a suggestion: a count above even the + /// raised limit is still rejected. + #[test] + fn process_execution_payload_still_rejects_more_than_the_schedule_allows() { + let header = empty_execution_payload_header(); + let mut config = Config::minimal(); + let raised_limit = config.max_blobs_per_block_electra + 3; + config.blob_schedule = vec![BlobScheduleEntry { + epoch: 0, + max_blobs_per_block: raised_limit, + }] + .try_into() + .expect("one entry is within the bound"); + + let state = fulu_state_with_validators(4, header.clone()); + let body = body_with_commitments(&state, &config, &header, (raised_limit + 1) as usize); + let engine = ExecutionEngine::valid(); + + assert!(process_execution_payload(&mut state.clone(), &body, &config, &engine).is_err()); + } + + /// With no schedule entries at all, [`Config::max_blobs_per_block`] + /// falls back to electra's own fixed limit, so fulu's check and + /// electra's must agree at that same boundary. + #[test] + fn process_execution_payload_matches_electra_when_the_schedule_is_empty() { + let header = empty_execution_payload_header(); + let config = Config::minimal(); + assert!(config.blob_schedule.is_empty()); + + let state = fulu_state_with_validators(4, header.clone()); + let engine = ExecutionEngine::valid(); + + let at_limit = body_with_commitments( + &state, + &config, + &header, + config.max_blobs_per_block_electra as usize, + ); + process_execution_payload(&mut state.clone(), &at_limit, &config, &engine) + .expect("exactly electra's own limit must still pass with no schedule"); + + let over_limit = body_with_commitments( + &state, + &config, + &header, + (config.max_blobs_per_block_electra + 1) as usize, + ); + assert!( + process_execution_payload(&mut state.clone(), &over_limit, &config, &engine).is_err() + ); + } +} diff --git a/crates/blockchain/state_transition/src/beacon/stf/mod.rs b/crates/blockchain/state_transition/src/beacon/stf/mod.rs new file mode 100644 index 000000000..743672863 --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/stf/mod.rs @@ -0,0 +1,353 @@ +//! The beacon state transition function. +//! +//! Applying a block is two stages. First the state is advanced to the block's +//! slot, one slot at a time, running epoch processing at each epoch boundary +//! crossed. Then the block's own contents are applied. Both stages can fail, and +//! a failure at any point means the block is invalid. +//! +//! # Failure is the normal case +//! +//! The specification expresses invalidity by letting an `assert` fail or an index +//! go out of range, and says that a `uint64` overflow or underflow is invalid +//! too. Here every one of those is a [`crate::beacon::Error`] returned through `?`, so +//! nothing panics on a hostile block. That is why balance arithmetic goes through +//! checked operations rather than `+` and `-`: in release builds Rust wraps +//! silently, which would turn an invalid block into a corrupted state. +//! +//! # Mutation in place, and what that costs the caller +//! +//! The spec mutates the state in place and so does this, which means a state +//! passed to [`state_transition`] is left partly modified when a block turns out +//! to be invalid. Callers that need to keep the pre-state must clone it first, +//! which is what the fixture runners do. The alternative, threading a fresh state +//! through every function, would depart from the spec's structure everywhere +//! without making anything safer. +//! +//! # Dispatching on the block's fork, not a shared body type +//! +//! [`containers::BeaconState`] and [`containers::SignedBeaconBlock`] are enums +//! over per-fork structs, so every entry point here has to decide, at some +//! point, which fork's rules apply. [`block::process_block`] makes that +//! decision once, by matching [`containers::SignedBeaconBlock`]'s variant, and +//! hands each fork's own concrete `BeaconBlock` to a function written just for +//! it: [`block::process_block_phase0`], [`block::process_block_altair`], and a +//! stub per later fork in this module's [`bellatrix`], [`capella`], [`deneb`], +//! [`electra`], and [`fulu`] siblings. +//! +//! Deliberately absent from that list is any shared `BeaconBlockBody` enum, a +//! `BeaconBlockBodyRef`, or a trait over bodies. Such a type would have to grow +//! a method (or a match arm) per fork-specific field or operation list, which +//! defeats the point of dispatching once: a caller of `body.attester_slashings()` +//! would still have to know which fork it is dealing with to make sense of what +//! comes back, since electra's attester slashings are not phase0's. What +//! actually lets one function serve every fork is narrower and cheaper than any +//! of that: [`block::process_block_header`], [`block::process_randao`], and +//! [`block::process_eth1_data`] take the handful of fields they each read +//! directly, rather than a whole body, so nothing about validating a header +//! cares whether the body it came from also carries a sync aggregate or an +//! execution payload. A shared step earns its genericity by needing less, not +//! by being handed a bigger abstraction to see through. +//! +//! [`state_transition`] adds one check the specification itself has no +//! occasion to make: that the block's fork actually matches the state's. See +//! its own documentation for why that check belongs there and not inside +//! [`block::process_block`]. + +pub mod altair; +pub mod bellatrix; +pub mod block; +pub mod capella; +pub mod deneb; +pub mod electra; +pub mod epoch; +pub mod fulu; +pub mod operations; + +use crate::beacon::containers; +use crate::beacon::containers::{BeaconState, phase0}; +use crate::beacon::error::{Error, Result, verify}; +use crate::beacon::helpers::accessors::{CommitteeCache, get_domain}; +use crate::beacon::helpers::misc::compute_signing_root; +use crate::beacon::preset; +use crate::beacon::primitives::{HashTreeRoot as _, Slot}; +use crate::beacon::{bls, config::Config, constants}; + +/// What the execution layer would answer for a block's payload. +/// +/// The specification models this as a call out to an execution client: +/// `notify_new_payload` and the rest of `verify_and_notify_new_payload`, present +/// from bellatrix on. Nothing in this module speaks to a real execution client, +/// and the fixture suites that exercise this path supply the answer directly as +/// a boolean in `execution.yaml` rather than a payload to actually validate, so +/// the whole interface collapses to that one value. +#[derive(Debug, Clone, Copy)] +pub struct ExecutionEngine { + pub execution_valid: bool, +} + +impl ExecutionEngine { + /// An engine that accepts every payload it is asked about. + pub fn valid() -> Self { + ExecutionEngine { + execution_valid: true, + } + } + + /// An engine that rejects every payload it is asked about. + pub fn invalid() -> Self { + ExecutionEngine { + execution_valid: false, + } + } +} + +/// Applies a signed block to the state. +/// +/// With `validate_result` set, the proposer's signature and the block's committed +/// `state_root` are both checked. The fixture suites that feed in blocks the +/// proposer never really signed clear it, which is also what a block producer +/// building on a state it already trusts would do. +/// +/// Checks the block's fork against the state's own before doing anything else, +/// which is a check the specification never has occasion to write: its +/// `BeaconState` and `BeaconBlock` are already one fork's own types, so a +/// mismatch between them cannot even be expressed there. Here it can be, since +/// both are enums, so this module has to enforce by hand an invariant the +/// specification gets for free. The check belongs in this function rather than +/// in [`block::process_block`] because a mismatch would not reliably fail +/// inside that dispatcher for the reason a reader would expect: most of what an +/// earlier fork's block shares with a later one (the header, the RANDAO reveal, +/// the eth1 vote, and every operation through deneb's shape) reads and writes +/// only the state's fork-invariant fields, so it runs to completion regardless +/// of which variant `state` actually is. A phase0-shaped block applied to an +/// altair state, for instance, would simply never reach `process_sync_aggregate`, +/// since a phase0 body has no such field to read, silently skipping a step the +/// specification requires instead of failing on it. Rejecting the mismatch +/// before any of that runs is what keeps the failure legible: "wrong fork," not +/// some unrelated-looking assertion three steps later. +pub fn state_transition( + state: &mut BeaconState, + signed_block: &containers::SignedBeaconBlock, + validate_result: bool, + config: &Config, + engine: &ExecutionEngine, + committees: &CommitteeCache, +) -> Result<()> { + process_slots(state, signed_block.slot(), config)?; + + // After `process_slots`, never before it. A block proposed at the first slot + // of a fork's activation epoch is the *post*-fork shape while the state + // arriving here is still the pre-fork one, which is exactly the case a fork + // transition consists of. `process_slots` is what upgrades the state, so + // checking first would reject every legitimate fork-boundary block and make + // crossing a fork impossible. + verify( + signed_block.fork_name() == state.fork_name(), + "the block's fork matches the state's", + )?; + + if validate_result { + verify( + verify_block_signature(state, signed_block), + "block signature", + )?; + } + + block::process_block(state, signed_block, config, engine, committees)?; + // As in `process_slot`: the post-state's root, checked below and needed + // again in the next slot, is then computed on the state's own nodes. + state.apply_pending_mutations(); + + if validate_result { + verify( + signed_block.state_root() == state.hash_tree_root(), + "block state root matches the post-state", + )?; + } + + Ok(()) +} + +/// Whether the block carries its proposer's signature. +pub fn verify_block_signature( + state: &BeaconState, + signed_block: &containers::SignedBeaconBlock, +) -> bool { + let Ok(proposer) = state.validator(signed_block.proposer_index()) else { + return false; + }; + let domain = get_domain(state, constants::DOMAIN_BEACON_PROPOSER, None); + let signing_root = compute_signing_root(signed_block.message_hash_tree_root(), domain); + bls::verify(&proposer.pubkey, signing_root, &signed_block.signature()) +} + +/// Advances the state to `slot`, running epoch processing at each boundary and +/// upgrading the state's shape at each fork boundary crossed. +/// +/// Rejects a slot at or before the current one, so this can only ever move +/// forward. +pub fn process_slots(state: &mut BeaconState, slot: Slot, config: &Config) -> Result<()> { + verify(state.slot() < slot, "target slot is after the current slot")?; + + while state.slot() < slot { + process_slot(state)?; + // Epoch processing runs on the last slot of an epoch, before the counter + // moves into the next one. + if (state.slot() + 1).is_multiple_of(preset::SLOTS_PER_EPOCH) { + epoch::process_epoch(state, config)?; + } + *state.slot_mut() += 1; + upgrade_at_fork_boundary(state, config)?; + } + + // Epoch processing above can leave writes buffered past the last + // `process_slot`'s own flush (it runs before, not after, that step). A + // state returned here without a block on top, such as a checkpoint state + // built for attestation targets or an empty-slot pre-state, would + // otherwise be cloned and cached with the whole epoch's writes still + // pending. + state.apply_pending_mutations(); + + Ok(()) +} + +/// Replaces the state with the next fork's shape if the slot just reached is the +/// first slot of that fork's activation epoch. +/// +/// The specification does not list this as a step of `process_slots`. Each fork's +/// `fork.md` instead describes it as an "irregular state change" made when +/// `state.slot % SLOTS_PER_EPOCH == 0` and the resulting epoch equals that fork's +/// activation epoch, leaving where to put it to the implementation. Here is the +/// only place that works: it has to happen after the slot counter advances and +/// before anything reads the state again, and `process_slots` is the one function +/// every path into a new slot goes through. +/// +/// Without this, nothing can cross a fork. Every per-fork state transition in the +/// crate could be perfectly correct and the chain would still stop dead at the +/// first activation epoch, because the state would keep the old fork's shape while +/// blocks arrived in the new one. +/// +/// The loop, rather than a single check, is for a configuration that activates +/// two forks at the same epoch. The fixture suites do exactly that: a `transition` +/// case moves one fork's activation epoch, and nothing stops it landing on +/// another's. Upgrading one fork per slot would silently leave the state a fork +/// behind. +fn upgrade_at_fork_boundary(state: &mut BeaconState, config: &Config) -> Result<()> { + if !state.slot().is_multiple_of(preset::SLOTS_PER_EPOCH) { + return Ok(()); + } + let epoch = state.slot() / preset::SLOTS_PER_EPOCH; + + while let Some(next) = state.fork_name().next() { + if config.fork_epoch(next) != epoch { + break; + } + // Bound the borrow of `state` to this statement, since the assignment + // needs it back mutably. + let upgraded = crate::beacon::upgrade::upgrade_state(state, next, config)?; + *state = upgraded; + } + + Ok(()) +} + +/// Records the outgoing slot's state and block roots. +/// +/// The `latest_block_header.state_root` fixup is the resolution of a +/// circularity: a block commits to the root of the state that results from +/// applying it, so the header stored while processing that block cannot yet know +/// it. It is left zero and filled in here, one slot later, which is the first +/// moment the value exists. +/// +/// A caller that already knows the root may perform that fixup early, writing +/// it into the field itself; [`BeaconState::compute_state_root`] then hands it +/// back instead of merkleizing a mainnet-sized registry a second time per +/// import. The value is the one this would have computed, so the state comes +/// out the same either way. +pub fn process_slot(state: &mut BeaconState) -> Result<()> { + // Fold buffered registry writes into their trees first, so the root below + // is computed on, and cached in, the state's own nodes rather than on a + // throwaway copy. + state.apply_pending_mutations(); + let previous_state_root = state.compute_state_root(); + let position = state.slot() as usize % preset::SLOTS_PER_HISTORICAL_ROOT; + state.state_roots_mut()[position] = previous_state_root; + + if state.latest_block_header().state_root.is_zero() { + state.latest_block_header_mut().state_root = previous_state_root; + } + + let previous_block_root = state.latest_block_header().hash_tree_root(); + state.block_roots_mut()[position] = previous_block_root; + + Ok(()) +} + +/// The phase0 state, or an error naming the function that needs one. +/// +/// Functions whose body is specific to phase0's state shape start here rather +/// than matching inline, so the fork check reads the same in each of them. +pub(crate) fn phase0_state<'a>( + state: &'a mut BeaconState, + function: &'static str, +) -> Result<&'a mut phase0::BeaconState> { + match state { + BeaconState::Phase0(state) => Ok(state), + other => Err(Error::UnsupportedForFork { + function, + fork: other.fork_name(), + }), + } +} + +/// The phase0 state, immutably. +pub(crate) fn phase0_state_ref<'a>( + state: &'a BeaconState, + function: &'static str, +) -> Result<&'a phase0::BeaconState> { + match state { + BeaconState::Phase0(state) => Ok(state), + other => Err(Error::UnsupportedForFork { + function, + fork: other.fork_name(), + }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::beacon::helpers::test_state; + + #[test] + fn process_slot_leaves_no_buffered_registry_writes() { + let mut state = test_state::with_validators(4); + state.balances_mut()[0] += 1; + state.validator_mut(1).unwrap().effective_balance -= 1; + assert!(state.balances().has_pending_updates()); + assert!(state.validators().has_pending_updates()); + + process_slot(&mut state).unwrap(); + + assert!(!state.balances().has_pending_updates()); + assert!(!state.validators().has_pending_updates()); + } + + /// Epoch processing (`process_rewards_and_penalties` here) writes every + /// validator's balance through `increase_balance`/`decrease_balance` + /// after the last `process_slot`'s own flush has already run, so without + /// `process_slots`' own flush at the end, a state handed back after + /// crossing an epoch boundary carries the whole epoch's balance writes + /// still buffered. + #[test] + fn process_slots_across_an_epoch_boundary_leaves_no_buffered_writes() { + let mut state = test_state::with_validators(4); + let config = Config::mainnet(); + let target_slot = state.slot() + preset::SLOTS_PER_EPOCH; + + process_slots(&mut state, target_slot, &config).unwrap(); + + assert!(!state.balances().has_pending_updates()); + assert!(!state.validators().has_pending_updates()); + } +} diff --git a/crates/blockchain/state_transition/src/beacon/stf/operations.rs b/crates/blockchain/state_transition/src/beacon/stf/operations.rs new file mode 100644 index 000000000..c6fd62293 --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/stf/operations.rs @@ -0,0 +1,694 @@ +//! Block operations: the five lists a proposer packs into a block body. +//! +//! Each list is processed independently and in a fixed order (proposer +//! slashings, then attester slashings, then attestations, then deposits, then +//! voluntary exits), which is why [`process_operations`] is nothing more than +//! five loops. What makes this section worth its own module is that three of +//! the five operations end in a slashing, one has a two-phase signature domain +//! rule unlike anything else in the state transition, and the deposit path +//! silently tolerates the one kind of invalidity (a bad signature) that every +//! other operation treats as fatal to the whole block. Getting each of those +//! exactly right is the point; the loops around them are incidental. + +use std::collections::HashSet; + +use crate::beacon::bls; +use crate::beacon::config::Config; +use crate::beacon::constants::{self, FAR_FUTURE_EPOCH}; +use crate::beacon::containers::BeaconState; +use crate::beacon::containers::phase0; +use crate::beacon::containers::shared::{ + Deposit, DepositMessage, ProposerSlashing, SignedVoluntaryExit, Validator, +}; +use crate::beacon::error::{Error, Result, verify}; +use crate::beacon::fork::ForkName; +use crate::beacon::helpers::accessors::{ + CommitteeCache, CommitteeCacheExt, get_beacon_proposer_index, get_current_epoch, get_domain, + get_previous_epoch, +}; +use crate::beacon::helpers::attestation::{get_indexed_attestation, is_valid_indexed_attestation}; +use crate::beacon::helpers::misc::{ + compute_deposit_domain, compute_epoch_at_slot, compute_signing_root, is_valid_merkle_branch, +}; +use crate::beacon::helpers::mutators::{ + increase_balance, initiate_validator_exit, slash_validator, +}; +use crate::beacon::helpers::predicates::{ + is_active_validator, is_slashable_attestation_data, is_slashable_validator, +}; +use crate::beacon::preset; +use crate::beacon::primitives::{Gwei, HashTreeRoot as _, ValidatorIndex}; + +/// Runs every operation in a block, in the specification's order. +/// +/// Takes each operation list as a slice rather than a whole body, which is +/// what lets this one function serve every fork through deneb even though +/// their body types are all distinct: nothing here needs to know what else a +/// fork's body carries alongside these five lists. Capella's body adds a sixth +/// list (BLS-to-execution changes), and electra reshapes the attestation types +/// this signature assumes, so both get their own `process_operations` once that +/// work lands, rather than this one growing parameters to cover them. +/// +/// The deposit count check comes first and covers every deposit at once rather +/// than any one operation: a block must include exactly as many deposits as are +/// outstanding, up to the per-block cap, so a proposer cannot fall behind the +/// deposit contract by including too few, nor claim more than exist. +/// +/// Eight parameters, one past clippy's default limit, for the same reason +/// capella's and deneb's own `process_operations` carry the identical +/// allowance: this mirrors the specification's `process_operations(state, +/// body)` unpacked into the lists it reads, which is the point rather than an +/// accident (see [`crate::beacon::stf`]'s module documentation), and the +/// committee cache is threaded rather than rebuilt here because its lifetime +/// is the caller's to decide (see +/// [`crate::beacon::helpers::accessors::CommitteeCache`]). +#[allow(clippy::too_many_arguments)] +pub fn process_operations( + state: &mut BeaconState, + proposer_slashings: &[ProposerSlashing], + attester_slashings: &[phase0::AttesterSlashing], + attestations: &[phase0::Attestation], + deposits: &[Deposit], + voluntary_exits: &[SignedVoluntaryExit], + config: &Config, + committees: &CommitteeCache, +) -> Result<()> { + // `eth1_deposit_index` only ever advances by one per processed deposit, + // and `deposit_count` only ever grows, so in a correctly-derived state the + // index never exceeds the count. Nothing here re-derives that invariant, + // so a malformed pre-state (or a future bug) must not be allowed to wrap + // this into a huge outstanding count that no block could ever satisfy. + let outstanding = state + .eth1_data() + .deposit_count + .checked_sub(state.eth1_deposit_index()) + .ok_or(Error::ArithmeticOverflow( + "eth1_data.deposit_count - eth1_deposit_index", + ))?; + verify( + deposits.len() as u64 == outstanding.min(preset::MAX_DEPOSITS as u64), + "len(body.deposits) == min(MAX_DEPOSITS, eth1_data.deposit_count - eth1_deposit_index)", + )?; + + for proposer_slashing in proposer_slashings { + process_proposer_slashing(state, proposer_slashing, config)?; + } + for attester_slashing in attester_slashings { + process_attester_slashing(state, attester_slashing, config)?; + } + for attestation in attestations { + // Phase0 defers an attestation's reward to the epoch boundary, so it + // needs its own version of this step (below); altair scores one the + // moment it is processed instead, and nothing about that changed + // through deneb, so every later fork this signature serves shares + // altair's version rather than getting one of its own. This is the + // same coexisting-by-fork pattern `crate::beacon::helpers::altair` and + // `crate::beacon::stf::epoch::rewards` already use for the two + // `get_base_reward` implementations: neither is renamed, and the call + // site picks between them by fully-qualified path. + if state.fork_name() == ForkName::Phase0 { + process_attestation(state, attestation, config, committees)?; + } else { + crate::beacon::stf::altair::process_attestation(state, attestation, committees)?; + } + } + for deposit in deposits { + process_deposit(state, deposit, config)?; + } + for voluntary_exit in voluntary_exits { + process_voluntary_exit(state, voluntary_exit, config)?; + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Proposer slashings +// --------------------------------------------------------------------------- + +/// Slashes a proposer caught signing two different headers for the same slot. +/// +/// The two headers must actually differ: a proposer can be asked to co-sign +/// the same header twice (by different requesters, or the same one twice), +/// and that is not evidence of anything. Only a genuine equivocation, two +/// distinct headers for the one slot, is slashable. +pub fn process_proposer_slashing( + state: &mut BeaconState, + proposer_slashing: &ProposerSlashing, + config: &Config, +) -> Result<()> { + let header_1 = &proposer_slashing.signed_header_1.message; + let header_2 = &proposer_slashing.signed_header_2.message; + + verify( + header_1.slot == header_2.slot, + "header_1.slot == header_2.slot", + )?; + verify( + header_1.proposer_index == header_2.proposer_index, + "header_1.proposer_index == header_2.proposer_index", + )?; + verify(header_1 != header_2, "header_1 != header_2")?; + + let proposer_index = header_1.proposer_index; + let current_epoch = get_current_epoch(state); + let proposer = state.validator(proposer_index)?; + verify( + is_slashable_validator(proposer, current_epoch), + "is_slashable_validator(proposer, get_current_epoch(state))", + )?; + // Copied out rather than re-borrowed per iteration below: both signatures + // are checked against the same proposer, and holding the borrow across + // the loop would conflict with `get_domain`'s borrow of `state`. + let pubkey = proposer.pubkey; + + for signed_header in [ + &proposer_slashing.signed_header_1, + &proposer_slashing.signed_header_2, + ] { + let epoch = compute_epoch_at_slot(signed_header.message.slot); + let domain = get_domain(state, constants::DOMAIN_BEACON_PROPOSER, Some(epoch)); + let signing_root = compute_signing_root(signed_header.message.hash_tree_root(), domain); + verify( + bls::verify(&pubkey, signing_root, &signed_header.signature), + "bls.Verify(proposer.pubkey, signing_root, signed_header.signature)", + )?; + } + + slash_validator(state, proposer_index, None, config)?; + Ok(()) +} + +// --------------------------------------------------------------------------- +// Attester slashings +// --------------------------------------------------------------------------- + +/// Slashes every slashable validator in the overlap of two conflicting +/// attestations' attesting sets. +/// +/// The two indexed attestations must each be independently valid (sorted, +/// unique, unslashed-signature-correct) before their overlap means anything: +/// evidence built from a forged or malformed attestation proves nothing. +/// It is not enough for the overlap to be non-empty either. If every +/// validator in it has already been slashed (and so is past +/// [`is_slashable_validator`]'s reach) or has already withdrawn, the +/// operation has no effect and including it would let a block waste space +/// (or, worse, let a proposer replay old evidence) for free. +pub fn process_attester_slashing( + state: &mut BeaconState, + attester_slashing: &phase0::AttesterSlashing, + config: &Config, +) -> Result<()> { + let attestation_1 = &attester_slashing.attestation_1; + let attestation_2 = &attester_slashing.attestation_2; + + verify( + is_slashable_attestation_data(&attestation_1.data, &attestation_2.data), + "is_slashable_attestation_data(attestation_1.data, attestation_2.data)", + )?; + verify( + is_valid_indexed_attestation(state, attestation_1), + "is_valid_indexed_attestation(state, attestation_1)", + )?; + verify( + is_valid_indexed_attestation(state, attestation_2), + "is_valid_indexed_attestation(state, attestation_2)", + )?; + + let current_epoch = get_current_epoch(state); + // `is_valid_indexed_attestation` already required both index lists to be + // sorted and unique, so walking `attestation_1`'s list in order while + // filtering by membership in `attestation_2`'s set yields the + // intersection already sorted, matching the specification's + // `sorted(indices)` without a separate sort. + let indices_2: HashSet = + attestation_2.attesting_indices.iter().copied().collect(); + + let mut slashed_any = false; + for &index in attestation_1.attesting_indices.iter() { + if !indices_2.contains(&index) { + continue; + } + if is_slashable_validator(state.validator(index)?, current_epoch) { + slash_validator(state, index, None, config)?; + slashed_any = true; + } + } + verify( + slashed_any, + "at least one validator in the intersection of the two attesting index sets was slashed", + )?; + Ok(()) +} + +// --------------------------------------------------------------------------- +// Attestations +// --------------------------------------------------------------------------- + +/// Records an attestation for later reward accounting, and checks its +/// signature. +/// +/// Phase0 cannot score an attestation on arrival, so this only validates its +/// shape (target epoch, inclusion window, committee) and its claimed source +/// checkpoint, then defers the actual reward to the epoch boundary by +/// appending a [`phase0::PendingAttestation`] to whichever of the state's two +/// attestation lists matches the target epoch. The specification checks the +/// signature last, after that append, so a block with a well-formed but +/// unsigned attestation still leaves the append in place before the whole +/// block is rejected; callers that need the pre-state intact must clone it +/// first (see [`crate::beacon::stf`]'s module documentation). +/// +/// Takes `config` only for symmetry with the other operation processors +/// [`process_operations`] dispatches to uniformly; nothing this function +/// calls needs a runtime configuration value. +pub fn process_attestation( + state: &mut BeaconState, + attestation: &phase0::Attestation, + _config: &Config, + committees: &CommitteeCache, +) -> Result<()> { + let data = attestation.data; + let current_epoch = get_current_epoch(state); + let previous_epoch = get_previous_epoch(state); + + verify( + data.target.epoch == previous_epoch || data.target.epoch == current_epoch, + "data.target.epoch in (get_previous_epoch(state), get_current_epoch(state))", + )?; + verify( + data.target.epoch == compute_epoch_at_slot(data.slot), + "data.target.epoch == compute_epoch_at_slot(data.slot)", + )?; + + // Both bounds are driven directly by `data.slot`, which comes straight off + // the wire, so a hostile value close to `u64::MAX` must not be allowed to + // wrap either bound into something that vacuously accepts the attestation. + let min_slot = data + .slot + .checked_add(preset::MIN_ATTESTATION_INCLUSION_DELAY) + .ok_or(Error::ArithmeticOverflow( + "data.slot + MIN_ATTESTATION_INCLUSION_DELAY", + ))?; + let max_slot = data + .slot + .checked_add(preset::SLOTS_PER_EPOCH) + .ok_or(Error::ArithmeticOverflow("data.slot + SLOTS_PER_EPOCH"))?; + verify( + min_slot <= state.slot() && state.slot() <= max_slot, + "data.slot + MIN_ATTESTATION_INCLUSION_DELAY <= state.slot <= data.slot + SLOTS_PER_EPOCH", + )?; + // The committee count read off the shared shuffling rather than through + // `get_committee_count_per_slot`, which would scan the whole registry per + // attestation for the same value. + let epoch_committees = committees.committees(state, data.target.epoch); + verify( + data.index < epoch_committees.committees_per_slot(), + "data.index < get_committee_count_per_slot(state, data.target.epoch)", + )?; + + let committee_len = epoch_committees.committee(data.slot, data.index)?.len(); + verify( + attestation.aggregation_bits.len() == committee_len, + "len(attestation.aggregation_bits) == len(committee)", + )?; + + // Safe: `min_slot <= state.slot()` above and `min_slot >= data.slot` + // (the inclusion delay is non-negative), so `data.slot <= state.slot()`. + let pending_attestation = phase0::PendingAttestation { + data, + aggregation_bits: attestation.aggregation_bits.clone(), + inclusion_delay: state.slot() - data.slot, + proposer_index: get_beacon_proposer_index(state)?, + }; + + if data.target.epoch == current_epoch { + verify( + data.source == state.current_justified_checkpoint(), + "data.source == state.current_justified_checkpoint", + )?; + super::phase0_state(state, "process_attestation")? + .current_epoch_attestations + .push(pending_attestation)?; + } else { + verify( + data.source == state.previous_justified_checkpoint(), + "data.source == state.previous_justified_checkpoint", + )?; + super::phase0_state(state, "process_attestation")? + .previous_epoch_attestations + .push(pending_attestation)?; + } + + let indexed_attestation = get_indexed_attestation(state, attestation, committees)?; + verify( + is_valid_indexed_attestation(state, &indexed_attestation), + "is_valid_indexed_attestation(state, get_indexed_attestation(state, attestation))", + )?; + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Deposits +// --------------------------------------------------------------------------- + +/// Builds the registry entry a new deposit creates. +/// +/// The effective balance rounds the deposit amount down to a multiple of +/// `EFFECTIVE_BALANCE_INCREMENT` before capping it. Subtracting the remainder +/// can never underflow: a modulus is always at most the value it divides. +pub fn get_validator_from_deposit( + pubkey: crate::beacon::primitives::BlsPubkey, + withdrawal_credentials: crate::beacon::primitives::Bytes32, + amount: Gwei, +) -> Validator { + let effective_balance = + (amount - amount % preset::EFFECTIVE_BALANCE_INCREMENT).min(preset::MAX_EFFECTIVE_BALANCE); + + Validator { + pubkey, + withdrawal_credentials, + effective_balance, + slashed: false, + activation_eligibility_epoch: FAR_FUTURE_EPOCH, + activation_epoch: FAR_FUTURE_EPOCH, + exit_epoch: FAR_FUTURE_EPOCH, + withdrawable_epoch: FAR_FUTURE_EPOCH, + } +} + +/// Appends a brand-new validator and its starting balance. +/// +/// Every per-validator list is positionally parallel and must be grown +/// together; nothing about this operation should ever be run for a `pubkey` +/// already in the registry (see [`apply_deposit`], the only caller). +/// +/// From altair on there are five such lists, not two. Altair adds +/// `previous_epoch_participation`, `current_epoch_participation`, and +/// `inactivity_scores`, each indexed by validator, and its specification grows +/// all three here alongside `validators` and `balances`. Missing them does not +/// fail loudly: every later read is bounds-checked, so the state simply carries +/// lists one entry short of the registry and produces a `hash_tree_root` that +/// disagrees with every other client. That is exactly how it showed up here, +/// as seven altair `deposit` cases failing on a post-state root with no other +/// symptom. +/// +/// Electra replaces this function outright, since a deposit there is queued +/// rather than credited, so `crate::beacon::stf::electra` has its own; this one serves +/// phase0 through deneb. The altair branch still covers every later fork +/// anyway, because being conservative here costs nothing and a silent +/// length mismatch costs a great deal. +pub fn add_validator_to_registry( + state: &mut BeaconState, + pubkey: crate::beacon::primitives::BlsPubkey, + withdrawal_credentials: crate::beacon::primitives::Bytes32, + amount: Gwei, +) -> Result<()> { + state.validators_mut().push(get_validator_from_deposit( + pubkey, + withdrawal_credentials, + amount, + ))?; + state.balances_mut().push(amount)?; + + if state.fork_name() >= ForkName::Altair { + let (previous, current, scores) = state.altair_validator_lists_mut()?; + previous.push(0)?; + current.push(0)?; + scores.push(0)?; + } + Ok(()) +} + +/// Credits a deposit: to a new validator if the public key is unseen, or as a +/// balance top-up if it already has an entry. +/// +/// The signature check here is the one place in the whole state transition +/// that does not use [`get_domain`]: a deposit is signed by a depositor who +/// has no way to know which fork, or even which chain, will eventually accept +/// it, so it cannot commit to a genesis validators root or a fork version the +/// way every other signed message does. [`compute_deposit_domain`] instead +/// mixes in only the network's genesis fork version with an all-zero +/// validators root, giving every deposit across every fork of one network the +/// same signing domain. +/// +/// A signature that fails this check is not an error: the deposit is simply +/// not credited to a new validator; see [`process_deposit`], the only caller, +/// for why that is safe. +pub fn apply_deposit( + state: &mut BeaconState, + pubkey: crate::beacon::primitives::BlsPubkey, + withdrawal_credentials: crate::beacon::primitives::Bytes32, + amount: Gwei, + signature: crate::beacon::primitives::BlsSignature, + config: &Config, +) -> Result<()> { + let existing_index = state + .validators() + .iter() + .position(|validator| validator.pubkey == pubkey); + + match existing_index { + None => { + let deposit_message = DepositMessage { + pubkey, + withdrawal_credentials, + amount, + }; + let domain = compute_deposit_domain(config.genesis_fork_version); + let signing_root = compute_signing_root(deposit_message.hash_tree_root(), domain); + if bls::verify(&pubkey, signing_root, &signature) { + add_validator_to_registry(state, pubkey, withdrawal_credentials, amount)?; + } + } + Some(index) => { + increase_balance(state, index as ValidatorIndex, amount)?; + } + } + + Ok(()) +} + +/// Verifies a deposit's merkle proof, then applies it. +/// +/// The index advances before the deposit is applied, and advances +/// unconditionally, whether or not the signature inside turns out to be +/// valid. Deposits are processed strictly in the order the deposit contract +/// received them, so the index is really "how many deposits have been +/// consumed", not "how many produced a validator"; conflating the two would +/// let one depositor's bad signature desynchronize every subsequent deposit's +/// expected merkle position from the contract's own tree. +pub fn process_deposit(state: &mut BeaconState, deposit: &Deposit, config: &Config) -> Result<()> { + verify( + is_valid_merkle_branch( + deposit.data.hash_tree_root(), + &deposit.proof, + (constants::DEPOSIT_CONTRACT_TREE_DEPTH + 1) as u64, + state.eth1_deposit_index(), + state.eth1_data().deposit_root, + ), + "is_valid_merkle_branch(hash_tree_root(deposit.data), deposit.proof, DEPOSIT_CONTRACT_TREE_DEPTH + 1, state.eth1_deposit_index, state.eth1_data.deposit_root)", + )?; + + *state.eth1_deposit_index_mut() += 1; + + apply_deposit( + state, + deposit.data.pubkey, + deposit.data.withdrawal_credentials, + deposit.data.amount, + deposit.data.signature, + config, + ) +} + +// --------------------------------------------------------------------------- +// Voluntary exits +// --------------------------------------------------------------------------- + +/// Starts a validator's voluntary exit. +/// +/// Four conditions gate it, each guarding against a different way an exit +/// could be abused: the validator must still be active (an exited or +/// unactivated validator has nothing left to exit from), not already +/// exiting (so this cannot be replayed to push the exit queue further out), +/// past its own requested epoch (an exit cannot be redeemed early), and past +/// `SHARD_COMMITTEE_PERIOD` since activation (so a validator cannot buy a +/// committee assignment and immediately leave before it can be held to +/// account for anything done in it). +pub fn process_voluntary_exit( + state: &mut BeaconState, + signed_voluntary_exit: &SignedVoluntaryExit, + config: &Config, +) -> Result<()> { + let voluntary_exit = signed_voluntary_exit.message; + let current_epoch = get_current_epoch(state); + let validator = state.validator(voluntary_exit.validator_index)?; + + verify( + is_active_validator(validator, current_epoch), + "is_active_validator(validator, get_current_epoch(state))", + )?; + verify( + validator.exit_epoch == FAR_FUTURE_EPOCH, + "validator.exit_epoch == FAR_FUTURE_EPOCH", + )?; + verify( + current_epoch >= voluntary_exit.epoch, + "get_current_epoch(state) >= voluntary_exit.epoch", + )?; + // `validator.activation_epoch` is settled, previously-validated state + // (assigned by registry updates from the chain's own current epoch), not + // a value an attacker supplies directly the way `data.slot` is above, but + // checked anyway since the cost of doing so is free. + let eligible_epoch = validator + .activation_epoch + .checked_add(config.shard_committee_period) + .ok_or(Error::ArithmeticOverflow( + "validator.activation_epoch + SHARD_COMMITTEE_PERIOD", + ))?; + verify( + current_epoch >= eligible_epoch, + "get_current_epoch(state) >= validator.activation_epoch + SHARD_COMMITTEE_PERIOD", + )?; + + let domain = get_domain( + state, + constants::DOMAIN_VOLUNTARY_EXIT, + Some(voluntary_exit.epoch), + ); + let signing_root = compute_signing_root(voluntary_exit.hash_tree_root(), domain); + verify( + bls::verify( + &validator.pubkey, + signing_root, + &signed_voluntary_exit.signature, + ), + "bls.Verify(validator.pubkey, signing_root, signed_voluntary_exit.signature)", + )?; + + initiate_validator_exit(state, voluntary_exit.validator_index, config)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use blst::min_pk::SecretKey; + + use super::*; + use crate::beacon::containers::phase0::{AttesterSlashing, IndexedAttestation}; + use crate::beacon::containers::shared::{ + AttestationData, BeaconBlockHeader, Checkpoint, SignedBeaconBlockHeader, + }; + use crate::beacon::primitives::{BlsPubkey, BlsSignature, Root}; + + #[test] + fn identical_proposer_slashing_headers_are_rejected() { + let mut state = crate::beacon::helpers::test_state::with_validators(4); + let config = Config::mainnet(); + + let header = BeaconBlockHeader { + slot: state.slot(), + proposer_index: 0, + ..Default::default() + }; + let signed_header = SignedBeaconBlockHeader { + message: header, + signature: BlsSignature::default(), + }; + // Two identical headers carry no evidence of equivocation: the spec + // requires them to differ before anything else about the slashing + // (proposer identity, signatures) is even inspected. + let slashing = ProposerSlashing { + signed_header_1: signed_header.clone(), + signed_header_2: signed_header, + }; + + assert!(process_proposer_slashing(&mut state, &slashing, &config).is_err()); + } + + #[test] + fn an_attester_slashing_that_slashes_nobody_is_rejected() { + let mut state = crate::beacon::helpers::test_state::with_validators(4); + let config = Config::mainnet(); + + // A real key pair rather than a placeholder: `is_valid_indexed_attestation` + // genuinely checks the aggregate signature, and only a valid one lets this + // test reach (and so actually exercise) the "at least one validator + // slashed" check rather than failing earlier for an unrelated reason. + let secret_key = SecretKey::key_gen(&[7u8; 32], &[]).expect("32 bytes of key material"); + let public_key = secret_key.sk_to_pk(); + { + let validator = state.validator_mut(0).expect("validator 0 exists"); + validator.pubkey = BlsPubkey(public_key.to_bytes()); + // Already slashed, so the one validator in the overlap of the two + // attestations' attesting sets is not slashable, and the + // operation must still be rejected even though the overlap + // itself is non-empty. + validator.slashed = true; + } + + let target_epoch = 1; + let data_1 = AttestationData { + slot: preset::SLOTS_PER_EPOCH, + index: 0, + beacon_block_root: Root::repeat_byte(1), + source: Checkpoint::default(), + target: Checkpoint { + epoch: target_epoch, + root: Root::repeat_byte(1), + }, + }; + // A double vote: same target epoch, different content, both from + // validator 0. That is enough for `is_slashable_attestation_data` + // without needing a surround vote's separate source/target spread. + let data_2 = AttestationData { + beacon_block_root: Root::repeat_byte(2), + target: Checkpoint { + epoch: target_epoch, + root: Root::repeat_byte(2), + }, + ..data_1 + }; + + // Both attestations share a target epoch, so they share a domain; + // signing under exactly what `is_valid_indexed_attestation` will + // recompute is what makes these signatures genuinely valid rather + // than merely well-formed. + let domain = get_domain( + &state, + constants::DOMAIN_BEACON_ATTESTER, + Some(target_epoch), + ); + const DST: &[u8] = b"BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_"; + let sign = |data: &AttestationData| { + let signing_root = compute_signing_root(data.hash_tree_root(), domain); + BlsSignature( + secret_key + .sign(signing_root.as_slice(), DST, &[]) + .to_bytes(), + ) + }; + + let attestation_1 = IndexedAttestation { + attesting_indices: vec![0] + .try_into() + .expect("one index, far below the committee limit"), + data: data_1, + signature: sign(&data_1), + }; + let attestation_2 = IndexedAttestation { + attesting_indices: vec![0] + .try_into() + .expect("one index, far below the committee limit"), + data: data_2, + signature: sign(&data_2), + }; + let slashing = AttesterSlashing { + attestation_1, + attestation_2, + }; + + assert!(process_attester_slashing(&mut state, &slashing, &config).is_err()); + } +} diff --git a/crates/blockchain/state_transition/src/beacon/upgrade.rs b/crates/blockchain/state_transition/src/beacon/upgrade.rs new file mode 100644 index 000000000..8541ae8ff --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/upgrade.rs @@ -0,0 +1,1181 @@ +//! Fork-boundary state upgrades. +//! +//! `process_slots` runs an irregular state change whenever it advances the +//! state across a fork's activation epoch: the state's shape itself changes, +//! which an ordinary block-driven mutation can never do. Each fork that +//! changes the state's shape gets one function here, named and structured +//! after the specification's own `upgrade_to_`, plus one arm in +//! [`upgrade_state`] so a caller that only knows the target [`ForkName`] does +//! not have to match on it itself. +//! +//! Every upgrade function takes the pre-state by reference and returns a new +//! post-state rather than mutating in place. The two states are different +//! Rust types (`phase0::BeaconState` and `altair::BeaconState` are not the +//! same struct), so an in-place upgrade is not expressible in the type system +//! to begin with, and returning a value keeps the field-by-field mapping laid +//! out once, in one place, checkable against the specification's own +//! constructor line by line. + +use crate::beacon::config::Config; +use crate::beacon::constants; +use crate::beacon::containers::{ + BeaconState, EpochParticipation, Fork, InactivityScores, altair, bellatrix, capella, deneb, + electra, fulu, phase0, +}; +use crate::beacon::error::{Error, Result}; +use crate::beacon::fork::ForkName; +use crate::beacon::helpers::accessors::CommitteeCache; +use crate::beacon::helpers::attestation::get_attesting_indices; +use crate::beacon::helpers::misc::{compute_activation_exit_epoch, compute_epoch_at_slot}; +use crate::beacon::lean_fork_unreachable; +use crate::beacon::preset; +use crate::beacon::primitives::{ + BlsPubkey, BlsSignature, Bytes32, ExecutionAddress, ExecutionBlockHash, Root, Uint256, + ValidatorIndex, +}; + +/// Replays `pending_attestations` into `post`'s `previous_epoch_participation`. +/// +/// Phase0 scores an attestation only at the epoch boundary, by replaying +/// whatever accumulated in `previous_epoch_attestations`. Altair scores one +/// the moment it is processed and keeps no backlog, so without this step the +/// upgrade would silently discard the attestations phase0 was still holding +/// for the epoch that was in progress when the fork activated: those flags +/// are exactly what the next epoch boundary's rewards pass reads. +/// +/// Takes `post` as the enum rather than the concrete altair struct because +/// [`get_attestation_participation_flag_indices`](crate::beacon::helpers::altair::get_attestation_participation_flag_indices) +/// and [`get_attesting_indices`] both read only fields every fork shares +/// (justified checkpoints, block roots, committees), so they are written +/// against `&BeaconState` like every other state accessor in this module. The +/// match is repeated once per attestation rather than hoisted above the loop +/// so that each iteration's immutable borrow (for those two calls) ends +/// before the mutable borrow (for writing the flags) begins; a single match +/// held across the whole loop would keep the mutable borrow alive throughout +/// and rule out the immutable calls entirely. +fn translate_participation( + post: &mut BeaconState, + pending_attestations: &[phase0::PendingAttestation], +) -> Result<()> { + // The upgrade's own cache, not one threaded in from the caller: these are + // the pre-state's `previous_epoch_attestations`, so every one of them + // targets the epoch before the fork and they share that one shuffling + // between them, and a fork transition runs once per network rather than + // once per block. Nothing outside this loop needs it afterwards. + let committees = CommitteeCache::default(); + + for attestation in pending_attestations { + let participation_flag_indices = + crate::beacon::helpers::altair::get_attestation_participation_flag_indices( + post, + &attestation.data, + attestation.inclusion_delay, + )?; + + // `get_attesting_indices` is typed for a phase0 `Attestation`, not a + // `PendingAttestation`. The two carry exactly the fields it reads + // (`data` and `aggregation_bits`); a signature-less `Attestation` + // assembled from them stands in rather than adding a second, + // decomposed entry point for one caller. + let attestation_for_indices = phase0::Attestation { + aggregation_bits: attestation.aggregation_bits.clone(), + data: attestation.data, + signature: BlsSignature::default(), + }; + let attesting_indices = get_attesting_indices(post, &attestation_for_indices, &committees)?; + + let altair_state = match post { + BeaconState::Altair(state) => state, + other => { + return Err(Error::UnsupportedForFork { + function: "translate_participation", + fork: other.fork_name(), + }); + } + }; + for index in attesting_indices { + let entry = altair_state + .previous_epoch_participation + .get_mut(index as usize) + .ok_or(Error::UnknownValidator(index))?; + for &flag_index in &participation_flag_indices { + *entry = crate::beacon::helpers::altair::add_flag(*entry, flag_index); + } + } + } + + Ok(()) +} + +/// Upgrades a phase0 state to altair's shape. +/// +/// Transcribed from `specs/altair/fork.md`'s `upgrade_to_altair`. Fields +/// through `slashings` carry over unchanged; `previous_epoch_attestations` +/// and `current_epoch_attestations` have no altair counterpart and are +/// dropped, replaced by freshly zeroed participation flags one entry per +/// validator (never left empty, since every validator needs a flag byte from +/// the moment the fork activates); `inactivity_scores` is likewise zeroed at +/// one entry per validator, since inactivity leak accounting starts fresh +/// here. `fork` is rebuilt rather than carried over: its `previous_version` +/// becomes the pre-state's `current_version`, and `current_version` becomes +/// `config.altair_fork_version`, which is what makes this the fork boundary +/// rather than a same-fork slot advance. +pub fn upgrade_to_altair( + pre: &phase0::BeaconState, + config: &Config, +) -> Result { + // Equivalent to the spec's `phase0.get_current_epoch(pre)`: computed + // straight from `pre.slot` rather than through the `get_current_epoch` + // accessor, which needs `&BeaconState` and so would otherwise force a + // whole-state clone just to read one field of it. + let epoch = compute_epoch_at_slot(pre.slot); + let validator_count = pre.validators.len(); + + let fork = Fork { + previous_version: pre.fork.current_version, + current_version: config.altair_fork_version, + epoch, + }; + + // `SyncCommittee` has no `Default` impl (its `SszVector` field does not, + // unlike `SszList`, since a vector can never be validly empty). This + // placeholder exists only so the struct literal below type-checks before + // the real committees, derived a few lines down from `post` itself, are + // known. It is never observed: `get_next_sync_committee` reads + // `validators`, `slot`, and `randao_mixes`, none of which are the sync + // committee fields it is about to overwrite. + let placeholder_sync_committee = altair::SyncCommittee { + pubkeys: altair::SyncCommitteePubkeys::try_from(vec![ + BlsPubkey::default(); + preset::SYNC_COMMITTEE_SIZE + ])?, + aggregate_pubkey: BlsPubkey::default(), + }; + + let post = altair::BeaconState { + genesis_time: pre.genesis_time, + genesis_validators_root: pre.genesis_validators_root, + slot: pre.slot, + fork, + latest_block_header: pre.latest_block_header.clone(), + block_roots: pre.block_roots.clone(), + state_roots: pre.state_roots.clone(), + historical_roots: pre.historical_roots.clone(), + eth1_data: pre.eth1_data.clone(), + eth1_data_votes: pre.eth1_data_votes.clone(), + eth1_deposit_index: pre.eth1_deposit_index, + validators: pre.validators.clone(), + balances: pre.balances.clone(), + randao_mixes: pre.randao_mixes.clone(), + slashings: pre.slashings.clone(), + previous_epoch_participation: EpochParticipation::try_from(vec![0u8; validator_count])?, + current_epoch_participation: EpochParticipation::try_from(vec![0u8; validator_count])?, + justification_bits: pre.justification_bits.clone(), + previous_justified_checkpoint: pre.previous_justified_checkpoint, + current_justified_checkpoint: pre.current_justified_checkpoint, + finalized_checkpoint: pre.finalized_checkpoint, + inactivity_scores: InactivityScores::try_from(vec![0u64; validator_count])?, + current_sync_committee: placeholder_sync_committee.clone(), + next_sync_committee: placeholder_sync_committee, + }; + let mut post = BeaconState::Altair(post); + + // Fill in previous epoch participation from the pre-state's pending + // attestations, before the sync committees below: matches the spec's own + // ordering, and neither step depends on the other's result. + translate_participation(&mut post, &pre.previous_epoch_attestations)?; + + // Fill in sync committees. The specification calls `get_next_sync_committee` + // twice rather than computing it once and cloning the result, and its own + // comment says why: at the fork boundary there has been no sync-committee + // period boundary yet, so the current and next committees are the *same* + // committee by construction, not merely equal by coincidence. Calling + // the function twice keeps that guarantee explicit rather than relying on + // a clone to preserve it. + let current_sync_committee = crate::beacon::helpers::altair::get_next_sync_committee(&post)?; + let next_sync_committee = crate::beacon::helpers::altair::get_next_sync_committee(&post)?; + + let BeaconState::Altair(mut post) = post else { + unreachable!("post was constructed as BeaconState::Altair immediately above") + }; + post.current_sync_committee = current_sync_committee; + post.next_sync_committee = next_sync_committee; + + Ok(post) +} + +// --------------------------------------------------------------------------- +// Fork-state projections +// --------------------------------------------------------------------------- +// +// `upgrade_to_altair` takes its pre-state as the concrete `phase0::BeaconState` +// because phase0 already has a crate-wide projection to reuse +// (`crate::beacon::stf::phase0_state_ref`). Every upgrade below instead takes +// `pre: &BeaconState`, the enum, and projects down to the concrete struct +// itself: `upgrade_state` calls every one of these through the same +// signature, and threading the enum through uniformly is what lets it +// dispatch on `to: ForkName` alone rather than also matching on `pre`'s own +// shape at each call site. Altair and fulu already have a crate-wide +// projection of their own to reuse (`crate::beacon::helpers::altair::altair_state_ref`, +// `crate::beacon::helpers::fulu::fulu_state_ref`); bellatrix, capella, deneb, and +// electra do not, so those four are projected locally here, on the same +// pattern `crate::beacon::stf::phase0_state`/`phase0_state_ref` set. None of the four +// needs a mutable counterpart: [`upgrade_to_electra`] is the one function +// here that mutates a post-state field-by-field rather than only building +// one, and the fields it reaches for that [`BeaconState`]'s own accessors do +// not cover (`exit_balance_to_consume`, `consolidation_balance_to_consume`, +// `pending_deposits`) already have a projection to reuse in +// `crate::beacon::helpers::electra` (`electra_state`, returning `ElectraOrFuluMut`), +// so this file does not need a second one of its own. + +/// The bellatrix state, or an error naming the function that needs one. +fn bellatrix_state_ref<'a>( + state: &'a BeaconState, + function: &'static str, +) -> Result<&'a bellatrix::BeaconState> { + match state { + BeaconState::Bellatrix(state) => Ok(state), + other => Err(Error::UnsupportedForFork { + function, + fork: other.fork_name(), + }), + } +} + +/// The capella state, or an error naming the function that needs one. +fn capella_state_ref<'a>( + state: &'a BeaconState, + function: &'static str, +) -> Result<&'a capella::BeaconState> { + match state { + BeaconState::Capella(state) => Ok(state), + other => Err(Error::UnsupportedForFork { + function, + fork: other.fork_name(), + }), + } +} + +/// The deneb state, or an error naming the function that needs one. +fn deneb_state_ref<'a>( + state: &'a BeaconState, + function: &'static str, +) -> Result<&'a deneb::BeaconState> { + match state { + BeaconState::Deneb(state) => Ok(state), + other => Err(Error::UnsupportedForFork { + function, + fork: other.fork_name(), + }), + } +} + +/// The electra state, or an error naming the function that needs one. +/// +/// [`upgrade_to_fulu`]'s only use: fulu's pre-state is electra's shape. +/// [`upgrade_to_electra`] never needs this projection for its own `pre` +/// (that is [`deneb_state_ref`]'s job) or for reading back its freshly built +/// `post` (every read there goes through [`BeaconState`]'s fork-invariant +/// accessors or `crate::beacon::helpers::electra::electra_state`). +fn electra_state_ref<'a>( + state: &'a BeaconState, + function: &'static str, +) -> Result<&'a electra::BeaconState> { + match state { + BeaconState::Electra(state) => Ok(state), + other => Err(Error::UnsupportedForFork { + function, + fork: other.fork_name(), + }), + } +} + +// --------------------------------------------------------------------------- +// Execution payload header helpers +// --------------------------------------------------------------------------- + +/// An all-zero bellatrix execution payload header. +/// +/// `specs/bellatrix/fork.md`'s `upgrade_to_bellatrix` writes this as the bare +/// constructor call `ExecutionPayloadHeader()`, meaning every field at its +/// type's default. That is not `#[derive(Default)]` here: `logs_bloom` is an +/// [`libssz_types::SszVector`], which libssz gives no `Default` impl (a vector, +/// unlike a list, can never be validly empty, so there is no such thing as *the* +/// default one), so its all-zero value is built explicitly at its exact length +/// instead. +fn empty_bellatrix_execution_payload_header() -> Result { + Ok(bellatrix::ExecutionPayloadHeader { + parent_hash: ExecutionBlockHash::ZERO, + fee_recipient: ExecutionAddress::ZERO, + state_root: Bytes32::ZERO, + receipts_root: Bytes32::ZERO, + logs_bloom: bellatrix::LogsBloom::try_from(vec![0u8; preset::BYTES_PER_LOGS_BLOOM])?, + prev_randao: Bytes32::ZERO, + block_number: 0, + gas_limit: 0, + gas_used: 0, + timestamp: 0, + extra_data: bellatrix::ExtraData::default(), + base_fee_per_gas: Uint256::ZERO, + block_hash: ExecutionBlockHash::ZERO, + transactions_root: Root::ZERO, + }) +} + +/// Upgrades an altair state to bellatrix's shape. +/// +/// Transcribed from `specs/bellatrix/fork.md`'s `upgrade_to_bellatrix`. Every +/// field through `next_sync_committee` carries over unchanged: bellatrix (the +/// Merge) changes no field altair already had. It adds exactly one, +/// `latest_execution_payload_header`, and that one starts as the all-zero +/// [`empty_bellatrix_execution_payload_header`] rather than anything copied +/// from `pre`: proof-of-work blocks were never beacon-chain execution +/// payloads, so there is no earlier payload for this field to summarize until +/// the first post-Merge block supplies one. +pub fn upgrade_to_bellatrix(pre: &BeaconState, config: &Config) -> Result { + let pre = crate::beacon::helpers::altair::altair_state_ref(pre, "upgrade_to_bellatrix")?; + let epoch = compute_epoch_at_slot(pre.slot); + + let fork = Fork { + previous_version: pre.fork.current_version, + current_version: config.bellatrix_fork_version, + epoch, + }; + + let post = bellatrix::BeaconState { + genesis_time: pre.genesis_time, + genesis_validators_root: pre.genesis_validators_root, + slot: pre.slot, + fork, + latest_block_header: pre.latest_block_header.clone(), + block_roots: pre.block_roots.clone(), + state_roots: pre.state_roots.clone(), + historical_roots: pre.historical_roots.clone(), + eth1_data: pre.eth1_data.clone(), + eth1_data_votes: pre.eth1_data_votes.clone(), + eth1_deposit_index: pre.eth1_deposit_index, + validators: pre.validators.clone(), + balances: pre.balances.clone(), + randao_mixes: pre.randao_mixes.clone(), + slashings: pre.slashings.clone(), + previous_epoch_participation: pre.previous_epoch_participation.clone(), + current_epoch_participation: pre.current_epoch_participation.clone(), + justification_bits: pre.justification_bits.clone(), + previous_justified_checkpoint: pre.previous_justified_checkpoint, + current_justified_checkpoint: pre.current_justified_checkpoint, + finalized_checkpoint: pre.finalized_checkpoint, + inactivity_scores: pre.inactivity_scores.clone(), + current_sync_committee: pre.current_sync_committee.clone(), + next_sync_committee: pre.next_sync_committee.clone(), + // [New in Bellatrix] + latest_execution_payload_header: empty_bellatrix_execution_payload_header()?, + }; + + Ok(BeaconState::Bellatrix(post)) +} + +/// Upgrades a bellatrix state to capella's shape. +/// +/// Transcribed from `specs/capella/fork.md`'s `upgrade_to_capella`. Every +/// field through `next_sync_committee` carries over unchanged, including +/// `historical_roots`: the specification copies it as-is rather than +/// truncating it. Freezing it in place (never appended to again, from this +/// fork on) is capella's actual change to it; `historical_summaries` (added +/// below) is where new history accumulates instead, which is why the two +/// coexist rather than one replacing the other outright. +/// +/// `latest_execution_payload_header` keeps its name and position but is +/// rebuilt as capella's own container, carrying every one of bellatrix's +/// header fields across individually and appending an empty +/// `withdrawals_root`: withdrawals are capella's new operation, so there is +/// no earlier payload's withdrawal root for this field to summarize yet, the +/// same reasoning [`upgrade_to_bellatrix`] applies to its own brand new +/// field. `next_withdrawal_index`, `next_withdrawal_validator_index`, and +/// `historical_summaries` are capella's other three additions, all starting +/// at their type's zero: the withdrawal sweep and the history it accumulates +/// both begin only once this fork is active. +pub fn upgrade_to_capella(pre: &BeaconState, config: &Config) -> Result { + let pre = bellatrix_state_ref(pre, "upgrade_to_capella")?; + let epoch = compute_epoch_at_slot(pre.slot); + + let fork = Fork { + previous_version: pre.fork.current_version, + current_version: config.capella_fork_version, + epoch, + }; + + let execution_header = &pre.latest_execution_payload_header; + let latest_execution_payload_header = capella::ExecutionPayloadHeader { + parent_hash: execution_header.parent_hash, + fee_recipient: execution_header.fee_recipient, + state_root: execution_header.state_root, + receipts_root: execution_header.receipts_root, + logs_bloom: execution_header.logs_bloom.clone(), + prev_randao: execution_header.prev_randao, + block_number: execution_header.block_number, + gas_limit: execution_header.gas_limit, + gas_used: execution_header.gas_used, + timestamp: execution_header.timestamp, + extra_data: execution_header.extra_data.clone(), + base_fee_per_gas: execution_header.base_fee_per_gas, + block_hash: execution_header.block_hash, + transactions_root: execution_header.transactions_root, + // [New in Capella] + withdrawals_root: Root::ZERO, + }; + + let post = capella::BeaconState { + genesis_time: pre.genesis_time, + genesis_validators_root: pre.genesis_validators_root, + slot: pre.slot, + fork, + latest_block_header: pre.latest_block_header.clone(), + block_roots: pre.block_roots.clone(), + state_roots: pre.state_roots.clone(), + historical_roots: pre.historical_roots.clone(), + eth1_data: pre.eth1_data.clone(), + eth1_data_votes: pre.eth1_data_votes.clone(), + eth1_deposit_index: pre.eth1_deposit_index, + validators: pre.validators.clone(), + balances: pre.balances.clone(), + randao_mixes: pre.randao_mixes.clone(), + slashings: pre.slashings.clone(), + previous_epoch_participation: pre.previous_epoch_participation.clone(), + current_epoch_participation: pre.current_epoch_participation.clone(), + justification_bits: pre.justification_bits.clone(), + previous_justified_checkpoint: pre.previous_justified_checkpoint, + current_justified_checkpoint: pre.current_justified_checkpoint, + finalized_checkpoint: pre.finalized_checkpoint, + inactivity_scores: pre.inactivity_scores.clone(), + current_sync_committee: pre.current_sync_committee.clone(), + next_sync_committee: pre.next_sync_committee.clone(), + latest_execution_payload_header, + // [New in Capella] + next_withdrawal_index: 0, + // [New in Capella] + next_withdrawal_validator_index: 0, + // [New in Capella] + historical_summaries: Default::default(), + }; + + Ok(BeaconState::Capella(post)) +} + +/// Upgrades a capella state to deneb's shape. +/// +/// Transcribed from `specs/deneb/fork.md`'s `upgrade_to_deneb`. The state's +/// field count does not change: every field through `historical_summaries` +/// carries over unchanged, and the only reshaping is +/// `latest_execution_payload_header`, rebuilt as deneb's own container with +/// every one of capella's header fields (now including `withdrawals_root`) +/// carried across individually, plus `blob_gas_used` and `excess_blob_gas` +/// starting at zero: deneb's blob fee market has no history to inherit +/// either, the same reasoning [`upgrade_to_bellatrix`] and +/// [`upgrade_to_capella`] apply to their own brand new fields. +pub fn upgrade_to_deneb(pre: &BeaconState, config: &Config) -> Result { + let pre = capella_state_ref(pre, "upgrade_to_deneb")?; + let epoch = compute_epoch_at_slot(pre.slot); + + let fork = Fork { + previous_version: pre.fork.current_version, + current_version: config.deneb_fork_version, + epoch, + }; + + let execution_header = &pre.latest_execution_payload_header; + let latest_execution_payload_header = deneb::ExecutionPayloadHeader { + parent_hash: execution_header.parent_hash, + fee_recipient: execution_header.fee_recipient, + state_root: execution_header.state_root, + receipts_root: execution_header.receipts_root, + logs_bloom: execution_header.logs_bloom.clone(), + prev_randao: execution_header.prev_randao, + block_number: execution_header.block_number, + gas_limit: execution_header.gas_limit, + gas_used: execution_header.gas_used, + timestamp: execution_header.timestamp, + extra_data: execution_header.extra_data.clone(), + base_fee_per_gas: execution_header.base_fee_per_gas, + block_hash: execution_header.block_hash, + transactions_root: execution_header.transactions_root, + withdrawals_root: execution_header.withdrawals_root, + // [New in Deneb:EIP4844] + blob_gas_used: 0, + // [New in Deneb:EIP4844] + excess_blob_gas: 0, + }; + + let post = deneb::BeaconState { + genesis_time: pre.genesis_time, + genesis_validators_root: pre.genesis_validators_root, + slot: pre.slot, + fork, + latest_block_header: pre.latest_block_header.clone(), + block_roots: pre.block_roots.clone(), + state_roots: pre.state_roots.clone(), + historical_roots: pre.historical_roots.clone(), + eth1_data: pre.eth1_data.clone(), + eth1_data_votes: pre.eth1_data_votes.clone(), + eth1_deposit_index: pre.eth1_deposit_index, + validators: pre.validators.clone(), + balances: pre.balances.clone(), + randao_mixes: pre.randao_mixes.clone(), + slashings: pre.slashings.clone(), + previous_epoch_participation: pre.previous_epoch_participation.clone(), + current_epoch_participation: pre.current_epoch_participation.clone(), + justification_bits: pre.justification_bits.clone(), + previous_justified_checkpoint: pre.previous_justified_checkpoint, + current_justified_checkpoint: pre.current_justified_checkpoint, + finalized_checkpoint: pre.finalized_checkpoint, + inactivity_scores: pre.inactivity_scores.clone(), + current_sync_committee: pre.current_sync_committee.clone(), + next_sync_committee: pre.next_sync_committee.clone(), + latest_execution_payload_header, + next_withdrawal_index: pre.next_withdrawal_index, + next_withdrawal_validator_index: pre.next_withdrawal_validator_index, + historical_summaries: pre.historical_summaries.clone(), + }; + + Ok(BeaconState::Deneb(post)) +} + +/// Upgrades a deneb state to electra's shape. +/// +/// Transcribed from `specs/electra/fork.md`'s `upgrade_to_electra`. Fields +/// through `historical_summaries` carry over unchanged; the nine electra +/// appends need more than a copy, because EIP-7251 changes what "how much +/// stake may enter or leave the validator set this epoch" even means. +/// +/// Before electra, activation and exit churn is a *count* of validators: +/// every validator activates at the same effective balance, so bounding how +/// many validators move per epoch bounds how much stake moves too. EIP-7251 +/// lets a validator's effective balance grow past that floor (given a +/// compounding withdrawal credential), which breaks the equivalence: one +/// large validator activating could move as much stake as thousands of +/// ordinary ones, so the churn limit has to be denominated in balance +/// instead. Electra cannot apply that retroactively: a validator already +/// active before this fork was admitted under the old count-based +/// accounting, which kept no record of how much of its stake that admitted. +/// Two migrations below are what electra does instead of pretending +/// otherwise: +/// +/// - A validator not yet active (`activation_epoch` still +/// [`constants::FAR_FUTURE_EPOCH`]) has its whole balance zeroed and +/// reinstated as a synthetic [`electra::PendingDeposit`], via +/// [`crate::beacon::helpers::electra::queue_entire_balance_and_reset_validator`], +/// so it re-enters through the queue the balance-based churn limit +/// actually governs, instead of activating under rules that no longer +/// exist. +/// - A validator that already upgraded to a compounding withdrawal +/// credential, and so is already sitting above +/// [`preset::MIN_ACTIVATION_BALANCE`], has that excess queued the same way +/// by [`crate::beacon::helpers::electra::queue_excess_active_balance`]: the +/// balance is real and already credited, but the count-based accounting +/// that admitted it never subjected it to any churn limit at all, so it +/// has to pass through the new one now, after the fact, rather than being +/// grandfathered in as if it already had. +/// +/// The two churn fields this upgrade sets, `exit_balance_to_consume` and +/// `consolidation_balance_to_consume`, are computed by calling +/// [`crate::beacon::helpers::electra::get_activation_exit_churn_limit`] and +/// [`crate::beacon::helpers::electra::get_consolidation_churn_limit`] on `post` +/// itself, after every fork-invariant field (crucially, `validators` and +/// `balances`) has already been copied across from `pre`, not on `pre` +/// directly. The specification's own pseudocode does this too, for a reason +/// invisible from the field values alone: those two helpers read through +/// `&BeaconState`, matching against the electra variant internally, so they +/// cannot run at all until the state they are reading exists as +/// `BeaconState::Electra` rather than still being `pre`'s +/// `deneb::BeaconState`. Numerically the two states agree at this exact +/// point (the validator-and-balance migrations below have not run yet), but +/// the ordering the specification chose is load-bearing for a different +/// reason than the numbers: it is the earliest point at which a value of the +/// right *type* exists to call them with. +/// +/// [`crate::beacon::helpers::electra::switch_to_compounding_validator`] is not +/// called here: the specification calls it from a validator's own +/// credential-switch request, not from this fork boundary. +pub fn upgrade_to_electra(pre: &BeaconState, config: &Config) -> Result { + let pre = deneb_state_ref(pre, "upgrade_to_electra")?; + // Reused for every one of the specification's own repeated + // `get_current_epoch(pre)` calls below: `pre` is never mutated between + // them, so recomputing it each time would just recompute this same + // value. + let epoch = compute_epoch_at_slot(pre.slot); + + let fork = Fork { + previous_version: pre.fork.current_version, + current_version: config.electra_fork_version, + epoch, + }; + + let mut earliest_exit_epoch = compute_activation_exit_epoch(epoch); + for validator in pre.validators.iter() { + if validator.exit_epoch != constants::FAR_FUTURE_EPOCH + && validator.exit_epoch > earliest_exit_epoch + { + earliest_exit_epoch = validator.exit_epoch; + } + } + earliest_exit_epoch = earliest_exit_epoch + .checked_add(1) + .ok_or(Error::ArithmeticOverflow("earliest_exit_epoch + 1"))?; + + let post = electra::BeaconState { + genesis_time: pre.genesis_time, + genesis_validators_root: pre.genesis_validators_root, + slot: pre.slot, + fork, + latest_block_header: pre.latest_block_header.clone(), + block_roots: pre.block_roots.clone(), + state_roots: pre.state_roots.clone(), + historical_roots: pre.historical_roots.clone(), + eth1_data: pre.eth1_data.clone(), + eth1_data_votes: pre.eth1_data_votes.clone(), + eth1_deposit_index: pre.eth1_deposit_index, + validators: pre.validators.clone(), + balances: pre.balances.clone(), + randao_mixes: pre.randao_mixes.clone(), + slashings: pre.slashings.clone(), + previous_epoch_participation: pre.previous_epoch_participation.clone(), + current_epoch_participation: pre.current_epoch_participation.clone(), + justification_bits: pre.justification_bits.clone(), + previous_justified_checkpoint: pre.previous_justified_checkpoint, + current_justified_checkpoint: pre.current_justified_checkpoint, + finalized_checkpoint: pre.finalized_checkpoint, + inactivity_scores: pre.inactivity_scores.clone(), + current_sync_committee: pre.current_sync_committee.clone(), + next_sync_committee: pre.next_sync_committee.clone(), + latest_execution_payload_header: pre.latest_execution_payload_header.clone(), + next_withdrawal_index: pre.next_withdrawal_index, + next_withdrawal_validator_index: pre.next_withdrawal_validator_index, + historical_summaries: pre.historical_summaries.clone(), + // [New in Electra:EIP6110] + deposit_requests_start_index: constants::UNSET_DEPOSIT_REQUESTS_START_INDEX, + // [New in Electra:EIP7251] Never overwritten: unlike the two fields + // below, the specification has no post-construction assignment for + // this one. + deposit_balance_to_consume: 0, + // [New in Electra:EIP7251] Overwritten below, once `post` exists as + // an electra state; see this function's own doc for why that + // ordering is load-bearing. + exit_balance_to_consume: 0, + // [New in Electra:EIP7251] + earliest_exit_epoch, + // [New in Electra:EIP7251] Overwritten below; see + // `exit_balance_to_consume` above. + consolidation_balance_to_consume: 0, + // [New in Electra:EIP7251] + earliest_consolidation_epoch: compute_activation_exit_epoch(epoch), + // [New in Electra:EIP7251] + pending_deposits: Default::default(), + // [New in Electra:EIP7251] + pending_partial_withdrawals: Default::default(), + // [New in Electra:EIP7251] + pending_consolidations: Default::default(), + }; + let mut post = BeaconState::Electra(post); + + // Churn is computed from `post`, not `pre`: see this function's own doc + // for why the ordering is load-bearing even though the two states still + // agree numerically at this point. + let exit_balance_to_consume = + crate::beacon::helpers::electra::get_activation_exit_churn_limit(&post, config)?; + let consolidation_balance_to_consume = + crate::beacon::helpers::electra::get_consolidation_churn_limit(&post, config)?; + { + let mut fields = + crate::beacon::helpers::electra::electra_state(&mut post, "upgrade_to_electra")?; + *fields.exit_balance_to_consume_mut() = exit_balance_to_consume; + *fields.consolidation_balance_to_consume_mut() = consolidation_balance_to_consume; + } + + // Add validators that are not yet active to the pending deposit queue: + // see this function's own doc for why a not-yet-active validator cannot + // simply keep activating under rules the state no longer tracks. Sorted + // by `(activation_eligibility_epoch, index)`, matching the + // specification's own tie-break exactly. + let mut pre_activation: Vec = post + .validators() + .iter() + .enumerate() + .filter(|(_, validator)| validator.activation_epoch == constants::FAR_FUTURE_EPOCH) + .map(|(index, _)| index as ValidatorIndex) + .collect(); + pre_activation.sort_by_key(|&index| { + let eligibility_epoch = post + .validator(index) + .expect("index was read from post.validators() above") + .activation_eligibility_epoch; + (eligibility_epoch, index) + }); + for index in pre_activation { + crate::beacon::helpers::electra::queue_entire_balance_and_reset_validator( + &mut post, index, + )?; + } + + // Ensure early adopters of a compounding withdrawal credential go + // through the same activation churn: a fresh pass over every validator + // (including the ones the loop above already zeroed, matching the + // specification's own unconditional second loop) rather than folded into + // it, since compounding eligibility and pre-activation are independent + // conditions on the same registry. + let compounding_indices: Vec = post + .validators() + .iter() + .enumerate() + .filter(|(_, validator)| { + crate::beacon::helpers::electra::has_compounding_withdrawal_credential(validator) + }) + .map(|(index, _)| index as ValidatorIndex) + .collect(); + for index in compounding_indices { + crate::beacon::helpers::electra::queue_excess_active_balance(&mut post, index)?; + } + + Ok(post) +} + +/// Upgrades an electra state to fulu's shape. +/// +/// Transcribed from `specs/fulu/fork.md`'s `upgrade_to_fulu`. Every field +/// through `pending_consolidations` carries over unchanged; the one addition +/// is `proposer_lookahead`, filled by +/// [`crate::beacon::helpers::fulu::initialize_proposer_lookahead`] rather than left +/// empty, since [`crate::beacon::helpers::fulu::get_beacon_proposer_index`] reads it +/// as a lookup from the moment this fork activates and has no on-demand +/// fallback to compute it from if it were left blank (see that module's own +/// docs for why the two coexist rather than one calling the other). +/// +/// [`initialize_proposer_lookahead`](crate::beacon::helpers::fulu::initialize_proposer_lookahead) +/// is called on `pre` while it is still electra's shape, one statement before +/// a `proposer_lookahead` field exists anywhere in `post` to write into: it +/// reads through fork-invariant accessors only, never a fulu-only field, so +/// there is nothing circular about calling it on a pre-fulu state. +pub fn upgrade_to_fulu(pre: &BeaconState, config: &Config) -> Result { + let pre_state = electra_state_ref(pre, "upgrade_to_fulu")?; + let epoch = compute_epoch_at_slot(pre_state.slot); + + let fork = Fork { + previous_version: pre_state.fork.current_version, + current_version: config.fulu_fork_version, + epoch, + }; + + // Called on the original enum-typed `pre`, not `pre_state`: see this + // function's own doc for why that is safe one statement before `post`'s + // `proposer_lookahead` field exists. + let proposer_lookahead = crate::beacon::helpers::fulu::initialize_proposer_lookahead(pre)?; + + let post = fulu::BeaconState { + genesis_time: pre_state.genesis_time, + genesis_validators_root: pre_state.genesis_validators_root, + slot: pre_state.slot, + fork, + latest_block_header: pre_state.latest_block_header.clone(), + block_roots: pre_state.block_roots.clone(), + state_roots: pre_state.state_roots.clone(), + historical_roots: pre_state.historical_roots.clone(), + eth1_data: pre_state.eth1_data.clone(), + eth1_data_votes: pre_state.eth1_data_votes.clone(), + eth1_deposit_index: pre_state.eth1_deposit_index, + validators: pre_state.validators.clone(), + balances: pre_state.balances.clone(), + randao_mixes: pre_state.randao_mixes.clone(), + slashings: pre_state.slashings.clone(), + previous_epoch_participation: pre_state.previous_epoch_participation.clone(), + current_epoch_participation: pre_state.current_epoch_participation.clone(), + justification_bits: pre_state.justification_bits.clone(), + previous_justified_checkpoint: pre_state.previous_justified_checkpoint, + current_justified_checkpoint: pre_state.current_justified_checkpoint, + finalized_checkpoint: pre_state.finalized_checkpoint, + inactivity_scores: pre_state.inactivity_scores.clone(), + current_sync_committee: pre_state.current_sync_committee.clone(), + next_sync_committee: pre_state.next_sync_committee.clone(), + latest_execution_payload_header: pre_state.latest_execution_payload_header.clone(), + next_withdrawal_index: pre_state.next_withdrawal_index, + next_withdrawal_validator_index: pre_state.next_withdrawal_validator_index, + historical_summaries: pre_state.historical_summaries.clone(), + deposit_requests_start_index: pre_state.deposit_requests_start_index, + deposit_balance_to_consume: pre_state.deposit_balance_to_consume, + exit_balance_to_consume: pre_state.exit_balance_to_consume, + earliest_exit_epoch: pre_state.earliest_exit_epoch, + consolidation_balance_to_consume: pre_state.consolidation_balance_to_consume, + earliest_consolidation_epoch: pre_state.earliest_consolidation_epoch, + pending_deposits: pre_state.pending_deposits.clone(), + pending_partial_withdrawals: pre_state.pending_partial_withdrawals.clone(), + pending_consolidations: pre_state.pending_consolidations.clone(), + // [New in Fulu:EIP7917] + proposer_lookahead: proposer_lookahead.try_into()?, + }; + + Ok(BeaconState::Fulu(post)) +} + +/// Applies the fork upgrade that produces `to`'s state shape from `state`. +/// +/// Dispatches over the per-fork upgrade functions by [`ForkName`] so a caller +/// that only knows the target fork as a name, such as `process_slots` +/// crossing a fork boundary mid-loop, does not have to match on it itself. +/// +/// Rejects any `to` whose upgrade does not apply to `state`'s current shape, +/// which covers both ways a caller could misuse this: skipping a fork (asking +/// for capella's shape from a still-altair state, say) and going backwards +/// (asking for bellatrix's shape from a state already past it). Neither needs +/// a check here: every `upgrade_to_` function already requires its +/// specific predecessor's shape (via its own fork-state projection), so a +/// mismatched `to` fails inside whichever function this dispatches to, with +/// the same [`Error::UnsupportedForFork`] any other fork-mismatched call in +/// this module produces. +pub fn upgrade_state(state: &BeaconState, to: ForkName, config: &Config) -> Result { + match to { + ForkName::Phase0 => Err(Error::UnsupportedForFork { + function: "upgrade_state", + fork: to, + }), + ForkName::Altair => { + let pre = crate::beacon::stf::phase0_state_ref(state, "upgrade_state")?; + Ok(BeaconState::Altair(upgrade_to_altair(pre, config)?)) + } + ForkName::Bellatrix => upgrade_to_bellatrix(state, config), + ForkName::Capella => upgrade_to_capella(state, config), + ForkName::Deneb => upgrade_to_deneb(state, config), + ForkName::Electra => upgrade_to_electra(state, config), + ForkName::Fulu => upgrade_to_fulu(state, config), + // The `fork:` form, not `state:`: this dispatches on the requested + // target, so it is the argument that is wrong, not what `state` holds. + ForkName::Lean => lean_fork_unreachable("upgrade_state"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Extracts the phase0 state `with_validators` builds, for tests that need + /// the concrete struct `upgrade_to_altair` takes rather than the enum. + fn phase0_test_state(count: usize) -> phase0::BeaconState { + match crate::beacon::helpers::test_state::with_validators(count) { + BeaconState::Phase0(state) => state, + _ => panic!("with_validators returns a phase0 state"), + } + } + + #[test] + fn previous_version_carries_the_pre_states_current_version() { + let mut pre = phase0_test_state(4); + // `with_validators` leaves `fork` at its all-zero default, which would + // make `previous_version == current_version` trivially true even if + // the upgrade forgot to read `pre` at all. A distinct sentinel value + // rules that out. + pre.fork.current_version = [9, 9, 9, 9]; + + let config = Config::mainnet(); + let post = upgrade_to_altair(&pre, &config).expect("upgrade succeeds"); + + assert_eq!(post.fork.previous_version, [9, 9, 9, 9]); + assert_eq!(post.fork.current_version, config.altair_fork_version); + } + + #[test] + fn participation_and_inactivity_lists_have_one_entry_per_validator() { + let pre = phase0_test_state(7); + let config = Config::mainnet(); + + let post = upgrade_to_altair(&pre, &config).expect("upgrade succeeds"); + + assert_eq!( + post.previous_epoch_participation.len(), + pre.validators.len() + ); + assert_eq!(post.current_epoch_participation.len(), pre.validators.len()); + assert_eq!(post.inactivity_scores.len(), pre.validators.len()); + } + + /// Advances a fresh phase0 test state through every upgrade up to and + /// including `to`, via [`upgrade_state`] itself. + /// + /// Chaining the real upgrade functions rather than hand-writing a struct + /// literal for each fork keeps every fixture below a state some upgrade + /// function in this file actually produced, which is exactly the kind of + /// input the function under test in each case below is checked against. + fn advance_to(to: ForkName, count: usize) -> (BeaconState, Config) { + let config = Config::mainnet(); + let mut state = crate::beacon::helpers::test_state::with_validators(count); + for fork in ForkName::ALL.into_iter().skip(1) { + state = upgrade_state(&state, fork, &config).expect("upgrade succeeds"); + if fork == to { + break; + } + } + (state, config) + } + + #[test] + fn bellatrix_carries_altairs_fields_and_starts_an_empty_payload_header() { + let (altair_state, config) = advance_to(ForkName::Altair, 4); + let BeaconState::Altair(altair_state) = altair_state else { + panic!("advance_to(Altair) returns an altair state"); + }; + + let post = upgrade_to_bellatrix(&BeaconState::Altair(altair_state.clone()), &config) + .expect("upgrade succeeds"); + let BeaconState::Bellatrix(post) = post else { + panic!("upgrade_to_bellatrix returns a bellatrix state"); + }; + + assert_eq!( + post.fork.previous_version, + altair_state.fork.current_version + ); + assert_eq!(post.fork.current_version, config.bellatrix_fork_version); + assert_eq!(post.validators.len(), altair_state.validators.len()); + assert_eq!(post.genesis_time, altair_state.genesis_time); + assert_eq!(post.latest_execution_payload_header.block_number, 0); + assert!(post.latest_execution_payload_header.block_hash.is_zero()); + } + + #[test] + fn bellatrix_rejects_a_state_that_skipped_altair() { + let phase0_state = crate::beacon::helpers::test_state::with_validators(4); + let config = Config::mainnet(); + assert!(upgrade_to_bellatrix(&phase0_state, &config).is_err()); + } + + #[test] + fn capella_carries_the_execution_header_across_and_leaves_historical_roots_alone() { + let (bellatrix_state, config) = advance_to(ForkName::Bellatrix, 4); + let BeaconState::Bellatrix(mut bellatrix_state) = bellatrix_state else { + panic!("advance_to(Bellatrix) returns a bellatrix state"); + }; + // A field genuinely carried across, distinguishable from the header's + // otherwise all-zero starting value. + bellatrix_state.latest_execution_payload_header.block_number = 42; + bellatrix_state + .historical_roots + .push(crate::beacon::primitives::Root::repeat_byte(7)) + .expect("well below HISTORICAL_ROOTS_LIMIT"); + + let post = upgrade_to_capella(&BeaconState::Bellatrix(bellatrix_state.clone()), &config) + .expect("upgrade succeeds"); + let BeaconState::Capella(post) = post else { + panic!("upgrade_to_capella returns a capella state"); + }; + + assert_eq!(post.fork.current_version, config.capella_fork_version); + assert_eq!(post.latest_execution_payload_header.block_number, 42); + assert!( + post.latest_execution_payload_header + .withdrawals_root + .is_zero() + ); + // Left alone, not truncated: capella freezes `historical_roots` in + // place rather than clearing it, since `historical_summaries` is + // where new history accumulates from here on. + assert_eq!( + post.historical_roots.into_inner(), + bellatrix_state.historical_roots.into_inner() + ); + assert_eq!(post.next_withdrawal_index, 0); + assert_eq!(post.next_withdrawal_validator_index, 0); + assert!(post.historical_summaries.is_empty()); + } + + #[test] + fn capella_rejects_a_state_that_skipped_bellatrix() { + let (altair_state, config) = advance_to(ForkName::Altair, 4); + assert!(upgrade_to_capella(&altair_state, &config).is_err()); + } + + #[test] + fn deneb_gains_blob_gas_fields_at_zero_and_carries_withdrawals_root() { + let (capella_state, config) = advance_to(ForkName::Capella, 4); + let BeaconState::Capella(mut capella_state) = capella_state else { + panic!("advance_to(Capella) returns a capella state"); + }; + capella_state + .latest_execution_payload_header + .withdrawals_root = crate::beacon::primitives::Root::repeat_byte(3); + capella_state.next_withdrawal_index = 5; + + let post = upgrade_to_deneb(&BeaconState::Capella(capella_state.clone()), &config) + .expect("upgrade succeeds"); + let BeaconState::Deneb(post) = post else { + panic!("upgrade_to_deneb returns a deneb state"); + }; + + assert_eq!(post.fork.current_version, config.deneb_fork_version); + assert_eq!(post.latest_execution_payload_header.blob_gas_used, 0); + assert_eq!(post.latest_execution_payload_header.excess_blob_gas, 0); + assert_eq!( + post.latest_execution_payload_header.withdrawals_root, + capella_state + .latest_execution_payload_header + .withdrawals_root + ); + // The state's own field count does not change at this fork: only + // `latest_execution_payload_header`'s shape does. + assert_eq!( + post.next_withdrawal_index, + capella_state.next_withdrawal_index + ); + } + + #[test] + fn deneb_rejects_a_state_that_skipped_capella() { + let (bellatrix_state, config) = advance_to(ForkName::Bellatrix, 4); + assert!(upgrade_to_deneb(&bellatrix_state, &config).is_err()); + } + + #[test] + fn upgrade_state_rejects_phase0_as_a_target() { + let phase0_state = crate::beacon::helpers::test_state::with_validators(4); + let config = Config::mainnet(); + assert!(upgrade_state(&phase0_state, ForkName::Phase0, &config).is_err()); + } + + #[test] + fn upgrade_state_rejects_going_backwards() { + let (deneb_state, config) = advance_to(ForkName::Deneb, 4); + // `deneb_state` is already past bellatrix; asking to upgrade it *to* + // bellatrix is a backwards move, and must fail the same way skipping + // a fork does. + assert!(upgrade_state(&deneb_state, ForkName::Bellatrix, &config).is_err()); + } + + #[test] + fn electra_sets_the_fork_version_and_deposit_requests_sentinel() { + let (deneb_state, config) = advance_to(ForkName::Deneb, 4); + let post = upgrade_to_electra(&deneb_state, &config).expect("upgrade succeeds"); + let BeaconState::Electra(post) = post else { + panic!("upgrade_to_electra returns an electra state"); + }; + + assert_eq!(post.fork.current_version, config.electra_fork_version); + assert_eq!( + post.deposit_requests_start_index, + constants::UNSET_DEPOSIT_REQUESTS_START_INDEX + ); + // Never overwritten by this upgrade; see its own doc. + assert_eq!(post.deposit_balance_to_consume, 0); + } + + #[test] + fn electra_earliest_exit_epoch_tracks_the_largest_pending_exit() { + let (deneb_state, config) = advance_to(ForkName::Deneb, 4); + let BeaconState::Deneb(mut deneb_state) = deneb_state else { + panic!("advance_to(Deneb) returns a deneb state"); + }; + let epoch = compute_epoch_at_slot(deneb_state.slot); + let default_earliest_exit_epoch = compute_activation_exit_epoch(epoch); + // Comfortably past the default, so the scan over `pre.validators` + // below is what has to produce this value, not the epoch-only + // fallback every other validator would leave it at. + let sentinel_exit_epoch = default_earliest_exit_epoch + 100; + deneb_state.validators[0].exit_epoch = sentinel_exit_epoch; + + let post = upgrade_to_electra(&BeaconState::Deneb(deneb_state), &config) + .expect("upgrade succeeds"); + let BeaconState::Electra(post) = post else { + panic!("upgrade_to_electra returns an electra state"); + }; + + assert_eq!(post.earliest_exit_epoch, sentinel_exit_epoch + 1); + } + + #[test] + fn electra_migrates_a_not_yet_active_validator_into_pending_deposits() { + let (deneb_state, config) = advance_to(ForkName::Deneb, 4); + let BeaconState::Deneb(mut deneb_state) = deneb_state else { + panic!("advance_to(Deneb) returns a deneb state"); + }; + let migrated_pubkey = deneb_state.validators[0].pubkey; + let migrated_credentials = deneb_state.validators[0].withdrawal_credentials; + deneb_state.validators[0].activation_epoch = constants::FAR_FUTURE_EPOCH; + deneb_state.balances[0] = 1_000_000_000; + + let post = upgrade_to_electra(&BeaconState::Deneb(deneb_state), &config) + .expect("upgrade succeeds"); + let BeaconState::Electra(post) = post else { + panic!("upgrade_to_electra returns an electra state"); + }; + + assert_eq!(post.balances[0], 0); + assert_eq!(post.validators[0].effective_balance, 0); + assert_eq!( + post.validators[0].activation_eligibility_epoch, + constants::FAR_FUTURE_EPOCH + ); + assert_eq!(post.pending_deposits.len(), 1); + let deposit = &post.pending_deposits[0]; + assert_eq!(deposit.amount, 1_000_000_000); + assert_eq!(deposit.pubkey, migrated_pubkey); + assert_eq!(deposit.withdrawal_credentials, migrated_credentials); + } + + #[test] + fn electra_churn_fields_are_populated_from_the_post_state() { + let (deneb_state, config) = advance_to(ForkName::Deneb, 4); + let post = upgrade_to_electra(&deneb_state, &config).expect("upgrade succeeds"); + let BeaconState::Electra(electra_post) = &post else { + panic!("upgrade_to_electra returns an electra state"); + }; + + // None of `advance_to`'s validators are pre-activation or + // compounding, so both migrations below ran as no-ops and total + // active balance is unchanged from the moment churn was computed: + // recomputing the churn limits against the finished state must + // reproduce exactly what `upgrade_to_electra` already stored, which + // is what proves the two calls were actually wired in rather than + // leaving the fields at their zero placeholder. + let expected_exit_churn = + crate::beacon::helpers::electra::get_activation_exit_churn_limit(&post, &config) + .unwrap(); + let expected_consolidation_churn = + crate::beacon::helpers::electra::get_consolidation_churn_limit(&post, &config).unwrap(); + + assert_eq!(electra_post.exit_balance_to_consume, expected_exit_churn); + assert_eq!( + electra_post.consolidation_balance_to_consume, + expected_consolidation_churn + ); + assert!(expected_exit_churn > 0); + } + + #[test] + fn electra_rejects_a_state_that_skipped_deneb() { + let (capella_state, config) = advance_to(ForkName::Capella, 4); + assert!(upgrade_to_electra(&capella_state, &config).is_err()); + } + + #[test] + fn fulu_proposer_lookahead_matches_initialize_proposer_lookahead() { + let (electra_state, config) = advance_to(ForkName::Electra, 4); + let post = upgrade_to_fulu(&electra_state, &config).expect("upgrade succeeds"); + let BeaconState::Fulu(post) = post else { + panic!("upgrade_to_fulu returns a fulu state"); + }; + + let expected = crate::beacon::helpers::fulu::initialize_proposer_lookahead(&electra_state) + .expect("initialize_proposer_lookahead succeeds"); + assert_eq!(post.proposer_lookahead.into_inner(), expected); + assert_eq!(post.fork.current_version, config.fulu_fork_version); + } + + #[test] + fn fulu_rejects_a_state_that_skipped_electra() { + let (deneb_state, config) = advance_to(ForkName::Deneb, 4); + assert!(upgrade_to_fulu(&deneb_state, &config).is_err()); + } + + #[test] + fn upgrade_state_walks_every_fork_boundary_end_to_end() { + // A cheap integration check on top of the per-function tests above: + // `advance_to` already exercises every arm of `upgrade_state`, so + // reaching fulu without an error proves the whole dispatch chain + // (not just each function in isolation) is wired correctly. + let (fulu_state, _config) = advance_to(ForkName::Fulu, 4); + assert_eq!(fulu_state.fork_name(), ForkName::Fulu); + } +} diff --git a/crates/blockchain/state_transition/src/lib.rs b/crates/blockchain/state_transition/src/lib.rs index cde9b615c..e924de347 100644 --- a/crates/blockchain/state_transition/src/lib.rs +++ b/crates/blockchain/state_transition/src/lib.rs @@ -10,6 +10,15 @@ use ethlambda_types::{ }; use tracing::{info, warn}; +/// The Ethereum **Beacon Chain** state transition, phase0 through fulu. +/// +/// A different protocol from the Lean consensus the rest of this crate +/// implements, kept in its own namespace for the same reason +/// `ethlambda_types::beacon` is: one crate can then hold both chains' rules, so +/// a caller dispatching on a state's fork reaches either without the two living +/// in separate dependency trees. Nothing above `beacon` here reads anything +/// inside it, and nothing inside it reads lean's own module tree. +pub mod beacon; pub mod justified_slots_ops; pub mod metrics; diff --git a/crates/blockchain/state_transition/src/metrics.rs b/crates/blockchain/state_transition/src/metrics.rs index 8df309099..e7da0ce85 100644 --- a/crates/blockchain/state_transition/src/metrics.rs +++ b/crates/blockchain/state_transition/src/metrics.rs @@ -45,6 +45,23 @@ pub fn inc_finalizations(result: &str) { LEAN_FINALIZATIONS_TOTAL.with_label_values(&[result]).inc(); } +static LEAN_BEACON_COMMITTEE_CACHE_LOOKUPS_TOTAL: LazyLock = LazyLock::new(|| { + register_int_counter_vec!( + "lean_beacon_committee_cache_lookups_total", + "Beacon committee shuffling lookups, by whether the committee cache served them", + &["result"] + ) + .unwrap() +}); + +/// Count one `CommitteeCache` lookup: `hit`, `miss` (a whole-epoch shuffle +/// was built and cached), or `unkeyable` (built for one caller, not cached). +pub fn inc_committee_cache_lookups(result: &str) { + LEAN_BEACON_COMMITTEE_CACHE_LOOKUPS_TOTAL + .with_label_values(&[result]) + .inc(); +} + static LEAN_STATE_TRANSITION_TIME_SECONDS: LazyLock = LazyLock::new(|| { register_histogram!( "lean_state_transition_time_seconds", @@ -103,3 +120,69 @@ pub fn time_block_processing() -> TimingGuard { pub fn time_attestations_processing() -> TimingGuard { TimingGuard::new(&LEAN_STATE_TRANSITION_ATTESTATIONS_PROCESSING_TIME_SECONDS) } + +static LEAN_BEACON_PUBKEY_CACHE_LOOKUPS_TOTAL: LazyLock = LazyLock::new(|| { + register_int_counter_vec!( + "lean_beacon_pubkey_cache_lookups_total", + "BLS public keys resolved for a signature check, by whether the validated-pubkey cache \ + already held them", + &["result"] + ) + .unwrap() +}); + +// The two label values resolved once: a lookup is counted on every signature +// check, and the vector's own label lookup would hash the label every time. +static LEAN_BEACON_PUBKEY_CACHE_HITS: LazyLock = + LazyLock::new(|| LEAN_BEACON_PUBKEY_CACHE_LOOKUPS_TOTAL.with_label_values(&["hit"])); +static LEAN_BEACON_PUBKEY_CACHE_MISSES: LazyLock = + LazyLock::new(|| LEAN_BEACON_PUBKEY_CACHE_LOOKUPS_TOTAL.with_label_values(&["miss"])); + +static LEAN_BEACON_PUBKEY_CACHE_ENTRIES: LazyLock = LazyLock::new(|| { + register_int_gauge!( + "lean_beacon_pubkey_cache_entries", + "Validated BLS public keys held by the process-wide pubkey cache" + ) + .unwrap() +}); + +/// Count one signature check's public keys: `hits` the cache already held, +/// `misses` it had to validate (whether or not they then passed). +pub fn inc_pubkey_cache_lookups(hits: u64, misses: u64) { + if hits > 0 { + LEAN_BEACON_PUBKEY_CACHE_HITS.inc_by(hits); + } + if misses > 0 { + LEAN_BEACON_PUBKEY_CACHE_MISSES.inc_by(misses); + } +} + +/// Count one key added to the pubkey cache. The cache never removes one. +pub fn inc_pubkey_cache_entries() { + LEAN_BEACON_PUBKEY_CACHE_ENTRIES.inc(); +} + +static LEAN_DATA_COLUMN_KZG_VERIFY_SECONDS: LazyLock = LazyLock::new(|| { + register_histogram!( + "lean_data_column_kzg_verify_seconds", + "Time spent batch-verifying one sidecar's cells against its own commitments", + vec![0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0] + ) + .unwrap() +}); + +/// Start timing a sidecar's KZG cell-proof batch. Records duration when the +/// guard is dropped. +/// +/// Here rather than beside the rest of the column metrics, because the batch +/// runs inside `beacon::gossip::column`'s rules, which both the gossip path +/// and the p2p layer's chain checks call. +pub fn time_data_column_kzg_verify() -> TimingGuard { + TimingGuard::new(&LEAN_DATA_COLUMN_KZG_VERIFY_SECONDS) +} + +/// Register the metrics above that should be visible before their first +/// observation. +pub fn init() { + LazyLock::force(&LEAN_DATA_COLUMN_KZG_VERIFY_SECONDS); +} diff --git a/crates/blockchain/state_transition/tests/beacon_spec/bls.rs b/crates/blockchain/state_transition/tests/beacon_spec/bls.rs new file mode 100644 index 000000000..df6371240 --- /dev/null +++ b/crates/blockchain/state_transition/tests/beacon_spec/bls.rs @@ -0,0 +1,175 @@ +//! The `bls` runner. +//! +//! Covers the two BLS handlers the release ships: `eth_aggregate_pubkeys` +//! (summing a list of public keys into one) and `eth_fast_aggregate_verify` +//! (verifying an aggregate signature against a shared message, with the eth2 +//! empty-committee carve-out). Both live in `crates/blockchain/state_transition/src/beacon/bls.rs`; see +//! that module's own doc for why they return the shapes they do +//! (`crate::Result` for aggregation, plain `bool` for verification). +//! +//! # Why `general`, not a preset +//! +//! Every other runner in this harness picks its fixture tree by [`super::PRESET`], +//! because the functions it exercises read a preset constant somewhere in their +//! call graph (committee sizes, epoch lengths, and so on). Neither BLS handler +//! does: aggregating public keys and checking a pairing equation are BLS12-381 +//! operations with no notion of a validator count or a slots-per-epoch. The +//! release ships exactly one copy of these fixtures, under `general`, rather +//! than one per preset, which is what `general` signals across this whole +//! fixture tree (see `crates/blockchain/state_transition/tests/beacon_spec/mod.rs`'s module doc): a suite +//! that does not vary with the compiled-in preset. +//! +//! # What `output: null` means +//! +//! `eth_aggregate_pubkeys` can fail: the specification asserts `len(pubkeys) > 0` +//! and that every key passes `KeyValidate` before summing, and a fixture case +//! whose `output` is `null` is exactly one of those assertions firing (an empty +//! list, the all-zero encoding, a malformed compression flag, or the point at +//! infinity, which fails `KeyValidate`'s non-identity check). That is the same +//! contract [`super::check_transition`] enforces for a case with no `post` +//! state: absence of the success value is the expectation, not a gap in the +//! fixture, so a run that returns `Ok` for such a case is exactly as wrong as +//! one that returns the wrong pubkey. `eth_fast_aggregate_verify` has no such +//! case: it returns a plain `bool` because the specification gives it no failure +//! mode distinct from "the check did not pass", so every fixture under that +//! handler ships a `true` or `false`, never a `null`. + +use ethlambda_state_transition::beacon::bls; +use ethlambda_state_transition::beacon::primitives::{ + BLS_PUBKEY_SIZE, BlsPubkey, BlsSignature, Root, +}; +use libtest_mimic::Trial; + +/// `eth_aggregate_pubkeys///data.yaml`'s shape: a list of hex +/// pubkeys in, one hex pubkey out, or `null` when the input must be rejected. +#[derive(serde::Deserialize)] +struct AggregatePubkeysCase { + input: Vec, + output: Option, +} + +/// `eth_fast_aggregate_verify///data.yaml`'s `input` key: the +/// pubkeys and message the signature is checked against. +#[derive(serde::Deserialize)] +struct FastAggregateVerifyInput { + pubkeys: Vec, + message: String, + signature: String, +} + +/// `eth_fast_aggregate_verify///data.yaml`'s shape. `output` is +/// always a plain bool; see the module doc for why this handler has no `null` +/// case the way `eth_aggregate_pubkeys` does. +#[derive(serde::Deserialize)] +struct FastAggregateVerifyCase { + input: FastAggregateVerifyInput, + output: bool, +} + +/// Decodes a `0x`-prefixed hex string into raw bytes. +fn decode_hex(value: &str) -> Result, String> { + let digits = value.strip_prefix("0x").unwrap_or(value); + hex::decode(digits).map_err(|err| format!("invalid hex `{value}`: {err}")) +} + +/// Decodes a `0x`-prefixed hex string into an exact-size byte array. +/// +/// Every fixed-length value these fixtures carry (pubkeys, signatures) has a +/// named size in [`ethlambda_state_transition::beacon::primitives`], so a mismatch is a fixture +/// bug worth reporting through the case's own failure message rather than +/// panicking the whole run on it. +fn parse_hex(value: &str) -> Result<[u8; N], String> { + decode_hex(value)? + .try_into() + .map_err(|bytes: Vec| format!("expected {N} bytes, got {} in `{value}`", bytes.len())) +} + +/// Runs one `eth_aggregate_pubkeys` case. +fn eth_aggregate_pubkeys_case(case: &super::Case) -> Result<(), String> { + let fixture: AggregatePubkeysCase = case.yaml("data"); + + let mut pubkeys = Vec::with_capacity(fixture.input.len()); + for hex in &fixture.input { + pubkeys.push(BlsPubkey(parse_hex(hex)?)); + } + + let result = bls::eth_aggregate_pubkeys(&pubkeys); + + match fixture.output { + // A hex pubkey: aggregation must succeed and land exactly on it. + Some(expected_hex) => { + let expected: [u8; BLS_PUBKEY_SIZE] = parse_hex(&expected_hex)?; + let actual = result.map_err(|err| format!("expected Ok(pubkey), got Err({err})"))?; + if actual.0 != expected { + return Err(format!( + "aggregated to 0x{}, expected 0x{}", + hex::encode(actual.0), + hex::encode(expected) + )); + } + Ok(()) + } + // `output: null`: one of the spec's own assertions must have rejected + // this input (see the module doc). Accepting it here would mean the + // implementation is looser than the spec, not that the fixture allows + // it either way. + None => { + if result.is_ok() { + return Err( + "accepted, but the fixture's output is null, so it must be rejected" + .to_string(), + ); + } + Ok(()) + } + } +} + +/// Runs one `eth_fast_aggregate_verify` case. +fn eth_fast_aggregate_verify_case(case: &super::Case) -> Result<(), String> { + let fixture: FastAggregateVerifyCase = case.yaml("data"); + + let mut pubkeys = Vec::with_capacity(fixture.input.pubkeys.len()); + for hex in &fixture.input.pubkeys { + pubkeys.push(BlsPubkey(parse_hex(hex)?)); + } + // `Root` (an alias of `H256`) has no fixed-size-array constructor of its + // own to call through the alias, so this goes through `from_slice` + // instead, matching how the rest of this harness turns a hex root into + // one (see `shuffling.rs`). + let message = Root::from_slice(&decode_hex(&fixture.input.message)?); + let signature = BlsSignature(parse_hex(&fixture.input.signature)?); + + let actual = bls::eth_fast_aggregate_verify(&pubkeys, message, &signature); + if actual != fixture.output { + return Err(format!( + "eth_fast_aggregate_verify returned {actual}, expected {}", + fixture.output + )); + } + Ok(()) +} + +/// This suite is not gated here: [`super::case_trial`] applies +/// [`super::Case::in_scope`]'s gate itself, and every case this handler +/// collects lands under altair, which is always in scope, so nothing here +/// needs to check the fork. +pub fn trials() -> Vec { + let cases = super::collect_all_handlers("general", "bls"); + let mut trials = vec![super::discovery_trial("bls", cases.len())]; + + for (handler, case) in cases { + trials.push(super::case_trial("bls", case, move |case| { + match handler.as_str() { + "eth_aggregate_pubkeys" => eth_aggregate_pubkeys_case(case), + "eth_fast_aggregate_verify" => eth_fast_aggregate_verify_case(case), + // A release that adds a handler must fail loudly here rather + // than silently matching zero cases, the same rule + // `epoch_processing`'s own dispatch follows. + other => Err(format!("unhandled bls handler `{other}`")), + } + })); + } + + trials +} diff --git a/crates/blockchain/state_transition/tests/beacon_spec/epoch_processing.rs b/crates/blockchain/state_transition/tests/beacon_spec/epoch_processing.rs new file mode 100644 index 000000000..2df06c9b8 --- /dev/null +++ b/crates/blockchain/state_transition/tests/beacon_spec/epoch_processing.rs @@ -0,0 +1,236 @@ +//! The `epoch_processing` runner. +//! +//! Each handler names one step of epoch processing and each case runs that step +//! alone against a `pre` state. Testing the steps in isolation is what makes a +//! failure here diagnostic: the `sanity` and `finality` suites run whole blocks, +//! so a wrong rounding in one step surfaces there as an opaque state root +//! mismatch, while here it names the step. +//! +//! Altair keeps most of phase0's steps unchanged (`registry_updates`, +//! `slashings`, and the four resets, plus `historical_roots_update`, which does +//! not become `historical_summaries_update` until capella) and replaces two +//! outright: `justification_and_finalization` now reads participation flags +//! instead of replaying `PendingAttestation`s, and `rewards_and_penalties` scores +//! those same flags instead of the five phase0 delta components. It also adds +//! three steps of its own (`inactivity_updates`, `participation_flag_updates`, +//! `sync_committee_updates`) that have no phase0 handler at all, so [`apply`] +//! matches on `(handler, fork)` rather than on the handler name alone wherever a +//! handler's function actually differs between the two. +//! +//! Later forks split further along that same `(handler, fork)` axis, and each +//! boundary below is transcribed from `beacon-chain.md`'s own "Modified in +//! " markers rather than assumed: +//! +//! - `registry_updates`: deneb's own change (EIP-7514's activation-churn cap) +//! is selected internally, by fork, inside `registry::process_registry_updates` +//! itself, so phase0 through deneb still share one call here. Electra +//! rewrites the function outright around a balance budget instead of a +//! per-epoch headcount, and fulu never revives the older rule, so both route +//! to electra's version instead. +//! - `effective_balance_updates`: phase0 through deneb round every validator +//! toward the one ceiling every validator shared before EIP-7251. Electra's +//! version reads a per-validator ceiling instead, since a compounding +//! validator's can be far higher, and fulu never reverts that, so both route +//! to electra's version too. +//! - `historical_roots_update` becomes `historical_summaries_update`, a +//! different handler name rather than a different fork's version of this +//! one, starting at capella. +//! - Electra adds `pending_deposits` and `pending_consolidations`, with no +//! earlier-fork counterpart at all; fulu carries both forward unchanged and +//! adds `proposer_lookahead` of its own, likewise with no earlier-fork +//! counterpart. + +use std::sync::Arc; + +use ethlambda_state_transition::beacon::ForkName; +use ethlambda_state_transition::beacon::config::Config; +use ethlambda_state_transition::beacon::containers::BeaconState; +use ethlambda_state_transition::beacon::stf::epoch; +use libtest_mimic::{Failed, Trial}; + +use super::{Case, PRESET, collect_all_handlers}; + +/// Runs the single epoch-processing step the handler names. +fn apply( + handler: &str, + fork: ForkName, + state: &mut BeaconState, + config: &Config, +) -> Result<(), String> { + let outcome = match handler { + // `epoch::process_justification_and_finalization` dispatches on + // `state.fork_name()` itself, unlike `rewards_and_penalties` below, + // which has no such wrapper yet: calling it here rather than reaching + // into `epoch::justification` (phase0-only) or `epoch::altair` + // directly is what keeps this arm correct as later forks add their + // own justification changes, with no edit needed here when they do. + "justification_and_finalization" => { + epoch::process_justification_and_finalization(state, config) + } + // Altair rewrites this to score participation flags instead of + // replaying `PendingAttestation`s. No later fork's `beacon-chain.md` + // touches it again: bellatrix, capella, deneb, electra, and fulu each + // call it straight through as part of reusing altair's larger driver + // (see each fork module's own doc), so altair's version serves + // everything from altair on, the same boundary + // `process_justification_and_finalization` above already encodes for + // its own step. + "rewards_and_penalties" => match fork { + ForkName::Phase0 => epoch::rewards::process_rewards_and_penalties(state, config), + _ => epoch::altair::process_rewards_and_penalties(state, config), + }, + // Deneb's own change (EIP-7514's activation-churn cap) is selected + // internally, by fork, inside `process_registry_updates` itself, so + // phase0 through deneb share one call here. Electra replaces the + // function outright with a balance-budget version, and fulu never + // revives the validator-count rule after that, so both route to + // electra's instead: the shared function refuses to run for either on + // purpose (`Error::UnsupportedForFork`), rather than silently applying + // deneb's superseded rule, so routing them here would fail loudly, not + // incorrectly. + "registry_updates" => match fork { + ForkName::Electra | ForkName::Fulu => { + epoch::electra::process_registry_updates(state, config) + } + _ => epoch::registry::process_registry_updates(state, config), + }, + // Altair's and bellatrix's own changes here are scoped to the + // proportional multiplier `registry::process_slashings` already selects + // by fork through `preset::retuned`, so one copy serves them. Electra is + // different: it restructures the division itself, dividing the adjusted + // slashing balance by the increment count once and multiplying per + // validator, rather than dividing by the total balance per validator and + // multiplying by the increment at the end. Those disagree under integer + // rounding, so electra and fulu need their own function, not just their + // own constant. + "slashings" => match fork { + ForkName::Electra | ForkName::Fulu => epoch::electra::process_slashings(state, config), + _ => epoch::registry::process_slashings(state, config), + }, + "eth1_data_reset" => epoch::process_eth1_data_reset(state), + // Phase0 through deneb round each validator toward the single ceiling + // every validator shared before EIP-7251. Electra's version instead + // reads a per-validator ceiling (`get_max_effective_balance`), since a + // compounding validator's can be far higher, and fulu never reverts + // that, so both route to electra's. + "effective_balance_updates" => match fork { + ForkName::Electra | ForkName::Fulu => { + epoch::electra::process_effective_balance_updates(state) + } + _ => epoch::process_effective_balance_updates(state), + }, + "slashings_reset" => epoch::process_slashings_reset(state), + "randao_mixes_reset" => epoch::process_randao_mixes_reset(state), + // Still `historical_roots_update` through bellatrix; the specification + // renames this to `historical_summaries_update` starting at capella, + // which is a different handler name, not a different fork's version + // of this one. + "historical_roots_update" => epoch::process_historical_roots_update(state), + // New in capella, replacing `historical_roots_update` above, and + // carried unchanged through every later fork: `historical_summaries_mut` + // itself accepts capella, deneb, electra, and fulu states alike (see + // its own doc for why), so one call here serves all four. + "historical_summaries_update" => epoch::capella::process_historical_summaries_update(state), + // Phase0 only: altair replaces the backlog this replays with + // participation flags, which have no epoch-end "roll the backlog + // over" step of their own to speak of here (see + // `participation_flag_updates` below). + "participation_record_updates" => epoch::process_participation_record_updates(state), + // New in altair; no phase0 case ever reaches these, and no later + // fork's own `process_epoch` redefines any of the three (each calls + // straight through to `epoch::altair`, per its own module doc), so one + // call per handler serves every fork that has it. + "inactivity_updates" => epoch::altair::process_inactivity_updates(state, config), + "participation_flag_updates" => epoch::altair::process_participation_flag_updates(state), + "sync_committee_updates" => epoch::altair::process_sync_committee_updates(state), + // New in electra (EIP-7251); fulu carries both pending queues forward + // unchanged, calling the very same functions (see + // `electra::process_epoch`'s own doc for why fulu's driver needs no + // rewrite of either). + "pending_deposits" => epoch::electra::process_pending_deposits(state, config), + "pending_consolidations" => epoch::electra::process_pending_consolidations(state, config), + // New in fulu (EIP-7917); no earlier fork has this handler at all. + "proposer_lookahead" => epoch::fulu::process_proposer_lookahead(state), + other => return Err(format!("unhandled epoch step `{other}`")), + }; + + outcome.map_err(|err| format!("{err:?}")) +} + +pub fn trials() -> Vec { + let config = Arc::new(Config::active()); + let cases = collect_all_handlers(PRESET, "epoch_processing"); + let mut trials = vec![super::discovery_trial("epoch_processing", cases.len())]; + + for (handler, case) in cases { + let config = Arc::clone(&config); + trials.push(super::case_trial("epoch_processing", case, move |case| { + let mut state = BeaconState::from_ssz(case.fork, &case.ssz_bytes("pre")) + .map_err(|err| format!("the fixture's pre-state does not decode: {err:?}"))?; + + let outcome = apply(&handler, case.fork, &mut state, &config); + super::check_transition(case, outcome, &state) + })); + } + + trials.push(every_shipped_handler_is_dispatched()); + + trials +} + +/// Builds the trial checking that every handler the fixture release ships is +/// dispatched by [`apply`]. +/// +/// A missing arm in `apply` would otherwise be reported per case as a failure, +/// which is correct but noisy. This checks the set of handlers is the one the +/// runner knows about, so a fixture release that adds a step fails here, once, +/// with a clear message. The list is flat across forks (it does not say which +/// handler belongs to which fork) because that is exactly what [`apply`]'s +/// `(handler, fork)` match already encodes and enforces at run time; duplicating +/// it here would only give the two a chance to drift apart. +/// +/// Built with [`Trial::test`] directly rather than [`super::case_trial`], +/// since it checks the whole handler set rather than one fixture case. +fn every_shipped_handler_is_dispatched() -> Trial { + Trial::test( + "epoch_processing/every_shipped_handler_is_dispatched", + || { + let known = [ + "justification_and_finalization", + "rewards_and_penalties", + "registry_updates", + "slashings", + "eth1_data_reset", + "effective_balance_updates", + "slashings_reset", + "randao_mixes_reset", + "historical_roots_update", + "historical_summaries_update", + "participation_record_updates", + "inactivity_updates", + "participation_flag_updates", + "sync_committee_updates", + "pending_deposits", + "pending_consolidations", + "proposer_lookahead", + ]; + + let mut unknown: Vec = collect_all_handlers(PRESET, "epoch_processing") + .into_iter() + .filter(|(_, case): &(String, Case)| case.in_scope()) + .map(|(handler, _)| handler) + .filter(|handler| !known.contains(&handler.as_str())) + .collect(); + unknown.sort_unstable(); + unknown.dedup(); + + if !unknown.is_empty() { + return Err(Failed::from(format!( + "the fixture release ships epoch steps this runner does not dispatch: {unknown:?}" + ))); + } + + Ok(()) + }, + ) +} diff --git a/crates/blockchain/state_transition/tests/beacon_spec/fork.rs b/crates/blockchain/state_transition/tests/beacon_spec/fork.rs new file mode 100644 index 000000000..7a51b7311 --- /dev/null +++ b/crates/blockchain/state_transition/tests/beacon_spec/fork.rs @@ -0,0 +1,92 @@ +//! The `fork` runner, handler `fork`. +//! +//! Each case ships a `pre` state in the *previous* fork's shape and a `post` +//! state in the *new* fork's shape, with no block: the case is purely a check +//! of the irregular state change itself. `case.fork` names the target fork +//! (the directory this crate's harness walks is `tests///...`, +//! where `` is the one the case upgrades *to*), matching every case's +//! `meta.yaml`, which carries the same name under its own `fork` key. The +//! source fork is not carried anywhere in the fixture beyond that: it is +//! always `ForkName::previous` of the target, since every upgrade in the +//! specification is defined from the one fork immediately before it. +//! +//! Gated on [`super::HIGHEST_IMPLEMENTED_FORK`] like every other runner, so +//! cases whose target this crate does not implement yet are counted as +//! skipped rather than silently dropped, the way `ssz_static` counts +//! container/fork pairs it does not decode. [`super::HIGHEST_IMPLEMENTED_FORK`] +//! is now fulu, and [`ethlambda_state_transition::beacon::upgrade::upgrade_state`] routes every +//! fork through its own `upgrade_to_*` function, so the two agree by +//! construction all the way to fulu; the two are still checking different +//! things, though: this gate is "has this crate's state transition caught up +//! to this fork at all," which is deliberately conservative, since running a +//! fork's upgrade before its state transition exists would let this suite go +//! green on a fork nothing else here can actually process yet. +//! +//! This exercises [`ethlambda_state_transition::beacon::upgrade::upgrade_state`] rather than each +//! fork's own `upgrade_to_*` function directly, so the dispatch by +//! `ForkName` gets fixture coverage too, not just the per-fork function it +//! delegates to. That is also what makes picking up a later fork here a +//! one-line change once its own `upgrade_to_*` lands: `upgrade_state` already +//! routes to it by name, so once [`super::HIGHEST_IMPLEMENTED_FORK`] moves +//! past this fork, [`fork_case`] finds its `pre` and `post` shapes on its own. + +use std::sync::Arc; + +use ethlambda_state_transition::beacon::config::Config; +use ethlambda_state_transition::beacon::containers::BeaconState; +use ethlambda_state_transition::beacon::upgrade::upgrade_state; +use libtest_mimic::Trial; + +use super::{Case, PRESET, collect}; + +/// Runs one case: decode `pre` in the source fork's shape, upgrade it, and +/// compare against `post` by `hash_tree_root`. +/// +/// Does not go through `super::check_transition`: that helper assumes the +/// post-state has the same fork as the pre-state, which is exactly what a +/// fork-upgrade case never does, so it is reimplemented here without the +/// `has("post")` branch that a rejection-capable runner needs. Every case in +/// this handler ships a `post`; none of the specification's `upgrade_to_*` +/// functions have an assertion that can fail on a wellformed pre-state, so +/// there is no rejection case to model. +fn fork_case(case: &Case, config: &Config) -> Result<(), String> { + let source = case + .fork + .previous() + .ok_or_else(|| format!("fork `{}` has no previous fork to upgrade from", case.fork))?; + let pre = BeaconState::from_ssz(source, &case.ssz_bytes("pre")) + .map_err(|err| format!("decoding the fixture's pre-state: {err:?}"))?; + + let actual = + upgrade_state(&pre, case.fork, config).map_err(|err| format!("upgrade_state: {err}"))?; + + let expected = BeaconState::from_ssz(case.fork, &case.ssz_bytes("post")) + .map_err(|err| format!("decoding the fixture's post-state: {err:?}"))?; + + let actual_root = actual.hash_tree_root(); + let expected_root = expected.hash_tree_root(); + if actual_root != expected_root { + return Err(format!( + "post-state root 0x{} != expected 0x{}", + hex::encode(actual_root.0), + hex::encode(expected_root.0) + )); + } + + Ok(()) +} + +pub fn trials() -> Vec { + let config = Arc::new(Config::active()); + let cases = collect(PRESET, "fork", "fork"); + let mut trials = vec![super::discovery_trial("fork", cases.len())]; + + for case in cases { + let config = Arc::clone(&config); + trials.push(super::case_trial("fork", case, move |case| { + fork_case(case, &config) + })); + } + + trials +} diff --git a/crates/blockchain/state_transition/tests/beacon_spec/fork_choice.rs b/crates/blockchain/state_transition/tests/beacon_spec/fork_choice.rs new file mode 100644 index 000000000..87eb3ff97 --- /dev/null +++ b/crates/blockchain/state_transition/tests/beacon_spec/fork_choice.rs @@ -0,0 +1,762 @@ +//! The `fork_choice` runner. +//! +//! Exercises `specs/phase0/fork-choice.md` end to end, plus every later +//! fork's modifications to it. Each case builds a [`Store`] from an anchor +//! state and block via [`fork_choice::get_forkchoice_store`], then replays +//! `steps.yaml` in order. A step is one of `tick`, `block`, `attestation`, +//! `attester_slashing`, or `on_merge_block` (bellatrix's own `pow_block` +//! step), driving the correspondingly named handler, or `checks`, which +//! asserts on the store's own fields and on [`fork_choice::get_head`], +//! [`fork_choice::get_proposer_head`], and +//! [`fork_choice::should_override_forkchoice_update`]. See +//! `tests/formats/fork_choice/README.md` in the pinned specification checkout +//! for the format in full. +//! +//! No released fixture targets phase0 directly: the earliest suite is +//! altair's, built from altair-shaped states because the generator needs a +//! later fork's containers even though altair changes nothing about fork +//! choice itself. This runner is gated on [`HIGHEST_IMPLEMENTED_FORK`] like +//! every other, so it picks up a phase0 suite automatically should a future +//! release add one, and a later fork's automatically once this crate catches +//! up to it. +//! +//! # A step's `valid: false` means the call must be rejected +//! +//! Matching [`super::check_transition`]'s rule for a missing `post` state, +//! `valid: false` on a `block`, `attestation`, or `attester_slashing` step +//! means the handler must return an error, and the store must be left exactly +//! as it was; treating a should-fail call that happens to succeed as a pass +//! would let this suite go green while checking nothing. `on_tick` cannot +//! fail in this crate, nor in the specification (it has no assertion to +//! fail), so a `tick` step asking for rejection is reported as a failure of +//! this suite's own assumptions rather than silently accepted. +//! +//! # An `on_block` step implies more than the README documents +//! +//! `tests/formats/fork_choice/README.md` says a successful `block` step +//! replays every attestation in the block's body through `on_attestation` +//! (with `is_from_block` true) once the block itself is accepted. The +//! reference test generator does one more thing the README omits: it also +//! replays every attester slashing in the block's body through +//! `on_attester_slashing`. See `add_block` in the pinned specification +//! checkout's `tests/core/pyspec/eth2spec/test/helpers/fork_choice.py`. Every +//! released fixture was generated against that code, not just the README's +//! prose, so skipping the slashing half would silently diverge from what a +//! case's later `checks` actually expect. [`apply_block`] does both. +//! +//! # `electra` and `fulu` need a different attestation and slashing shape +//! +//! `phase0::Attestation`/`phase0::AttesterSlashing` decode phase0 through +//! deneb's blocks and standalone `attestation_`/ +//! `attester_slashing_` files unchanged; electra and fulu need +//! `electra::Attestation`/`electra::AttesterSlashing` instead (EIP-7549). Both +//! [`fork_choice::Attestation`] and [`fork_choice::AttesterSlashing`] wrap the +//! two shapes, so [`decode_attestation`] and [`decode_attester_slashing`] +//! each dispatch on `case.fork` once, matching how [`decode_anchor_block`] +//! already dispatches for blocks. A block's own body is read through +//! [`fork_choice::block_operations`], which makes that same dispatch beside +//! the two enums it builds, so this runner and the chain actor cannot +//! disagree about a fork's attestation shape. +//! +//! # Checks this runner does not model +//! +//! `viable_for_head_roots_and_weights` is part of the format, but no case at +//! any implemented fork's `checks` step names it, on either preset, so it is +//! not modeled here rather than guessed at. `should_override_forkchoice_update` +//! *is* exercised, once per fork from bellatrix on, and [`apply_checks`] +//! checks it. +//! +//! # `columns: []` means "simulate unavailable", not "vacuously available" +//! +//! Fulu's own `is_data_available` (`specs/fulu/fork-choice.md`) is `all(... +//! for column_sidecar in column_sidecars)`, which is vacuously true over an +//! empty list. The reference test generator's own mock of +//! `retrieve_column_sidecars` disagrees on purpose: `with_blob_data_fulu` in +//! `tests/core/pyspec/eth2spec/test/helpers/fork_choice.py` asserts `False` +//! ("Simulation: not all required columns have been sampled") whenever it is +//! asked to return zero sidecars, rather than returning the empty list +//! literally. So a `columns: []` field on a `block` step means the block +//! must be rejected before this crate's own `is_data_available` ever runs; +//! [`block_blob_evidence`] enforces that directly, rather than trusting +//! [`fork_choice::is_data_available_columns`] to reject it (it will not: an +//! absent `columns` field, meaning a block that never samples any column at +//! all, has to reach that same vacuous truth for an ordinary non-blob block +//! to validate). +//! +//! # `anchor_block.ssz_snappy` is unsigned; `get_forkchoice_store` wants signed +//! +//! [`fork_choice::get_forkchoice_store`] takes a [`SignedBeaconBlock`], +//! matching what the store holds blocks as (see that module's own +//! documentation for why the store holds a signed block at all). The fixture's +//! `anchor_block.ssz_snappy` is an unsigned `BeaconBlock`, so +//! [`decode_anchor_block`] decodes it as the fork's own unsigned container +//! and wraps it in a zero-signature signed one; the anchor block's signature +//! is never actually checked, since a trusted anchor is not verified against +//! anything. A `block` step's own file is already a signed block, so +//! [`decode_signed_block`] just decodes it directly through +//! [`SignedBeaconBlock::from_ssz`]. + +use std::sync::Arc; + +use ethlambda_state_transition::beacon::ForkName; +use ethlambda_state_transition::beacon::config::Config; +use ethlambda_state_transition::beacon::containers::{ + BeaconState, Checkpoint, SignedBeaconBlock, altair, bellatrix, capella, deneb, electra, fulu, + phase0, +}; +use ethlambda_state_transition::beacon::fork_choice::{self, DataAvailability, Store}; +use ethlambda_state_transition::beacon::helpers::accessors::CommitteeCache; +use ethlambda_state_transition::beacon::preset; +use ethlambda_state_transition::beacon::primitives::{KzgProof, Root}; +use libssz::SszDecode; +use libssz_types::SszList; +use libtest_mimic::Trial; + +use super::{Case, PRESET, collect_all_handlers, lean_is_not_a_fixture_fork}; + +// --------------------------------------------------------------------------- +// `steps.yaml` deserialization +// --------------------------------------------------------------------------- + +/// One entry of a case's `steps.yaml`. +/// +/// The format overlays six step kinds into one YAML sequence item: exactly +/// one of [`Step::tick`], [`Step::block`], [`Step::attestation`], +/// [`Step::attester_slashing`], and [`Step::pow_block`] is set for an +/// execution step, or none of them for a [`Step::checks`] step. Modeled as +/// one struct with every field optional, rather than a `#[serde(untagged)]` +/// enum over five variants, because the four execution kinds that carry a +/// validity outcome also share [`Step::valid`], which an enum would have to +/// repeat on every variant instead of naming once. +#[derive(serde::Deserialize)] +struct Step { + /// The Unix-second time to advance the store to, for an `on_tick` step. + tick: Option, + /// The `block_` file naming the block for an `on_block` step. + block: Option, + /// `[New in Deneb/Electra]` the `blobs_` file naming this `block` + /// step's blob evidence, paired positionally with [`Step::proofs`]. + blobs: Option, + /// `[New in Deneb/Electra]` this `block` step's proofs, one per blob in + /// [`Step::blobs`], as `0x`-prefixed byte48 hex strings rather than a + /// separate file. + proofs: Option>, + /// `[New in Fulu]` the `column_` files naming this `block` step's + /// column evidence, replacing [`Step::blobs`]/[`Step::proofs`]. See the + /// module documentation for why an empty (but present) list is not the + /// same as an absent one. + columns: Option>, + /// The `attestation_` file naming the attestation for an + /// `on_attestation` step. + attestation: Option, + /// The `attester_slashing_` file naming the slashing for an + /// `on_attester_slashing` step. + attester_slashing: Option, + /// `[New in Bellatrix]` the `pow_block_` file naming the PoW block + /// an `on_merge_block` step adds to the store for later `get_pow_block` + /// lookups. + pow_block: Option, + /// Whether this step's call is expected to succeed. Only `block`, + /// `attestation`, and `attester_slashing` steps carry `false` in any + /// released fixture, but the format allows it on any execution step. + #[serde(default = "default_valid")] + valid: bool, + /// The assertions to check against the current store. + checks: Option, +} + +/// [`Step::valid`]'s default: a step not naming its own validity is expected +/// to succeed. +fn default_valid() -> bool { + true +} + +/// A `checks` step's assertions against the store. +/// +/// See the module documentation for the one field of the format this leaves +/// out, and why. +#[derive(serde::Deserialize)] +pub(super) struct Checks { + time: Option, + genesis_time: Option, + head: Option, + justified_checkpoint: Option, + finalized_checkpoint: Option, + proposer_boost_root: Option, + get_proposer_head: Option, + /// `[New in Bellatrix]` see + /// [`fork_choice::should_override_forkchoice_update`]. + should_override_forkchoice_update: Option, +} + +/// The expected value of [`fork_choice::get_head`], as `checks.head` gives it: +/// the root and, redundantly, the slot of the block it names. +#[derive(serde::Deserialize)] +struct HeadCheck { + slot: u64, + root: String, +} + +/// The expected value of a checkpoint field (`justified_checkpoint` or +/// `finalized_checkpoint`). +#[derive(serde::Deserialize)] +struct CheckpointCheck { + epoch: u64, + root: String, +} + +/// The expected value of +/// [`fork_choice::should_override_forkchoice_update`]: the fixed +/// `validator_is_connected` answer to call it with, and the result it must +/// then return. +#[derive(serde::Deserialize)] +struct ShouldOverrideForkchoiceUpdateCheck { + validator_is_connected: bool, + result: bool, +} + +/// Parses a fixture's `0x`-prefixed hex root. +/// +/// Duplicated from `ssz_static`'s private helper of the same purpose rather +/// than shared, matching how each runner in this test suite is otherwise +/// self-contained. +fn parse_root(hex_root: &str) -> Root { + let stripped = hex_root.strip_prefix("0x").unwrap_or(hex_root); + let bytes = hex::decode(stripped).expect("the fixture's root is valid hex"); + Root::from_slice(&bytes) +} + +/// Parses a fixture's `0x`-prefixed byte48 hex string into a [`KzgProof`], +/// the shape a `block` step's inline `proofs` field carries them in rather +/// than a separate file. +fn parse_kzg_proof(hex_proof: &str) -> KzgProof { + let stripped = hex_proof.strip_prefix("0x").unwrap_or(hex_proof); + let bytes = hex::decode(stripped).expect("the fixture's proof is valid hex"); + KzgProof( + bytes + .try_into() + .expect("a KZG proof is KZG_POINT_SIZE bytes"), + ) +} + +// --------------------------------------------------------------------------- +// Decoding blocks, attestations, and slashings +// --------------------------------------------------------------------------- + +/// Decodes `.ssz_snappy` as `T`, turning a decode failure into a case +/// failure rather than a panic: a block, attestation, or slashing this crate +/// cannot parse is exactly the kind of thing this suite exists to catch. +fn decode(case: &Case, name: &str) -> Result { + T::from_ssz_bytes(&case.ssz_bytes(name)).map_err(|err| format!("decoding {name}: {err:?}")) +} + +/// Decodes `anchor_block.ssz_snappy` for [`fork_choice::get_forkchoice_store`]. +/// +/// The file is an unsigned `BeaconBlock`; see the module documentation for +/// why this wraps it in a signed container with a zero signature rather than +/// decoding straight into one. `ForkName::Fulu` decodes as +/// [`electra::SignedBeaconBlock`], matching how [`SignedBeaconBlock::Fulu`] +/// wraps that same type rather than a `fulu`-specific one. +pub(super) fn decode_anchor_block(case: &Case) -> Result { + match case.fork { + ForkName::Phase0 => Ok(SignedBeaconBlock::Phase0(phase0::SignedBeaconBlock { + message: decode(case, "anchor_block")?, + signature: Default::default(), + })), + ForkName::Altair => Ok(SignedBeaconBlock::Altair(altair::SignedBeaconBlock { + message: decode(case, "anchor_block")?, + signature: Default::default(), + })), + ForkName::Bellatrix => Ok(SignedBeaconBlock::Bellatrix(bellatrix::SignedBeaconBlock { + message: decode(case, "anchor_block")?, + signature: Default::default(), + })), + ForkName::Capella => Ok(SignedBeaconBlock::Capella(capella::SignedBeaconBlock { + message: decode(case, "anchor_block")?, + signature: Default::default(), + })), + ForkName::Deneb => Ok(SignedBeaconBlock::Deneb(deneb::SignedBeaconBlock { + message: decode(case, "anchor_block")?, + signature: Default::default(), + })), + ForkName::Electra => Ok(SignedBeaconBlock::Electra(electra::SignedBeaconBlock { + message: decode(case, "anchor_block")?, + signature: Default::default(), + })), + ForkName::Fulu => Ok(SignedBeaconBlock::Fulu(electra::SignedBeaconBlock { + message: decode(case, "anchor_block")?, + signature: Default::default(), + })), + ForkName::Lean => lean_is_not_a_fixture_fork("fork_choice"), + } +} + +/// Decodes a `block_.ssz_snappy` file named by an `on_block` step. The +/// file is already a signed block of the case's own fork, so this is a +/// direct decode through the fork-generic decoder, unlike +/// [`decode_anchor_block`]. +pub(super) fn decode_signed_block(case: &Case, name: &str) -> Result { + SignedBeaconBlock::from_ssz(case.fork, &case.ssz_bytes(name)) + .map_err(|err| format!("decoding {name}: {err:?}")) +} + +/// Decodes an `attestation_.ssz_snappy` file, in whichever of +/// [`fork_choice::Attestation`]'s two shapes `case.fork` needs. See the +/// module documentation for why electra and fulu need +/// [`electra::Attestation`] rather than [`phase0::Attestation`]. +fn decode_attestation(case: &Case, name: &str) -> Result { + match case.fork { + ForkName::Electra | ForkName::Fulu => { + Ok(fork_choice::Attestation::Electra(decode(case, name)?)) + } + _ => Ok(fork_choice::Attestation::Phase0(decode(case, name)?)), + } +} + +/// Decodes an `attester_slashing_.ssz_snappy` file. See +/// [`decode_attestation`] for why the fork decides the shape. +fn decode_attester_slashing( + case: &Case, + name: &str, +) -> Result { + match case.fork { + ForkName::Electra | ForkName::Fulu => { + Ok(fork_choice::AttesterSlashing::Electra(decode(case, name)?)) + } + _ => Ok(fork_choice::AttesterSlashing::Phase0(decode(case, name)?)), + } +} + +/// Decodes a `pow_block_.ssz_snappy` file named by an `on_merge_block` +/// step. +fn decode_pow_block(case: &Case, name: &str) -> Result { + decode(case, name) +} + +/// Extracts a `block` step's blob evidence, if it carries any: deneb and +/// electra's `blobs`/`proofs` fields, or fulu's `columns` field. Absent +/// either, this is [`DataAvailability::NotRequired`], the right value for +/// every pre-deneb block and for a later-fork block with no blob +/// commitments. +/// +/// See the module documentation for why an empty (but present) `columns` +/// list is rejected directly here rather than passed through to +/// [`fork_choice::is_data_available_columns`]. +fn block_blob_evidence(case: &Case, step: &Step) -> Result { + if let Some(names) = &step.columns { + if names.is_empty() { + return Err( + "columns: [] simulates the reference generator's retrieve_column_sidecars \ + raising \"not all required columns have been sampled\" (see this runner's \ + module documentation), so the block must be rejected before \ + is_data_available_columns ever runs" + .to_string(), + ); + } + let sidecars = names + .iter() + .map(|name| decode::(case, name)) + .collect::, _>>()?; + return Ok(DataAvailability::Columns(sidecars)); + } + + if let Some(name) = &step.blobs { + let blobs: SszList = + case.ssz(name); + let proofs = step + .proofs + .as_deref() + .unwrap_or_default() + .iter() + .map(|hex_proof| parse_kzg_proof(hex_proof)) + .collect(); + return Ok(DataAvailability::Blobs { + blobs: blobs.into_inner(), + proofs, + }); + } + + Ok(DataAvailability::NotRequired) +} + +// --------------------------------------------------------------------------- +// Applying a step +// --------------------------------------------------------------------------- + +/// Applies one `block` step: decodes the named file and its blob evidence, +/// calls `on_block`, and, only if the block was accepted, replays every +/// attestation and attester slashing carried in its body. See the module +/// documentation for why the slashing half belongs here even though the +/// format's own README omits it. +/// +/// `committees` is the case's one [`CommitteeCache`], shared by every step as +/// the node's chain actor shares its own across imports; see [`run_case`]. +fn apply_block( + store: &mut Store, + case: &Case, + step: &Step, + name: &str, + expect_valid: bool, + config: &Config, + committees: &CommitteeCache, +) -> Result<(), String> { + let signed_block = decode_signed_block(case, name)?; + + let blob_evidence = match block_blob_evidence(case, step) { + Ok(evidence) => evidence, + // A simulated-unavailable `columns: []` never even reaches + // `on_block` (see `block_blob_evidence`'s documentation), but the + // fixture is asking about exactly the same "was this block + // rejected" question `on_block`'s own `Err` below answers, so this + // is folded into the same `expect_valid` check rather than treated + // as this suite's own setup failing. + Err(simulated_unavailable) => { + return if expect_valid { + Err(format!("{name} was rejected: {simulated_unavailable}")) + } else { + Ok(()) + }; + } + }; + // Collected before `on_block` moves `signed_block` in, so they are still + // available for the replay below after a successful call. + let (attestations, attester_slashings) = fork_choice::block_operations(&signed_block); + + match ( + fork_choice::on_block( + store, + signed_block, + config, + &blob_evidence, + &fork_choice::PayloadValidity::NotRequired, + committees, + ), + expect_valid, + ) { + (Ok(()), false) => { + return Err(format!( + "{name} was accepted, but the step expects it to be rejected" + )); + } + (Err(err), true) => return Err(format!("{name} was rejected: {err:?}")), + // Correctly rejected: the handler's contract leaves `store` untouched, + // so there is nothing from this block left to replay. + (Err(_), false) => return Ok(()), + (Ok(()), true) => {} + } + + for attestation in &attestations { + fork_choice::on_attestation(store, attestation, true, config, committees).map_err( + |err| format!("on_attestation for an attestation carried in {name}: {err:?}"), + )?; + } + for attester_slashing in &attester_slashings { + fork_choice::on_attester_slashing(store, attester_slashing).map_err(|err| { + format!("on_attester_slashing for a slashing carried in {name}: {err:?}") + })?; + } + + Ok(()) +} + +/// Applies one non-`checks` execution step, dispatching on which of +/// [`Step::tick`], [`Step::block`], [`Step::attestation`], +/// [`Step::attester_slashing`], or [`Step::pow_block`] is set. +fn apply_execution_step( + store: &mut Store, + case: &Case, + step: &Step, + config: &Config, + committees: &CommitteeCache, +) -> Result<(), String> { + if let Some(time) = step.tick { + if !step.valid { + // The specification's `on_tick` has no assertion to fail, and + // neither does this crate's: there is no way to honor a fixture + // that asked for a rejected tick, so this fails loudly instead of + // quietly treating the step as a pass. No released fixture + // exercises this; see the module documentation. + return Err( + "a tick step expects rejection, but on_tick cannot fail in this crate".to_string(), + ); + } + fork_choice::on_tick(store, time, config); + return Ok(()); + } + + if let Some(name) = &step.block { + return apply_block(store, case, step, name, step.valid, config, committees); + } + + if let Some(name) = &step.attestation { + let attestation = decode_attestation(case, name)?; + return match ( + fork_choice::on_attestation(store, &attestation, false, config, committees), + step.valid, + ) { + (Ok(()), false) => Err(format!( + "{name} was accepted, but the step expects it to be rejected" + )), + (Err(err), true) => Err(format!("{name} was rejected: {err:?}")), + _ => Ok(()), + }; + } + + if let Some(name) = &step.attester_slashing { + let attester_slashing = decode_attester_slashing(case, name)?; + return match ( + fork_choice::on_attester_slashing(store, &attester_slashing), + step.valid, + ) { + (Ok(()), false) => Err(format!( + "{name} was accepted, but the step expects it to be rejected" + )), + (Err(err), true) => Err(format!("{name} was rejected: {err:?}")), + _ => Ok(()), + }; + } + + if let Some(name) = &step.pow_block { + // No validity outcome to honor here: an `on_merge_block` step only + // ever adds data a fixture suite already trusts (see + // `fork_choice::insert_pow_block`'s own documentation), so there is + // nothing for `step.valid` to mean. + let pow_block = decode_pow_block(case, name)?; + fork_choice::insert_pow_block(store, pow_block); + return Ok(()); + } + + Err( + "step has none of tick, block, attestation, attester_slashing, or pow_block set" + .to_string(), + ) +} + +// --------------------------------------------------------------------------- +// Checking the store +// --------------------------------------------------------------------------- + +/// Checks one scalar field against its expected value. +fn check_u64(label: &str, expected: u64, actual: u64) -> Result<(), String> { + if actual == expected { + Ok(()) + } else { + Err(format!("{label}: expected {expected}, got {actual}")) + } +} + +/// Checks one root-valued field against its expected, hex-encoded value. +fn check_root(label: &str, expected_hex: &str, actual: Root) -> Result<(), String> { + let expected = parse_root(expected_hex); + if actual == expected { + Ok(()) + } else { + Err(format!( + "{label}: expected 0x{}, got 0x{}", + hex::encode(expected.0), + hex::encode(actual.0), + )) + } +} + +/// Checks `justified_checkpoint` or `finalized_checkpoint` against its +/// expected epoch and root. +fn check_checkpoint( + label: &str, + expected: &CheckpointCheck, + actual: Checkpoint, +) -> Result<(), String> { + let expected_checkpoint = Checkpoint { + epoch: expected.epoch, + root: parse_root(&expected.root), + }; + if actual == expected_checkpoint { + Ok(()) + } else { + Err(format!( + "{label}: expected {{epoch: {}, root: 0x{}}}, got {{epoch: {}, root: 0x{}}}", + expected_checkpoint.epoch, + hex::encode(expected_checkpoint.root.0), + actual.epoch, + hex::encode(actual.root.0), + )) + } +} + +/// Checks `head` against [`fork_choice::get_head`]'s root, and that root's +/// slot (read through [`fork_choice::get_forkchoice_store`]'s store) against +/// the fixture's redundant `slot` field. +fn check_head(expected: &HeadCheck, store: &mut Store, config: &Config) -> Result<(), String> { + let actual_root = + fork_choice::get_head(store, config).map_err(|err| format!("get_head: {err:?}"))?; + let actual_slot = store + .get_signed_block(&actual_root) + .expect("get") + .map(|block| block.slot()) + .ok_or_else(|| { + format!( + "get_head returned 0x{}, which is not in store.blocks", + hex::encode(actual_root.0) + ) + })?; + + let expected_root = parse_root(&expected.root); + if actual_root == expected_root && actual_slot == expected.slot { + Ok(()) + } else { + Err(format!( + "head: expected {{slot: {}, root: 0x{}}}, got {{slot: {actual_slot}, root: 0x{}}}", + expected.slot, + hex::encode(expected_root.0), + hex::encode(actual_root.0), + )) + } +} + +/// Checks `get_proposer_head`, computed the same way the reference test +/// harness does: from the current head and current slot, not a value the +/// fixture supplies separately. +fn check_get_proposer_head( + expected_hex: &str, + store: &mut Store, + config: &Config, +) -> Result<(), String> { + let head = fork_choice::get_head(store, config).map_err(|err| format!("get_head: {err:?}"))?; + let slot = fork_choice::get_current_slot(store, config); + let actual = fork_choice::get_proposer_head(store, head, slot, config) + .map_err(|err| format!("get_proposer_head: {err:?}"))?; + check_root("get_proposer_head", expected_hex, actual) +} + +/// Checks `should_override_forkchoice_update`, the same way +/// [`check_get_proposer_head`] checks `get_proposer_head`: from the current +/// head, not a root the fixture supplies separately. `validator_is_connected` +/// is a fixed answer regardless of which proposer index is asked, matching +/// what the fixture format itself supplies: one bool for the whole call, not +/// a per-validator registry. +fn check_should_override_forkchoice_update( + expected: &ShouldOverrideForkchoiceUpdateCheck, + store: &mut Store, + config: &Config, +) -> Result<(), String> { + let head = fork_choice::get_head(store, config).map_err(|err| format!("get_head: {err:?}"))?; + let actual = fork_choice::should_override_forkchoice_update( + store, + head, + |_| expected.validator_is_connected, + config, + ) + .map_err(|err| format!("should_override_forkchoice_update: {err:?}"))?; + if actual == expected.result { + Ok(()) + } else { + Err(format!( + "should_override_forkchoice_update: expected {}, got {actual}", + expected.result + )) + } +} + +/// Applies one `checks` step: every field the fixture sets must match. +pub(super) fn apply_checks( + store: &mut Store, + checks: &Checks, + config: &Config, +) -> Result<(), String> { + if let Some(expected) = checks.time { + // The fixture's `time` is the specification's seconds; the store keeps + // one millisecond row for both chains, so the caller converts. + let time_seconds = store.time_ms().expect("store time exists") / 1_000; + check_u64("time", expected, time_seconds)?; + } + if let Some(expected) = checks.genesis_time { + check_u64("genesis_time", expected, store.config().genesis_time)?; + } + if let Some(expected) = &checks.justified_checkpoint { + check_checkpoint( + "justified_checkpoint", + expected, + store.beacon_justified_checkpoint(), + )?; + } + if let Some(expected) = &checks.finalized_checkpoint { + check_checkpoint( + "finalized_checkpoint", + expected, + store.beacon_finalized_checkpoint(), + )?; + } + if let Some(expected) = &checks.proposer_boost_root { + check_root("proposer_boost_root", expected, store.proposer_boost_root())?; + } + if let Some(expected) = &checks.head { + check_head(expected, store, config)?; + } + if let Some(expected) = &checks.get_proposer_head { + check_get_proposer_head(expected, store, config)?; + } + if let Some(expected) = &checks.should_override_forkchoice_update { + check_should_override_forkchoice_update(expected, store, config)?; + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Running a case +// --------------------------------------------------------------------------- + +/// Runs one case: builds the store from its anchor, then applies every step +/// in `steps.yaml` in order, stopping at the first one that fails. +/// +/// Stopping rather than continuing matches how a rejected call leaves `store` +/// untouched but a call that unexpectedly succeeds (or fails) leaves it in a +/// state the fixture's remaining steps were never written to expect; nothing +/// past that point would be checking anything meaningful. +/// +/// One [`CommitteeCache`] serves the whole case, the way the node's chain +/// actor holds one across every import, rather than a fresh one per call. +/// The fixtures build sibling branches on purpose, and sharing is what puts +/// the cache's key to the test: two states from different branches that +/// agree on an epoch's deciding block share its entry, and if the key ever +/// named a shuffling one of them does not actually have, a committee here +/// would come out wrong and the case would fail. A fresh cache per call would +/// pass whatever the key did. +fn run_case(case: &Case, config: &Config) -> Result<(), String> { + let anchor_state = BeaconState::from_ssz(case.fork, &case.ssz_bytes("anchor_state")) + .map_err(|err| format!("decoding anchor_state: {err:?}"))?; + let anchor_block = decode_anchor_block(case)?; + + let backend = Arc::new(ethlambda_storage::backend::InMemoryBackend::new()); + let mut store = fork_choice::get_forkchoice_store(backend, anchor_state, anchor_block, config) + .map_err(|err| format!("get_forkchoice_store: {err:?}"))?; + + let committees = CommitteeCache::default(); + let steps: Vec = case.yaml("steps"); + for (index, step) in steps.iter().enumerate() { + let outcome = match &step.checks { + Some(checks) => apply_checks(&mut store, checks, config), + None => apply_execution_step(&mut store, case, step, config, &committees), + }; + outcome.map_err(|err| format!("step {index}: {err}"))?; + } + + Ok(()) +} + +/// The handler half of [`collect_all_handlers`]'s pair is discarded: every +/// case in this suite runs through [`run_case`] the same way regardless of +/// which handler it came from, unlike `ssz_static`, which dispatches on it. +pub fn trials() -> Vec { + let config = Arc::new(Config::active()); + let cases = collect_all_handlers(PRESET, "fork_choice"); + let mut trials = vec![super::discovery_trial("fork_choice", cases.len())]; + + for (_handler, case) in cases { + let config = Arc::clone(&config); + trials.push(super::case_trial("fork_choice", case, move |case| { + run_case(case, &config) + })); + } + + trials +} diff --git a/crates/blockchain/state_transition/tests/beacon_spec/genesis.rs b/crates/blockchain/state_transition/tests/beacon_spec/genesis.rs new file mode 100644 index 000000000..db800e936 --- /dev/null +++ b/crates/blockchain/state_transition/tests/beacon_spec/genesis.rs @@ -0,0 +1,141 @@ +//! The `genesis` runner. +//! +//! Two handlers, matching the specification's own split of the topic. +//! `initialization` replays Eth1 deposit history through +//! `initialize_beacon_state_from_eth1` and checks the resulting candidate +//! state against a fixture-provided expected state. `validity` feeds a +//! ready-made candidate state straight to `is_valid_genesis_state` and checks +//! the boolean it returns. +//! +//! This release's fixtures ship `genesis` cases for the `minimal` preset only +//! (there is no `genesis` directory anywhere under `mainnet` in the extracted +//! tree), so this whole runner is gated on the `preset-minimal` feature. That +//! keeps the mainnet build from failing [`super::discovery_trial`]'s "matched +//! no fixture cases" check over a suite the release never populates for it, +//! without having to teach the shared harness a new kind of "expected empty" +//! outcome for one runner. +#![cfg(feature = "preset-minimal")] + +use std::sync::Arc; + +use ethlambda_state_transition::beacon::config::Config; +use ethlambda_state_transition::beacon::containers::{BeaconState, phase0, shared}; +use ethlambda_state_transition::beacon::genesis::{ + initialize_beacon_state_from_eth1, is_valid_genesis_state, +}; +use ethlambda_state_transition::beacon::primitives::{HashTreeRoot as _, Root}; +use libssz::SszDecode as _; +use libtest_mimic::Trial; + +use super::{Case, PRESET, collect}; + +#[derive(serde::Deserialize)] +struct Eth1 { + eth1_block_hash: String, + eth1_timestamp: u64, +} + +#[derive(serde::Deserialize)] +struct Meta { + deposits_count: usize, +} + +fn parse_root(hex_root: &str) -> Root { + let stripped = hex_root.strip_prefix("0x").unwrap_or(hex_root); + let bytes = hex::decode(stripped).expect("the fixture holds a hex-encoded 32-byte root"); + Root::from_slice(&bytes) +} + +/// Replays one `initialization` case and compares the resulting candidate's +/// root against the fixture's expected state. +/// +/// Compared by `hash_tree_root` rather than by full structural equality, +/// matching how `ssz_static` checks a container: the root is the +/// specification's own notion of "the same state", and is far more readable +/// on failure than a field-by-field dump of two multi-thousand-validator +/// states would be. +fn initialization_case(case: &Case, config: &Config) -> Result<(), String> { + let eth1: Eth1 = case.yaml("eth1"); + let meta: Meta = case.yaml("meta"); + + let eth1_block_hash = parse_root(ð1.eth1_block_hash); + let deposits: Vec = (0..meta.deposits_count) + .map(|index| case.ssz::(&format!("deposits_{index}"))) + .collect(); + + let expected = phase0::BeaconState::from_ssz_bytes(&case.ssz_bytes("state")) + .map_err(|err| format!("decoding the fixture's expected state: {err:?}"))?; + + let actual = + initialize_beacon_state_from_eth1(eth1_block_hash, eth1.eth1_timestamp, &deposits, config) + .map_err(|err| format!("initialize_beacon_state_from_eth1: {err}"))?; + + let expected_root = expected.hash_tree_root(); + let actual_root = actual.hash_tree_root(); + if actual_root != expected_root { + return Err(format!( + "hash_tree_root 0x{} != expected 0x{}", + hex::encode(actual_root.0), + hex::encode(expected_root.0), + )); + } + + Ok(()) +} + +/// Runs one `validity` case: decode the candidate state the fixture ships and +/// check `is_valid_genesis_state` against the fixture's expected boolean. +fn validity_case(case: &Case, config: &Config) -> Result<(), String> { + let state = phase0::BeaconState::from_ssz_bytes(&case.ssz_bytes("genesis")) + .map_err(|err| format!("decoding the candidate state: {err:?}"))?; + let expected: bool = case.yaml("is_valid"); + + let actual = is_valid_genesis_state(&BeaconState::Phase0(state), config); + if actual != expected { + return Err(format!( + "is_valid_genesis_state returned {actual}, expected {expected}" + )); + } + + Ok(()) +} + +pub fn trials() -> Vec { + let config = Arc::new(Config::minimal()); + + let initialization_cases = collect(PRESET, "genesis", "initialization"); + let mut trials = vec![super::discovery_trial( + "genesis/initialization", + initialization_cases.len(), + )]; + + for case in initialization_cases { + let config = Arc::clone(&config); + // Only phase0 has a genesis-construction fixture suite in this + // release; a later fork adding one would need its own state type + // here rather than silently being decoded as phase0's. Gated through + // [`super::case_trial`]'s own `in_scope` check rather than a bespoke + // phase0 check here, which keeps this runner consistent with the rest + // even though, today, there is nothing after phase0 for it to skip. + trials.push(super::case_trial( + "genesis/initialization", + case, + move |case| initialization_case(case, &config), + )); + } + + let validity_cases = collect(PRESET, "genesis", "validity"); + trials.push(super::discovery_trial( + "genesis/validity", + validity_cases.len(), + )); + + for case in validity_cases { + let config = Arc::clone(&config); + trials.push(super::case_trial("genesis/validity", case, move |case| { + validity_case(case, &config) + })); + } + + trials +} diff --git a/crates/blockchain/state_transition/tests/beacon_spec/gossip.rs b/crates/blockchain/state_transition/tests/beacon_spec/gossip.rs new file mode 100644 index 000000000..854d5b2bd --- /dev/null +++ b/crates/blockchain/state_transition/tests/beacon_spec/gossip.rs @@ -0,0 +1,350 @@ +//! The `networking/gossip_*` runners: the specification's gossip validation +//! vectors (`tests/formats/networking/gossip_validation.md` in +//! consensus-specs), run against `beacon::gossip`. +//! +//! These come from their own fixture tree; see [`super::gossip_fixture_root`]. + +use std::num::NonZeroUsize; +use std::sync::Arc; + +use ethlambda_state_transition::beacon::ForkName; +use ethlambda_state_transition::beacon::config::Config; +use ethlambda_state_transition::beacon::containers::{ + BeaconState, Checkpoint, SignedAggregateAndProof, SignedBeaconBlock, electra, fulu, phase0, +}; +use ethlambda_state_transition::beacon::fork_choice::{ + self, DataAvailability, PayloadValidity, Store, +}; +use ethlambda_state_transition::beacon::gossip::{ + self as rules, Outcome, SeenAggregates, SeenAttestations, SeenBlocks, SeenColumns, +}; +use ethlambda_state_transition::beacon::helpers::accessors::CommitteeCache; +use ethlambda_state_transition::beacon::primitives::Root; +use ethlambda_storage::ForkCheckpoints; +use libssz::SszDecode; +use libtest_mimic::Trial; + +use super::{Case, PRESET, collect_gossip}; + +/// The handlers this runner covers. +const HANDLERS: &[&str] = &[ + "gossip_beacon_block", + "gossip_data_column_sidecar", + "gossip_beacon_aggregate_and_proof", + "gossip_beacon_attestation", +]; + +/// Vectors that disagree with a deliberate deviation, by case name. +const SKIPPED: &[(&str, &str)] = &[ + ( + "gossip_beacon_block__reject_parent_consensus_failed_execution_not_verified", + "a parent seen without a post-state is queued, not rejected, until a bad-block cache exists", + ), + ( + "gossip_data_column_sidecar__reject_parent_failed_validation", + "a parent seen without a post-state is queued, not rejected, until a bad-block cache exists", + ), + ( + "gossip_beacon_aggregate_and_proof__reject_block_failed_validation", + "a vote block seen without a post-state is ignored, not rejected, until a bad-block cache exists", + ), + ( + "gossip_beacon_attestation__reject_block_failed_validation", + "a vote block seen without a post-state is ignored, not rejected, until a bad-block cache exists", + ), +]; + +/// Capacity for a case's seen caches. A case sends a handful of messages. +const SEEN_CAPACITY: usize = 64; + +#[derive(serde::Deserialize)] +struct Meta { + topic: String, + #[serde(default)] + blocks: Vec, + finalized_checkpoint: Option, + current_time_ms: u64, + messages: Vec, +} + +#[derive(serde::Deserialize)] +struct StoreBlock { + block: String, + #[serde(default)] + failed: bool, + #[serde(default)] + pending: bool, + payload_status: Option, +} + +#[derive(serde::Deserialize)] +struct FinalizedOverride { + epoch: u64, + root: Option, + block: Option, +} + +#[derive(serde::Deserialize)] +struct GossipMessage { + offset_ms: u64, + subnet_id: Option, + message: String, + expected: String, + reason: Option, +} + +fn parse_root(hex_root: &str) -> Root { + let stripped = hex_root.strip_prefix("0x").unwrap_or(hex_root); + let bytes = hex::decode(stripped).expect("the fixture's root is valid hex"); + Root::from_slice(&bytes) +} + +fn decode_block(case: &Case, name: &str) -> Result { + SignedBeaconBlock::from_ssz(case.fork, &case.ssz_bytes(name)) + .map_err(|err| format!("decoding {name}: {err:?}")) +} + +/// `SignedAggregateAndProof` has no `from_ssz(fork, bytes)` of its own (it +/// carries no state, unlike a block or a state, so nothing else in this crate +/// needed one yet): every fork through deneb shares phase0's shape, and +/// electra and fulu share electra's, exactly the split +/// [`SignedAggregateAndProof`]'s own doc describes for +/// [`SignedBeaconBlock::Fulu`]. +fn decode_signed_aggregate(case: &Case, name: &str) -> Result { + let bytes = case.ssz_bytes(name); + match case.fork { + ForkName::Phase0 + | ForkName::Altair + | ForkName::Bellatrix + | ForkName::Capella + | ForkName::Deneb => Ok(SignedAggregateAndProof::Phase0( + phase0::SignedAggregateAndProof::from_ssz_bytes(&bytes) + .map_err(|err| format!("decoding {name}: {err:?}"))?, + )), + ForkName::Electra | ForkName::Fulu => Ok(SignedAggregateAndProof::Electra( + electra::SignedAggregateAndProof::from_ssz_bytes(&bytes) + .map_err(|err| format!("decoding {name}: {err:?}"))?, + )), + other => Err(format!("no aggregate shape for fork {other:?}")), + } +} + +/// The case's own `config.yaml`, when it carries one: the vectors that pin a +/// non-default blob schedule ship a full config alongside their blocks and +/// state, which the compiled-in preset config does not share. +/// +/// Most cases carry no `config.yaml` at all. Per the consensus-specs tests +/// format README, a present `config.yaml` replaces the default runtime +/// config, and an absent one means the preset default; the README says +/// nothing about what fork epochs a present one holds. That is instead an +/// observation of the vectors' own `config.yaml` files (checked by hand): +/// every one of them sets every fork epoch to zero. So the fallback here +/// models that same all-forks-at-genesis shape by hand: the compiled preset +/// with every fork from altair up to and including the case's own fork pulled +/// back to genesis, which is enough for `Config::fork_at_epoch` to resolve +/// every case's low-epoch state without leaving a later fork's tree to +/// inherit an earlier fork's fallback. +fn case_config(case: &Case, state: &BeaconState) -> Config { + let mut config: Config = case.yaml_opt("config").unwrap_or_else(|| { + ForkName::ALL + .into_iter() + .take_while(|fork| *fork <= case.fork) + .filter(|fork| *fork != ForkName::Phase0) + .fold(Config::active(), |config, fork| { + config.with_fork_epoch(fork, 0) + }) + }); + config.genesis_time = state.genesis_time(); + // Newer configs name only `SLOT_DURATION_MS`; without `SECONDS_PER_SLOT` + // the field would keep mainnet's default. + config.seconds_per_slot = config.slot_duration_ms / 1000; + config +} + +/// The store the case describes: its anchor, then each listed block. +fn build_store( + case: &Case, + meta: &Meta, + state: BeaconState, + config: &Config, +) -> Result { + let (anchor, rest) = meta + .blocks + .split_first() + .ok_or("a gossip case lists at least its anchor block")?; + let anchor_block = decode_block(case, &anchor.block)?; + let backend = Arc::new(ethlambda_storage::backend::InMemoryBackend::new()); + let mut store = fork_choice::get_forkchoice_store(backend, state, anchor_block, config) + .map_err(|err| format!("get_forkchoice_store: {err:?}"))?; + + // The store's clock at the case's base time, so `on_block` accepts every + // listed block. + let now_s = (config.genesis_time_ms() + meta.current_time_ms) / 1000; + fork_choice::on_tick(&mut store, now_s, config); + + for entry in rest { + let block = decode_block(case, &entry.block)?; + let root = block.message_hash_tree_root(); + // Seen without a post-state. An `INVALIDATED` payload lands here too: + // this store never keeps a post-state for one, since `on_block` fails + // it and invalidates the branch. + if entry.failed || entry.pending || entry.payload_status.as_deref() == Some("INVALIDATED") { + store + .insert_pending_block(root, block) + .map_err(|err| format!("storing {}: {err}", entry.block))?; + continue; + } + let validity = match entry.payload_status.as_deref() { + None => PayloadValidity::NotRequired, + Some("VALID") => PayloadValidity::Validated, + Some("NOT_VALIDATED") => PayloadValidity::Optimistic, + Some(other) => return Err(format!("unknown payload_status {other}")), + }; + fork_choice::on_block( + &mut store, + block, + config, + &DataAvailability::NotRequired, + &validity, + &CommitteeCache::default(), + ) + .map_err(|err| format!("importing {}: {err:?}", entry.block))?; + } + + if let Some(finalized) = &meta.finalized_checkpoint { + let root = match (&finalized.root, &finalized.block) { + (Some(hex), None) => parse_root(hex), + (None, Some(name)) => decode_block(case, name)?.message_hash_tree_root(), + _ => return Err("finalized_checkpoint names exactly one of root and block".into()), + }; + let checkpoint = Store::beacon_checkpoint_as_stored(Checkpoint { + epoch: finalized.epoch, + root, + }); + let head = store + .head() + .map_err(|err| format!("reading the head: {err}"))?; + store + .update_checkpoints(ForkCheckpoints::new(head, None, Some(checkpoint))) + .map_err(|err| format!("overriding the finalized checkpoint: {err}"))?; + } + + Ok(store) +} + +/// `valid` is Accept, `reject` is Reject, `ignore` is Ignore or Queue (both +/// are IGNORE to gossipsub). +fn check(message: &GossipMessage, outcome: Outcome) -> Result<(), String> { + let matches = match message.expected.as_str() { + "valid" => outcome == Outcome::Accept, + "ignore" => matches!(outcome, Outcome::Ignore(_) | Outcome::Queue(_)), + "reject" => matches!(outcome, Outcome::Reject(_)), + other => return Err(format!("unknown expected result {other}")), + }; + if matches { + return Ok(()); + } + Err(format!( + "{}: expected {} ({}), got {outcome:?}", + message.message, + message.expected, + message.reason.as_deref().unwrap_or("no reason given"), + )) +} + +fn run_case(case: &Case) -> Result<(), String> { + let meta: Meta = case.yaml("meta"); + let state = BeaconState::from_ssz(case.fork, &case.ssz_bytes("state")) + .map_err(|err| format!("decoding state: {err:?}"))?; + let config = case_config(case, &state); + let store = build_store(case, &meta, state, &config)?; + let capacity = NonZeroUsize::new(SEEN_CAPACITY).expect("non-zero"); + let mut seen_blocks = SeenBlocks::new(capacity); + let mut seen_columns = SeenColumns::new(capacity); + let mut seen_aggregates = SeenAggregates::new(capacity, capacity); + let mut seen_attestations = SeenAttestations::new(capacity); + + for (index, message) in meta.messages.iter().enumerate() { + let now_ms = config.genesis_time_ms() + meta.current_time_ms + message.offset_ms; + let outcome = match meta.topic.as_str() { + "beacon_block" => { + let block = decode_block(case, &message.message)?; + let outcome = rules::block::validate(&seen_blocks, &store, &block, now_ms); + if outcome == Outcome::Accept { + let root = block.message_hash_tree_root(); + seen_blocks.record(block.slot(), block.proposer_index(), root); + } + outcome + } + "data_column_sidecar" => { + let sidecar = + fulu::DataColumnSidecar::from_ssz_bytes(&case.ssz_bytes(&message.message)) + .map_err(|err| format!("decoding {}: {err:?}", message.message))?; + let subnet_id = message + .subnet_id + .ok_or("a data_column_sidecar message names its subnet")?; + let outcome = + rules::column::validate(&seen_columns, &store, &sidecar, subnet_id, now_ms); + if outcome == Outcome::Accept { + let header = &sidecar.signed_block_header.message; + seen_columns.record(header.slot, header.proposer_index, sidecar.index); + } + outcome + } + "beacon_aggregate_and_proof" => { + let aggregate = decode_signed_aggregate(case, &message.message)?; + let outcome = match rules::aggregate::validate( + &seen_aggregates, + &store, + &aggregate, + now_ms, + ) { + Ok(_) => Outcome::Accept, + Err(outcome) => outcome, + }; + if outcome == Outcome::Accept { + seen_aggregates.record(&aggregate); + } + outcome + } + "beacon_attestation" => { + let attestation = + electra::SingleAttestation::from_ssz_bytes(&case.ssz_bytes(&message.message)) + .map_err(|err| format!("decoding {}: {err:?}", message.message))?; + let subnet_id = message + .subnet_id + .ok_or("a beacon_attestation message names its subnet")?; + let outcome = rules::attestation::validate( + &seen_attestations, + &store, + &attestation, + subnet_id, + now_ms, + ); + if outcome == Outcome::Accept { + seen_attestations.record(&attestation); + } + outcome + } + other => return Err(format!("topic {other} has no runner")), + }; + check(message, outcome).map_err(|err| format!("message {index}: {err}"))?; + } + Ok(()) +} + +pub fn trials() -> Vec { + let mut trials = Vec::new(); + for handler in HANDLERS { + let cases = collect_gossip(PRESET, handler); + trials.push(super::discovery_trial( + &format!("gossip/{handler}"), + cases.len(), + )); + for case in cases { + let ignored = !case.in_scope() || SKIPPED.iter().any(|(name, _)| *name == case.name); + trials.push(super::case_trial("gossip", case, run_case).with_ignored_flag(ignored)); + } + } + trials +} diff --git a/crates/blockchain/state_transition/tests/beacon_spec/harness.rs b/crates/blockchain/state_transition/tests/beacon_spec/harness.rs new file mode 100644 index 000000000..de0b885a7 --- /dev/null +++ b/crates/blockchain/state_transition/tests/beacon_spec/harness.rs @@ -0,0 +1,142 @@ +//! Self-tests for the fixture harness. +//! +//! These check the harness itself, not the crate under test: that it finds the +//! fixture tree, that discovery reaches every supported fork, that it skips +//! forks this crate does not implement, and that snappy decompression produces +//! bytes an SSZ decoder accepts. A harness bug otherwise shows up as a suite +//! that quietly matches nothing. +//! +//! No case collection to guard here, unlike every other runner: these trials +//! are fixed in number and known before the fixture tree is even walked, so +//! there is no [`super::discovery_trial`] alongside them. + +use ethlambda_state_transition::beacon::ForkName; +use ethlambda_state_transition::beacon::primitives::{HashTreeRoot as _, Root}; +use libssz::{SszDecode as _, SszEncode as _}; +use libssz_derive::{HashTreeRoot, SszDecode, SszEncode}; +use libtest_mimic::Trial; + +use super::{Case, PRESET, collect, fixture_root}; + +#[derive(serde::Deserialize)] +struct RootFile { + root: String, +} + +/// The specification's `Checkpoint`, declared here rather than imported. +/// +/// This is the harness proving out the whole chain a container runner depends +/// on: raw-snappy decompression, SSZ decoding, merkleization, and re-encoding, +/// checked against a fixture's own expected root. `Checkpoint` is the smallest +/// container that exists in every fork, so it isolates that chain from anything +/// fork-specific. The real containers land with their own suites. +#[derive(Debug, Clone, PartialEq, SszEncode, SszDecode, HashTreeRoot)] +struct Checkpoint { + epoch: u64, + root: Root, +} + +pub fn trials() -> Vec { + vec![ + Trial::test("harness/fixture_tree_is_present", || { + let root = fixture_root(); + assert!( + root.join(PRESET).is_dir(), + "no {PRESET} fixtures under {}", + root.display() + ); + assert!( + root.join("general").is_dir(), + "no general fixtures under {}", + root.display() + ); + Ok(()) + }), + Trial::test("harness/discovery_finds_a_known_suite", || { + // Checkpoint exists in every fork's ssz_static suite, and is the smallest + // container that does, which makes it a stable canary for discovery. + let cases = collect(PRESET, "ssz_static", "Checkpoint"); + assert!(!cases.is_empty(), "no ssz_static/Checkpoint cases found"); + + for case in &cases { + assert!( + case.has("serialized"), + "{} has no serialized file", + case.id() + ); + assert!(case.has("roots"), "{} has no roots file", case.id()); + } + Ok(()) + }), + Trial::test("harness/discovery_covers_every_supported_fork", || { + let cases = collect(PRESET, "ssz_static", "Checkpoint"); + for fork in ForkName::ALL { + assert!( + cases.iter().any(|case| case.fork == fork), + "discovery found no {fork} cases; the fixture release is expected to \ + cover every fork this crate implements" + ); + } + Ok(()) + }), + Trial::test("harness/discovery_skips_forks_out_of_scope", || { + // The release ships suites for forks after fulu, and for standalone EIPs. + // Those must not reach a runner, since this crate has no types for them. + let cases = collect(PRESET, "ssz_static", "Checkpoint"); + for case in &cases { + assert!( + ForkName::ALL.contains(&case.fork), + "{} is outside the implemented forks", + case.id() + ); + } + Ok(()) + }), + Trial::test("harness/snappy_decompression_yields_decodable_ssz", || { + let cases = collect(PRESET, "ssz_static", "Checkpoint"); + let case: &Case = cases.first().expect("at least one Checkpoint case"); + + // A Checkpoint is an epoch and a root, so its SSZ encoding is fixed-length. + let bytes = case.ssz_bytes("serialized"); + assert_eq!( + bytes.len(), + 40, + "a Checkpoint is a u64 epoch followed by a 32-byte root" + ); + + let roots: RootFile = case.yaml("roots"); + assert!(roots.root.starts_with("0x"), "roots.yaml holds a hex root"); + Ok(()) + }), + Trial::test( + "harness/container_round_trip_matches_the_fixture_root", + || { + for case in collect(PRESET, "ssz_static", "Checkpoint") { + let bytes = case.ssz_bytes("serialized"); + let expected: RootFile = case.yaml("roots"); + + let checkpoint = Checkpoint::from_ssz_bytes(&bytes) + .map_err(|err| format!("{}: decode failed: {err:?}", case.id()))?; + let root = format!("0x{}", hex::encode(checkpoint.hash_tree_root().0)); + if root != expected.root { + return Err(format!( + "{}: root {root} != expected {}", + case.id(), + expected.root + ) + .into()); + } + if checkpoint.to_ssz() != bytes { + return Err(format!( + "{}: re-encoding did not reproduce the fixture bytes", + case.id() + ) + .into()); + } + } + + Ok(()) + }, + ), + ] +} diff --git a/crates/blockchain/state_transition/tests/beacon_spec/kzg.rs b/crates/blockchain/state_transition/tests/beacon_spec/kzg.rs new file mode 100644 index 000000000..6bdbe0bd7 --- /dev/null +++ b/crates/blockchain/state_transition/tests/beacon_spec/kzg.rs @@ -0,0 +1,473 @@ +//! The `kzg` runner. +//! +//! Covers every function `crates/blockchain/state_transition/src/beacon/kzg.rs` wraps from `c-kzg`: deneb's +//! seven blob-commitment handlers (`blob_to_kzg_commitment`, +//! `compute_kzg_proof`, `compute_blob_kzg_proof`, `verify_kzg_proof`, +//! `verify_blob_kzg_proof`, `verify_blob_kzg_proof_batch`, `compute_challenge`) +//! and fulu's five cell-proof handlers for PeerDAS +//! (`compute_cells`, `compute_cells_and_kzg_proofs`, +//! `recover_cells_and_kzg_proofs`, `verify_cell_kzg_proof_batch`, +//! `compute_verify_cell_kzg_proof_batch_challenge`). Before this runner, no +//! fixture exercised any of them outside `kzg.rs`'s own ad hoc test module, +//! which predates this crate's shared fixture harness and is not wired into +//! `spec_tests` or its per-case reporting. +//! +//! # Why `general`, not a preset +//! +//! [`super::PRESET`] selects between `minimal` and `mainnet` because container +//! bounds like `SLOTS_PER_EPOCH` are compiled in. Nothing about a blob, a cell, +//! or a KZG point is preset-dependent in that sense: `BYTES_PER_BLOB`, +//! `CELLS_PER_EXT_BLOB`, and the rest come from the KZG trusted setup and the +//! EIP-4844/EIP-7594 scheme itself, which is one fixed size regardless of which +//! preset the beacon state around it uses. So the release ships exactly one +//! `kzg` tree per fork, under `general`, the same reason [`super::collect_all_handlers`]'s +//! own doc gives for BLS. +//! +//! # What `output: null` means +//! +//! A case whose `output` is `null` means the operation must be rejected, the +//! same rule [`super::check_transition`] enforces elsewhere in this harness for +//! a case with no `post` state. The two look different only because a state +//! transition case expresses "no expected result" by omitting a file, while a +//! KZG case has no state to omit and expresses it with YAML's null instead. A +//! run that returns a value for such a case has to fail, not pass by +//! coincidence: the KZG suite spends a large share of its cases on malformed +//! input precisely because acceptance is easy to get right by accident and +//! rejection is not, so that half of the suite is where most of its value is. +//! +//! Some of this crate's KZG functions cannot even be called on a case that +//! must be rejected, rather than being called and returning `Err`: a case +//! whose `z`, `y`, `commitment`, `proof`, or `cell` field is not the exact byte +//! length its typed argument requires ([`Bytes32`], [`KzgCommitment`], +//! [`KzgProof`], [`Cell`]) fails before this runner can construct the value to +//! pass in. [`expect_rejected`] covers both paths with one check, since the +//! fixture's contract is the same either way: `output` must be `null`. +//! +//! # Why dispatch is on the handler alone +//! +//! [`super::ssz_static`] and [`super::epoch_processing`] match on +//! `(handler, fork)`, because the same handler name can select a different +//! function per fork there (`rewards_and_penalties` is phase0's replaying +//! `PendingAttestation`s versus altair's scoring participation flags, for +//! instance). Nothing here has that shape: deneb's seven handlers and fulu's +//! five are two disjoint sets, no name appears in both, and neither fork's +//! `polynomial-commitments*.md` redefines a function the other fork also +//! defines. So [`run`] matches on the handler name by itself; adding the fork +//! to the match would be dead weight, not a safety net, since no arm could +//! ever need it. + +use c_kzg::Cell; +use ethlambda_state_transition::beacon::kzg; +use ethlambda_state_transition::beacon::primitives::{Bytes32, H256, KzgCommitment, KzgProof}; +use libtest_mimic::Trial; +use serde_yaml_ng::Value; + +use super::{Case, collect_all_handlers}; + +/// Decodes a `0x`-prefixed hex string into bytes of whatever length it has. +/// +/// This is how a blob is decoded: its typed argument is `&[u8]`, not a fixed +/// array, so [`kzg`]'s own length check (`to_blob`, inside every function that +/// takes one) is what rejects a case whose blob is the wrong length, not this +/// runner. Every fixed-size field below builds on this, then narrows further. +fn hex_bytes(value: &Value) -> Vec { + let hex_str = value.as_str().expect("value is a hex string"); + hex::decode( + hex_str + .strip_prefix("0x") + .expect("hex string has a 0x prefix"), + ) + .expect("value is valid hex") +} + +/// Decodes a hex string into exactly `N` bytes, or `None` if its length does +/// not match. +/// +/// `N` is not a spec constant, only the width one of this module's own byte +/// arrays needs, so nothing here reaches for a name; the constant that does +/// matter, e.g. `BYTES_PER_CELL`, only lives inside `c-kzg` and this module +/// never repeats its value. +fn hex_array(value: &Value) -> Option<[u8; N]> { + hex_bytes(value).try_into().ok() +} + +/// Decodes a 32-byte field element (`z`, `y`, or a coset evaluation). +fn hex_bytes32(value: &Value) -> Option { + hex_array::<32>(value).map(H256) +} + +/// Decodes a compressed G1 point as a commitment. +fn hex_commitment(value: &Value) -> Option { + hex_array(value).map(KzgCommitment) +} + +/// Decodes a compressed G1 point as a proof. +fn hex_proof(value: &Value) -> Option { + hex_array(value).map(KzgProof) +} + +/// Decodes one cell of an extended blob. +/// +/// Unlike the fixed arrays above, [`Cell::from_bytes`] does its own length +/// check rather than this runner doing it through `try_into`, since `Cell` is +/// `c-kzg`'s type, not this crate's; `None` on a length mismatch either way. +fn hex_cell(value: &Value) -> Option { + Cell::from_bytes(&hex_bytes(value)).ok() +} + +/// Parses a YAML list of plain integers (`cell_indices`, `commitment_indices`), +/// as opposed to the hex-string lists every other list field in this suite +/// holds. +fn u64_list(value: &Value) -> Vec { + value + .as_sequence() + .expect("value is a list") + .iter() + .map(|entry| entry.as_u64().expect("list entry is an integer")) + .collect() +} + +/// Parses every element of a YAML list with `parse`, unconditionally. +/// +/// For fields whose own function call rejects a bad element instead of this +/// runner needing to notice first, e.g. blobs in a batch: `kzg`'s own +/// per-blob length check runs inside the call itself, not here. +fn hex_list(value: &Value, parse: impl Fn(&Value) -> T) -> Vec { + value + .as_sequence() + .expect("value is a list") + .iter() + .map(parse) + .collect() +} + +/// Parses every element of a YAML list with a fallible `parse`, or `None` if +/// any element fails. +/// +/// For fields whose typed element (a commitment, a proof, a cell) this runner +/// must construct itself before it can even call the function under test, so +/// one malformed element has to short-circuit the whole case to a rejection +/// before that call happens. +fn hex_list_opt(value: &Value, parse: impl Fn(&Value) -> Option) -> Option> { + value + .as_sequence() + .expect("value is a list") + .iter() + .map(parse) + .collect() +} + +/// Checks that `actual` equals `expected`, formatting either mismatch. +fn expect_eq(actual: T, expected: T) -> Result<(), String> { + if actual == expected { + Ok(()) + } else { + Err(format!("{actual:?} != {expected:?}")) + } +} + +/// Checks that `output` is `null`, the fixture's way of demanding a rejection. +/// +/// `reason` names why this case could not produce a value: either an input +/// field failed to parse at its required fixed size, before this crate's KZG +/// function could even be called, or that function itself returned `Err`. See +/// the module doc for why both collapse to the same check. +fn expect_rejected(output: &Value, reason: &str) -> Result<(), String> { + if output.is_null() { + Ok(()) + } else { + Err(format!( + "rejected ({reason}), but the fixture expects output {output:?}" + )) + } +} + +/// Checks a list of cells against the fixture's own list, element by element. +fn expect_cells_eq(actual: &[Cell], expected: &[Value]) -> Result<(), String> { + if actual.len() != expected.len() { + return Err(format!( + "produced {} cells, fixture expects {}", + actual.len(), + expected.len() + )); + } + for (cell, expected_cell) in actual.iter().zip(expected) { + expect_eq(Some(*cell), hex_cell(expected_cell))?; + } + Ok(()) +} + +/// Checks a list of proofs against the fixture's own list, element by element. +fn expect_proofs_eq(actual: &[KzgProof], expected: &[Value]) -> Result<(), String> { + if actual.len() != expected.len() { + return Err(format!( + "produced {} proofs, fixture expects {}", + actual.len(), + expected.len() + )); + } + for (proof, expected_proof) in actual.iter().zip(expected) { + expect_eq(Some(*proof), hex_proof(expected_proof))?; + } + Ok(()) +} + +fn check_blob_to_kzg_commitment(input: &Value, output: &Value) -> Result<(), String> { + let blob = hex_bytes(&input["blob"]); + match kzg::blob_to_kzg_commitment(&blob) { + Ok(commitment) => expect_eq(Some(commitment), hex_commitment(output)), + Err(err) => expect_rejected(output, &format!("{err:?}")), + } +} + +fn check_compute_kzg_proof(input: &Value, output: &Value) -> Result<(), String> { + let blob = hex_bytes(&input["blob"]); + let Some(z) = hex_bytes32(&input["z"]) else { + return expect_rejected(output, "z is not 32 bytes"); + }; + match kzg::compute_kzg_proof(&blob, &z) { + Ok((proof, y)) => { + let expected = output + .as_sequence() + .ok_or_else(|| format!("output {output:?} is not [proof, y]"))?; + expect_eq(Some(proof), hex_proof(&expected[0]))?; + expect_eq(Some(y), hex_bytes32(&expected[1])) + } + Err(err) => expect_rejected(output, &format!("{err:?}")), + } +} + +fn check_compute_blob_kzg_proof(input: &Value, output: &Value) -> Result<(), String> { + let blob = hex_bytes(&input["blob"]); + let Some(commitment) = hex_commitment(&input["commitment"]) else { + return expect_rejected(output, "commitment is not 48 bytes"); + }; + match kzg::compute_blob_kzg_proof(&blob, &commitment) { + Ok(proof) => expect_eq(Some(proof), hex_proof(output)), + Err(err) => expect_rejected(output, &format!("{err:?}")), + } +} + +fn check_verify_kzg_proof(input: &Value, output: &Value) -> Result<(), String> { + let (Some(commitment), Some(z), Some(y), Some(proof)) = ( + hex_commitment(&input["commitment"]), + hex_bytes32(&input["z"]), + hex_bytes32(&input["y"]), + hex_proof(&input["proof"]), + ) else { + return expect_rejected(output, "commitment, z, y, or proof is the wrong length"); + }; + match kzg::verify_kzg_proof(&commitment, &z, &y, &proof) { + Ok(verified) => expect_eq(Some(verified), output.as_bool()), + Err(err) => expect_rejected(output, &format!("{err:?}")), + } +} + +fn check_verify_blob_kzg_proof(input: &Value, output: &Value) -> Result<(), String> { + let blob = hex_bytes(&input["blob"]); + let (Some(commitment), Some(proof)) = ( + hex_commitment(&input["commitment"]), + hex_proof(&input["proof"]), + ) else { + return expect_rejected(output, "commitment or proof is the wrong length"); + }; + match kzg::verify_blob_kzg_proof(&blob, &commitment, &proof) { + Ok(verified) => expect_eq(Some(verified), output.as_bool()), + Err(err) => expect_rejected(output, &format!("{err:?}")), + } +} + +fn check_verify_blob_kzg_proof_batch(input: &Value, output: &Value) -> Result<(), String> { + // Blobs are decoded unconditionally, unlike commitments and proofs below: + // a batch member's own length check happens inside `verify_blob_kzg_proof_batch` + // itself (`to_blob`, per element), not here. + let blobs = hex_list(&input["blobs"], hex_bytes); + let blob_refs: Vec<&[u8]> = blobs.iter().map(Vec::as_slice).collect(); + let (Some(commitments), Some(proofs)) = ( + hex_list_opt(&input["commitments"], hex_commitment), + hex_list_opt(&input["proofs"], hex_proof), + ) else { + return expect_rejected(output, "a commitment or proof is the wrong length"); + }; + match kzg::verify_blob_kzg_proof_batch(&blob_refs, &commitments, &proofs) { + Ok(verified) => expect_eq(Some(verified), output.as_bool()), + Err(err) => expect_rejected(output, &format!("{err:?}")), + } +} + +/// `compute_challenge`'s fixtures carry no `output: null` case: unlike every +/// handler above, hashing a Fiat-Shamir transcript never fails on the +/// *content* of `blob` or `commitment`, only on `blob`'s length, which +/// [`kzg::compute_challenge`] itself checks (see its own doc). The `Err` arm +/// stays here anyway, matching every other handler, so a future fixture that +/// does exercise that length check is still handled rather than silently +/// mismatching the return type. +fn check_compute_challenge(input: &Value, output: &Value) -> Result<(), String> { + let blob = hex_bytes(&input["blob"]); + let Some(commitment) = hex_commitment(&input["commitment"]) else { + return expect_rejected(output, "commitment is not 48 bytes"); + }; + match kzg::compute_challenge(&blob, &commitment) { + Ok(challenge) => expect_eq(Some(challenge), hex_bytes32(output)), + Err(err) => expect_rejected(output, &format!("{err:?}")), + } +} + +fn check_compute_cells(input: &Value, output: &Value) -> Result<(), String> { + let blob = hex_bytes(&input["blob"]); + match kzg::compute_cells(&blob) { + Ok(cells) => { + let expected = output + .as_sequence() + .ok_or_else(|| format!("output {output:?} is not a list of cells"))?; + expect_cells_eq(cells.as_ref(), expected) + } + Err(err) => expect_rejected(output, &format!("{err:?}")), + } +} + +fn check_compute_cells_and_kzg_proofs(input: &Value, output: &Value) -> Result<(), String> { + let blob = hex_bytes(&input["blob"]); + match kzg::compute_cells_and_kzg_proofs(&blob) { + Ok((cells, proofs)) => { + let expected = output + .as_sequence() + .ok_or_else(|| format!("output {output:?} is not [cells, proofs]"))?; + let expected_cells = expected[0] + .as_sequence() + .ok_or_else(|| "cells is not a list".to_string())?; + let expected_proofs = expected[1] + .as_sequence() + .ok_or_else(|| "proofs is not a list".to_string())?; + expect_cells_eq(cells.as_ref(), expected_cells)?; + expect_proofs_eq(proofs.as_ref(), expected_proofs) + } + Err(err) => expect_rejected(output, &format!("{err:?}")), + } +} + +fn check_recover_cells_and_kzg_proofs(input: &Value, output: &Value) -> Result<(), String> { + let cell_indices = u64_list(&input["cell_indices"]); + let Some(cells) = hex_list_opt(&input["cells"], hex_cell) else { + return expect_rejected(output, "a cell is the wrong length"); + }; + match kzg::recover_cells_and_kzg_proofs(&cell_indices, &cells) { + Ok((recovered_cells, recovered_proofs)) => { + let expected = output + .as_sequence() + .ok_or_else(|| format!("output {output:?} is not [cells, proofs]"))?; + let expected_cells = expected[0] + .as_sequence() + .ok_or_else(|| "cells is not a list".to_string())?; + let expected_proofs = expected[1] + .as_sequence() + .ok_or_else(|| "proofs is not a list".to_string())?; + expect_cells_eq(recovered_cells.as_ref(), expected_cells)?; + expect_proofs_eq(recovered_proofs.as_ref(), expected_proofs) + } + Err(err) => expect_rejected(output, &format!("{err:?}")), + } +} + +fn check_verify_cell_kzg_proof_batch(input: &Value, output: &Value) -> Result<(), String> { + // A length mismatch *between* the four arrays (some `invalid_missing_*` + // cases supply fewer cells than cell_indices, for instance) is not caught + // here: every element still decodes at its own correct size, so + // `hex_list_opt` succeeds, and it is `verify_cell_kzg_proof_batch` itself + // that rejects the mismatched lengths (see its own doc). + let cell_indices = u64_list(&input["cell_indices"]); + let (Some(commitments), Some(cells), Some(proofs)) = ( + hex_list_opt(&input["commitments"], hex_commitment), + hex_list_opt(&input["cells"], hex_cell), + hex_list_opt(&input["proofs"], hex_proof), + ) else { + return expect_rejected(output, "a commitment, cell, or proof is the wrong length"); + }; + match kzg::verify_cell_kzg_proof_batch(&commitments, &cell_indices, &cells, &proofs) { + Ok(verified) => expect_eq(Some(verified), output.as_bool()), + Err(err) => expect_rejected(output, &format!("{err:?}")), + } +} + +/// Like `compute_challenge`, this fixture set carries no `output: null` case, +/// for the same reason: the transcript is raw bytes and indices, hashed, with +/// no curve-point validation along the way. What it can still fail on is the +/// array-length agreement [`kzg::compute_verify_cell_kzg_proof_batch_challenge`] +/// checks itself (`commitment_indices`, `cell_indices`, and `proofs` one entry +/// per coset), so the `Err` arm is kept for the same forward-compatibility +/// reason `check_compute_challenge` keeps its own. +fn check_compute_verify_cell_kzg_proof_batch_challenge( + input: &Value, + output: &Value, +) -> Result<(), String> { + let commitment_indices = u64_list(&input["commitment_indices"]); + let cell_indices = u64_list(&input["cell_indices"]); + let (Some(commitments), Some(cosets_evals), Some(proofs)) = ( + hex_list_opt(&input["commitments"], hex_commitment), + hex_list_opt(&input["cosets_evals"], |coset| { + hex_list_opt(coset, hex_bytes32) + }), + hex_list_opt(&input["proofs"], hex_proof), + ) else { + return expect_rejected( + output, + "a commitment, coset evaluation, or proof is the wrong length", + ); + }; + match kzg::compute_verify_cell_kzg_proof_batch_challenge( + &commitments, + &commitment_indices, + &cell_indices, + &cosets_evals, + &proofs, + ) { + Ok(challenge) => expect_eq(Some(challenge), hex_bytes32(output)), + Err(err) => expect_rejected(output, &format!("{err:?}")), + } +} + +/// Dispatches one case to the check function its handler names. +/// +/// See the module doc for why this matches on `handler` alone rather than on +/// `(handler, fork)`. +fn run(handler: &str, case: &Case) -> Result<(), String> { + let data: Value = case.yaml("data"); + let input = &data["input"]; + let output = &data["output"]; + + match handler { + "blob_to_kzg_commitment" => check_blob_to_kzg_commitment(input, output), + "compute_kzg_proof" => check_compute_kzg_proof(input, output), + "compute_blob_kzg_proof" => check_compute_blob_kzg_proof(input, output), + "verify_kzg_proof" => check_verify_kzg_proof(input, output), + "verify_blob_kzg_proof" => check_verify_blob_kzg_proof(input, output), + "verify_blob_kzg_proof_batch" => check_verify_blob_kzg_proof_batch(input, output), + "compute_challenge" => check_compute_challenge(input, output), + "compute_cells" => check_compute_cells(input, output), + "compute_cells_and_kzg_proofs" => check_compute_cells_and_kzg_proofs(input, output), + "recover_cells_and_kzg_proofs" => check_recover_cells_and_kzg_proofs(input, output), + "verify_cell_kzg_proof_batch" => check_verify_cell_kzg_proof_batch(input, output), + "compute_verify_cell_kzg_proof_batch_challenge" => { + check_compute_verify_cell_kzg_proof_batch_challenge(input, output) + } + // A fixture release that adds a thirteenth handler must fail loudly + // here rather than pass by never being dispatched: an unmatched + // handler has no check function to fall back on, unlike a fork this + // crate has not implemented yet, which `case_trial` marks ignored + // instead of failed. + other => Err(format!("unhandled kzg handler `{other}`")), + } +} + +pub fn trials() -> Vec { + let cases = collect_all_handlers("general", "kzg"); + let mut trials = vec![super::discovery_trial("kzg", cases.len())]; + + for (handler, case) in cases { + trials.push(super::case_trial("kzg", case, move |case| { + run(&handler, case) + })); + } + + trials +} diff --git a/crates/blockchain/state_transition/tests/beacon_spec/merkle_proof.rs b/crates/blockchain/state_transition/tests/beacon_spec/merkle_proof.rs new file mode 100644 index 000000000..df6308eb4 --- /dev/null +++ b/crates/blockchain/state_transition/tests/beacon_spec/merkle_proof.rs @@ -0,0 +1,161 @@ +//! The `merkle_proof` runner, for fulu's `blob_kzg_commitments` branch. +//! +//! Each case carries a `BeaconBlockBody` and the branch proving its +//! `blob_kzg_commitments` list root sits at its claimed position. This is the +//! one suite that pins the two numbers +//! `verify_data_column_sidecar_inclusion_proof` hard-codes: the depth, and the +//! subtree index the branch was generated for. A wrong index verifies nothing +//! and rejects every honest sidecar. +//! +//! The fixture's `leaf_index` is the *generalized* index, so the subtree index +//! is that minus the tree's leaf offset, which is two to the +//! `KZG_COMMITMENTS_INCLUSION_PROOF_DEPTH`th power: Fulu's body's field count +//! rounds up to that many leaves. +//! +//! Cases are selected by name, not fork: deneb and electra ship a differently +//! shaped suite, `blob_kzg_commitment_merkle_proof`, singular: a branch per +//! commitment rather than one for the whole list, which is what fulu's plural +//! `blob_kzg_commitments_merkle_proof` replaced. A later fork reusing fulu's +//! shape would match the same name pattern and land on `case_trial`'s own +//! ignored-until-implemented gate, rather than being dropped here without a +//! trace. +//! +//! Each case checks `is_valid_merkle_branch` directly against the fixture's +//! own numbers first, then again through `verify_data_column_sidecar_inclusion_proof` +//! itself with a sidecar built from the fixture: the first half proves the +//! depth and index are right in isolation, the second proves the production +//! function is actually wired to them, and that pairing a valid column with +//! an unrelated block's header is rejected. + +use ethlambda_state_transition::beacon::containers::electra::BeaconBlockBody; +use ethlambda_state_transition::beacon::containers::{ + BeaconBlockHeader, SignedBeaconBlockHeader, fulu, +}; +use ethlambda_state_transition::beacon::fork_choice::verify_data_column_sidecar_inclusion_proof; +use ethlambda_state_transition::beacon::helpers::misc::is_valid_merkle_branch; +use ethlambda_state_transition::beacon::preset; +use ethlambda_state_transition::beacon::primitives::{Bytes32, HashTreeRoot as _}; +use libssz::SszDecode as _; +use libtest_mimic::Trial; + +use super::{PRESET, collect}; + +#[derive(serde::Deserialize)] +struct Proof { + leaf: String, + leaf_index: u64, + branch: Vec, +} + +/// Parses a fixture's `0x`-prefixed 32-byte hex string. +/// +/// Duplicated rather than shared, matching how each runner in this test suite +/// keeps its own parse-and-strip helper (see `fork_choice`'s `parse_root`). +fn bytes32(hex_string: &str) -> Bytes32 { + let stripped = hex_string.strip_prefix("0x").unwrap_or(hex_string); + let bytes = hex::decode(stripped).expect("a hex-encoded 32-byte value"); + Bytes32::from_slice(&bytes) +} + +pub fn trials() -> Vec { + let cases: Vec<_> = collect(PRESET, "merkle_proof", "single_merkle_proof") + .into_iter() + .filter(|case| case.name.contains("blob_kzg_commitments_")) + .collect(); + + let mut trials = vec![super::discovery_trial("merkle_proof", cases.len())]; + + for case in cases { + trials.push(super::case_trial("merkle_proof", case, move |case| { + let body = BeaconBlockBody::from_ssz_bytes(&case.ssz_bytes("object")) + .map_err(|err| format!("body decode failed: {err:?}"))?; + let proof: Proof = case.yaml("proof"); + + let leaf = body.blob_kzg_commitments.hash_tree_root(); + if leaf != bytes32(&proof.leaf) { + return Err(format!( + "the commitments list root {leaf:?} is not the fixture's leaf {}", + proof.leaf + )); + } + + let depth = preset::KZG_COMMITMENTS_INCLUSION_PROOF_DEPTH as u64; + let subtree_index = proof.leaf_index - 2u64.pow(depth as u32); + let branch: Vec = proof.branch.iter().map(|node| bytes32(node)).collect(); + + if branch.len() as u64 != depth { + return Err(format!( + "the fixture branch is {} deep, the preset says {depth}", + branch.len() + )); + } + + if !is_valid_merkle_branch(leaf, &branch, depth, subtree_index, body.hash_tree_root()) { + return Err("the fixture branch did not verify against the body root".to_string()); + } + + // The index is load-bearing, not decoration: the same branch at a + // neighbouring position must fail, or the check proves nothing. + if is_valid_merkle_branch( + leaf, + &branch, + depth, + subtree_index ^ 1, + body.hash_tree_root(), + ) { + return Err("the branch verified at the wrong index".to_string()); + } + + // The checks above only prove is_valid_merkle_branch itself is + // sound at this depth and index; they never call + // verify_data_column_sidecar_inclusion_proof, so a wrong constant + // wired into *that* function would slip past every case above. + // Build a sidecar naming this body's root and drive the real + // function with it, fields verify_data_column_sidecar (the + // structural check) already owns left at defaults. + let sidecar = fulu::DataColumnSidecar { + index: 0, + column: Default::default(), + kzg_commitments: body.blob_kzg_commitments.clone(), + kzg_proofs: Default::default(), + signed_block_header: SignedBeaconBlockHeader { + message: BeaconBlockHeader { + slot: 0, + proposer_index: 0, + parent_root: Bytes32::ZERO, + state_root: Bytes32::ZERO, + body_root: body.hash_tree_root(), + }, + signature: Default::default(), + }, + kzg_commitments_inclusion_proof: branch + .try_into() + .map_err(|err| format!("branch is not depth-sized: {err:?}"))?, + }; + + if !verify_data_column_sidecar_inclusion_proof(&sidecar) { + return Err( + "verify_data_column_sidecar_inclusion_proof rejected a fixture-valid sidecar" + .to_string(), + ); + } + + // The property this whole check exists for: pairing a genuinely + // valid column with a different block's header must fail, since + // the KZG checks alone cannot tell the two apart. + let mut wrong_block = sidecar; + wrong_block.signed_block_header.message.body_root = Bytes32::repeat_byte(0xff); + if verify_data_column_sidecar_inclusion_proof(&wrong_block) { + return Err( + "verify_data_column_sidecar_inclusion_proof accepted a sidecar against an \ + unrelated block" + .to_string(), + ); + } + + Ok(()) + })); + } + + trials +} diff --git a/crates/blockchain/state_transition/tests/beacon_spec/mod.rs b/crates/blockchain/state_transition/tests/beacon_spec/mod.rs new file mode 100644 index 000000000..7b2024105 --- /dev/null +++ b/crates/blockchain/state_transition/tests/beacon_spec/mod.rs @@ -0,0 +1,491 @@ +//! Loading the Ethereum consensus spec test fixtures. +//! +//! The fixture tree is the definition of correctness for this crate. Every +//! runner below discovers its cases from disk rather than listing them, so a +//! fixture release that adds cases is picked up without touching code, and a +//! suite that silently stops matching any case fails instead of reporting green. +//! +//! # Layout +//! +//! The three release tarballs all unpack to `tests//...`, so they extract +//! into one directory: +//! +//! ```text +//! consensus-spec-tests/tests/////// +//! ``` +//! +//! `` is `general` for the configuration-independent suites (BLS, KZG), +//! and the preset name otherwise. Since the preset is compiled in, a test run +//! walks only the tree matching its own build. +//! +//! # File formats +//! +//! State and container files are `.ssz_snappy`: SSZ, compressed with *raw* +//! snappy, not the framed format. Everything else is YAML. + +#![allow( + dead_code, + reason = "each runner uses a different part of this harness" +)] + +pub mod bls; +pub mod epoch_processing; +pub mod fork; +pub mod fork_choice; +pub mod genesis; +pub mod gossip; +pub mod harness; +pub mod kzg; +pub mod merkle_proof; +pub mod networking; +pub mod operations; +pub mod rewards; +pub mod sanity; +pub mod shuffling; +pub mod ssz_static; +pub mod sync; +pub mod transition; + +use std::collections::BTreeSet; +use std::fs; +use std::path::{Path, PathBuf}; + +use ethlambda_state_transition::beacon::ForkName; +use ethlambda_state_transition::beacon::containers::BeaconState; +use libssz::SszDecode; +use libtest_mimic::{Failed, Trial}; +use serde::de::DeserializeOwned; + +/// The configuration directory whose fixtures match the compiled-in preset. +pub const PRESET: &str = if cfg!(feature = "preset-minimal") { + "minimal" +} else { + "mainnet" +}; + +/// The newest fork whose state transition this crate implements. +/// +/// Every runner gates on this, and a case past it becomes an ignored test +/// rather than a missing one, so the output can never imply more coverage than +/// exists. Turning on a fork is a one-line change here once its state +/// transition lands: bump this constant and every runner picks up that fork's +/// cases on its own, since [`case_trial`] reads the gate itself. A runner may +/// still need its own edit to *map* a fork's new or changed handlers to the +/// right function, since that mapping is specific to each runner; only the gate +/// is one line. +pub const HIGHEST_IMPLEMENTED_FORK: ForkName = ForkName::Fulu; + +/// The root of the extracted fixture tree. +/// +/// Panics rather than skipping when the fixtures are absent. A spec suite that +/// reports success because it found nothing to run is worse than one that fails. +pub fn fixture_root() -> PathBuf { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../../consensus-spec-tests") + .join("tests"); + assert!( + root.is_dir(), + "spec test fixtures are missing from {}; run `make consensus-spec-tests`", + root.display() + ); + root +} + +/// The root of the gossip vector tree. +/// +/// The gossip vectors ship in a newer release than [`fixture_root`]'s, so they +/// live in a tree of their own; see `CONSENSUS_SPEC_GOSSIP_TESTS_VERSION` in +/// the Makefile. Panics when absent, for the same reason [`fixture_root`] does. +pub fn gossip_fixture_root() -> PathBuf { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../../consensus-spec-tests-gossip") + .join("tests"); + assert!( + root.is_dir(), + "gossip test fixtures are missing from {}; run `make consensus-spec-gossip-tests`", + root.display() + ); + root +} + +/// One fixture case: a directory of input and expected-output files. +#[derive(Debug, Clone)] +pub struct Case { + /// The case directory. + pub path: PathBuf, + /// The fork whose rules apply. + pub fork: ForkName, + /// The suite directory name, which groups related cases. + pub suite: String, + /// The case directory name, which is what test output should identify. + pub name: String, +} + +impl Case { + /// A human-readable identifier for failure messages. + /// + /// Includes the two directory levels above the case, since case names repeat + /// across handlers and a bare name would not say which one failed. + pub fn id(&self) -> String { + let handler = self + .path + .parent() + .and_then(|suite| suite.parent()) + .and_then(|handler| handler.file_name()) + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_default(); + format!("{}/{}/{}/{}", self.fork, handler, self.suite, self.name) + } + + /// Whether the case carries the named file. + /// + /// Absence is meaningful: an operations case with no `post` expects the + /// operation to be rejected. + pub fn has(&self, name: &str) -> bool { + self.path.join(format!("{name}.ssz_snappy")).is_file() + || self.path.join(format!("{name}.yaml")).is_file() + } + + /// Reads and decompresses `.ssz_snappy`, returning the SSZ bytes. + /// + /// Containers whose shape depends on the fork are decoded by the caller, + /// which knows the fork, so this stops at the bytes. + pub fn ssz_bytes(&self, name: &str) -> Vec { + let path = self.path.join(format!("{name}.ssz_snappy")); + let compressed = + fs::read(&path).unwrap_or_else(|err| panic!("reading {}: {err}", path.display())); + snap::raw::Decoder::new() + .decompress_vec(&compressed) + .unwrap_or_else(|err| panic!("decompressing {}: {err}", path.display())) + } + + /// Decodes `.ssz_snappy` into a fork-independent container. + pub fn ssz(&self, name: &str) -> T { + let bytes = self.ssz_bytes(name); + T::from_ssz_bytes(&bytes) + .unwrap_or_else(|err| panic!("decoding {} of {}: {err:?}", name, self.id())) + } + + /// Decodes an indexed file such as `blocks_0.ssz_snappy`. + pub fn ssz_bytes_indexed(&self, name: &str, index: usize) -> Vec { + self.ssz_bytes(&format!("{name}_{index}")) + } + + /// Parses `.yaml`. + pub fn yaml(&self, name: &str) -> T { + let path = self.path.join(format!("{name}.yaml")); + let text = fs::read_to_string(&path) + .unwrap_or_else(|err| panic!("reading {}: {err}", path.display())); + serde_yaml_ng::from_str(&text) + .unwrap_or_else(|err| panic!("parsing {}: {err}", path.display())) + } + + /// Parses `.yaml` if it exists. + pub fn yaml_opt(&self, name: &str) -> Option { + self.path + .join(format!("{name}.yaml")) + .is_file() + .then(|| self.yaml(name)) + } + + /// Whether this case's fork has a state transition this crate implements. + /// + /// The one gate every runner checks before running a case. Forks after + /// [`HIGHEST_IMPLEMENTED_FORK`] fail this, and [`case_trial`] marks such a + /// case ignored rather than running it, so a fixture release this crate has + /// only partially caught up with is never misreported as fully covered. + pub fn in_scope(&self) -> bool { + self.fork <= HIGHEST_IMPLEMENTED_FORK + } +} + +/// Collects every case for one runner and handler across all supported forks. +/// +/// `config` selects the fixture tree: [`PRESET`] for the preset-dependent +/// suites, or `"general"` for the configuration-independent ones. Forks this +/// crate does not implement are skipped, so an upstream release that adds a fork +/// does not break the build. +pub fn collect(config: &str, runner: &str, handler: &str) -> Vec { + collect_in(&fixture_root(), config, runner, handler) +} + +/// Cases for one handler of the `networking` runner, from the gossip tree. +pub fn collect_gossip(config: &str, handler: &str) -> Vec { + collect_in(&gossip_fixture_root(), config, "networking", handler) +} + +/// [`collect`] over any fixture tree laid out like the release tarballs. +fn collect_in(root: &Path, config: &str, runner: &str, handler: &str) -> Vec { + let root = root.join(config); + let mut cases = Vec::new(); + + for fork_entry in read_dir_sorted(&root) { + let Some(fork) = fork_entry.file_name().to_str().and_then(ForkName::parse) else { + continue; + }; + + let handler_dir = fork_entry.path().join(runner).join(handler); + collect_suites(&handler_dir, fork, &mut |case| cases.push(case)); + } + + cases +} + +/// Collects cases for one runner across every handler it has. +/// +/// Returns pairs of handler name and case, which suits runners like +/// `epoch_processing` where the handler names the sub-function under test. +pub fn collect_all_handlers(config: &str, runner: &str) -> Vec<(String, Case)> { + let root = fixture_root().join(config); + let mut out = Vec::new(); + + for fork_entry in read_dir_sorted(&root) { + let Some(fork) = fork_entry.file_name().to_str().and_then(ForkName::parse) else { + continue; + }; + let runner_dir = fork_entry.path().join(runner); + if !runner_dir.is_dir() { + continue; + } + + for handler_entry in read_dir_sorted(&runner_dir) { + let handler = handler_entry.file_name().to_string_lossy().into_owned(); + // Walk this fork's handler directory directly. Delegating to + // `collect` here would be wrong: `collect` walks every fork itself, + // so nesting it inside this fork loop would yield each case once per + // fork that happens to ship the runner. + collect_suites(&handler_entry.path(), fork, &mut |case| { + out.push((handler.clone(), case)) + }); + } + } + + out +} + +/// Walks the suite and case directories under one fork's handler directory. +/// +/// Shared by both collectors so there is one definition of what a case directory +/// is, and so neither can drift from the other. +fn collect_suites(handler_dir: &Path, fork: ForkName, emit: &mut impl FnMut(Case)) { + if !handler_dir.is_dir() { + return; + } + + for suite_entry in read_dir_sorted(handler_dir) { + let suite = suite_entry.file_name().to_string_lossy().into_owned(); + for case_entry in read_dir_sorted(&suite_entry.path()) { + emit(Case { + path: case_entry.path(), + fork, + suite: suite.clone(), + name: case_entry.file_name().to_string_lossy().into_owned(), + }); + } + } +} + +/// Directory entries, sorted, so a failing run is reproducible and its output +/// is comparable between runs. +fn read_dir_sorted(path: &Path) -> Vec { + let mut entries: Vec<_> = match fs::read_dir(path) { + Ok(entries) => entries.filter_map(Result::ok).collect(), + Err(_) => return Vec::new(), + }; + entries.sort_by_key(fs::DirEntry::file_name); + entries +} + +/// Fixture fork directories this crate deliberately does not model. +/// +/// `gloas` is the fork after fulu, and this crate stops at fulu. `eip7805` is not +/// a fork in the sequence at all: the release ships a directory per in-flight EIP +/// whose cases are generated against a variant of some fork's rules, so there is +/// no [`ForkName`] for it to parse as. +/// +/// Naming them is not bookkeeping for its own sake. A directory [`ForkName::parse`] +/// does not recognize is how [`collect`] skips a fork, and that skip is *silent* +/// in a way [`Case::in_scope`] is not: the cases never become tests at all, so +/// they are not counted as ignored either, and nothing in the output says they +/// exist. This module's own doc promises the opposite, that a suite which stops +/// matching fails rather than reporting green, and an unparsed fork slips past +/// [`HIGHEST_IMPLEMENTED_FORK`] entirely because the gate never sees the case. +/// So [`fixture_fork_trials`] checks this list against the tree instead. +pub const UNMODELED_FORKS: &[&str] = &["gloas", "eip7805"]; + +/// Panics: a fixture case cannot be a lean case. +/// +/// [`ForkName`] carries a `Lean` variant, because `ethlambda-types` holds one +/// fork name for both chains, so every match on a case's own fork needs an arm +/// for it. No case can ever take that arm: a case's fork comes from +/// [`ForkName::parse`] on a fixture directory name, and `Lean` is deliberately +/// absent from `ForkName::ALL`, which is what `parse` searches. Written once +/// here rather than as a catch-all `_` at each site, so that a *real* fork added +/// to the enum still breaks every match that has to grow an arm for it. +/// +/// `#[track_caller]` so the panic reports the runner's own arm rather than this +/// file, matching the two functions the module under test uses for its own +/// lean arms. +#[cold] +#[track_caller] +pub fn lean_is_not_a_fixture_fork(handler: &str) -> ! { + unreachable!( + "ForkName::Lean reached the {handler} fixture handler; \ + a case's fork is parsed from a directory name, and lean is not one" + ) +} + +/// Accounts for every fork directory the fixture release ships. +/// +/// Reports each directory in [`UNMODELED_FORKS`] as an ignored test, so a +/// deliberate exclusion is visible in the output rather than inferred from its +/// absence, and fails when the tree holds a fork directory that is neither +/// parseable nor listed. That failure is the point: a release that adds a fork +/// would otherwise have its cases skipped without a trace, and someone has to +/// decide whether to implement it or name it here. +pub fn fixture_fork_trials() -> Vec { + let mut unknown: Vec = Vec::new(); + let mut unmodeled: BTreeSet = BTreeSet::new(); + + // Both trees, since `collect` is called with `general` for the + // configuration-independent suites as well as with the preset's own name. + for config in [PRESET, "general"] { + for entry in read_dir_sorted(&fixture_root().join(config)) { + if !entry.path().is_dir() { + continue; + } + let name = entry.file_name().to_string_lossy().into_owned(); + if ForkName::parse(&name).is_some() { + continue; + } + if UNMODELED_FORKS.contains(&name.as_str()) { + unmodeled.insert(name); + } else { + unknown.push(format!("{config}/{name}")); + } + } + } + + let mut trials: Vec = unmodeled + .into_iter() + .map(|name| { + Trial::test(format!("fixture_forks/unmodeled/{name}"), || Ok(())) + .with_ignored_flag(true) + }) + .collect(); + + trials.push(Trial::test( + "fixture_forks/every_directory_is_accounted_for", + move || { + if unknown.is_empty() { + return Ok(()); + } + Err(Failed::from(format!( + "the fixture release ships fork directories this harness neither parses nor \ + lists in UNMODELED_FORKS, so every case under them is skipped without \ + appearing anywhere in the output: {}", + unknown.join(", ") + ))) + }, + )); + + trials +} + +/// Wraps one fixture case as a test of its own. +/// +/// Each case becomes a separate entry in the test binary, named for the case +/// rather than for the suite around it, so a failure points at the one case that +/// failed and every other case in that suite still reports its own result. The +/// suites used to be one test apiece, aggregating outcomes and failing as a +/// whole, which meant a single bad case marked thousands of passing ones as part +/// of a failed test and the name in the output was the suite's. +/// +/// The name is prefixed with `runner` so that a filter can select a whole suite +/// (`cargo test --test spec_tests -- operations`) as well as one case. The +/// runner name is not part of [`Case::id`], which starts at the fork, so nothing +/// is repeated here. +/// +/// A case whose fork this crate does not implement is marked ignored rather than +/// run, which is what [`Case::in_scope`] gates. The harness used to tally those +/// itself and print the count; the test harness counts ignored tests already, +/// and names each one, which is strictly more than the tally said. +/// +/// A panic inside `run` fails this case alone: the harness catches it per test. +/// So a fixture that will not decode takes its own case down and no other. +pub fn case_trial( + runner: &str, + case: Case, + run: impl FnOnce(&Case) -> Result<(), String> + Send + 'static, +) -> Trial { + let ignored = !case.in_scope(); + let name = format!("{runner}/{}", case.id()); + Trial::test(name, move || run(&case).map_err(Failed::from)).with_ignored_flag(ignored) +} + +/// Asserts a suite matched at least one fixture case. +/// +/// The aggregate each suite used to report through carried this check, and +/// dropping it along with that aggregate would have quietly given up the thing +/// it guarded: a suite whose +/// fixtures moved, or whose runner or handler name went stale upstream, matches +/// nothing, and a run of no cases at all passes. Per-case tests make that worse +/// rather than better, since a suite that matches nothing now contributes no +/// tests to even look for. Hence one test per suite whose whole job is to fail +/// when the suite is empty. +/// +/// A case that matched but was skipped still counts as matching: the fixture +/// layout is fine, this crate just does not implement that fork yet, which is +/// what the ignored tests report. +pub fn discovery_trial(runner: &str, matched: usize) -> Trial { + let runner = runner.to_string(); + Trial::test(format!("{runner}/matched_fixture_cases"), move || { + if matched == 0 { + return Err(Failed::from(format!( + "{runner} matched no fixture cases; the fixture layout or the suite name is wrong" + ))); + } + Ok(()) + }) +} + +/// Judges a state transition against what the case expects. +/// +/// The rule the whole fixture format rests on: a case with a `post` state must +/// succeed and land exactly on it, and a case *without* one must be rejected. +/// Getting the second half right is what keeps the suites honest, since an +/// implementation that accepted everything would otherwise pass every case that +/// ships a `post`. +/// +/// Comparison is by `hash_tree_root` rather than field by field, because that is +/// the value consensus actually agrees on, and a mismatch anywhere in the state +/// changes it. +pub fn check_transition( + case: &Case, + outcome: Result<(), String>, + state: &BeaconState, +) -> Result<(), String> { + match (case.has("post"), outcome) { + (true, Ok(())) => { + let expected = BeaconState::from_ssz(case.fork, &case.ssz_bytes("post")) + .map_err(|err| format!("the fixture's post-state does not decode: {err:?}"))?; + let actual_root = state.hash_tree_root(); + let expected_root = expected.hash_tree_root(); + if actual_root != expected_root { + return Err(format!( + "post-state root 0x{} != expected 0x{}", + hex::encode(actual_root.0), + hex::encode(expected_root.0) + )); + } + Ok(()) + } + (true, Err(err)) => Err(format!( + "rejected, but the case expects a post-state: {err}" + )), + (false, Ok(())) => { + Err("accepted, but the case has no post-state, so it must be rejected".to_string()) + } + (false, Err(_)) => Ok(()), + } +} diff --git a/crates/blockchain/state_transition/tests/beacon_spec/networking.rs b/crates/blockchain/state_transition/tests/beacon_spec/networking.rs new file mode 100644 index 000000000..ffccca2d1 --- /dev/null +++ b/crates/blockchain/state_transition/tests/beacon_spec/networking.rs @@ -0,0 +1,134 @@ +//! The `networking` runner: das-core's custody selection. +//! +//! Two handlers, both pure functions of public inputs, which is the whole +//! point of the deterministic selection: every peer can compute what every +//! other peer owes without asking. A disagreement here is invisible on the +//! wire until columns are requested from a peer that never custodied them. +//! +//! `node_id` arrives as a decimal integer of up to 78 digits, wider than any +//! Rust primitive, so it is read as a string and parsed into the 32 big-endian +//! bytes the helper takes. + +use ethlambda_state_transition::beacon::das::{ + compute_columns_for_custody_group, get_custody_groups, +}; +use libtest_mimic::Trial; +use num_bigint::BigUint; + +use super::{Case, PRESET, collect_all_handlers}; + +#[derive(serde::Deserialize)] +struct CustodyGroupsMeta { + node_id: String, + custody_group_count: u64, + result: Vec, +} + +#[derive(serde::Deserialize)] +struct ColumnsMeta { + custody_group: u64, + result: Vec, +} + +/// The fixture's decimal node id as 32 big-endian bytes, left-padded. +/// +/// `BigUint::to_bytes_be` emits the minimum number of bytes, so a small node id +/// would land in the low bytes of a zeroed array if it were copied to the +/// front. Padding on the left is what keeps it the same number. +/// +/// The over-32-bytes branch is unreachable against today's fixture tree, since +/// every case's `node_id` tops out at the all-ones 32-byte maximum. Kept +/// anyway as the direct statement of what this parse cannot promise, the same +/// reason `kzg.rs` keeps its own arms for outcomes the current release never +/// exercises. +fn node_id_bytes(decimal: &str) -> Result<[u8; 32], String> { + let value = BigUint::parse_bytes(decimal.trim().as_bytes(), 10) + .ok_or_else(|| format!("node_id {decimal} is not a decimal integer"))?; + let bytes = value.to_bytes_be(); + if bytes.len() > 32 { + return Err(format!("node_id {decimal} does not fit in 32 bytes")); + } + let mut padded = [0u8; 32]; + padded[32 - bytes.len()..].copy_from_slice(&bytes); + Ok(padded) +} + +/// Compares a computed custody index list against the fixture's, the way +/// `shuffling.rs` compares a shuffle: a length mismatch is its own error, +/// otherwise the first differing position is reported alone. `get_custody_groups` +/// cases run up to `NUMBER_OF_CUSTODY_GROUPS` entries long, and printing both +/// full vectors on a mismatch would bury the one index that actually diverged. +fn expect_indices_eq(actual: &[u64], expected: &[u64]) -> Result<(), String> { + if actual.len() != expected.len() { + return Err(format!( + "produced {} entries, fixture expects {}", + actual.len(), + expected.len() + )); + } + for (index, (computed, wanted)) in actual.iter().zip(expected).enumerate() { + if computed != wanted { + return Err(format!("index {index}: got {computed}, expected {wanted}")); + } + } + Ok(()) +} + +fn check_get_custody_groups(case: &Case) -> Result<(), String> { + let meta: CustodyGroupsMeta = case.yaml("meta"); + let node_id = node_id_bytes(&meta.node_id)?; + let computed = + get_custody_groups(node_id, meta.custody_group_count).map_err(|err| format!("{err}"))?; + expect_indices_eq(&computed, &meta.result) +} + +fn check_compute_columns_for_custody_group(case: &Case) -> Result<(), String> { + let meta: ColumnsMeta = case.yaml("meta"); + let computed = + compute_columns_for_custody_group(meta.custody_group).map_err(|err| format!("{err}"))?; + expect_indices_eq(&computed, &meta.result) +} + +/// Dispatches one case to the check function its handler names. +/// +/// The `other` arm is what makes a fixture release adding a third handler +/// under `networking/` visible: without it, an unrecognized handler would +/// simply contribute no trials, and neither `discovery_trial` counts by +/// handler name, so a silently-skipped handler would report nothing wrong. +/// Mirrors `kzg.rs`'s own dispatch for the same reason. +fn run(handler: &str, case: &Case) -> Result<(), String> { + match handler { + "get_custody_groups" => check_get_custody_groups(case), + "compute_columns_for_custody_group" => check_compute_columns_for_custody_group(case), + other => Err(format!("unhandled networking handler `{other}`")), + } +} + +pub fn trials() -> Vec { + let cases = collect_all_handlers(PRESET, "networking"); + + let groups_count = cases + .iter() + .filter(|(handler, _)| handler == "get_custody_groups") + .count(); + let columns_count = cases + .iter() + .filter(|(handler, _)| handler == "compute_columns_for_custody_group") + .count(); + + let mut trials = vec![ + super::discovery_trial("networking/get_custody_groups", groups_count), + super::discovery_trial( + "networking/compute_columns_for_custody_group", + columns_count, + ), + ]; + + for (handler, case) in cases { + trials.push(super::case_trial("networking", case, move |case| { + run(&handler, case) + })); + } + + trials +} diff --git a/crates/blockchain/state_transition/tests/beacon_spec/operations.rs b/crates/blockchain/state_transition/tests/beacon_spec/operations.rs new file mode 100644 index 000000000..e79d29f2e --- /dev/null +++ b/crates/blockchain/state_transition/tests/beacon_spec/operations.rs @@ -0,0 +1,505 @@ +//! The `operations` runner. +//! +//! Each case holds a `pre` state, one operation, and a `post` state. The absence +//! of `post` is the assertion: the operation must be rejected, and a run that +//! accepts it fails. That makes this suite the crate's main check that invalid +//! input is refused rather than absorbed, so the "expected rejection" path gets +//! as much attention here as the success path. +//! +//! The handler names the operation, and the operation's file is named after the +//! handler, except `block_header` (a whole block), `execution_payload` (a whole +//! body, since checking a payload needs the block's blob commitments and, for +//! deneb on, occasionally the version raising that count), and `withdrawals` +//! (a bare `ExecutionPayload`, since the sweep needs nothing else from the +//! body) and `bls_to_execution_change` (named `address_change` in the fixture +//! tree). +//! +//! Unlike [`super::sanity`], which runs whole blocks through +//! [`ethlambda_state_transition::beacon::stf::state_transition`], this calls each operation's own +//! function directly, so nothing here can lean on `process_block`'s per-fork +//! dispatch: every handler that changed shape or behavior between forks has to +//! be routed to the right function by hand, keyed on [`Case::fork`]. Each +//! routing decision below is justified by a "Modified" or "New" marker in the +//! pinned specification's own table of contents for that fork, not by +//! inspection of this crate's source; a handler with no such marker for a +//! given fork keeps calling whichever earlier fork's function last introduced +//! or changed it. +//! +//! - `proposer_slashing` needs no routing at all: no fork's specification ever +//! lists a modified `process_proposer_slashing`, so phase0's function serves +//! every fork this crate implements. +//! - `attester_slashing` and `deposit` need no *function* routing through +//! deneb (neither fork's specification lists either as modified before +//! electra), but `attester_slashing`'s container does change shape at +//! electra (see below), and electra's own specification lists both +//! `process_attester_slashing` (transcribed against the new container, not +//! behaviorally different) and `process_deposit` (behaviorally different: +//! a deposit is queued rather than credited) as its own functions from +//! there on. +//! - `attestation` needs both. The container +//! ([`phase0::Attestation`]) is the one phase0, altair, bellatrix, capella, +//! and deneb all share, and [`electra::Attestation`] (EIP-7549's committee +//! bitfield) takes over at electra. *How* an attestation is scored changes +//! twice more within that container-stable range: phase0 defers to the +//! epoch boundary ([`operations::process_attestation`]), altair scores it +//! immediately ([`altair_stf::process_attestation`]) and that version +//! serves bellatrix and capella too since neither fork's specification +//! modifies it again, and deneb widens the inclusion window and the +//! timely-target condition (EIP-7045) with its own version +//! ([`deneb_stf::process_attestation`]). Calling an earlier fork's version +//! on a later case would not even fail loudly in every instance, since it +//! would return [`ethlambda_state_transition::beacon::Error::UnsupportedForFork`] only where +//! the two forks' state shapes actually differ, which is still a rejection, +//! just not the one the fixture is testing for. +//! - `voluntary_exit` changes twice after phase0: deneb pins the signature to +//! a fixed fork version for EIP-7044, and electra adds a +//! pending-partial-withdrawal check and swaps in electra's own +//! exit-queue accounting for EIP-7251. +//! - `block_header`'s file is a whole `BeaconBlock`, and that type is +//! different per fork (altair's carries a `sync_aggregate` its body root +//! folds in, bellatrix's an execution payload on top of that, and so on). +//! [`block::process_block_header`] itself takes only the four +//! fork-invariant fields a header needs, not a block, precisely so this +//! runner (and every per-fork `process_block_*` driver) can decode with the +//! fork's own concrete type and still call one shared function. +//! - `sync_aggregate` is new in altair, with no phase0 case, and no later +//! fork's specification ever lists a modified version of it, so it needs +//! no fork match beyond that. +//! - `execution_payload` is new in bellatrix and its specification lists a +//! modified version at every fork from capella on: capella and later drop +//! the still-mid-merge-transition check, deneb folds in the blob +//! commitment count, electra reads a different configuration field for +//! that same count, and fulu reads a schedule instead of a fixed field. +//! Five forks, five distinct functions, none reusable for another fork's +//! case. +//! - `bls_to_execution_change` and `withdrawals` are both new in capella. +//! `bls_to_execution_change` is never listed as modified again: its +//! function reads and writes only fork-invariant fields, so capella's own +//! serves every later fork too. `withdrawals` is listed as modified once +//! more, at electra (EIP-7251's partial-withdrawal queue); deneb's own +//! specification lists no change to it at all, so deneb's copy exists only +//! to hold a differently-typed `payload` parameter, not different logic. +//! - `consolidation_request`, `deposit_request`, and `withdrawal_request` are +//! all new in electra, alongside `withdrawals`, `process_attestation`, +//! `process_deposit`, and `process_voluntary_exit`; fulu's specification +//! lists none of the seven as modified again, so electra's functions serve +//! fulu's cases too. +use std::sync::Arc; + +use ethlambda_state_transition::beacon::ForkName; +use ethlambda_state_transition::beacon::config::Config; +use ethlambda_state_transition::beacon::containers::{ + BeaconState, altair, bellatrix, capella, deneb, electra, phase0, shared, +}; +use ethlambda_state_transition::beacon::helpers::accessors::CommitteeCache; +use ethlambda_state_transition::beacon::primitives::HashTreeRoot as _; +use ethlambda_state_transition::beacon::stf::altair as altair_stf; +use ethlambda_state_transition::beacon::stf::bellatrix as bellatrix_stf; +use ethlambda_state_transition::beacon::stf::capella as capella_stf; +use ethlambda_state_transition::beacon::stf::deneb as deneb_stf; +use ethlambda_state_transition::beacon::stf::electra as electra_stf; +use ethlambda_state_transition::beacon::stf::fulu as fulu_stf; +use ethlambda_state_transition::beacon::stf::{ExecutionEngine, block, operations}; +use libtest_mimic::{Failed, Trial}; + +use super::{Case, PRESET, collect_all_handlers, lean_is_not_a_fixture_fork}; + +/// The `{execution_valid: bool}` an `execution_payload` case ships alongside +/// its block, standing in for whatever a real execution client would have +/// answered. See [`ExecutionEngine`]'s own documentation for why this crate +/// collapses that whole interface to one boolean. +#[derive(serde::Deserialize)] +struct ExecutionYaml { + execution_valid: bool, +} + +/// Applies one operation to the case's pre-state. +/// +/// Returns the post-state on success. Anything the crate rejects comes back as an +/// error, which the caller compares against whether the case has a `post`. +fn apply( + handler: &str, + case: &Case, + state: &mut BeaconState, + config: &Config, +) -> Result<(), String> { + // Only the `attestation` handler reads this. Bound up here so each of its + // calls fits on one line; a case processes a single attestation, so there + // is nothing for the cache to share between calls. + let committees = CommitteeCache::default(); + let outcome = match handler { + // Every fork's own `BeaconBlock` carries a different concrete body + // (altair's adds a sync aggregate, bellatrix's an execution payload, + // and so on), but `process_block_header` reads only the four + // fork-invariant fields below, so decoding with the fork's own type + // and then handing those four fields to one shared function is all + // this needs; see this module's own documentation. + "block_header" => match case.fork { + ForkName::Phase0 => { + let block: phase0::BeaconBlock = case.ssz("block"); + block::process_block_header( + state, + block.slot, + block.proposer_index, + block.parent_root, + block.body.hash_tree_root(), + ) + } + ForkName::Altair => { + let block: altair::BeaconBlock = case.ssz("block"); + block::process_block_header( + state, + block.slot, + block.proposer_index, + block.parent_root, + block.body.hash_tree_root(), + ) + } + ForkName::Bellatrix => { + let block: bellatrix::BeaconBlock = case.ssz("block"); + block::process_block_header( + state, + block.slot, + block.proposer_index, + block.parent_root, + block.body.hash_tree_root(), + ) + } + ForkName::Capella => { + let block: capella::BeaconBlock = case.ssz("block"); + block::process_block_header( + state, + block.slot, + block.proposer_index, + block.parent_root, + block.body.hash_tree_root(), + ) + } + ForkName::Deneb => { + let block: deneb::BeaconBlock = case.ssz("block"); + block::process_block_header( + state, + block.slot, + block.proposer_index, + block.parent_root, + block.body.hash_tree_root(), + ) + } + // Electra and fulu share one `BeaconBlock`: fulu's specification + // changes nothing about the block or body shape, only + // `process_execution_payload` (see the `execution_payload` arm + // below), so there is no `fulu::BeaconBlock` to decode. + ForkName::Electra | ForkName::Fulu => { + let block: electra::BeaconBlock = case.ssz("block"); + block::process_block_header( + state, + block.slot, + block.proposer_index, + block.parent_root, + block.body.hash_tree_root(), + ) + } + // No `other` arm: the patterns above already cover every `ForkName` + // there is, so a catch-all here would be dead code rather than a + // safety net. `Lean` is covered by name for the same reason, rather + // than swept into one; see `lean_is_not_a_fixture_fork`. + ForkName::Lean => lean_is_not_a_fixture_fork("block_header"), + }, + "attestation" => match case.fork { + // Phase0 defers every attestation's reward to the epoch boundary. + ForkName::Phase0 => { + let attestation: phase0::Attestation = case.ssz("attestation"); + operations::process_attestation(state, &attestation, config, &committees) + } + // Altair scores an attestation immediately instead, and neither + // bellatrix's nor capella's specification lists a further change, + // so altair's own function serves both. + ForkName::Altair | ForkName::Bellatrix | ForkName::Capella => { + let attestation: phase0::Attestation = case.ssz("attestation"); + altair_stf::process_attestation(state, &attestation, &committees) + } + // Deneb widens the inclusion window and the timely-target + // condition (EIP-7045); the container is still phase0's. + ForkName::Deneb => { + let attestation: phase0::Attestation = case.ssz("attestation"); + deneb_stf::process_attestation(state, &attestation, &committees) + } + // Electra reshapes the container itself (EIP-7549's + // `committee_bits`), and fulu's specification makes no further + // change to either the container or the function. + ForkName::Electra | ForkName::Fulu => { + let attestation: electra::Attestation = case.ssz("attestation"); + electra_stf::process_attestation(state, &attestation, &committees) + } + ForkName::Lean => lean_is_not_a_fixture_fork("attestation"), + }, + "attester_slashing" => match case.fork { + // Unchanged from phase0 through deneb, container included. + ForkName::Phase0 + | ForkName::Altair + | ForkName::Bellatrix + | ForkName::Capella + | ForkName::Deneb => { + let slashing: phase0::AttesterSlashing = case.ssz("attester_slashing"); + operations::process_attester_slashing(state, &slashing, config) + } + // Electra's container widens the same way `Attestation`'s does + // (see the `attestation` arm above); the function itself is not + // behaviorally modified, only transcribed against the new type. + // Fulu's specification changes neither. + ForkName::Electra | ForkName::Fulu => { + let slashing: electra::AttesterSlashing = case.ssz("attester_slashing"); + electra_stf::process_attester_slashing(state, &slashing, config) + } + ForkName::Lean => lean_is_not_a_fixture_fork("attester_slashing"), + }, + // `ProposerSlashing` never changes shape, and no fork's specification + // ever lists a modified `process_proposer_slashing`, so this needs no + // per-fork routing at all. + "proposer_slashing" => { + let slashing: shared::ProposerSlashing = case.ssz("proposer_slashing"); + operations::process_proposer_slashing(state, &slashing, config) + } + "deposit" => match case.fork { + // The `Deposit` container never changes shape; through deneb a + // deposit's amount is credited the moment it is processed. + ForkName::Phase0 + | ForkName::Altair + | ForkName::Bellatrix + | ForkName::Capella + | ForkName::Deneb => { + let deposit: shared::Deposit = case.ssz("deposit"); + operations::process_deposit(state, &deposit, config) + } + // Electra queues a deposit's amount instead of crediting it + // directly (EIP-7251), so the epoch boundary can rate-limit + // activation by balance rather than by validator count; fulu's + // specification makes no further change. + ForkName::Electra | ForkName::Fulu => { + let deposit: shared::Deposit = case.ssz("deposit"); + electra_stf::process_deposit(state, &deposit, config) + } + ForkName::Lean => lean_is_not_a_fixture_fork("deposit"), + }, + "voluntary_exit" => match case.fork { + // Unchanged through capella. + ForkName::Phase0 | ForkName::Altair | ForkName::Bellatrix | ForkName::Capella => { + let exit: shared::SignedVoluntaryExit = case.ssz("voluntary_exit"); + operations::process_voluntary_exit(state, &exit, config) + } + // Deneb pins the signature to `CAPELLA_FORK_VERSION` (EIP-7044), + // so an exit signed long before it takes effect never expires + // out from under its own signer at a later fork boundary. + ForkName::Deneb => { + let exit: shared::SignedVoluntaryExit = case.ssz("voluntary_exit"); + deneb_stf::process_voluntary_exit(state, &exit, config) + } + // Electra adds a pending-partial-withdrawal check and its own + // balance-churn exit-queue accounting (EIP-7251); fulu's + // specification changes neither. + ForkName::Electra | ForkName::Fulu => { + let exit: shared::SignedVoluntaryExit = case.ssz("voluntary_exit"); + electra_stf::process_voluntary_exit(state, &exit, config) + } + ForkName::Lean => lean_is_not_a_fixture_fork("voluntary_exit"), + }, + // New in altair, and never listed as modified again, so this needs no + // further fork match. + "sync_aggregate" => { + let sync_aggregate: altair::SyncAggregate = case.ssz("sync_aggregate"); + altair_stf::process_sync_aggregate(state, &sync_aggregate) + } + // The fixture's operation file is a whole `BeaconBlockBody`, not a + // bare payload: checking one needs fields that live on the body + // alongside it (deneb's and later's blob commitments), not on the + // payload itself. See this module's own documentation for why every + // fork from bellatrix on gets its own arm here. + "execution_payload" => { + let execution: ExecutionYaml = case.yaml("execution"); + let engine = ExecutionEngine { + execution_valid: execution.execution_valid, + }; + match case.fork { + ForkName::Bellatrix => { + let body: bellatrix::BeaconBlockBody = case.ssz("body"); + bellatrix_stf::process_execution_payload( + state, + &body.execution_payload, + config, + &engine, + ) + } + // Capella's specification drops the still-mid-merge-transition + // check bellatrix's version makes, on the grounds that no + // chain reaching capella can still be pre-merge. + ForkName::Capella => { + let body: capella::BeaconBlockBody = case.ssz("body"); + capella_stf::process_execution_payload( + state, + &body.execution_payload, + config, + &engine, + ) + } + // Deneb adds the blob commitment count check (EIP-4844), + // which reads the body's own `blob_kzg_commitments` rather + // than anything on the payload. + ForkName::Deneb => { + let body: deneb::BeaconBlockBody = case.ssz("body"); + deneb_stf::process_execution_payload( + state, + &body.execution_payload, + &body.blob_kzg_commitments, + config, + &engine, + ) + } + // Electra reads a configuration field of its own for that + // same count (EIP-7691) rather than deneb's fixed one, so it + // is not deneb's function under a new name; both take the + // whole body directly instead of a payload and a commitment + // list separately. + ForkName::Electra => { + let body: electra::BeaconBlockBody = case.ssz("body"); + electra_stf::process_execution_payload(state, &body, config, &engine) + } + // Fulu reads a schedule-aware limit instead of electra's + // fixed configuration field (EIP-7892), and carries no + // `BeaconBlockBody` of its own to decode, since fulu changes + // nothing else about the block or body shape. + ForkName::Fulu => { + let body: electra::BeaconBlockBody = case.ssz("body"); + fulu_stf::process_execution_payload(state, &body, config, &engine) + } + other => { + return Err(format!( + "execution_payload has no handler for fork `{other}`" + )); + } + } + } + // New in capella, named `address_change` in the fixture tree even + // though the handler is `bls_to_execution_change`. Reads and writes + // only fork-invariant fields (the validator registry and the + // genesis validators root), and no later fork's specification lists + // a modified version, so capella's own function serves every fork + // from here on. + "bls_to_execution_change" => { + let signed_change: capella::SignedBLSToExecutionChange = case.ssz("address_change"); + capella_stf::process_bls_to_execution_change(state, &signed_change, config) + } + "withdrawals" => match case.fork { + ForkName::Capella => { + let payload: capella::ExecutionPayload = case.ssz("execution_payload"); + capella_stf::process_withdrawals(state, &payload) + } + // Deneb's own specification lists no modified `process_withdrawals` + // at all: this is identical to capella's beyond the type of + // `payload` it takes, since `payload.withdrawals` has to compare + // against a `deneb::ExecutionPayload`, a different Rust type from + // `capella::ExecutionPayload` even though both alias the same + // element type for the list itself. + ForkName::Deneb => { + let payload: deneb::ExecutionPayload = case.ssz("execution_payload"); + deneb_stf::process_withdrawals(state, &payload) + } + // Electra adds the partial-withdrawal queue sweep (EIP-7251); + // fulu's specification changes nothing further, and both share + // deneb's `ExecutionPayload` type for the `payload` parameter, + // the same reuse `execution_payload`'s electra arm above relies + // on. + ForkName::Electra | ForkName::Fulu => { + let payload: deneb::ExecutionPayload = case.ssz("execution_payload"); + electra_stf::process_withdrawals(state, &payload) + } + other => return Err(format!("withdrawals has no handler for fork `{other}`")), + }, + // All three are new in electra, alongside `withdrawals`; fulu's + // specification lists none of them as modified, so electra's own + // functions serve fulu's cases too. + "consolidation_request" => { + let request: electra::ConsolidationRequest = case.ssz("consolidation_request"); + electra_stf::process_consolidation_request(state, &request, config) + } + "deposit_request" => { + let request: electra::DepositRequest = case.ssz("deposit_request"); + electra_stf::process_deposit_request(state, &request) + } + "withdrawal_request" => { + let request: electra::WithdrawalRequest = case.ssz("withdrawal_request"); + electra_stf::process_withdrawal_request(state, &request, config) + } + other => return Err(format!("unhandled operation `{other}`")), + }; + + outcome.map_err(|err| format!("{err:?}")) +} + +pub fn trials() -> Vec { + let config = Arc::new(Config::active()); + let cases = collect_all_handlers(PRESET, "operations"); + let mut trials = vec![super::discovery_trial("operations", cases.len())]; + + for (handler, case) in cases { + let config = Arc::clone(&config); + trials.push(super::case_trial("operations", case, move |case| { + let mut state = BeaconState::from_ssz(case.fork, &case.ssz_bytes("pre")) + .map_err(|err| format!("the fixture's pre-state does not decode: {err:?}"))?; + + let outcome = apply(&handler, case, &mut state, &config); + super::check_transition(case, outcome, &state) + })); + } + + trials.push(Trial::test( + "operations/every_shipped_handler_is_dispatched", + every_shipped_handler_is_dispatched, + )); + + trials +} + +/// Handlers the fixture release ships that this runner does not dispatch. +/// +/// A missing arm in [`apply`] would otherwise be reported per case as a +/// failure, which is correct but noisy. This asserts the set of handlers is +/// the one the runner knows about, so a fixture release that adds an +/// operation fails here, once, with a clear message. The list is flat across +/// forks (it does not say which handler belongs to which fork) because that +/// is exactly what [`apply`]'s per-handler, per-fork match already encodes +/// and enforces at run time; duplicating it here would only give the two a +/// chance to drift apart. +fn every_shipped_handler_is_dispatched() -> Result<(), Failed> { + let known = [ + "attestation", + "attester_slashing", + "block_header", + "bls_to_execution_change", + "consolidation_request", + "deposit", + "deposit_request", + "execution_payload", + "proposer_slashing", + "sync_aggregate", + "voluntary_exit", + "withdrawal_request", + "withdrawals", + ]; + + let mut unknown: Vec = collect_all_handlers(PRESET, "operations") + .into_iter() + .filter(|(_, case): &(String, Case)| case.in_scope()) + .map(|(handler, _)| handler) + .filter(|handler| !known.contains(&handler.as_str())) + .collect(); + unknown.sort_unstable(); + unknown.dedup(); + + if !unknown.is_empty() { + return Err(Failed::from(format!( + "the fixture release ships operations this runner does not dispatch: {unknown:?}" + ))); + } + + Ok(()) +} diff --git a/crates/blockchain/state_transition/tests/beacon_spec/rewards.rs b/crates/blockchain/state_transition/tests/beacon_spec/rewards.rs new file mode 100644 index 000000000..d97853f54 --- /dev/null +++ b/crates/blockchain/state_transition/tests/beacon_spec/rewards.rs @@ -0,0 +1,191 @@ +//! The `rewards` runner. +//! +//! Unlike the other suites, these cases do not compare states. They compare +//! per-component delta vectors directly, each as a `Deltas` container of +//! rewards and penalties indexed by validator. That is a much sharper instrument +//! than a post-state comparison: `process_rewards_and_penalties` sums every +//! component into balances, so a sign error in one component and a compensating +//! error in another would produce the right balances and the wrong deltas. +//! +//! Phase0 and altair ship different components, because altair pays an +//! attestation's reward the moment it is included rather than at the epoch +//! boundary (see [`super::epoch_processing`]'s module doc for the same point +//! made about the epoch-processing steps this reward change is entangled +//! with). Phase0 ships `source_deltas`, `target_deltas`, `head_deltas`, +//! `inclusion_delay_deltas`, and `inactivity_penalty_deltas`. Altair ships the +//! same five minus `inclusion_delay_deltas`, since there is no inclusion-delay +//! reward left to isolate once inclusion itself is when the reward is paid; +//! its first three come from +//! [`altair_helpers::get_flag_index_deltas`] called once per timeliness flag +//! (source, target, head, in that order), and its `inactivity_penalty_deltas` +//! from [`altair_helpers::get_inactivity_penalty_deltas`]. [`components`] +//! below is where each fork's file list is declared, so the absence of +//! `inclusion_delay_deltas` for altair is an expected shape, not a missing +//! file to fall back from. +//! +//! Every fork's own `beacon-chain.md` was checked for a "Modified" +//! `get_flag_index_deltas` or `get_inactivity_penalty_deltas` section, since +//! either would mean altair's four-file shape stops applying somewhere past +//! it. Only bellatrix's carries one, "Modified `get_inactivity_penalty_deltas`", +//! and the change it documents is a swapped denominator constant +//! (`INACTIVITY_PENALTY_QUOTIENT_BELLATRIX` for +//! `INACTIVITY_PENALTY_QUOTIENT_ALTAIR`), not a different set of files or a +//! different function shape; capella, deneb, electra, and fulu redefine +//! neither function at all. So altair's four components, and the two helper +//! functions that produce them, serve every fork through fulu; the fixture +//! directories confirm this, shipping the same four files from altair on. + +use std::sync::Arc; + +use ethlambda_state_transition::beacon::ForkName; +use ethlambda_state_transition::beacon::Result as BeaconResult; +use ethlambda_state_transition::beacon::config::Config; +use ethlambda_state_transition::beacon::constants::{ + TIMELY_HEAD_FLAG_INDEX, TIMELY_SOURCE_FLAG_INDEX, TIMELY_TARGET_FLAG_INDEX, +}; +use ethlambda_state_transition::beacon::containers::BeaconState; +use ethlambda_state_transition::beacon::helpers::altair as altair_helpers; +use ethlambda_state_transition::beacon::preset; +use ethlambda_state_transition::beacon::primitives::Gwei; +use ethlambda_state_transition::beacon::stf::epoch::rewards; +use libssz::SszDecode; +use libssz_derive::{HashTreeRoot, SszDecode, SszEncode}; +use libssz_types::SszList; +use libtest_mimic::Trial; + +use super::{PRESET, collect_all_handlers, lean_is_not_a_fixture_fork}; + +/// One component's reward and penalty per validator, as the fixtures encode it. +/// +/// Declared here rather than in the crate because the specification has no such +/// container: it is purely how the test format packages the two vectors that +/// every delta function returns as a pair. +#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode, HashTreeRoot)] +struct Deltas { + rewards: SszList, + penalties: SszList, +} + +/// One delta function's output: a reward and a penalty per validator. +type DeltaPair = (Vec, Vec); + +/// Compares one component against its fixture file. +fn compare(name: &str, expected: &Deltas, actual: &DeltaPair) -> Result<(), String> { + let (rewards, penalties) = actual; + + for (label, computed, want) in [ + ("rewards", rewards, &*expected.rewards), + ("penalties", penalties, &*expected.penalties), + ] { + if computed.len() != want.len() { + return Err(format!( + "{name} {label}: {} entries, expected {}", + computed.len(), + want.len() + )); + } + // Report the first differing validator rather than the whole vector, + // since a systematic error differs at every index and the first one is + // enough to find it. + if let Some((index, (got, want))) = computed + .iter() + .zip(want.iter()) + .enumerate() + .find(|(_, (got, want))| got != want) + { + return Err(format!( + "{name} {label}: validator {index} got {got}, expected {want}" + )); + } + } + + Ok(()) +} + +/// The named delta components a fork's `rewards` cases ship, in the order the +/// fixture computed them. +/// +/// One place to look up "what should this fork's case directory hold," so +/// [`rewards`] itself does not have to know why the two lists differ. +fn components( + fork: ForkName, + state: &BeaconState, + config: &Config, +) -> Result, String> { + let raw: Vec<(&'static str, BeaconResult)> = match fork { + ForkName::Phase0 => vec![ + ("source_deltas", rewards::get_source_deltas(state, config)), + ("target_deltas", rewards::get_target_deltas(state, config)), + ("head_deltas", rewards::get_head_deltas(state, config)), + ( + "inclusion_delay_deltas", + rewards::get_inclusion_delay_deltas(state, config), + ), + ( + "inactivity_penalty_deltas", + rewards::get_inactivity_penalty_deltas(state, config), + ), + ], + // No `inclusion_delay_deltas` here: see this module's doc for why + // altair has nothing left for that component to compute. Bellatrix + // through fulu reuse this same list; see this module's doc for why + // none of them changes it. + ForkName::Altair + | ForkName::Bellatrix + | ForkName::Capella + | ForkName::Deneb + | ForkName::Electra + | ForkName::Fulu => vec![ + ( + "source_deltas", + altair_helpers::get_flag_index_deltas(state, TIMELY_SOURCE_FLAG_INDEX), + ), + ( + "target_deltas", + altair_helpers::get_flag_index_deltas(state, TIMELY_TARGET_FLAG_INDEX), + ), + ( + "head_deltas", + altair_helpers::get_flag_index_deltas(state, TIMELY_HEAD_FLAG_INDEX), + ), + ( + "inactivity_penalty_deltas", + altair_helpers::get_inactivity_penalty_deltas(state, config), + ), + ], + ForkName::Lean => lean_is_not_a_fixture_fork("rewards"), + }; + + raw.into_iter() + .map(|(name, result)| { + result + .map(|deltas| (name, deltas)) + .map_err(|err| format!("{name}: {err:?}")) + }) + .collect() +} + +pub fn trials() -> Vec { + let config = Arc::new(Config::active()); + let cases = collect_all_handlers(PRESET, "rewards"); + let mut trials = vec![super::discovery_trial("rewards", cases.len())]; + + for (_handler, case) in cases { + let config = Arc::clone(&config); + trials.push(super::case_trial("rewards", case, move |case| { + let state = BeaconState::from_ssz(case.fork, &case.ssz_bytes("pre")) + .map_err(|err| format!("the fixture's pre-state does not decode: {err:?}"))?; + + for (name, computed) in components(case.fork, &state, &config)? { + let bytes = case.ssz_bytes(name); + let expected = Deltas::from_ssz_bytes(&bytes) + .map_err(|err| format!("{name} does not decode: {err:?}"))?; + compare(name, &expected, &computed)?; + } + + Ok(()) + })); + } + + trials +} diff --git a/crates/blockchain/state_transition/tests/beacon_spec/sanity.rs b/crates/blockchain/state_transition/tests/beacon_spec/sanity.rs new file mode 100644 index 000000000..eb18e67a8 --- /dev/null +++ b/crates/blockchain/state_transition/tests/beacon_spec/sanity.rs @@ -0,0 +1,111 @@ +//! The `sanity`, `finality`, and `random` runners. +//! +//! All three have the same shape, so they share one implementation: a `pre` +//! state, a sequence of blocks, and a `post` state, with the absence of `post` +//! meaning some block in the sequence must be rejected. They differ only in what +//! they choose to exercise, which is the whole point of keeping them separate +//! upstream: `finality` drives the four finalization rules, `random` drives +//! pseudo-random operation mixes, and `sanity` covers the ordinary cases. +//! +//! `sanity/slots` is the exception, advancing slots with no blocks at all, which +//! is what isolates epoch processing from block processing. +//! +//! Unlike the `operations` suite, these run the full [`state_transition`], so +//! they check the proposer signature and the committed `state_root` too. + +use std::sync::Arc; + +use ethlambda_state_transition::beacon::config::Config; +use ethlambda_state_transition::beacon::containers::{BeaconState, SignedBeaconBlock}; +use ethlambda_state_transition::beacon::helpers::accessors::CommitteeCache; +use ethlambda_state_transition::beacon::stf::{self, ExecutionEngine}; +use libtest_mimic::Trial; + +use super::{Case, PRESET, collect}; + +#[derive(serde::Deserialize)] +struct BlocksMeta { + blocks_count: usize, +} + +/// Applies every block in the case, stopping at the first one rejected. +fn apply_blocks(case: &Case, state: &mut BeaconState, config: &Config) -> Result<(), String> { + let meta: BlocksMeta = case.yaml("meta"); + + // `sanity/blocks`, `finality`, and `random` ship no `execution.yaml` + // (unlike the fork suites from bellatrix on that exercise + // `notify_new_payload`), so there is no engine answer to read from the + // case. A valid engine is the right default here: these cases are + // testing consensus-layer rules, not execution-payload rejection, so an + // engine that never objects keeps that variable out of the result. + let engine = ExecutionEngine::valid(); + + // Cache the previous block's state root the way `fork_choice::on_block` + // does, so every multi-block case drives `BeaconState::compute_state_root`'s + // cached arm and the `post` comparison proves it changes nothing. Applied + // before the next block and never after the last, so what + // `check_transition` hashes is the specification's own state. + let mut previous_state_root = None; + + // One cache across the case's blocks, as the node holds one across its + // imports, so consecutive blocks of an epoch share its shuffling. + let committees = CommitteeCache::default(); + + for index in 0..meta.blocks_count { + let bytes = case.ssz_bytes_indexed("blocks", index); + let block = SignedBeaconBlock::from_ssz(case.fork, &bytes) + .map_err(|err| format!("block {index} does not decode: {err:?}"))?; + + if let Some(root) = previous_state_root.take() { + state.latest_block_header_mut().state_root = root; + } + + stf::state_transition(state, &block, true, config, &engine, &committees) + .map_err(|err| format!("block {index} rejected: {err:?}"))?; + + previous_state_root = Some(block.state_root()); + } + + Ok(()) +} + +/// Advances the state by the case's slot count, applying no blocks. +fn apply_slots(case: &Case, state: &mut BeaconState, config: &Config) -> Result<(), String> { + // `slots.yaml` holds a bare integer, and it is a count to advance BY, not a + // slot to advance TO. + let count: u64 = case.yaml("slots"); + let target = state.slot() + count; + stf::process_slots(state, target, config).map_err(|err| format!("{err:?}")) +} + +/// Builds one runner and handler pair's trials, since all four share this shape. +fn run(runner: &str, handler: &str, slots: bool) -> Vec { + let config = Arc::new(Config::active()); + let cases = collect(PRESET, runner, handler); + let mut trials = vec![super::discovery_trial(runner, cases.len())]; + + for case in cases { + let config = Arc::clone(&config); + trials.push(super::case_trial(runner, case, move |case| { + let mut state = BeaconState::from_ssz(case.fork, &case.ssz_bytes("pre")) + .map_err(|err| format!("the fixture's pre-state does not decode: {err:?}"))?; + + let outcome = if slots { + apply_slots(case, &mut state, &config) + } else { + apply_blocks(case, &mut state, &config) + }; + super::check_transition(case, outcome, &state) + })); + } + + trials +} + +pub fn trials() -> Vec { + let mut trials = run("sanity", "blocks", false); + trials.extend(run("sanity", "slots", true)); + trials.extend(run("finality", "finality", false)); + trials.extend(run("random", "random", false)); + trials +} diff --git a/crates/blockchain/state_transition/tests/beacon_spec/shuffling.rs b/crates/blockchain/state_transition/tests/beacon_spec/shuffling.rs new file mode 100644 index 000000000..dfe74ad1f --- /dev/null +++ b/crates/blockchain/state_transition/tests/beacon_spec/shuffling.rs @@ -0,0 +1,70 @@ +//! The `shuffling` runner. +//! +//! Each case gives a seed, a set size, and the full permutation the +//! specification's shuffle produces. `mapping[i]` is where index `i` ends up, so +//! checking every entry pins down `compute_shuffled_index` exactly, for every +//! index rather than for a sampled few. +//! +//! This is the only fixture suite that tests the shuffle directly. Everything +//! else depends on it only through committee assignment, where a subtle error +//! would show up as a confusing signature failure much later, so it is worth +//! getting green on its own before any of that is built. + +use ethlambda_state_transition::beacon::helpers::shuffling::compute_shuffled_index; +use ethlambda_state_transition::beacon::primitives::Root; +use libtest_mimic::Trial; + +use super::{PRESET, collect}; + +#[derive(serde::Deserialize)] +struct Mapping { + seed: String, + count: u64, + mapping: Vec, +} + +/// This suite is not gated here: [`super::case_trial`] applies +/// [`super::Case::in_scope`]'s gate itself, so every case collected below is +/// simply handed to it. +pub fn trials() -> Vec { + let cases = collect(PRESET, "shuffling", "core"); + let mut trials = vec![super::discovery_trial("shuffling", cases.len())]; + + for case in cases { + trials.push(super::case_trial("shuffling", case, move |case| { + let mapping: Mapping = case.yaml("mapping"); + + let stripped = mapping.seed.strip_prefix("0x").unwrap_or(&mapping.seed); + let seed_bytes = hex::decode(stripped).expect("the seed is a hex string"); + let seed = Root::from_slice(&seed_bytes); + + if mapping.mapping.len() as u64 != mapping.count { + return Err(format!( + "fixture claims count {} but lists {} entries", + mapping.count, + mapping.mapping.len() + )); + } + + for (index, expected) in mapping.mapping.iter().enumerate() { + let actual = compute_shuffled_index(index as u64, mapping.count, seed) + .map_err(|err| format!("index {index}: {err}"))?; + if actual != *expected { + return Err(format!( + "index {index} shuffled to {actual}, expected {expected}" + )); + } + } + + // An index at the boundary must be rejected rather than wrapping, + // which the fixtures do not cover but the specification asserts. + if compute_shuffled_index(mapping.count, mapping.count, seed).is_ok() { + return Err("an out-of-range index was accepted".to_string()); + } + + Ok(()) + })); + } + + trials +} diff --git a/crates/blockchain/state_transition/tests/beacon_spec/ssz_static.rs b/crates/blockchain/state_transition/tests/beacon_spec/ssz_static.rs new file mode 100644 index 000000000..2e6a092ad --- /dev/null +++ b/crates/blockchain/state_transition/tests/beacon_spec/ssz_static.rs @@ -0,0 +1,349 @@ +//! The `ssz_static` runner. +//! +//! For each case: decode `serialized.ssz_snappy`, check its `hash_tree_root` +//! against `roots.yaml`, and check that re-encoding reproduces the fixture's +//! bytes exactly. That covers all three of the things a container has to get +//! right, and the round trip catches a field that decodes but encodes back +//! differently, which a root check alone can miss. +//! +//! The case's `value.yaml` is deliberately unused. Reading it would mean a serde +//! implementation for every container, and it tests nothing the other two files +//! do not already pin down. +//! +//! Containers this crate has not defined yet fail their own case instead of +//! being passed over quietly, so the output never implies more coverage than +//! there is. `LightClient*` is a special case within that: this crate +//! deliberately does not implement it, so those cases are marked ignored +//! instead of failed, distinct from a genuine gap. See [`trials`] for both. + +use ethlambda_state_transition::beacon::ForkName; +use ethlambda_state_transition::beacon::containers::{ + altair, bellatrix, capella, deneb, electra, fulu, phase0, shared, +}; +use ethlambda_state_transition::beacon::primitives::{HashTreeRoot, Root}; +use libssz::{SszDecode, SszEncode}; +use libtest_mimic::Trial; + +use super::{Case, PRESET, collect_all_handlers}; + +/// Decodes a case, then checks its root and its re-encoding. +fn check(case: &Case) -> Result<(), String> +where + T: SszDecode + SszEncode + HashTreeRoot, +{ + let bytes = case.ssz_bytes("serialized"); + + let value = T::from_ssz_bytes(&bytes).map_err(|err| format!("decode failed: {err:?}"))?; + + let expected: RootFile = case.yaml("roots"); + let expected_root: Root = parse_root(&expected.root); + // Disambiguated because the generic bound brings both this crate's + // convenience trait and libssz's underlying one into scope. + let actual = HashTreeRoot::hash_tree_root(&value); + if actual != expected_root { + return Err(format!( + "hash_tree_root 0x{} != expected {}", + hex::encode(actual.0), + expected.root + )); + } + + let reencoded = value.to_ssz(); + if reencoded != bytes { + return Err(format!( + "re-encoding produced {} bytes, fixture has {}", + reencoded.len(), + bytes.len() + )); + } + + Ok(()) +} + +#[derive(serde::Deserialize)] +struct RootFile { + root: String, +} + +fn parse_root(hex_root: &str) -> Root { + let stripped = hex_root.strip_prefix("0x").unwrap_or(hex_root); + let bytes = hex::decode(stripped).expect("roots.yaml holds a hex string"); + Root::from_slice(&bytes) +} + +pub fn trials() -> Vec { + let cases = collect_all_handlers(PRESET, "ssz_static"); + let mut trials = vec![super::discovery_trial("ssz_static", cases.len())]; + + for (handler, case) in cases { + // `LightClient*` is deliberately out of scope for this crate, so a + // pair with this prefix is marked ignored directly rather than run + // through `case_trial`: `case_trial`'s own ignored flag answers a + // different question (whether the case's *fork* is implemented), and + // every one of these pairs has a fork this crate does implement. The + // name still matches `case_trial`'s own shape so the two stay uniform + // in the test list. + if handler.starts_with("LightClient") { + let name = format!("ssz_static/{}", case.id()); + trials.push(Trial::test(name, || Ok(())).with_ignored_flag(true)); + continue; + } + + trials.push(super::case_trial("ssz_static", case, move |case| { + let fork = case.fork; + + // The fork-invariant containers are checked against every fork's cases, + // since a container that does not change shape should decode identically + // under all of them. That is coverage for free, and it would catch a + // container wrongly believed to be fork-invariant. + match handler.as_str() { + "Fork" => check::(case), + "ForkData" => check::(case), + "Checkpoint" => check::(case), + "Validator" => check::(case), + "AttestationData" => check::(case), + "Eth1Data" => check::(case), + "Eth1Block" => check::(case), + "DepositMessage" => check::(case), + "DepositData" => check::(case), + "Deposit" => check::(case), + "SigningData" => check::(case), + "HistoricalBatch" => check::(case), + "HistoricalSummary" => check::(case), + "BeaconBlockHeader" => check::(case), + "SignedBeaconBlockHeader" => check::(case), + "ProposerSlashing" => check::(case), + "VoluntaryExit" => check::(case), + "SignedVoluntaryExit" => check::(case), + + // Electra reshapes the attestation containers, widening the + // aggregation bits and attesting indices from one committee to a + // whole slot's worth, so phase0's definitions hold only through + // deneb, and electra's take over from electra on. Fulu makes no + // further change here, so electra's types cover it too. + "Attestation" if fork <= ForkName::Deneb => check::(case), + "Attestation" if fork >= ForkName::Electra => check::(case), + "IndexedAttestation" if fork <= ForkName::Deneb => { + check::(case) + } + "IndexedAttestation" if fork >= ForkName::Electra => { + check::(case) + } + "AttesterSlashing" if fork <= ForkName::Deneb => { + check::(case) + } + "AttesterSlashing" if fork >= ForkName::Electra => { + check::(case) + } + "AggregateAndProof" if fork <= ForkName::Deneb => { + check::(case) + } + "AggregateAndProof" if fork >= ForkName::Electra => { + check::(case) + } + "SignedAggregateAndProof" if fork <= ForkName::Deneb => { + check::(case) + } + "SignedAggregateAndProof" if fork >= ForkName::Electra => { + check::(case) + } + // Pre-electra, a lone attester's unaggregated vote reused + // Attestation itself with one bit set, since aggregation_bits was + // already scoped to a single committee. Electra's + // aggregation_bits spans a whole slot, so a lone bit no longer + // says which committee it belongs to, and SingleAttestation + // exists to carry that index explicitly instead. + "SingleAttestation" if fork >= ForkName::Electra => { + check::(case) + } + // The fixtures ship this for every fork even though no state + // after phase0 holds one; that is upstream's choice, and checking + // it costs nothing. + "PendingAttestation" => check::(case), + + // The state changes shape in almost every fork, so it gets one + // arm per fork rather than a range, unlike the block family + // below. + "BeaconState" if fork == ForkName::Phase0 => check::(case), + "BeaconState" if fork == ForkName::Altair => check::(case), + "BeaconState" if fork == ForkName::Bellatrix => { + check::(case) + } + "BeaconState" if fork == ForkName::Capella => check::(case), + "BeaconState" if fork == ForkName::Deneb => check::(case), + "BeaconState" if fork == ForkName::Electra => check::(case), + // Fulu appends proposer_lookahead to electra's state, so unlike + // the block family below it needs its own type here. + "BeaconState" if fork == ForkName::Fulu => check::(case), + + "BeaconBlock" if fork == ForkName::Phase0 => check::(case), + "BeaconBlockBody" if fork == ForkName::Phase0 => { + check::(case) + } + "SignedBeaconBlock" if fork == ForkName::Phase0 => { + check::(case) + } + + "BeaconBlock" if fork == ForkName::Altair => check::(case), + "BeaconBlockBody" if fork == ForkName::Altair => { + check::(case) + } + "SignedBeaconBlock" if fork == ForkName::Altair => { + check::(case) + } + + "BeaconBlock" if fork == ForkName::Bellatrix => { + check::(case) + } + "BeaconBlockBody" if fork == ForkName::Bellatrix => { + check::(case) + } + "SignedBeaconBlock" if fork == ForkName::Bellatrix => { + check::(case) + } + + "BeaconBlock" if fork == ForkName::Capella => check::(case), + "BeaconBlockBody" if fork == ForkName::Capella => { + check::(case) + } + "SignedBeaconBlock" if fork == ForkName::Capella => { + check::(case) + } + + "BeaconBlock" if fork == ForkName::Deneb => check::(case), + "BeaconBlockBody" if fork == ForkName::Deneb => { + check::(case) + } + "SignedBeaconBlock" if fork == ForkName::Deneb => { + check::(case) + } + + // Fulu does not change the block's shape: its body still carries + // exactly the execution_payload and execution_requests electra's + // does, so there is no fulu::BeaconBlock, fulu::BeaconBlockBody, + // or fulu::SignedBeaconBlock to define. Electra's types are + // checked against both forks' cases instead of being duplicated, + // which is the same reasoning that lets the sync committee + // containers below use one type across many forks. + "BeaconBlock" if fork >= ForkName::Electra => check::(case), + "BeaconBlockBody" if fork >= ForkName::Electra => { + check::(case) + } + "SignedBeaconBlock" if fork >= ForkName::Electra => { + check::(case) + } + + // The sync committee containers arrive in altair and, unlike the + // state, do not change shape again, so they are checked against every + // fork from altair on. + "SyncCommittee" if fork >= ForkName::Altair => check::(case), + "SyncAggregate" if fork >= ForkName::Altair => check::(case), + "SyncCommitteeMessage" if fork >= ForkName::Altair => { + check::(case) + } + "SyncCommitteeContribution" if fork >= ForkName::Altair => { + check::(case) + } + "ContributionAndProof" if fork >= ForkName::Altair => { + check::(case) + } + "SignedContributionAndProof" if fork >= ForkName::Altair => { + check::(case) + } + "SyncAggregatorSelectionData" if fork >= ForkName::Altair => { + check::(case) + } + + // The execution payload pair changes shape at bellatrix, capella, + // and deneb, then holds steady: neither electra nor fulu touches + // it, since their own changes land elsewhere (the attestation and + // pending-queue containers above, and fulu's proposer lookahead), + // so deneb's definitions cover deneb, electra, and fulu alike. + "ExecutionPayload" if fork == ForkName::Bellatrix => { + check::(case) + } + "ExecutionPayloadHeader" if fork == ForkName::Bellatrix => { + check::(case) + } + "ExecutionPayload" if fork == ForkName::Capella => { + check::(case) + } + "ExecutionPayloadHeader" if fork == ForkName::Capella => { + check::(case) + } + "ExecutionPayload" if fork >= ForkName::Deneb => { + check::(case) + } + "ExecutionPayloadHeader" if fork >= ForkName::Deneb => { + check::(case) + } + + // Transcribed from fork-choice.md rather than beacon-chain.md, and + // unchanged since the merge introduced it, so one type covers + // every fork that carries it. + "PowBlock" if fork >= ForkName::Bellatrix => check::(case), + + // The withdrawal machinery arrives in capella and does not change + // shape again through fulu. + "Withdrawal" if fork >= ForkName::Capella => check::(case), + "BLSToExecutionChange" if fork >= ForkName::Capella => { + check::(case) + } + "SignedBLSToExecutionChange" if fork >= ForkName::Capella => { + check::(case) + } + + // Blob wire types arrive in deneb and are unchanged by fulu's data + // column sampling: sampling changes how the data behind a blob's + // commitment travels over the network, not the blob sidecar or + // identifier themselves, so both keep deneb's shape through fulu. + "BlobIdentifier" if fork >= ForkName::Deneb => check::(case), + "BlobSidecar" if fork >= ForkName::Deneb => check::(case), + + // Electra's balance-churn queues and execution-layer-triggered + // requests are unchanged by fulu, so one set of types covers both. + "DepositRequest" if fork >= ForkName::Electra => { + check::(case) + } + "WithdrawalRequest" if fork >= ForkName::Electra => { + check::(case) + } + "ConsolidationRequest" if fork >= ForkName::Electra => { + check::(case) + } + "ExecutionRequests" if fork >= ForkName::Electra => { + check::(case) + } + "PendingDeposit" if fork >= ForkName::Electra => { + check::(case) + } + "PendingPartialWithdrawal" if fork >= ForkName::Electra => { + check::(case) + } + "PendingConsolidation" if fork >= ForkName::Electra => { + check::(case) + } + + // Data availability sampling is fulu-only: no earlier fork has + // these containers at all. + "DataColumnSidecar" if fork == ForkName::Fulu => { + check::(case) + } + "MatrixEntry" if fork == ForkName::Fulu => check::(case), + "DataColumnsByRootIdentifier" if fork == ForkName::Fulu => { + check::(case) + } + + // No arm matched, so this crate has no container for this + // handler/fork pair, and this is not `LightClient*` (that + // prefix is filtered out above before this trial is even + // built). That makes this a genuine gap: a handler this crate + // should cover has gone unmapped, so the case fails loudly + // instead of being silently skipped. + _ => Err(format!("no container is mapped for {fork}/{handler}")), + } + })); + } + + trials +} diff --git a/crates/blockchain/state_transition/tests/beacon_spec/sync.rs b/crates/blockchain/state_transition/tests/beacon_spec/sync.rs new file mode 100644 index 000000000..0b50bddb8 --- /dev/null +++ b/crates/blockchain/state_transition/tests/beacon_spec/sync.rs @@ -0,0 +1,269 @@ +//! The `sync` runner: optimistic sync. +//! +//! The fixture format is the `fork_choice` runner's, plus one step kind. An +//! `on_payload_info` step seeds the status a mock execution client returns for +//! one payload, keyed by that payload's execution block hash; a later `block` +//! step then imports a block whose payload has that hash, and the seeded status +//! is what [`fork_choice::on_block`] is given. +//! +//! Only the steps this suite's released cases actually use are handled. There +//! is exactly one case, `from_syncing_to_invalid`, at every fork from bellatrix +//! on and in both presets, and it uses `tick`, `on_payload_info`, `block` and +//! `checks`. A step kind outside that set fails loudly rather than being +//! skipped: a suite that silently ignores what it does not understand reports +//! coverage it does not have. +//! +//! # Why this is not a flag on the `fork_choice` runner +//! +//! The two runners answer to different fixture directories and different step +//! vocabularies, and `fork_choice`'s cases must keep getting +//! [`PayloadValidity::NotRequired`] for every block: no released `fork_choice` +//! case carries an `on_payload_info` step, so any that suddenly consulted a +//! registry would be reading state no fixture set. +//! +//! # The one place a rejected block still changes the store +//! +//! Everywhere else in this test suite, a step expecting `valid: false` can +//! stop there, because a rejected handler call leaves the store as it found +//! it. An `INVALIDATED` payload is the exception the specification writes in: +//! the case's last step rejects a block *and* requires the branch under its +//! condemned ancestor to have left fork choice, which is why the head it then +//! checks is chain a's tip rather than chain b's. + +use std::sync::Arc; + +use ethlambda_state_transition::beacon::config::Config; +use ethlambda_state_transition::beacon::containers::{BeaconState, SignedBeaconBlock}; +use ethlambda_state_transition::beacon::fork_choice::{ + self, PayloadStatusEnum, PayloadStatusV1, PayloadValidity, Store, +}; +use ethlambda_state_transition::beacon::helpers::accessors::CommitteeCache; +use ethlambda_state_transition::beacon::primitives::ExecutionBlockHash; +use libtest_mimic::Trial; + +use super::{Case, PRESET, collect_all_handlers}; + +/// One entry of a case's `steps.yaml`. +/// +/// The same overlay-of-optional-fields shape the `fork_choice` runner's own +/// `Step` uses, narrowed to the kinds this suite's cases carry and widened by +/// [`Step::block_hash`]/[`Step::payload_status`]. +#[derive(serde::Deserialize)] +struct Step { + /// The Unix-second time to advance the store to, for an `on_tick` step. + tick: Option, + /// The `block_` file naming the block for an `on_block` step. + block: Option, + /// An `on_payload_info` step's payload, identified by its execution block + /// hash. Always paired with [`Step::payload_status`]. + block_hash: Option, + /// The status the mock execution client is to return for + /// [`Step::block_hash`]'s payload. + payload_status: Option, + /// Whether this step's call is expected to succeed. + #[serde(default = "default_valid")] + valid: bool, + /// The assertions to check against the current store. + checks: Option, +} + +/// [`Step::valid`]'s default: a step not naming its own validity is expected +/// to succeed. +fn default_valid() -> bool { + true +} + +/// The `payload_status` object of an `on_payload_info` step. +#[derive(serde::Deserialize)] +struct StepPayloadStatus { + status: String, + latest_valid_hash: Option, + validation_error: Option, +} + +/// Parses a fixture's `0x`-prefixed hex execution block hash. +fn parse_execution_block_hash(value: &str) -> Result { + let stripped = value.strip_prefix("0x").unwrap_or(value); + let bytes = hex::decode(stripped).map_err(|err| format!("decoding {value}: {err}"))?; + let array: [u8; 32] = bytes + .try_into() + .map_err(|_| format!("{value} is not 32 bytes"))?; + Ok(ExecutionBlockHash::from(array)) +} + +/// Reads one of the format's status strings. +fn parse_status(name: &str) -> Result { + match name { + "VALID" => Ok(PayloadStatusEnum::Valid), + "INVALID" => Ok(PayloadStatusEnum::Invalid), + "SYNCING" => Ok(PayloadStatusEnum::Syncing), + "ACCEPTED" => Ok(PayloadStatusEnum::Accepted), + "INVALID_BLOCK_HASH" => Ok(PayloadStatusEnum::InvalidBlockHash), + other => Err(format!("unknown payload status {other}")), + } +} + +/// The verdict [`fork_choice::on_block`] gets for this block: whatever an +/// `on_payload_info` step seeded for its own payload's hash. +/// +/// An unseeded payload answers [`PayloadValidity::NotRequired`], which is both +/// what the format implies (its note requires a status to be initialized +/// *before* the corresponding `on_block` step) and what keeps a pre-bellatrix +/// block, which has no payload to seed a status for, behaving as it always did. +/// +/// Goes through [`fork_choice::payload_validity`] rather than matching on the +/// status here, so this suite proves the production reading of +/// `optimistic-sync.md`'s two aliases rather than a copy of it living in a +/// test. +fn seeded_validity(store: &Store, block: &SignedBeaconBlock) -> PayloadValidity { + let Some(el_hash) = block.execution_block_hash() else { + return PayloadValidity::NotRequired; + }; + match store.beacon_payload_status(el_hash) { + Some(status) => fork_choice::payload_validity(&status), + None => PayloadValidity::NotRequired, + } +} + +/// Applies one non-`checks` step, dispatching on which of [`Step::tick`], +/// [`Step::block_hash`] or [`Step::block`] is set. +fn apply_step( + store: &mut Store, + case: &Case, + step: &Step, + config: &Config, + committees: &CommitteeCache, +) -> Result<(), String> { + if let Some(time) = step.tick { + fork_choice::on_tick(store, time, config); + return Ok(()); + } + + if let (Some(block_hash), Some(status)) = (&step.block_hash, &step.payload_status) { + let block_hash = parse_execution_block_hash(block_hash)?; + let latest_valid_hash = match &status.latest_valid_hash { + Some(value) => Some(parse_execution_block_hash(value)?), + None => None, + }; + store.insert_beacon_payload_status( + block_hash, + PayloadStatusV1 { + status: parse_status(&status.status)?, + latest_valid_hash, + validation_error: status.validation_error.clone(), + }, + ); + return Ok(()); + } + + if let Some(name) = &step.block { + return apply_block(store, case, name, step.valid, config, committees); + } + + Err("a step carried no kind this runner handles".to_string()) +} + +/// Decodes a `block` step's block, reads the verdict an earlier +/// `on_payload_info` step seeded for it, calls [`fork_choice::on_block`], and, +/// only if the block was accepted, replays every attestation and attester +/// slashing carried in its body. +/// +/// The replay is what makes this case's head move: chain b's blocks carry the +/// attestations that give it more weight than chain a, so without it the +/// invalidation at the end would have nothing to take back. +fn apply_block( + store: &mut Store, + case: &Case, + name: &str, + expect_valid: bool, + config: &Config, + committees: &CommitteeCache, +) -> Result<(), String> { + let signed_block = super::fork_choice::decode_signed_block(case, name)?; + let validity = seeded_validity(store, &signed_block); + // Collected before `on_block` moves `signed_block` in, so they are still + // available for the replay below after a successful call. + let (attestations, attester_slashings) = fork_choice::block_operations(&signed_block); + + match ( + fork_choice::on_block( + store, + signed_block, + config, + &fork_choice::DataAvailability::NotRequired, + &validity, + committees, + ), + expect_valid, + ) { + (Ok(()), false) => { + return Err(format!( + "{name} was accepted, but the step expects it to be rejected" + )); + } + (Err(err), true) => return Err(format!("{name} was rejected: {err:?}")), + // Correctly rejected. Unlike every other runner's equivalent arm, the + // store is not necessarily untouched: see the module documentation. + (Err(_), false) => return Ok(()), + (Ok(()), true) => {} + } + + for attestation in &attestations { + fork_choice::on_attestation(store, attestation, true, config, committees).map_err( + |err| format!("on_attestation for an attestation carried in {name}: {err:?}"), + )?; + } + for attester_slashing in &attester_slashings { + fork_choice::on_attester_slashing(store, attester_slashing).map_err(|err| { + format!("on_attester_slashing for a slashing carried in {name}: {err:?}") + })?; + } + + Ok(()) +} + +/// Builds the store from the case's anchor and applies every entry of +/// `steps.yaml` in order, stopping at the first one that fails. +/// +/// One [`CommitteeCache`] for the whole case, for the reason the fork-choice +/// runner's own `run_case` gives: this case builds two chains on purpose, so +/// sharing is what tests the cache's key. +fn run_case(case: &Case, config: &Config) -> Result<(), String> { + let anchor_state = BeaconState::from_ssz(case.fork, &case.ssz_bytes("anchor_state")) + .map_err(|err| format!("decoding anchor_state: {err:?}"))?; + let anchor_block = super::fork_choice::decode_anchor_block(case)?; + + let backend = Arc::new(ethlambda_storage::backend::InMemoryBackend::new()); + let mut store = fork_choice::get_forkchoice_store(backend, anchor_state, anchor_block, config) + .map_err(|err| format!("get_forkchoice_store: {err:?}"))?; + + let committees = CommitteeCache::default(); + let steps: Vec = case.yaml("steps"); + for (index, step) in steps.iter().enumerate() { + let outcome = match &step.checks { + Some(checks) => super::fork_choice::apply_checks(&mut store, checks, config), + None => apply_step(&mut store, case, step, config, &committees), + }; + outcome.map_err(|err| format!("step {index}: {err}"))?; + } + + Ok(()) +} + +/// The handler half of [`collect_all_handlers`]'s pair is discarded: every case +/// in this suite runs through [`run_case`] the same way regardless of which +/// handler it came from. +pub fn trials() -> Vec { + let config = Arc::new(Config::active()); + let cases = collect_all_handlers(PRESET, "sync"); + let mut trials = vec![super::discovery_trial("sync", cases.len())]; + + for (_handler, case) in cases { + let config = Arc::clone(&config); + trials.push(super::case_trial("sync", case, move |case| { + run_case(case, &config) + })); + } + + trials +} diff --git a/crates/blockchain/state_transition/tests/beacon_spec/transition.rs b/crates/blockchain/state_transition/tests/beacon_spec/transition.rs new file mode 100644 index 000000000..7e4dd5627 --- /dev/null +++ b/crates/blockchain/state_transition/tests/beacon_spec/transition.rs @@ -0,0 +1,217 @@ +//! The `transition` runner. +//! +//! Every other suite in this harness exercises one fork's rules against a +//! `pre` state already shaped for that fork. This one is the exception: its +//! block sequence starts on one fork and, partway through, crosses into the +//! next one, so the state itself has to change shape mid-run. It is the only +//! suite that drives [`upgrade::upgrade_state`] the way a real chain does, +//! from the middle of applying a chain of blocks rather than as a +//! self-contained fixture of its own, which is also why [`Config`] carries +//! fork activation epochs as runtime fields at all: see its own module doc. +//! +//! See `tests/formats/transition/README.md` in the pinned specification +//! checkout for the format in full. In outline: `meta.yaml` names +//! [`Meta::post_fork`], the fork the case ends on, and [`Meta::fork_epoch`], +//! the epoch that fork activates at (overriding [`Config`]'s own schedule for +//! that one boundary, per [`Config::with_fork_epoch`]'s own doc). The fork the +//! case *starts* on is never written down explicitly: the README states that +//! forks activate strictly in sequence, so it is always [`ForkName::previous`] +//! of `post_fork`. `pre.ssz_snappy` and `post.ssz_snappy` are a +//! [`BeaconState`] of the starting and ending fork respectively; the numbered +//! `blocks_.ssz_snappy` files are [`SignedBeaconBlock`]s, of the +//! starting fork's shape up through [`Meta::fork_block`] and the ending +//! fork's shape from the next index on, or entirely the ending fork's shape +//! if `fork_block` is absent. No released case carries an `execution.yaml`, so +//! [`ExecutionEngine::valid`] is the right default for the same reason +//! `sanity.rs` gives it one: these cases test consensus-layer rules, not +//! execution-payload rejection. +//! +//! The case directory this suite's cases live under is itself named after +//! `post_fork`, which is how [`super::collect`] assigns [`Case::fork`] and +//! therefore how [`Case::in_scope`] gates them: a case whose chain ends on a +//! fork this crate does not implement is skipped whole, without inspecting +//! its `meta.yaml` at all, matching how every other runner uses that gate. +//! [`transition`] still parses `post_fork` back out of `meta.yaml` and checks +//! it against the directory rather than trusting the directory alone, since +//! the directory-encodes-`post_fork` convention is this suite's own layout, +//! not something the format's README commits to. +//! +//! # The upgrade belongs to `process_slots`, so this runner drives blocks only +//! +//! Every fork past phase0 documents the same rule in its own `fork.md` +//! (altair's is representative): once `process_slots` advances `state.slot` to +//! exactly the first slot of that fork's activation epoch, the state's shape +//! must change there and then, inside the loop, before slot processing +//! continues. Altair's `fork.md` also says why it has to live inside the loop +//! rather than around it in the outer `state_transition`: a caller normally +//! only invokes `process_slots` for whatever slot the next block sits at, so +//! with empty slots at the boundary nothing outside `process_slots` ever +//! learns the exact slot the fork activated at in order to upgrade there. +//! +//! [`ethlambda_state_transition::beacon::stf::process_slots`] does this itself, which is what +//! lets this suite call [`stf::state_transition`] for every block exactly as +//! the other suites do, with no fork handling of its own beyond decoding each +//! block at the right shape. An earlier version of this runner drove the +//! upgrade from the outside, because `process_slots` had not yet grown the +//! check; doing that now would upgrade twice and fail, so the workaround is +//! gone rather than merely unused. +//! +//! One thing does remain this runner's own responsibility. A case whose blocks +//! all sit before the boundary still ends on `post_fork`'s shape, since that is +//! what `post.ssz_snappy` is encoded as, and with no post-boundary block to +//! carry `state_transition` across, nothing would advance `state.slot` far +//! enough to trigger the upgrade. [`apply_blocks`] finishes by advancing to the +//! boundary slot for that case, which is the same thing a real chain does when +//! it produces no block for the epoch a fork activates in. + +use std::sync::Arc; + +use ethlambda_state_transition::beacon::ForkName; +use ethlambda_state_transition::beacon::config::Config; +use ethlambda_state_transition::beacon::containers::{BeaconState, SignedBeaconBlock}; +use ethlambda_state_transition::beacon::helpers::accessors::CommitteeCache; +use ethlambda_state_transition::beacon::helpers::misc::compute_start_slot_at_epoch; +use ethlambda_state_transition::beacon::primitives::Epoch; +use ethlambda_state_transition::beacon::stf::{self, ExecutionEngine}; +use libtest_mimic::Trial; + +use super::{Case, PRESET, collect}; + +/// One case's `meta.yaml`. +/// +/// See the module documentation for how [`Self::post_fork`] alone fixes the +/// starting fork too, and for what a missing [`Self::fork_block`] means. +#[derive(serde::Deserialize)] +struct Meta { + /// The fork the chain has finished upgrading to by the last block. Named + /// as a `String` here, rather than parsed straight into a [`ForkName`] by + /// a custom `Deserialize`, because a case whose fork this crate does not + /// recognize still has to produce a readable failure message rather than + /// a serde error pointing at the wrong layer. + post_fork: String, + /// The epoch [`Self::post_fork`] activates at, overriding [`Config`]'s own + /// schedule for this one boundary via [`Config::with_fork_epoch`]. + fork_epoch: Epoch, + /// The index of the last `blocks_.ssz_snappy` file still shaped + /// like the fork before [`Self::post_fork`]. Every later index, up to + /// [`Self::blocks_count`], is shaped like `post_fork` itself. Absent when + /// every block in the case already belongs to `post_fork`, per the + /// format's README. + #[serde(default)] + fork_block: Option, + /// How many `blocks_.ssz_snappy` files the case carries. + blocks_count: usize, +} + +/// Applies every block in the case, letting [`stf::process_slots`] perform the +/// shape upgrade as it advances into the fork's activation epoch. +/// +/// Each block is decoded at its own fork's shape, which is the one thing the +/// case's `meta.yaml` has to be consulted for; from there +/// [`stf::state_transition`] handles a pre-fork and a post-fork block +/// identically. See the module documentation for why the trailing +/// [`stf::process_slots`] call is still needed for a case that never gets a +/// post-boundary block. +fn apply_blocks( + case: &Case, + meta: &Meta, + pre_fork: ForkName, + post_fork: ForkName, + state: &mut BeaconState, + config: &Config, +) -> Result<(), String> { + let config = config.clone().with_fork_epoch(post_fork, meta.fork_epoch); + // A missing `fork_block` means every block already belongs to `post_fork` + // (index 0 included); otherwise `fork_block` is the *last* pre-fork + // index, so the first post-fork one is the index right after it. + let first_post_fork_index = meta.fork_block.map_or(0, |last_pre_fork_index| { + last_pre_fork_index.saturating_add(1) + }); + + let engine = ExecutionEngine::valid(); + + // Cache the previous block's state root, as `sanity::apply_blocks` does and + // for the same reason; here it also covers advancing across a fork + // boundary. + let mut previous_state_root = None; + + // One cache across the case's blocks, as the node holds one across its + // imports, so consecutive blocks of an epoch share its shuffling. + let committees = CommitteeCache::default(); + + for index in 0..meta.blocks_count { + let fork = if index < first_post_fork_index { + pre_fork + } else { + post_fork + }; + + let bytes = case.ssz_bytes_indexed("blocks", index); + let block = SignedBeaconBlock::from_ssz(fork, &bytes) + .map_err(|err| format!("block {index} does not decode as {fork}: {err:?}"))?; + + if let Some(root) = previous_state_root.take() { + state.latest_block_header_mut().state_root = root; + } + + stf::state_transition(state, &block, true, &config, &engine, &committees) + .map_err(|err| format!("block {index} rejected: {err:?}"))?; + + previous_state_root = Some(block.state_root()); + } + + let boundary_slot = compute_start_slot_at_epoch(meta.fork_epoch); + if state.slot() < boundary_slot { + // Only where something advances the state and consumes it; left set + // otherwise, the field would still be there when `check_transition` + // hashes it. + if let Some(root) = previous_state_root.take() { + state.latest_block_header_mut().state_root = root; + } + stf::process_slots(state, boundary_slot, &config) + .map_err(|err| format!("advancing to the fork boundary: {err:?}"))?; + } + + Ok(()) +} + +pub fn trials() -> Vec { + let config = Arc::new(Config::active()); + let cases = collect(PRESET, "transition", "core"); + let mut trials = vec![super::discovery_trial("transition", cases.len())]; + + for case in cases { + let config = Arc::clone(&config); + trials.push(super::case_trial("transition", case, move |case| { + let meta: Meta = case.yaml("meta"); + let post_fork = ForkName::parse(&meta.post_fork).unwrap_or_else(|| { + panic!( + "{}: meta.yaml's post_fork ({}) is not a fork this crate recognizes", + case.id(), + meta.post_fork + ) + }); + // The case directory encodes `post_fork` too (see the module doc), so + // this checks the harness's own assumption about the fixture layout + // rather than anything about this crate's state transition. A panic + // here fails this one case, same as everywhere else in this closure. + assert_eq!( + post_fork, + case.fork, + "{}: meta.yaml's post_fork does not match the case's own directory", + case.id() + ); + let pre_fork = post_fork + .previous() + .unwrap_or_else(|| panic!("{}: post_fork can never be phase0", case.id())); + + let mut state = BeaconState::from_ssz(pre_fork, &case.ssz_bytes("pre")) + .map_err(|err| format!("the fixture's pre-state does not decode: {err:?}"))?; + + let outcome = apply_blocks(case, &meta, pre_fork, post_fork, &mut state, &config); + super::check_transition(case, outcome, &state) + })); + } + + trials +} diff --git a/crates/blockchain/state_transition/tests/beacon_spec_tests.rs b/crates/blockchain/state_transition/tests/beacon_spec_tests.rs new file mode 100644 index 000000000..40f3a0331 --- /dev/null +++ b/crates/blockchain/state_transition/tests/beacon_spec_tests.rs @@ -0,0 +1,58 @@ +//! The Ethereum consensus spec test suites. +//! +//! One integration binary holds every runner, so the fixture harness in +//! [`beacon_spec`] is compiled once and each runner lives in its own file with a single +//! owner. Add a runner by adding a module to [`beacon_spec`] and one line below. +//! +//! Run with `make test-beacon`, which downloads the fixtures and builds the +//! crate once per preset. +//! +//! # Why this binary supplies its own harness +//! +//! Built with `harness = false`, so [`main`] below is the entry point instead of +//! the one the `#[test]` attribute generates. The reason is that a fixture case +//! is not known until the fixture tree is walked, and `#[test]` needs its tests +//! at compile time. Under the generated harness the only thing a suite could be +//! was one test looping over its own cases, which made every failure a failure +//! of the whole suite: the name in the output was the suite's, one bad case +//! marked thousands of passing ones as part of a failed test, and a run stopped +//! reporting anything per case at all. +//! +//! `libtest_mimic` takes a list of tests built at run time and otherwise behaves +//! as the standard harness does, so each case is named, counted, filtered, and +//! attributed on its own, and `--test-threads`, `--ignored`, `--list`, and a +//! plain substring filter all keep working. Every case is independent, so the +//! harness's own pool runs them concurrently, one case per work item, which is +//! finer-grained than anything a suite-per-test layout could balance. + +mod beacon_spec; + +fn main() { + let args = libtest_mimic::Arguments::from_args(); + + let mut trials = Vec::new(); + trials.extend(beacon_spec::harness::trials()); + trials.extend(beacon_spec::fixture_fork_trials()); + trials.extend(beacon_spec::bls::trials()); + trials.extend(beacon_spec::kzg::trials()); + trials.extend(beacon_spec::merkle_proof::trials()); + trials.extend(beacon_spec::networking::trials()); + trials.extend(beacon_spec::epoch_processing::trials()); + trials.extend(beacon_spec::fork::trials()); + trials.extend(beacon_spec::fork_choice::trials()); + trials.extend(beacon_spec::gossip::trials()); + trials.extend(beacon_spec::operations::trials()); + trials.extend(beacon_spec::rewards::trials()); + trials.extend(beacon_spec::sanity::trials()); + trials.extend(beacon_spec::shuffling::trials()); + trials.extend(beacon_spec::ssz_static::trials()); + trials.extend(beacon_spec::sync::trials()); + trials.extend(beacon_spec::transition::trials()); + + // The release ships genesis fixtures for the minimal preset only, so the + // whole module is compiled out otherwise (see its own inner attribute). + #[cfg(feature = "preset-minimal")] + trials.extend(beacon_spec::genesis::trials()); + + libtest_mimic::run(&args, trials).exit(); +} diff --git a/crates/blockchain/tests/forkchoice_spectests.rs b/crates/blockchain/tests/forkchoice_spectests.rs index d9f492554..ecbfd3289 100644 --- a/crates/blockchain/tests/forkchoice_spectests.rs +++ b/crates/blockchain/tests/forkchoice_spectests.rs @@ -221,9 +221,10 @@ fn validate_checks( all_blocks: &HashMap, ) -> datatest_stable::Result<()> { // Validate time check: fixtures encode the expected store time in intervals - // since genesis (matching `Store::time()`). + // since genesis, which is `Store::intervals_since_genesis()` and not the + // `Store::time_ms()` row it derives from. if let Some(expected_time) = checks.time { - let actual_time = st.time().expect("store time is always set"); + let actual_time = st.intervals_since_genesis(); if actual_time != expected_time { return Err(format!( "Step {}: time mismatch: expected {}, got {}", diff --git a/crates/blockchain/tests/signature_spectests.rs b/crates/blockchain/tests/signature_spectests.rs index 64b6f7be7..d8d0c9894 100644 --- a/crates/blockchain/tests/signature_spectests.rs +++ b/crates/blockchain/tests/signature_spectests.rs @@ -76,7 +76,7 @@ fn run(path: &Path) -> datatest_stable::Result<()> { // Advance time to the block's slot let block_time_ms = - genesis_time * 1000 + signed_block.message.slot * st.config().milliseconds_per_slot; + genesis_time * 1000 + signed_block.message.slot * st.config().slot_duration_ms; store::on_tick(&mut st, block_time_ms, true); // Process the block (this includes signature verification) diff --git a/crates/common/ssz-tree/Cargo.toml b/crates/common/ssz-tree/Cargo.toml new file mode 100644 index 000000000..a2a1114ae --- /dev/null +++ b/crates/common/ssz-tree/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "ethlambda-ssz-tree" +authors.workspace = true +edition.workspace = true +keywords.workspace = true +license.workspace = true +readme.workspace = true +repository.workspace = true +rust-version.workspace = true +version.workspace = true + +[dependencies] +libssz.workspace = true +libssz-merkle.workspace = true +libssz-types.workspace = true +rayon.workspace = true + +[dev-dependencies] +libssz-derive.workspace = true +proptest = "1" diff --git a/crates/common/ssz-tree/src/interface.rs b/crates/common/ssz-tree/src/interface.rs new file mode 100644 index 000000000..671f6c60c --- /dev/null +++ b/crates/common/ssz-tree/src/interface.rs @@ -0,0 +1,216 @@ +//! The tree-plus-pending-writes core that [`List`](crate::List) and +//! [`Vector`](crate::Vector) share, including their SSZ codec. + +use std::sync::Arc; + +use libssz::{BYTES_PER_LENGTH_OFFSET, DecodeError, SszDecode, SszEncode}; + +use crate::iter::{Iter, TreeIter}; +use crate::tree::Tree; +use crate::update_map::UpdateMap; +use crate::{Hash256, Value}; + +/// A committed tree plus the writes not yet folded into it. +#[derive(Clone)] +pub(crate) struct Interface { + tree: Arc>, + depth: usize, + /// Elements in `tree`. + committed_len: usize, + /// Elements including pending pushes, which sit in `updates` at + /// `committed_len..len`. + len: usize, + updates: U, +} + +impl> Interface { + /// `values` in a new tree of height `depth`, with nothing pending. The + /// caller checks the type's limit. + pub(crate) fn from_values(values: impl IntoIterator, depth: usize) -> Self { + let (tree, len) = Tree::from_values(values, depth); + Self { + tree, + depth, + committed_len: len, + len, + updates: U::default(), + } + } + + pub(crate) fn len(&self) -> usize { + self.len + } + + pub(crate) fn get(&self, index: usize) -> Option<&T> { + if index >= self.len { + return None; + } + self.updates + .get(index) + .or_else(|| self.tree.get(index, self.depth)) + } + + /// A pending copy of the element at `index`, made on first access. + pub(crate) fn get_mut(&mut self, index: usize) -> Option<&mut T> { + if index >= self.len { + return None; + } + if self.updates.get(index).is_none() { + // Pending pushes are always in `updates`, so this is a committed + // element. + let value = self + .tree + .get(index, self.depth) + .expect("an index below the committed length is in the tree") + .clone(); + self.updates.insert(index, value); + } + self.updates.get_mut(index) + } + + /// Buffers `value` as the new last element. The caller checks the limit. + pub(crate) fn push(&mut self, value: T) { + self.updates.insert(self.len, value); + self.len += 1; + } + + pub(crate) fn has_pending_updates(&self) -> bool { + !self.updates.is_empty() + } + + /// Folds every pending write into the tree, in one pass. + /// + /// Cannot fail: every pending index was bounds-checked when it was + /// written. + pub(crate) fn apply_updates(&mut self) { + if self.updates.is_empty() { + return; + } + let updates = std::mem::take(&mut self.updates).into_sorted_vec(); + let mut updates = updates.into_iter().peekable(); + self.tree = Tree::with_updated_leaves(&self.tree, self.depth, 0, &mut updates); + debug_assert!(updates.next().is_none(), "every pending write is in range"); + self.committed_len = self.len; + } + + pub(crate) fn iter_from(&self, start: usize) -> Iter<'_, T, U> { + let start = start.min(self.len); + Iter { + tree: TreeIter::new(&self.tree, self.depth, start), + updates: &self.updates, + no_updates: self.updates.is_empty(), + index: start, + committed_len: self.committed_len, + end: self.len, + } + } + + /// The data root (before any length mix-in), pending writes included. + /// + /// With writes pending, the root is computed on a copy with them applied, + /// so it is right but the hashes computed for the new paths are thrown + /// away. Subtrees the copy shares with `self` do keep what is computed for + /// them. + pub(crate) fn root(&self) -> Hash256 { + if !self.has_pending_updates() { + return self.tree.hash(self.depth); + } + let mut applied = self.clone(); + applied.apply_updates(); + applied.tree.hash(applied.depth) + } + + /// Whether both committed trees are the same allocation: a cheap check of + /// sharing, not of equality. + pub(crate) fn ptr_eq(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.tree, &other.tree) + } + + /// Applies pending writes, then swaps every subtree equal to `base`'s at + /// the same position for `base`'s own. `base`'s pending writes are + /// ignored: its committed tree is still a valid base. + pub(crate) fn rebase_on(&mut self, base: &Self) { + self.apply_updates(); + let shared_prefix = self.committed_len.min(base.committed_len); + self.tree = Tree::rebase_on(&self.tree, &base.tree, self.depth, 0, shared_prefix); + } + + /// The SSZ length of the elements, as a list or vector body. + pub(crate) fn encoded_len(&self) -> usize { + if ::is_fixed_size() { + return ::fixed_size() * self.len; + } + self.iter_from(0) + .map(|value| BYTES_PER_LENGTH_OFFSET + value.encoded_len()) + .sum() + } + + /// Appends the elements' SSZ encoding: back to back for fixed-size + /// elements, or an offset table followed by the bodies, as `SszList` + /// writes it. + pub(crate) fn ssz_append(&self, buf: &mut Vec) { + if ::is_fixed_size() { + buf.reserve(::fixed_size() * self.len); + for value in self.iter_from(0) { + value.ssz_append(buf); + } + return; + } + let start = buf.len(); + buf.resize(start + self.len * BYTES_PER_LENGTH_OFFSET, 0); + for (index, value) in self.iter_from(0).enumerate() { + let offset = (buf.len() - start) as u32; + let position = start + index * BYTES_PER_LENGTH_OFFSET; + buf[position..position + BYTES_PER_LENGTH_OFFSET] + .copy_from_slice(&offset.to_le_bytes()); + value.ssz_append(buf); + } + } + + /// Decodes at most `max_len` elements from `bytes` into a tree of height + /// `depth`. + /// + /// Fixed-size elements are decoded straight into the tree, so decoding + /// never holds a `Vec` of every element next to the tree built from it. + pub(crate) fn from_ssz_bytes( + bytes: &[u8], + max_len: usize, + depth: usize, + ) -> Result { + // Matches `libssz::decode_list_with_max`: an empty input is an empty + // list regardless of the element type, checked before any fixed-size + // arithmetic (which would divide by a zero-sized element). + if bytes.is_empty() { + return Ok(Self::from_values(std::iter::empty(), depth)); + } + if !::is_fixed_size() { + let values = libssz::decode_list_with_max::(bytes, max_len)?; + return Ok(Self::from_values(values, depth)); + } + let size = ::fixed_size(); + if size == 0 || !bytes.len().is_multiple_of(size) { + return Err(DecodeError::InvalidByteLength { + expected: size, + got: bytes.len(), + }); + } + let count = bytes.len() / size; + if count > max_len { + return Err(DecodeError::InvalidByteLength { + expected: max_len, + got: count, + }); + } + let mut error = None; + let values = bytes.chunks_exact(size).map_while(|chunk| { + T::from_ssz_bytes(chunk) + .map_err(|err| error = Some(err)) + .ok() + }); + let interface = Self::from_values(values, depth); + match error { + Some(err) => Err(err), + None => Ok(interface), + } + } +} diff --git a/crates/common/ssz-tree/src/iter.rs b/crates/common/ssz-tree/src/iter.rs new file mode 100644 index 000000000..d25b9c1c8 --- /dev/null +++ b/crates/common/ssz-tree/src/iter.rs @@ -0,0 +1,182 @@ +//! In-order iteration over a tree, and over a tree plus its pending writes. + +use std::iter::FusedIterator; +use std::sync::Arc; + +use crate::tree::Tree; +use crate::update_map::UpdateMap; +use crate::{Value, child_height, packing_factor}; + +/// Walks a tree's elements left to right from a given index. +pub(crate) struct TreeIter<'a, T> { + /// For each inner node on the path to the current leaf, its children still + /// to visit; the innermost node's on top. + stack: Vec>>>, + /// What is left of the current leaf. + leaf: std::slice::Iter<'a, T>, +} + +impl<'a, T: Value> TreeIter<'a, T> { + /// An iterator at element `start` of `tree`, a tree of height `depth`. + pub(crate) fn new(tree: &'a Tree, depth: usize, start: usize) -> Self { + let packing = packing_factor::(); + let chunk_index = start / packing; + let mut stack = Vec::new(); + let mut node = tree; + let mut height = depth; + let leaf = loop { + match node { + // As in `Tree::get`, but deferring the children right of the + // path, which come after `start`. + Tree::Node(inner) => { + let below = child_height::(height); + let slot = (chunk_index >> below) & ((1 << (height - below)) - 1); + let Some(child) = inner.children.get(slot) else { + // `start` is past the data. + break std::slice::Iter::default(); + }; + stack.push(inner.children[slot + 1..].iter()); + node = child; + height = below; + } + // The low bits of `start` are its offset in the leaf's aligned + // run. + Tree::Leaf(leaf) => { + let offset = start & ((packing << height) - 1); + break leaf.values.get(offset..).unwrap_or(&[]).iter(); + } + Tree::Zero(_) => break std::slice::Iter::default(), + } + }; + Self { stack, leaf } + } +} + +impl<'a, T> Iterator for TreeIter<'a, T> { + type Item = &'a T; + + fn next(&mut self) -> Option<&'a T> { + loop { + if let Some(value) = self.leaf.next() { + return Some(value); + } + // The next unvisited child of the innermost node that has one. + let mut node = loop { + match self.stack.last_mut()?.next() { + Some(child) => break &**child, + None => { + self.stack.pop(); + } + } + }; + // Descend to its leftmost leaf, deferring the other children of + // each node on the way down. + self.leaf = loop { + match node { + Tree::Node(inner) => { + let mut children = inner.children.iter(); + let Some(first) = children.next() else { + self.stack.clear(); + return None; + }; + self.stack.push(children); + node = first; + } + Tree::Leaf(leaf) => break leaf.values.iter(), + // The data is a prefix of the tree, so everything from the + // first zero subtree on is padding. + Tree::Zero(_) => { + self.stack.clear(); + return None; + } + } + }; + } + } +} + +/// An iterator over the elements of a [`List`](crate::List) or +/// [`Vector`](crate::Vector), pending writes included. +pub struct Iter<'a, T, U> { + pub(crate) tree: TreeIter<'a, T>, + pub(crate) updates: &'a U, + /// Whether `updates` is empty, so the common case skips a lookup per + /// element. + pub(crate) no_updates: bool, + pub(crate) index: usize, + /// Elements the tree holds; the rest up to `end` are pending pushes. + pub(crate) committed_len: usize, + pub(crate) end: usize, +} + +impl<'a, T: Value, U: UpdateMap> Iterator for Iter<'a, T, U> { + type Item = &'a T; + + fn next(&mut self) -> Option<&'a T> { + if self.index >= self.end { + return None; + } + let index = self.index; + self.index += 1; + // Advance the tree walk even when a pending write overrides this + // element, so the two stay aligned. + let committed = if index < self.committed_len { + self.tree.next() + } else { + None + }; + if self.no_updates { + return committed; + } + self.updates.get(index).or(committed) + } + + fn size_hint(&self) -> (usize, Option) { + let remaining = self.end - self.index; + (remaining, Some(remaining)) + } +} + +impl> ExactSizeIterator for Iter<'_, T, U> {} + +impl> FusedIterator for Iter<'_, T, U> {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tree_iter_yields_every_value_from_any_start() { + let values: Vec = (0..37).collect(); + let (tree, _) = Tree::from_values(values.clone(), 4); + for start in 0..=values.len() { + let got: Vec = TreeIter::new(&tree, 4, start).copied().collect(); + assert_eq!(got, values[start..], "start {start}"); + } + } + + #[test] + fn tree_iter_walks_composite_leaves() { + let values: Vec<[u8; 48]> = (0..6u8).map(|i| [i; 48]).collect(); + let (tree, _) = Tree::from_values(values.clone(), 3); + let got: Vec<[u8; 48]> = TreeIter::new(&tree, 3, 2).copied().collect(); + assert_eq!(got, values[2..]); + } + + #[test] + fn tree_iter_crosses_leaf_boundaries_from_any_start() { + // 512 u64s to a leaf: three leaves, the last partial. + let values: Vec = (0..1300).collect(); + let (tree, _) = Tree::from_values(values.clone(), 9); + for start in [0usize, 1, 511, 512, 513, 1023, 1024, 1299, 1300] { + let got: Vec = TreeIter::new(&tree, 9, start).copied().collect(); + assert_eq!(got, values[start..], "start {start}"); + } + } + + #[test] + fn tree_iter_over_an_empty_tree_is_empty() { + let (tree, _) = Tree::::from_values(Vec::new(), 4); + assert_eq!(TreeIter::new(&tree, 4, 0).count(), 0); + } +} diff --git a/crates/common/ssz-tree/src/lib.rs b/crates/common/ssz-tree/src/lib.rs new file mode 100644 index 000000000..561be2a9b --- /dev/null +++ b/crates/common/ssz-tree/src/lib.rs @@ -0,0 +1,225 @@ +//! Persistent binary Merkle trees for SSZ lists and vectors. +//! +//! [`List`] and [`Vector`] keep their elements in a tree instead of a +//! `Vec`. The tree has the shape of the type's SSZ Merkle tree, so every node +//! caches its own hash, and a clone shares every node with the original +//! through `Arc`. After a write, only the nodes on the paths to the changed +//! leaves are rebuilt and rehashed; the rest are shared with the previous +//! version, cached hashes included. +//! +//! # Leaves hold a run of elements +//! +//! A leaf is not one SSZ chunk but a whole subtree's worth of elements, about +//! `LEAF_BYTES` of them, stored contiguously. The Merkle shape is still the +//! SSZ one: a leaf hashes its elements up to its own height, so the root is +//! unchanged, but there are far fewer nodes to walk and allocate. A lookup +//! descends to the leaf and indexes into it, and iteration walks each leaf as +//! a slice. The cost is on writes: a rebuilt leaf copies its run and rehashes +//! its own subtree, so a composite leaf keeps each element's root to rehash +//! only the elements that changed. +//! +//! Inner nodes are wide in the same way: each holds about `NODE_BYTES` of +//! child pointers and spans that many binary levels at once, so a lookup +//! crosses a handful of nodes. A rebuilt inner node rehashes its binary levels +//! from its children's cached roots. +//! +//! The result is shaped much like a B+-tree over indices: page-sized nodes +//! with a high fan-out, and every element in the leaves. Unlike one, it has no +//! keys to search and never splits or rebalances. An index's bits pick the +//! child at each node, the shape is fixed by the SSZ depth, and a write copies +//! the path to its leaf rather than updating in place. +//! +//! Modeled on Sigma Prime's milhouse (the tree lighthouse keeps its +//! `BeaconState` lists in), but implementing the libssz traits this workspace +//! derives, so a [`List`] can replace a `libssz_types::SszList` field of a +//! derived container. +//! +//! # Buffered writes +//! +//! `get_mut`, `push` and `IndexMut` leave the tree alone. They write into an +//! [`UpdateMap`], and `apply_updates` later folds every pending write into the +//! tree in one pass, so N writes rebuild their paths once rather than N times. +//! Reads check the pending writes first, so a write is visible as soon as it +//! is made. +//! +//! Hashing with writes still pending gives the right root, but computes it on +//! a throwaway copy, so none of the new hashes are kept. Call `apply_updates` +//! before hashing on a hot path. +//! +//! # The hasher argument +//! +//! `HashTreeRoot::hash_tree_root` takes a hasher; these types ignore it and +//! always use SHA-256 (`libssz_merkle::Sha2Hasher`). A cached hash is only +//! valid for the function that produced it, and SHA-256 is the only one the +//! consensus specs use. + +mod interface; +mod iter; +mod list; +mod rebase; +mod tree; +mod update_map; +mod vector; + +pub use iter::Iter; +pub use list::List; +pub use update_map::{UpdateMap, VecMap}; +pub use vector::Vector; + +use libssz::{SszDecode, SszEncode}; +use libssz_merkle::HashTreeRoot; + +/// A 32-byte Merkle node. +pub(crate) type Hash256 = libssz_merkle::Node; + +/// Bytes in one Merkle chunk. +const BYTES_PER_CHUNK: usize = 32; + +/// What a tree can hold: anything SSZ-encodable and merkleizable that can +/// be shared across threads. +pub trait Value: + SszEncode + SszDecode + HashTreeRoot + Clone + PartialEq + Send + Sync + 'static +{ +} + +impl Value for T where + T: SszEncode + SszDecode + HashTreeRoot + Clone + PartialEq + Send + Sync + 'static +{ +} + +/// Whether `T` is packed several to a chunk (an SSZ basic type) rather than +/// stored one per leaf (a composite). +pub(crate) fn is_packed() -> bool { + T::is_basic_type() +} + +/// Elements per leaf: `32 / size` for a packed type, 1 for a composite. +/// +/// # Panics +/// +/// On a basic type whose size does not divide 32, such as a 20-byte address. +/// `libssz_types::SszList` packs those across chunk boundaries, which a tree +/// with one chunk per leaf cannot reproduce, so accepting one would silently +/// produce a different root. +pub(crate) fn packing_factor() -> usize { + if !is_packed::() { + return 1; + } + let size = ::fixed_size(); + assert!( + size > 0 && BYTES_PER_CHUNK.is_multiple_of(size), + "a packed element's size must divide {BYTES_PER_CHUNK}, got {size}" + ); + BYTES_PER_CHUNK / size +} + +/// Height of the tree for a type holding at most `limit` elements: the +/// smallest `d` with `2^d` chunks holding `limit` elements, which is the depth +/// SSZ merkleization pads the type to. +pub(crate) fn tree_depth(limit: usize) -> usize { + let chunks = limit.div_ceil(packing_factor::()); + chunks.next_power_of_two().trailing_zeros() as usize +} + +/// Roughly how many bytes of elements one leaf holds: a page, so a lookup ends +/// in one contiguous run and iteration walks few nodes. +pub(crate) const LEAF_BYTES: usize = 4096; + +/// The height of `T`'s leaves in a tree tall enough to hold them: the largest +/// power-of-two run of elements whose in-memory size fits in [`LEAF_BYTES`], +/// counted in chunks. At least one chunk, so a leaf is never below height 0. +pub(crate) fn max_leaf_height() -> usize { + let fits = (LEAF_BYTES / size_of::().max(1)).max(1); + let elements = 1usize << fits.ilog2(); + let chunks = (elements / packing_factor::()).max(1); + chunks.ilog2() as usize +} + +/// The height of the leaves in a tree of height `depth`: [`max_leaf_height`], +/// or the whole tree if it is shorter than that, in which case the root is +/// the only leaf. +pub(crate) fn leaf_height(depth: usize) -> usize { + max_leaf_height::().min(depth) +} + +/// Roughly how many bytes of child pointers one inner node holds, so a lookup +/// crosses a handful of nodes rather than one per binary level. +pub(crate) const NODE_BYTES: usize = 4096; + +/// How many binary levels one inner node spans: it has up to `2^NODE_LEVELS` +/// children, each one `Arc` wide. +pub(crate) const NODE_LEVELS: usize = + (NODE_BYTES / size_of::>()).ilog2() as usize; + +/// The height of the children of the inner node at `height`. +/// +/// Inner nodes sit every [`NODE_LEVELS`] levels above the leaves, counted up +/// from [`max_leaf_height`], so only the root can span fewer levels than that. +/// Only meaningful above the leaf height: a tree no taller than a leaf has no +/// inner nodes. +pub(crate) fn child_height(height: usize) -> usize { + let leaf = max_leaf_height::(); + debug_assert!(height > leaf, "no inner node at height {height}"); + leaf + NODE_LEVELS * ((height - leaf - 1) / NODE_LEVELS) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn basic_types_pack_to_a_chunk_and_composites_do_not() { + assert_eq!(packing_factor::(), 32); + assert_eq!(packing_factor::(), 4); + assert_eq!(packing_factor::<[u8; 32]>(), 1); + // Longer than a chunk, so not an SSZ basic type. + assert_eq!(packing_factor::<[u8; 48]>(), 1); + } + + #[test] + fn depth_is_the_ssz_padding_depth() { + assert_eq!(tree_depth::(0), 0); + assert_eq!(tree_depth::(4), 0); + assert_eq!(tree_depth::(5), 1); + assert_eq!(tree_depth::(1 << 40), 38); + assert_eq!(tree_depth::<[u8; 48]>(5), 3); + assert_eq!(tree_depth::<[u8; 48]>(1 << 40), 40); + } + + #[test] + fn a_leaf_holds_about_a_page_of_elements() { + // 512 u64s = 128 chunks. + assert_eq!(max_leaf_height::(), 7); + // 4096 u8s = 128 chunks. + assert_eq!(max_leaf_height::(), 7); + // 128 roots, one chunk each. + assert_eq!(max_leaf_height::<[u8; 32]>(), 7); + // 4096 / 48 = 85, rounded down to 64. + assert_eq!(max_leaf_height::<[u8; 48]>(), 6); + // Wider than a page: one element per leaf. + assert_eq!(max_leaf_height::<[u8; 5000]>(), 0); + } + + #[test] + fn an_inner_node_holds_about_a_page_of_children() { + assert_eq!(NODE_LEVELS, 9); + // u64 leaves sit at height 7: nodes at 16, 25, 34, and the root at 38. + assert_eq!(child_height::(8), 7); + assert_eq!(child_height::(16), 7); + assert_eq!(child_height::(17), 16); + assert_eq!(child_height::(25), 16); + assert_eq!(child_height::(38), 34); + } + + #[test] + fn a_tree_shorter_than_a_leaf_is_one_leaf() { + assert_eq!(leaf_height::(3), 3); + assert_eq!(leaf_height::(38), 7); + } + + #[test] + #[should_panic(expected = "must divide 32")] + fn a_basic_type_that_straddles_chunks_is_refused() { + packing_factor::<[u8; 20]>(); + } +} diff --git a/crates/common/ssz-tree/src/list.rs b/crates/common/ssz-tree/src/list.rs new file mode 100644 index 000000000..f0f98b6f1 --- /dev/null +++ b/crates/common/ssz-tree/src/list.rs @@ -0,0 +1,566 @@ +//! [`List`]: an SSZ `List[T, N]` kept in a persistent Merkle tree. + +use std::fmt; +use std::ops::{Index, IndexMut}; + +use libssz::{DecodeError, SszDecode, SszEncode}; +use libssz_merkle::{HashTreeRoot, Sha2Hasher, Sha256Hasher, mix_in_length}; +use libssz_types::TypeError; + +use crate::interface::Interface; +use crate::iter::Iter; +use crate::update_map::{UpdateMap, VecMap}; +use crate::{Hash256, Value, tree_depth}; + +/// An SSZ list of at most `N` elements, kept in a persistent Merkle tree. +/// +/// Stands in for `libssz_types::SszList` in a derived container: the +/// same SSZ encoding, the same `hash_tree_root`, and the same element API +/// (`len`, `get`, `get_mut`, `push`, `iter`, `[i]`), minus slice access, since +/// the elements are not contiguous. +/// +/// A clone is O(1) and shares the whole tree. Writes are buffered in `U` (see +/// [`UpdateMap`]) until [`List::apply_updates`]. +#[derive(Clone)] +pub struct List> { + interface: Interface, +} + +impl> List { + fn depth() -> usize { + tree_depth::(N) + } + + /// An empty list. + pub fn empty() -> Self { + Self { + interface: Interface::from_values(std::iter::empty(), Self::depth()), + } + } + + /// The number of elements, pending pushes included. + pub fn len(&self) -> usize { + self.interface.len() + } + + /// Whether the list holds no elements. + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// The type's element limit, `N`. + pub fn max_capacity(&self) -> usize { + N + } + + /// The element at `index`, or `None` past the end. + pub fn get(&self, index: usize) -> Option<&T> { + self.interface.get(index) + } + + /// The element at `index`, to be written. The write is buffered until + /// [`List::apply_updates`]. + pub fn get_mut(&mut self, index: usize) -> Option<&mut T> { + self.interface.get_mut(index) + } + + /// Appends `value`, buffered until [`List::apply_updates`]. + pub fn push(&mut self, value: T) -> Result<(), TypeError> { + if self.len() >= N { + return Err(TypeError::OverCapacity { + max: N, + got: self.len() + 1, + }); + } + self.interface.push(value); + Ok(()) + } + + /// The elements in order, pending writes included. + pub fn iter(&self) -> Iter<'_, T, U> { + self.interface.iter_from(0) + } + + /// The elements from `index` on; empty if `index` is past the end. + pub fn iter_from(&self, index: usize) -> Iter<'_, T, U> { + self.interface.iter_from(index) + } + + /// A `Vec` copy of the elements, pending writes included. + pub fn to_vec(&self) -> Vec { + self.iter().cloned().collect() + } + + /// Folds every buffered write into the tree, rebuilding the touched paths + /// once. + pub fn apply_updates(&mut self) { + self.interface.apply_updates(); + } + + /// Whether any write is buffered and not yet folded into the tree. + pub fn has_pending_updates(&self) -> bool { + self.interface.has_pending_updates() + } + + /// Makes this list share every unchanged subtree with `base`, after + /// applying its own pending writes. The contents do not change. + pub fn rebase_on(&mut self, base: &Self) { + self.interface.rebase_on(&base.interface); + } + + /// Whether both lists' committed trees are the same allocation: a cheap + /// check of sharing, not of equality. + pub fn ptr_eq(&self, other: &Self) -> bool { + self.interface.ptr_eq(&other.interface) + } +} + +impl> Default for List { + fn default() -> Self { + Self::empty() + } +} + +impl> TryFrom> for List { + type Error = TypeError; + + fn try_from(values: Vec) -> Result { + if values.len() > N { + return Err(TypeError::OverCapacity { + max: N, + got: values.len(), + }); + } + Ok(Self { + interface: Interface::from_values(values, Self::depth()), + }) + } +} + +impl> Index for List { + type Output = T; + + fn index(&self, index: usize) -> &T { + let len = self.len(); + self.get(index) + .unwrap_or_else(|| panic!("index {index} out of bounds for a list of length {len}")) + } +} + +impl> IndexMut for List { + fn index_mut(&mut self, index: usize) -> &mut T { + let len = self.len(); + self.get_mut(index) + .unwrap_or_else(|| panic!("index {index} out of bounds for a list of length {len}")) + } +} + +impl> PartialEq for List { + fn eq(&self, other: &Self) -> bool { + if self.len() != other.len() { + return false; + } + let nothing_pending = !self.has_pending_updates() && !other.has_pending_updates(); + (nothing_pending && self.ptr_eq(other)) || self.iter().eq(other.iter()) + } +} + +impl> Eq for List {} + +impl> fmt::Debug for List { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_list().entries(self.iter()).finish() + } +} + +impl<'a, T: Value, const N: usize, U: UpdateMap> IntoIterator for &'a List { + type Item = &'a T; + type IntoIter = Iter<'a, T, U>; + + fn into_iter(self) -> Self::IntoIter { + self.iter() + } +} + +impl> SszEncode for List { + fn is_fixed_size() -> bool { + false + } + + fn fixed_size() -> usize { + 0 + } + + fn encoded_len(&self) -> usize { + self.interface.encoded_len() + } + + fn ssz_append(&self, buf: &mut Vec) { + self.interface.ssz_append(buf); + } +} + +impl> SszDecode for List { + fn is_fixed_size() -> bool { + false + } + + fn fixed_size() -> usize { + 0 + } + + fn from_ssz_bytes(bytes: &[u8]) -> Result { + Ok(Self { + interface: Interface::from_ssz_bytes(bytes, N, Self::depth())?, + }) + } +} + +impl> HashTreeRoot for List { + /// Ignores `hasher` and uses SHA-256: see the crate docs. + fn hash_tree_root(&self, _hasher: &impl Sha256Hasher) -> Hash256 { + mix_in_length(&Sha2Hasher, &self.interface.root(), self.len()) + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use libssz_derive::{HashTreeRoot, SszDecode, SszEncode}; + use libssz_types::SszList; + + use super::*; + + #[derive(Debug, Clone, Default, PartialEq, Eq, SszEncode, SszDecode, HashTreeRoot)] + struct Item { + id: u64, + data: [u8; 32], + } + + fn item(id: u64) -> Item { + Item { + id, + data: [id as u8; 32], + } + } + + fn root(list: &L) -> Hash256 { + HashTreeRoot::hash_tree_root(list, &Sha2Hasher) + } + + fn model(values: &[T]) -> SszList { + values.to_vec().try_into().expect("within the limit") + } + + type Balances = List; + type Items = List>; + + #[test] + fn an_empty_list_hashes_like_an_empty_ssz_list() { + assert_eq!(root(&Balances::empty()), root(&model::(&[]))); + assert_eq!(root(&Items::default()), root(&model::(&[]))); + } + + #[test] + fn a_built_list_matches_ssz_list_at_every_length() { + for len in [1u64, 3, 4, 5, 8, 9, 255, 256, 257] { + let values: Vec = (0..len).map(|i| i * 7 + 1).collect(); + let list = Balances::try_from(values.clone()).unwrap(); + assert_eq!(list.len(), values.len()); + assert_eq!(list.to_vec(), values); + assert_eq!(root(&list), root(&model::(&values)), "len {len}"); + } + } + + #[test] + fn a_registry_sized_limit_hashes_like_ssz_list() { + let values: Vec = (0..10).collect(); + let list = List::::try_from(values.clone()).unwrap(); + assert_eq!(root(&list), root(&model::(&values))); + } + + #[test] + fn writes_are_visible_before_they_are_applied() { + let mut list = Items::try_from(vec![item(0), item(1)]).unwrap(); + list.get_mut(1).unwrap().id = 11; + list.push(item(2)).unwrap(); + assert!(list.has_pending_updates()); + assert_eq!(list.len(), 3); + assert_eq!(list[1].id, 11); + assert_eq!(list.get(2), Some(&item(2))); + assert_eq!(list.get(3), None); + let collected: Vec = list.iter().map(|item| item.id).collect(); + assert_eq!(collected, vec![0, 11, 2]); + } + + #[test] + fn applied_writes_hash_like_the_same_ssz_list() { + let mut list = Balances::try_from((0..10).collect::>()).unwrap(); + list[3] = 300; + list.push(10).unwrap(); + list.push(11).unwrap(); + let expected: Vec = (0..12).map(|i| if i == 3 { 300 } else { i }).collect(); + + // Hashing with writes pending is correct and leaves them pending. + assert_eq!(root(&list), root(&model::(&expected))); + assert!(list.has_pending_updates()); + + list.apply_updates(); + assert!(!list.has_pending_updates()); + assert_eq!(list.to_vec(), expected); + assert_eq!(root(&list), root(&model::(&expected))); + } + + #[test] + fn a_clone_does_not_see_later_writes() { + let mut original = Items::try_from(vec![item(0), item(1)]).unwrap(); + let snapshot = original.clone(); + original[0].id = 99; + original.apply_updates(); + original.push(item(2)).unwrap(); + assert_eq!(snapshot.to_vec(), vec![item(0), item(1)]); + assert_eq!(original.len(), 3); + } + + #[test] + fn pushing_past_the_limit_is_an_error() { + let mut list = List::::empty(); + list.push(1).unwrap(); + list.push(2).unwrap(); + assert_eq!( + list.push(3), + Err(TypeError::OverCapacity { max: 2, got: 3 }) + ); + assert_eq!( + List::::try_from(vec![1, 2, 3]), + Err(TypeError::OverCapacity { max: 2, got: 3 }) + ); + } + + #[test] + fn get_mut_past_the_end_is_none() { + let mut list = Balances::try_from(vec![1, 2]).unwrap(); + assert!(list.get_mut(2).is_none()); + assert!(!list.has_pending_updates()); + } + + #[test] + #[should_panic(expected = "out of bounds")] + fn indexing_past_the_end_panics() { + let list = Balances::try_from(vec![1, 2]).unwrap(); + let _ = list[2]; + } + + #[test] + fn equality_is_by_contents_not_by_history() { + let built = Balances::try_from(vec![1, 2, 3]).unwrap(); + let mut pushed = Balances::empty(); + for value in [1, 2, 3] { + pushed.push(value).unwrap(); + } + assert_eq!(built, pushed); + pushed.apply_updates(); + assert_eq!(built, pushed); + pushed[0] = 5; + assert_ne!(built, pushed); + } + + #[test] + fn a_write_breaks_equality_even_while_the_tree_is_still_shared() { + let b = Balances::try_from(vec![1, 2, 3]).unwrap(); + let mut a = b.clone(); + assert!(a.ptr_eq(&b)); + a[0] = 100; + // `a` still shares `b`'s tree (the write is only buffered), so the + // `ptr_eq` fast path in `PartialEq` must not fire here. + assert!(a.ptr_eq(&b)); + assert_ne!(a, b); + a.apply_updates(); + assert_ne!(a, b); + } + + #[test] + fn clones_with_the_same_pending_write_are_equal() { + let base = Balances::try_from(vec![1, 2, 3]).unwrap(); + let mut a = base.clone(); + let mut b = base.clone(); + a[0] = 42; + b[0] = 42; + assert!(a.ptr_eq(&b)); + assert_eq!(a, b); + } + + #[test] + fn cloning_after_a_write_carries_the_pending_write_to_both_copies() { + let mut a = Balances::try_from(vec![1, 2, 3]).unwrap(); + a[0] = 42; + let b = a.clone(); + a.apply_updates(); + assert!(!a.has_pending_updates()); + assert!(b.has_pending_updates()); + assert_eq!(a, b); + assert_eq!(b[0], 42); + } + + #[test] + fn pushing_on_a_clone_makes_the_lists_unequal() { + let a = Balances::try_from(vec![1, 2, 3]).unwrap(); + let mut b = a.clone(); + b.push(4).unwrap(); + // Same tree, different length: `len` must be checked before `ptr_eq`. + assert!(a.ptr_eq(&b)); + assert_ne!(a, b); + } + + #[test] + fn iter_from_starts_at_the_given_index() { + let list = Balances::try_from((0..9).collect::>()).unwrap(); + let tail: Vec = list.iter_from(6).copied().collect(); + assert_eq!(tail, vec![6, 7, 8]); + assert_eq!(list.iter_from(9).count(), 0); + assert_eq!(list.iter_from(50).count(), 0); + assert_eq!(list.iter().len(), 9); + } + + /// A variable-size element (an `SszList` itself), like the beacon + /// `Blob` type: exercises the offset-table encode/decode path rather + /// than the fixed-size one. + type VarElem = SszList; + type VarList = List; + type VarModel = SszList; + + fn var_elem(bytes: &[u8]) -> VarElem { + bytes.to_vec().try_into().expect("within the limit") + } + + #[test] + fn fixed_size_elements_round_trip_like_ssz_list() { + for len in [0usize, 1, 3, 255, 1024] { + let values: Vec = (0..len as u64).map(|i| i * 7 + 1).collect(); + let list = Balances::try_from(values.clone()).unwrap(); + let model = model::(&values); + let encoded = list.to_ssz(); + assert_eq!(encoded, model.to_ssz(), "len {len}"); + assert_eq!( + Balances::from_ssz_bytes(&encoded).unwrap(), + list, + "len {len}" + ); + } + } + + #[test] + fn composite_fixed_size_elements_round_trip_like_ssz_list() { + for len in [0usize, 1, 3, 100] { + let values: Vec = (0..len as u64).map(item).collect(); + let list = Items::try_from(values.clone()).unwrap(); + let model = model::(&values); + let encoded = list.to_ssz(); + assert_eq!(encoded, model.to_ssz(), "len {len}"); + assert_eq!(Items::from_ssz_bytes(&encoded).unwrap(), list, "len {len}"); + } + } + + #[test] + fn variable_size_elements_round_trip_like_ssz_list() { + let cases: [Vec; 2] = [ + vec![], + vec![ + var_elem(&[]), + var_elem(&[1, 2, 3]), + var_elem(&(0..64).collect::>()), + var_elem(&[9]), + ], + ]; + for values in cases { + let list = VarList::try_from(values.clone()).unwrap(); + let model: VarModel = values.clone().try_into().unwrap(); + let encoded = list.to_ssz(); + assert_eq!(encoded, model.to_ssz(), "len {}", values.len()); + assert_eq!( + VarList::from_ssz_bytes(&encoded).unwrap(), + list, + "len {}", + values.len() + ); + } + } + + #[test] + fn encoding_with_pending_writes_matches_encoding_after_apply_updates() { + let mut list = Balances::try_from(vec![1, 2, 3]).unwrap(); + list[0] = 99; + list.push(4).unwrap(); + assert!(list.has_pending_updates()); + let pending_bytes = list.to_ssz(); + + let mut applied = list.clone(); + applied.apply_updates(); + assert_eq!(pending_bytes, applied.to_ssz()); + } + + #[test] + fn decoding_too_many_fixed_size_elements_is_rejected_like_ssz_list() { + let values: Vec = (0..5).collect(); + let encoded = model::(&values).to_ssz(); + let list_err = List::::from_ssz_bytes(&encoded).unwrap_err(); + let ssz_err = SszList::::from_ssz_bytes(&encoded).unwrap_err(); + assert_eq!(list_err, ssz_err); + } + + #[test] + fn decoding_a_length_not_a_multiple_of_the_element_size_is_rejected_like_ssz_list() { + let bytes = vec![0u8; 7]; // u64 is 8 bytes wide; 7 does not divide evenly. + let list_err = List::::from_ssz_bytes(&bytes).unwrap_err(); + let ssz_err = SszList::::from_ssz_bytes(&bytes).unwrap_err(); + assert_eq!(list_err, ssz_err); + } + + #[test] + fn decoding_a_first_offset_that_is_not_a_multiple_of_four_is_rejected_like_ssz_list() { + let bytes = vec![5, 0, 0, 0, 0]; // first offset = 5, not a multiple of 4. + let list_err = VarList::from_ssz_bytes(&bytes).unwrap_err(); + let ssz_err = VarModel::from_ssz_bytes(&bytes).unwrap_err(); + assert_eq!(list_err, ssz_err); + } + + #[test] + fn decoding_a_decreasing_offset_is_rejected_like_ssz_list() { + // Two items: offset table says item 0 starts at 8, item 1 at 4 (before + // item 0), which is not monotonically increasing. + let bytes = vec![8, 0, 0, 0, 4, 0, 0, 0, 0, 0, 0, 0]; + let list_err = VarList::from_ssz_bytes(&bytes).unwrap_err(); + let ssz_err = VarModel::from_ssz_bytes(&bytes).unwrap_err(); + assert_eq!(list_err, ssz_err); + } + + #[test] + fn decoding_an_offset_past_the_end_is_rejected_like_ssz_list() { + let bytes = vec![8, 0, 0, 0]; // claims an item starts at byte 8 of a 4-byte input. + let list_err = VarList::from_ssz_bytes(&bytes).unwrap_err(); + let ssz_err = VarModel::from_ssz_bytes(&bytes).unwrap_err(); + assert_eq!(list_err, ssz_err); + } + + #[test] + fn rebasing_a_rebuilt_list_shares_the_base_and_keeps_its_writes() { + let values: Vec = (0..1000).collect(); + let base = Balances::try_from(values.clone()).unwrap(); + root(&base); + + let mut rebuilt = Balances::try_from(values.clone()).unwrap(); + assert!(!rebuilt.ptr_eq(&base)); + rebuilt.rebase_on(&base); + assert!(rebuilt.ptr_eq(&base)); + + // Pending writes are applied first, so they survive the rebase. + rebuilt[7] = 70_000; + rebuilt.rebase_on(&base); + assert!(!rebuilt.has_pending_updates()); + assert_eq!(rebuilt[7], 70_000); + let mut expected = values; + expected[7] = 70_000; + assert_eq!(root(&rebuilt), root(&model::(&expected))); + } +} diff --git a/crates/common/ssz-tree/src/rebase.rs b/crates/common/ssz-tree/src/rebase.rs new file mode 100644 index 000000000..8b4141f70 --- /dev/null +++ b/crates/common/ssz-tree/src/rebase.rs @@ -0,0 +1,151 @@ +//! Sharing subtrees between two versions of a list that were built apart, +//! such as a state decoded from storage and a resident relative of it. + +use std::sync::{Arc, OnceLock}; + +use crate::tree::{Node, Tree}; +use crate::{Value, child_height, packing_factor}; + +impl Tree { + /// A tree equal to `orig` that reuses `base`'s node wherever the two + /// subtrees at the same position hold the same elements. + /// + /// `orig` and `base` have height `height` and cover elements from `first` + /// on. `shared_prefix` is how many elements both lists hold. A subtree + /// wholly inside that prefix holds the same number of elements in both + /// trees, so there equal cached hashes prove equal contents and end the + /// walk. At the boundary a shorter list can hash the same as a longer one + /// (a trailing zero value looks like padding), so there only comparing + /// values counts. + /// + /// When `orig` has no cached hashes (it was just decoded) the walk + /// compares values all the way down: O(n), with no hashing. Every leaf it + /// swaps for `base`'s arrives with `base`'s cached hash. + pub(crate) fn rebase_on( + orig: &Arc, + base: &Arc, + height: usize, + first: usize, + shared_prefix: usize, + ) -> Arc { + if Arc::ptr_eq(orig, base) { + return Arc::clone(base); + } + match (&**orig, &**base) { + (Tree::Leaf(a), Tree::Leaf(b)) if a.values == b.values => Arc::clone(base), + (Tree::Zero(a), Tree::Zero(b)) if a == b => Arc::clone(base), + (Tree::Node(orig_node), Tree::Node(base_node)) => { + let packing = packing_factor::(); + let end = first + (packing << height); + let hashes_prove_equal = end <= shared_prefix + && orig.cached_hash().is_some() + && orig.cached_hash() == base.cached_hash(); + if hashes_prove_equal { + return Arc::clone(base); + } + let below = child_height::(height); + let span = packing << below; + // Children pair up by slot; one orig has past base's last + // child has nothing to share with. + let children: Vec> = orig_node + .children + .iter() + .enumerate() + .map(|(slot, child)| match base_node.children.get(slot) { + Some(base_child) => Self::rebase_on( + child, + base_child, + below, + first + slot * span, + shared_prefix, + ), + None => Arc::clone(child), + }) + .collect(); + let all_base = children.len() == base_node.children.len() + && children + .iter() + .zip(&base_node.children) + .all(|(child, base_child)| Arc::ptr_eq(child, base_child)); + if all_base { + // Every element below matched, so the whole subtree does. + return Arc::clone(base); + } + let all_orig = children + .iter() + .zip(&orig_node.children) + .all(|(child, orig_child)| Arc::ptr_eq(child, orig_child)); + if all_orig { + return Arc::clone(orig); + } + // The contents are still orig's, so orig's hash (if any) holds. + let hash = orig.cached_hash().map(OnceLock::from).unwrap_or_default(); + Arc::new(Tree::Node(Node { hash, children })) + } + _ => Arc::clone(orig), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn children(tree: &Tree) -> &[Arc>] { + match tree { + Tree::Node(inner) => &inner.children, + _ => panic!("not an inner node"), + } + } + + #[test] + fn only_the_changed_path_stays_unshared() { + // 2048 u64s in four leaves of 512, depth 9. + let base_values: Vec = (0..2048).collect(); + let mut orig_values = base_values.clone(); + orig_values[600] = 99; + let (base, _) = Tree::from_values(base_values, 9); + let (orig, _) = Tree::from_values(orig_values, 9); + base.hash(9); + + let rebased = Tree::rebase_on(&orig, &base, 9, 0, 2048); + + // One inner node over the four leaves. Elements 512..1024 hold the + // change, so that leaf is orig's and the root is new... + let (rebased_leaves, base_leaves) = (children(&rebased), children(&base)); + assert!(!Arc::ptr_eq(&rebased, &base)); + assert!(!Arc::ptr_eq(&rebased_leaves[1], &base_leaves[1])); + // ...but every unchanged leaf is base's. + for slot in [0, 2, 3] { + assert!( + Arc::ptr_eq(&rebased_leaves[slot], &base_leaves[slot]), + "leaf {slot}" + ); + } + // And the contents are orig's. + assert_eq!(rebased.hash(9), orig.hash(9)); + assert_eq!(rebased.get(600, 9), Some(&99)); + } + + #[test] + fn an_identical_rebuilt_tree_becomes_the_base() { + let values: Vec = (0..16).collect(); + let (base, _) = Tree::from_values(values.clone(), 2); + let (orig, _) = Tree::from_values(values, 2); + let rebased = Tree::rebase_on(&orig, &base, 2, 0, 16); + assert!(Arc::ptr_eq(&rebased, &base)); + } + + #[test] + fn a_trailing_zero_is_not_mistaken_for_padding() { + // [1, 2, 3, 0] and [1, 2, 3] pack into the same chunk, so the leaves and + // roots hash the same, but they are different lists. + let (base, _) = Tree::::from_values(vec![1, 2, 3, 0], 1); + let (orig, _) = Tree::::from_values(vec![1, 2, 3], 1); + assert_eq!(base.hash(1), orig.hash(1)); + + let rebased = Tree::rebase_on(&orig, &base, 1, 0, 3); + assert!(!Arc::ptr_eq(&rebased, &base)); + assert_eq!(rebased.get(3, 1), None); + } +} diff --git a/crates/common/ssz-tree/src/tree.rs b/crates/common/ssz-tree/src/tree.rs new file mode 100644 index 000000000..2eb8c70d3 --- /dev/null +++ b/crates/common/ssz-tree/src/tree.rs @@ -0,0 +1,687 @@ +//! The persistent Merkle tree behind [`List`](crate::List) and +//! [`Vector`](crate::Vector). + +use std::iter::Peekable; +use std::sync::{Arc, OnceLock}; + +use libssz::SszEncode; +use libssz_merkle::{HashTreeRoot, Sha2Hasher, ZERO_HASHES, hash_nodes, merkleize, pack}; +use rayon::prelude::*; + +use crate::{ + Hash256, NODE_LEVELS, Value, child_height, is_packed, leaf_height, max_leaf_height, + packing_factor, +}; + +/// Height at or above which a node with no cached hash hashes its children in +/// parallel. +/// +/// Below it a subtree has fewer than 2^12 chunks, few enough that handing parts +/// of it to other threads costs more than hashing it on this one. A starting +/// value, measured by the `tree_bench` benchmark in `ethlambda-types`. +const PARALLEL_HASH_HEIGHT: usize = 12; + +/// A Merkle tree with the shape of the SSZ one, whose nodes cache their own +/// hash. +/// +/// Nodes are shared through `Arc` and never modified once built. An update +/// builds new nodes along the paths it touches and reuses the rest, so two +/// versions of a list share every subtree they have in common, cached hash +/// included. +/// +/// Heights count SSZ chunks: a node at height `h` covers `2^h` chunks, the +/// subtree SSZ merkleization would build there. Every [`Tree::Leaf`] sits at the +/// same height, [`leaf_height`] of the tree's depth, and holds that whole +/// subtree's elements in one run. Above the leaves, each [`Tree::Node`] stands +/// for [`NODE_LEVELS`] binary levels at once (the root for fewer, see +/// [`child_height`]), so its children are the SSZ subtrees at the bottom of +/// those levels. Subtrees past the end of the data are [`Tree::Zero`] or, below +/// an inner node, simply absent, and are never materialized. +#[derive(Debug)] +pub(crate) enum Tree { + /// A subtree of the given height whose chunks are all zero: its hash is + /// `ZERO_HASHES[height]`, looked up rather than computed. + Zero(usize), + /// The elements of one leaf-height subtree, in order. + Leaf(Leaf), + /// An inner node, above the leaf height. + Node(Node), +} + +/// An inner node: the non-empty subtrees at its bottom level, left to right. +/// +/// The data is a prefix of the list, so every child past the last one here +/// is a zero subtree, and every child before the last one is full. +#[derive(Debug)] +pub(crate) struct Node { + /// The root of the node's subtree. + pub(crate) hash: OnceLock, + pub(crate) children: Vec>>, +} + +/// A leaf's run of elements and its cached hashes. +/// +/// Holds up to `packing_factor << height` elements: every element of its +/// subtree, or fewer at the right edge of the data, where the missing chunks +/// are zero padding. +#[derive(Debug)] +pub(crate) struct Leaf { + /// The root of the leaf's subtree. + pub(crate) hash: OnceLock, + /// Each element's own root, in the order of `values`, for a composite type. + /// Never set for a packed type, whose chunks are the values' bytes. + /// + /// Kept so that rebuilding a leaf around a few changed elements rehashes + /// only those: see [`Tree::updated_leaf`]. + pub(crate) roots: OnceLock>, + pub(crate) values: Vec, +} + +impl Tree { + pub(crate) fn node(children: Vec>) -> Self { + debug_assert!(!children.is_empty(), "an empty subtree is a Tree::Zero"); + Tree::Node(Node { + hash: OnceLock::new(), + children, + }) + } + + fn leaf(values: Vec, roots: Option>) -> Self { + Tree::Leaf(Leaf { + hash: OnceLock::new(), + roots: roots.map(OnceLock::from).unwrap_or_default(), + values, + }) + } +} + +impl Leaf { + /// The root of this leaf's subtree, which has height `height`. + fn root(&self, height: usize) -> Hash256 { + let chunks = 1 << height; + if is_packed::() { + let mut bytes = Vec::with_capacity(self.values.len() * ::fixed_size()); + for value in &self.values { + value.ssz_append(&mut bytes); + } + return merkleize(&Sha2Hasher, &pack(&bytes), Some(chunks)); + } + merkleize(&Sha2Hasher, self.element_roots(), Some(chunks)) + } + + /// Each element's root, computed and kept on first use. + /// + /// `get` and `set` rather than `get_or_init`, for the reason [`cached`] + /// gives: an element's `hash_tree_root` may itself run on rayon. + fn element_roots(&self) -> &[Hash256] { + if let Some(roots) = self.roots.get() { + return roots; + } + let roots: Box<[Hash256]> = self.values.iter().map(element_root).collect(); + // A racing worker may have stored the same roots first. + let _ = self.roots.set(roots); + self.roots.get().expect("set just above") + } +} + +impl Tree { + /// Builds a tree of height `depth` holding `values` in order. + /// + /// Returns the tree and how many values it holds. + /// + /// # Panics + /// + /// If there are more values than a tree of height `depth` holds. Callers + /// check the type's limit first. + pub(crate) fn from_values( + values: impl IntoIterator, + depth: usize, + ) -> (Arc, usize) { + let leaf_height = leaf_height::(depth); + let per_leaf = packing_factor::() << leaf_height; + let mut len = 0; + let mut leaves = Vec::new(); + let mut run = Vec::with_capacity(per_leaf); + for value in values { + len += 1; + run.push(value); + if run.len() == per_leaf { + let full = std::mem::replace(&mut run, Vec::with_capacity(per_leaf)); + leaves.push(Arc::new(Self::leaf(full, None))); + } + } + if !run.is_empty() { + run.shrink_to_fit(); + leaves.push(Arc::new(Self::leaf(run, None))); + } + let levels = depth - leaf_height; + assert!( + levels >= usize::BITS as usize || leaves.len() <= 1 << levels, + "{len} values do not fit in a tree of depth {depth}" + ); + (Self::from_leaves(leaves, leaf_height, depth), len) + } + + /// Groups `level`, left to right, from `leaf_height` up into a tree of + /// height `depth`, [`NODE_LEVELS`] binary levels per inner node. + fn from_leaves(mut level: Vec>, leaf_height: usize, depth: usize) -> Arc { + if level.is_empty() { + return Arc::new(Tree::Zero(depth)); + } + let mut height = leaf_height; + while height < depth { + let parent = (height + NODE_LEVELS).min(depth); + debug_assert_eq!(child_height::(parent), height); + let fan_out = 1 << (parent - height); + let mut nodes = level.into_iter().peekable(); + let mut next = Vec::with_capacity(nodes.len().div_ceil(fan_out)); + while nodes.peek().is_some() { + let children: Vec<_> = nodes.by_ref().take(fan_out).collect(); + next.push(Arc::new(Self::node(children))); + } + level = next; + height = parent; + } + level + .pop() + .expect("a non-empty level groups up into one root") + } + + /// The element at `index` of a tree of height `depth`, or `None` past the + /// end of the data. + /// + /// `index` must be below the type's limit: bits of the chunk index above + /// `depth` are not looked at. + pub(crate) fn get(&self, index: usize, depth: usize) -> Option<&T> { + let packing = packing_factor::(); + debug_assert!( + index < packing << depth, + "index {index} is past the tree's capacity at depth {depth}" + ); + let chunk_index = index / packing; + let mut node = self; + let mut height = depth; + loop { + match node { + Tree::Node(inner) => { + let below = child_height::(height); + let slot = (chunk_index >> below) & ((1 << (height - below)) - 1); + node = inner.children.get(slot)?; + height = below; + } + // The leaf covers an aligned run of `packing << height` + // elements, so the low bits of `index` are the offset in it. + Tree::Leaf(leaf) => return leaf.values.get(index & ((packing << height) - 1)), + Tree::Zero(_) => return None, + } + } + } + + /// This subtree's root, computing and caching whatever is not cached yet. + /// + /// `height` is this node's height. An inner node folds its children's + /// roots up its binary levels, hashing the children in parallel at or + /// above [`PARALLEL_HASH_HEIGHT`]. + pub(crate) fn hash(&self, height: usize) -> Hash256 { + match self { + Tree::Zero(zero_height) => ZERO_HASHES[*zero_height], + Tree::Leaf(leaf) => cached(&leaf.hash, || leaf.root(height)), + Tree::Node(inner) => cached(&inner.hash, || { + let below = child_height::(height); + let roots: Vec = + if height >= PARALLEL_HASH_HEIGHT && inner.children.len() > 1 { + inner + .children + .par_iter() + .map(|child| child.hash(below)) + .collect() + } else { + inner + .children + .iter() + .map(|child| child.hash(below)) + .collect() + }; + fold_roots(roots, below, height) + }), + } + } + + /// The hash this node already has, if any. A [`Tree::Zero`] always has one. + pub(crate) fn cached_hash(&self) -> Option { + match self { + Tree::Zero(height) => Some(ZERO_HASHES[*height]), + Tree::Leaf(leaf) => leaf.hash.get().copied(), + Tree::Node(inner) => inner.hash.get().copied(), + } + } + + /// A copy of `node` with `updates` applied, sharing every child that no + /// update touches. + /// + /// `node` has height `height` and covers the elements from `first` up to + /// `first + (packing << height)`. `updates` yields `(index, value)` in + /// strictly ascending index order; this consumes the ones inside that + /// range and leaves the rest for the caller. + pub(crate) fn with_updated_leaves( + node: &Arc, + height: usize, + first: usize, + updates: &mut Peekable, + ) -> Arc + where + I: Iterator, + { + let packing = packing_factor::(); + let end = first + (packing << height); + match updates.peek() { + Some(&(index, _)) if index < end => {} + _ => return Arc::clone(node), + } + // A tree shorter than a leaf is one leaf, at its root; otherwise the + // walk meets the leaves at the leaf height itself. + if height <= max_leaf_height::() { + return Arc::new(node.updated_leaf(first, end, updates)); + } + let below = child_height::(height); + let span = packing << below; + let mut children = match &**node { + Tree::Node(inner) => inner.children.clone(), + Tree::Zero(_) => Vec::new(), + Tree::Leaf(_) => unreachable!("a leaf above the leaf height"), + }; + while let Some(&(index, _)) = updates.peek() { + if index >= end { + break; + } + let slot = (index - first) / span; + let child_first = first + slot * span; + if let Some(child) = children.get(slot) { + children[slot] = Self::with_updated_leaves(child, below, child_first, updates); + } else { + // The data is a prefix, so a child past the last one only + // appears as the next one, grown from nothing by pushes. + assert_eq!(slot, children.len(), "a new child follows the last one"); + let empty = Arc::new(Tree::Zero(below)); + children.push(Self::with_updated_leaves( + &empty, + below, + child_first, + updates, + )); + } + } + Arc::new(Self::node(children)) + } + + /// The leaf at this position once the updates for elements `first..end` + /// are applied. + /// + /// Copies the run and writes the updates into it. For a composite type + /// whose element roots this leaf already has, the new leaf gets them too, + /// with each updated element's root computed here, so hashing it later + /// only folds roots rather than rehashing every element in the run. + fn updated_leaf(&self, first: usize, end: usize, updates: &mut Peekable) -> Self + where + I: Iterator, + { + let (mut values, mut roots) = match self { + Tree::Leaf(leaf) => ( + leaf.values.clone(), + leaf.roots.get().map(|roots| roots.to_vec()), + ), + Tree::Zero(_) => (Vec::new(), None), + Tree::Node(_) => unreachable!("an inner node at the leaf height"), + }; + while let Some((index, value)) = updates.next_if(|(index, _)| *index < end) { + let offset = index - first; + if let Some(roots) = roots.as_mut() { + let root = element_root(&value); + if offset < roots.len() { + roots[offset] = root; + } else { + roots.push(root); + } + } + if offset < values.len() { + values[offset] = value; + } else { + // Pushes are buffered in order with no gaps, so a value past the + // end of the leaf is always the next one. + assert_eq!(offset, values.len(), "a pushed value follows the last one"); + values.push(value); + } + } + Self::leaf(values, roots.map(Vec::into_boxed_slice)) + } +} + +/// The root at height `height` of the subtrees in `roots`, which sit at height +/// `below`, left to right, with zero subtrees after them. +/// +/// Not `merkleize`: that pads with the zero hashes of chunk-level subtrees, +/// right only for chunks, while a missing child here is a zero subtree of the +/// children's own height, and one level up of that height plus one. +fn fold_roots(mut layer: Vec, below: usize, height: usize) -> Hash256 { + debug_assert!(layer.len() <= 1 << (height - below)); + if layer.is_empty() { + return ZERO_HASHES[height]; + } + for &zero in &ZERO_HASHES[below..height] { + let pairs = layer.len().div_ceil(2); + for pair in 0..pairs { + let left = layer[2 * pair]; + let right = layer.get(2 * pair + 1).copied().unwrap_or(zero); + layer[pair] = hash_nodes(&Sha2Hasher, &left, &right); + } + layer.truncate(pairs); + } + layer[0] +} + +/// A composite element's own root, as a leaf keeps it. +fn element_root(value: &T) -> Hash256 { + HashTreeRoot::hash_tree_root(value, &Sha2Hasher) +} + +/// The hash in `cell`, computing and storing it first if the cell is empty. +/// +/// Uses `get` and `set` rather than `OnceLock::get_or_init`. `get_or_init` +/// holds the cell's lock while `compute` runs, and `compute` hashes the +/// children on rayon: a worker blocked on a cell another worker +/// is filling may be the very worker that one's parallel hash waits on, which +/// deadlocks. Racing workers here are safe: each may end up rehashing the +/// whole uncached subtree below this node rather than just this one cell, and +/// more than two workers can race on it, but every racer computes the same +/// value, so the cost is wasted work, never a wrong hash. +fn cached(cell: &OnceLock, compute: impl FnOnce() -> Hash256) -> Hash256 { + if let Some(hash) = cell.get() { + return *hash; + } + let hash = compute(); + // A racing worker may have stored the same value first. + let _ = cell.set(hash); + hash +} + +#[cfg(test)] +mod tests { + use libssz::SszEncode; + use libssz_merkle::{merkleize, pack}; + + use super::*; + + /// The root libssz computes for `values` packed and padded to `2^depth` + /// chunks. + fn expected_root(values: &[u64], depth: usize) -> Hash256 { + let mut bytes = Vec::new(); + for value in values { + value.ssz_append(&mut bytes); + } + merkleize(&Sha2Hasher, &pack(&bytes), Some(1 << depth)) + } + + #[test] + fn a_built_tree_hashes_like_merkleize() { + for len in [0usize, 1, 3, 4, 5, 8, 9, 31, 32, 33] { + let values: Vec = (0..len as u64).map(|i| i * 3 + 1).collect(); + let (tree, count) = Tree::from_values(values.clone(), 4); + assert_eq!(count, len); + assert_eq!(tree.hash(4), expected_root(&values, 4), "len {len}"); + } + } + + #[test] + fn get_finds_every_value_and_nothing_past_the_end() { + let values: Vec = (100..137).collect(); + let (tree, _) = Tree::from_values(values.clone(), 4); + for (index, value) in values.iter().enumerate() { + assert_eq!(tree.get(index, 4), Some(value)); + } + assert_eq!(tree.get(values.len(), 4), None); + assert_eq!(tree.get(63, 4), None); + } + + /// An inner node's children. + fn children(tree: &Tree) -> &[Arc>] { + match tree { + Tree::Node(inner) => &inner.children, + _ => panic!("not an inner node"), + } + } + + /// Leaf height of `u64` (512 to a leaf) plus two: one inner node of four + /// leaves. + const FOUR_U64_LEAVES: usize = 9; + + #[test] + fn updating_leaves_rebuilds_only_their_paths() { + let values: Vec = (0..2048).collect(); + let (tree, _) = Tree::from_values(values.clone(), FOUR_U64_LEAVES); + assert_eq!( + tree.hash(FOUR_U64_LEAVES), + expected_root(&values, FOUR_U64_LEAVES) + ); + + // Both writes land in the first leaf. + let updates = vec![(1usize, 1000u64), (2, 2000)]; + let updated = Tree::with_updated_leaves( + &tree, + FOUR_U64_LEAVES, + 0, + &mut updates.into_iter().peekable(), + ); + + let mut expected = values.clone(); + expected[1] = 1000; + expected[2] = 2000; + assert_eq!( + updated.hash(FOUR_U64_LEAVES), + expected_root(&expected, FOUR_U64_LEAVES) + ); + + // Elements 512..2048 were untouched, so the root's other three leaves + // are the same nodes; only the first was rebuilt. + let (old, new) = (children(&tree), children(&updated)); + assert_eq!((old.len(), new.len()), (4, 4)); + assert!(!Arc::ptr_eq(&old[0], &new[0])); + for slot in 1..4 { + assert!(Arc::ptr_eq(&old[slot], &new[slot]), "leaf {slot}"); + } + + // The original tree is unchanged. + assert_eq!(tree.get(1, FOUR_U64_LEAVES), Some(&1)); + } + + #[test] + fn a_tree_of_several_leaves_hashes_like_merkleize() { + // Around each leaf boundary, and a full tree. + for len in [0usize, 1, 511, 512, 513, 1024, 1500, 2048] { + let values: Vec = (0..len as u64).map(|i| i * 3 + 1).collect(); + let (tree, count) = Tree::from_values(values.clone(), FOUR_U64_LEAVES); + assert_eq!(count, len); + assert_eq!( + tree.hash(FOUR_U64_LEAVES), + expected_root(&values, FOUR_U64_LEAVES), + "len {len}" + ); + for (index, value) in values.iter().enumerate() { + assert_eq!(tree.get(index, FOUR_U64_LEAVES), Some(value), "len {len}"); + } + // `get` takes an index below the capacity, so a full tree has no + // "one past the end" to ask about. + if len < 2048 { + assert_eq!(tree.get(len, FOUR_U64_LEAVES), None); + } + } + } + + /// 200 composite elements, 64 to a leaf, in a tree of 256 chunks: three + /// full leaves and a partial one. + fn composite_values() -> Vec<[u8; 48]> { + (0..200u16).map(|i| [(i % 251) as u8; 48]).collect() + } + + fn composite_root(values: &[[u8; 48]], depth: usize) -> Hash256 { + let roots: Vec = values.iter().map(element_root).collect(); + merkleize(&Sha2Hasher, &roots, Some(1 << depth)) + } + + #[test] + fn a_composite_tree_of_several_leaves_hashes_and_finds_every_value() { + let values = composite_values(); + let (tree, _) = Tree::from_values(values.clone(), 8); + assert_eq!(tree.hash(8), composite_root(&values, 8)); + for (index, value) in values.iter().enumerate() { + assert_eq!(tree.get(index, 8), Some(value)); + } + assert_eq!(tree.get(values.len(), 8), None); + } + + #[test] + fn a_rebuilt_leaf_keeps_the_element_roots_it_already_had() { + let values = composite_values(); + let (tree, _) = Tree::from_values(values.clone(), 8); + tree.hash(8); + + // Element 70 is in the second leaf; 199 is pushed after the last. + let updates = vec![(70usize, [7u8; 48]), (200, [9u8; 48])]; + let updated = Tree::with_updated_leaves(&tree, 8, 0, &mut updates.into_iter().peekable()); + + let mut expected = values.clone(); + expected[70] = [7u8; 48]; + expected.push([9u8; 48]); + + // The second leaf holds elements 64..128, the root's second child. + let Tree::Leaf(leaf) = &*children(&updated)[1] else { + panic!("height 6 is the leaf height of a 48-byte element"); + }; + let carried = leaf.roots.get().expect("carried over from the hashed leaf"); + let fresh: Vec = expected[64..128].iter().map(element_root).collect(); + assert_eq!(&carried[..], &fresh[..]); + + // The last leaf was hashed too, so the pushed value's root is carried + // in with it, and the whole tree still hashes like merkleize. + assert_eq!(updated.hash(8), composite_root(&expected, 8)); + } + + #[test] + fn updates_past_the_end_extend_the_tree() { + let (tree, _) = Tree::from_values(vec![7u64, 8, 9], 4); + let updates = vec![(3usize, 10u64), (4, 11), (5, 12)]; + let updated = Tree::with_updated_leaves(&tree, 4, 0, &mut updates.into_iter().peekable()); + assert_eq!(updated.hash(4), expected_root(&[7, 8, 9, 10, 11, 12], 4)); + assert_eq!(updated.get(5, 4), Some(&12)); + } + + #[test] + fn composite_leaves_hash_like_merkleize_of_their_roots() { + let values: Vec<[u8; 48]> = (0..5u8).map(|i| [i; 48]).collect(); + let (tree, _) = Tree::from_values(values.clone(), 3); + let roots: Vec = values + .iter() + .map(|value| HashTreeRoot::hash_tree_root(value, &Sha2Hasher)) + .collect(); + assert_eq!(tree.hash(3), merkleize(&Sha2Hasher, &roots, Some(8))); + } + + /// More than one bottom inner node's worth of u64 leaves (512 x 512 + /// values), at the registry limit's depth: two levels of inner nodes over + /// the leaves, plus the root spanning what is left. + #[test] + fn a_registry_depth_tree_of_two_inner_levels_reads_writes_and_hashes() { + const DEPTH: usize = 38; + const LEN: usize = 300_000; + let values: Vec = (0..LEN as u64).map(|i| i ^ 0x5555).collect(); + let (tree, _) = Tree::from_values(values.clone(), DEPTH); + assert_eq!(tree.hash(DEPTH), expected_root(&values, DEPTH)); + + // Around the boundary between the first two bottom inner nodes. + let boundary = 512 * 512; + for index in [0, 511, 512, boundary - 1, boundary, boundary + 1, LEN - 1] { + assert_eq!( + tree.get(index, DEPTH), + Some(&values[index]), + "index {index}" + ); + } + assert_eq!(tree.get(LEN, DEPTH), None); + for start in [boundary - 1, boundary, LEN - 3] { + let got: Vec = crate::iter::TreeIter::new(&tree, DEPTH, start) + .copied() + .collect(); + assert_eq!(got, values[start..], "start {start}"); + } + + // One write in each bottom node, and a push after the last value. + let updates = vec![(7usize, 1u64), (boundary + 9, 2), (LEN, 3)]; + let updated = + Tree::with_updated_leaves(&tree, DEPTH, 0, &mut updates.into_iter().peekable()); + let mut expected = values; + expected[7] = 1; + expected[boundary + 9] = 2; + expected.push(3); + assert_eq!(updated.hash(DEPTH), expected_root(&expected, DEPTH)); + assert_eq!(updated.get(LEN, DEPTH), Some(&3)); + } + + #[test] + fn a_tall_tree_hashes_in_parallel_to_the_same_root() { + // Height 14 is above PARALLEL_HASH_HEIGHT, so the top levels go + // through rayon::join. + let values: Vec = (0..40_000).collect(); + let (tree, _) = Tree::from_values(values.clone(), 14); + assert_eq!(tree.hash(14), expected_root(&values, 14)); + } + + #[test] + fn pushing_into_an_empty_tree_above_height_zero_hashes_correctly() { + // n = 5 crosses the leaf 0 / leaf 1 boundary (u64 packs 4 to a leaf); + // n = 100 lands deep in the right half of a height-5 (32-leaf) tree. + for n in [1usize, 4, 5, 20, 100] { + let (tree, count) = Tree::::from_values(vec![], 5); + assert_eq!(count, 0); + let values: Vec = (0..n as u64).collect(); + let updates: Vec<(usize, u64)> = values.iter().copied().enumerate().collect(); + let updated = + Tree::with_updated_leaves(&tree, 5, 0, &mut updates.into_iter().peekable()); + assert_eq!(updated.hash(5), expected_root(&values, 5), "n {n}"); + } + } + + #[test] + fn updating_composite_leaves_overwrites_and_pushes() { + let values: Vec<[u8; 48]> = (0..5u8).map(|i| [i; 48]).collect(); + let (tree, _) = Tree::from_values(values.clone(), 3); + + // Overwrite element 2, push a new element 5 (past the end). + let overwritten = [99u8; 48]; + let pushed = [100u8; 48]; + let updates = vec![(2usize, overwritten), (5, pushed)]; + let updated = Tree::with_updated_leaves(&tree, 3, 0, &mut updates.into_iter().peekable()); + + let mut expected = values; + expected[2] = overwritten; + expected.push(pushed); + let roots: Vec = expected + .iter() + .map(|value| HashTreeRoot::hash_tree_root(value, &Sha2Hasher)) + .collect(); + assert_eq!(updated.hash(3), merkleize(&Sha2Hasher, &roots, Some(8))); + assert_eq!(updated.get(2, 3), Some(&overwritten)); + assert_eq!(updated.get(5, 3), Some(&pushed)); + } + + #[test] + fn a_tree_of_depth_zero_builds_updates_and_hashes() { + let (tree, count) = Tree::from_values(vec![42u64], 0); + assert_eq!(count, 1); + assert_eq!(tree.hash(0), expected_root(&[42], 0)); + assert_eq!(tree.get(0, 0), Some(&42)); + + let updates = vec![(0usize, 100u64)]; + let updated = Tree::with_updated_leaves(&tree, 0, 0, &mut updates.into_iter().peekable()); + assert_eq!(updated.hash(0), expected_root(&[100], 0)); + assert_eq!(updated.get(0, 0), Some(&100)); + } +} diff --git a/crates/common/ssz-tree/src/update_map.rs b/crates/common/ssz-tree/src/update_map.rs new file mode 100644 index 000000000..2167511ad --- /dev/null +++ b/crates/common/ssz-tree/src/update_map.rs @@ -0,0 +1,134 @@ +//! Where a [`List`](crate::List) or [`Vector`](crate::Vector) buffers writes +//! until `apply_updates` folds them into its tree. + +use std::collections::BTreeMap; + +/// Pending writes, keyed by element index. +/// +/// Two implementations with different costs: [`BTreeMap`] is sparse, suited +/// to rare, scattered writes of large elements (the validator registry); +/// [`VecMap`] is dense, suited to writing most of a list at once (balances at +/// an epoch boundary). The map is a type parameter of each list, so the choice +/// can be benchmarked per field. +pub trait UpdateMap: Default + Clone + Send + Sync { + /// The pending value at `index`, if any. + fn get(&self, index: usize) -> Option<&T>; + + /// A mutable handle to the pending value at `index`, if any. + fn get_mut(&mut self, index: usize) -> Option<&mut T>; + + /// Sets the pending value at `index`, replacing any earlier one. + fn insert(&mut self, index: usize, value: T); + + /// Number of pending writes. + fn len(&self) -> usize; + + fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Every pending write, in ascending index order. + fn into_sorted_vec(self) -> Vec<(usize, T)>; +} + +impl UpdateMap for BTreeMap { + fn get(&self, index: usize) -> Option<&T> { + BTreeMap::get(self, &index) + } + + fn get_mut(&mut self, index: usize) -> Option<&mut T> { + BTreeMap::get_mut(self, &index) + } + + fn insert(&mut self, index: usize, value: T) { + BTreeMap::insert(self, index, value); + } + + fn len(&self) -> usize { + BTreeMap::len(self) + } + + fn into_sorted_vec(self) -> Vec<(usize, T)> { + self.into_iter().collect() + } +} + +/// A dense map: one slot per index, up to the highest index written. +/// +/// Growing to the highest written index is the price of O(1) access: one +/// write near the end of a 2.4M-element list allocates 2.4M slots. Lighthouse +/// pays the same price for its balances. +#[derive(Debug, Clone)] +pub struct VecMap { + slots: Vec>, + len: usize, +} + +impl Default for VecMap { + fn default() -> Self { + Self { + slots: Vec::new(), + len: 0, + } + } +} + +impl UpdateMap for VecMap { + fn get(&self, index: usize) -> Option<&T> { + self.slots.get(index)?.as_ref() + } + + fn get_mut(&mut self, index: usize) -> Option<&mut T> { + self.slots.get_mut(index)?.as_mut() + } + + fn insert(&mut self, index: usize, value: T) { + if index >= self.slots.len() { + self.slots.resize_with(index + 1, || None); + } + if self.slots[index].replace(value).is_none() { + self.len += 1; + } + } + + fn len(&self) -> usize { + self.len + } + + fn into_sorted_vec(self) -> Vec<(usize, T)> { + self.slots + .into_iter() + .enumerate() + .filter_map(|(index, slot)| slot.map(|value| (index, value))) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn buffers_writes>() { + let mut map = M::default(); + assert!(map.is_empty()); + map.insert(5, 50); + map.insert(2, 20); + map.insert(5, 55); + assert_eq!(map.len(), 2); + assert_eq!(map.get(5), Some(&55)); + assert_eq!(map.get(3), None); + assert_eq!(map.get(99), None); + *map.get_mut(2).unwrap() += 1; + assert_eq!(map.into_sorted_vec(), vec![(2, 21), (5, 55)]); + } + + #[test] + fn btree_map_buffers_writes() { + buffers_writes::>(); + } + + #[test] + fn vec_map_buffers_writes() { + buffers_writes::>(); + } +} diff --git a/crates/common/ssz-tree/src/vector.rs b/crates/common/ssz-tree/src/vector.rs new file mode 100644 index 000000000..0ec13690c --- /dev/null +++ b/crates/common/ssz-tree/src/vector.rs @@ -0,0 +1,373 @@ +//! [`Vector`]: an SSZ `Vector[T, N]` kept in a persistent Merkle tree. + +use std::fmt; +use std::ops::{Index, IndexMut}; + +use libssz::{DecodeError, SszDecode, SszEncode}; +use libssz_merkle::{HashTreeRoot, Sha256Hasher}; +use libssz_types::TypeError; + +use crate::interface::Interface; +use crate::iter::Iter; +use crate::update_map::{UpdateMap, VecMap}; +use crate::{Hash256, Value, tree_depth}; + +/// An SSZ vector of exactly `N` elements, kept in a persistent Merkle tree. +/// +/// The fixed-length counterpart of [`List`](crate::List): the same tree, +/// buffered writes and O(1) clone, with no `push` and no length mix-in in the +/// root. Stands in for `libssz_types::SszVector` in a derived container. +#[derive(Clone)] +pub struct Vector> { + interface: Interface, +} + +impl> Vector { + fn depth() -> usize { + tree_depth::(N) + } + + /// The number of elements: always `N`. + pub fn len(&self) -> usize { + N + } + + /// Whether `N` is zero. + pub fn is_empty(&self) -> bool { + N == 0 + } + + /// The element at `index`, or `None` past the end. + pub fn get(&self, index: usize) -> Option<&T> { + self.interface.get(index) + } + + /// The element at `index`, to be written. The write is buffered until + /// [`Vector::apply_updates`]. + pub fn get_mut(&mut self, index: usize) -> Option<&mut T> { + self.interface.get_mut(index) + } + + /// The elements in order, pending writes included. + pub fn iter(&self) -> Iter<'_, T, U> { + self.interface.iter_from(0) + } + + /// The elements from `index` on; empty if `index` is past the end. + pub fn iter_from(&self, index: usize) -> Iter<'_, T, U> { + self.interface.iter_from(index) + } + + /// A `Vec` copy of the elements, pending writes included. + pub fn to_vec(&self) -> Vec { + self.iter().cloned().collect() + } + + /// Folds every buffered write into the tree, rebuilding the touched paths + /// once. + pub fn apply_updates(&mut self) { + self.interface.apply_updates(); + } + + /// Whether any write is buffered and not yet folded into the tree. + pub fn has_pending_updates(&self) -> bool { + self.interface.has_pending_updates() + } + + /// Makes this vector share every unchanged subtree with `base`, after + /// applying its own pending writes. The contents do not change. + pub fn rebase_on(&mut self, base: &Self) { + self.interface.rebase_on(&base.interface); + } + + /// Whether both vectors' committed trees are the same allocation: a cheap + /// check of sharing, not of equality. + pub fn ptr_eq(&self, other: &Self) -> bool { + self.interface.ptr_eq(&other.interface) + } +} + +impl> Default for Vector { + /// A vector of `N` default-valued elements. + fn default() -> Self { + let values = std::iter::repeat_with(T::default).take(N); + Self { + interface: Interface::from_values(values, Self::depth()), + } + } +} + +impl> TryFrom> for Vector { + type Error = TypeError; + + fn try_from(values: Vec) -> Result { + if values.len() != N { + return Err(TypeError::InvalidLength { + expected: N, + got: values.len(), + }); + } + Ok(Self { + interface: Interface::from_values(values, Self::depth()), + }) + } +} + +impl> Index for Vector { + type Output = T; + + fn index(&self, index: usize) -> &T { + self.get(index) + .unwrap_or_else(|| panic!("index {index} out of bounds for a vector of length {N}")) + } +} + +impl> IndexMut for Vector { + fn index_mut(&mut self, index: usize) -> &mut T { + self.get_mut(index) + .unwrap_or_else(|| panic!("index {index} out of bounds for a vector of length {N}")) + } +} + +impl> PartialEq for Vector { + fn eq(&self, other: &Self) -> bool { + let nothing_pending = !self.has_pending_updates() && !other.has_pending_updates(); + (nothing_pending && self.ptr_eq(other)) || self.iter().eq(other.iter()) + } +} + +impl> Eq for Vector {} + +impl> fmt::Debug for Vector { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_list().entries(self.iter()).finish() + } +} + +impl<'a, T: Value, const N: usize, U: UpdateMap> IntoIterator for &'a Vector { + type Item = &'a T; + type IntoIter = Iter<'a, T, U>; + + fn into_iter(self) -> Self::IntoIter { + self.iter() + } +} + +impl> SszEncode for Vector { + fn is_fixed_size() -> bool { + ::is_fixed_size() + } + + fn fixed_size() -> usize { + if ::is_fixed_size() { + ::fixed_size() * N + } else { + 0 + } + } + + fn encoded_len(&self) -> usize { + self.interface.encoded_len() + } + + fn ssz_append(&self, buf: &mut Vec) { + self.interface.ssz_append(buf); + } +} + +impl> SszDecode for Vector { + fn is_fixed_size() -> bool { + ::is_fixed_size() + } + + fn fixed_size() -> usize { + if ::is_fixed_size() { + ::fixed_size() * N + } else { + 0 + } + } + + /// Rejects what `SszVector` rejects, with the same error. + /// + /// `SszVector` decodes without a cap and checks the count afterwards, + /// while the shared decoder caps at `N` like a list and would report too + /// many fixed-size elements as `InvalidByteLength`. Checking the count of + /// a fixed-size `T` first reports it as `InvalidFixedLength`, as + /// `SszVector` does. A variable-size `T` accepts and rejects the same + /// inputs, but too many elements may still get a different error. + fn from_ssz_bytes(bytes: &[u8]) -> Result { + if ::is_fixed_size() { + let size = ::fixed_size(); + if size != 0 && bytes.len().is_multiple_of(size) { + let count = bytes.len() / size; + if count != N { + return Err(DecodeError::InvalidFixedLength { + expected: N, + got: count, + }); + } + } + } + let interface = Interface::from_ssz_bytes(bytes, N, Self::depth())?; + if interface.len() != N { + return Err(DecodeError::InvalidFixedLength { + expected: N, + got: interface.len(), + }); + } + Ok(Self { interface }) + } +} + +impl> HashTreeRoot for Vector { + /// Ignores `hasher` and uses SHA-256: see the crate docs. + fn hash_tree_root(&self, _hasher: &impl Sha256Hasher) -> Hash256 { + self.interface.root() + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use libssz_derive::{HashTreeRoot, SszDecode, SszEncode}; + use libssz_merkle::Sha2Hasher; + use libssz_types::SszVector; + + use super::*; + + /// A composite element with a `Default`, which `[u8; 48]` lacks. + #[derive(Debug, Clone, Default, PartialEq, Eq, SszEncode, SszDecode, HashTreeRoot)] + struct Pair { + a: u64, + b: u64, + } + + fn root(vector: &V) -> Hash256 { + HashTreeRoot::hash_tree_root(vector, &Sha2Hasher) + } + + fn model(values: &[T]) -> SszVector { + values.to_vec().try_into().expect("exactly N values") + } + + #[test] + fn a_default_vector_hashes_like_an_all_default_ssz_vector() { + assert_eq!( + root(&Vector::::default()), + root(&model::(&[0; 37])) + ); + assert_eq!( + root(&Vector::>::default()), + root(&model::(&vec![Pair::default(); 5])) + ); + } + + #[test] + fn writes_hash_like_the_same_ssz_vector() { + let mut values: Vec = (0..37).collect(); + let mut vector = Vector::::try_from(values.clone()).unwrap(); + vector[36] = 1000; + *vector.get_mut(0).unwrap() = 5; + values[36] = 1000; + values[0] = 5; + assert_eq!(root(&vector), root(&model::(&values))); + vector.apply_updates(); + assert_eq!(vector.to_vec(), values); + assert_eq!(root(&vector), root(&model::(&values))); + } + + #[test] + fn a_vector_needs_exactly_n_values() { + assert_eq!( + Vector::::try_from(vec![1, 2]), + Err(TypeError::InvalidLength { + expected: 3, + got: 2 + }) + ); + assert!(Vector::::try_from(vec![1, 2, 3]).is_ok()); + } + + #[test] + fn a_vector_of_fixed_size_elements_is_fixed_size() { + assert!( as SszEncode>::is_fixed_size()); + assert_eq!( as SszEncode>::fixed_size(), 24); + assert!( as SszDecode>::is_fixed_size()); + assert_eq!( as SszDecode>::fixed_size(), 24); + } + + #[test] + fn ssz_bytes_match_ssz_vector_and_round_trip() { + let values: Vec = (10..47).collect(); + let vector = Vector::::try_from(values.clone()).unwrap(); + let bytes = vector.to_ssz(); + assert_eq!(bytes, model::(&values).to_ssz()); + let decoded = Vector::::from_ssz_bytes(&bytes).unwrap(); + assert_eq!(decoded, vector); + } + + #[test] + fn decoding_the_wrong_count_is_an_error() { + assert!(Vector::::from_ssz_bytes(&[0u8; 16]).is_err()); + assert!(Vector::::from_ssz_bytes(&[0u8; 32]).is_err()); + assert!(Vector::::from_ssz_bytes(&[0u8; 23]).is_err()); + } + + /// `Interface::from_ssz_bytes` treats empty input as an empty result (to + /// match `libssz`'s list decoding), so a fixed-size `Vector` must reject + /// it itself, through the same count check as any other wrong length. + #[test] + fn decoding_empty_bytes_is_rejected_like_ssz_vector() { + let vector_err = Vector::::from_ssz_bytes(&[]).unwrap_err(); + let ssz_err = SszVector::::from_ssz_bytes(&[]).unwrap_err(); + assert_eq!(vector_err, ssz_err); + } + + /// Minimized case from the `model.rs` decode-parity property: 7 zero + /// `u64`s (56 bytes) into a `Vector`. Before the pre-check in + /// `from_ssz_bytes`, this came back as `InvalidByteLength` here (the + /// shared decoder's own list-style cap) but `InvalidFixedLength` from + /// `SszVector` (which decodes uncapped, then checks the count). + #[test] + fn decoding_too_many_fixed_size_elements_matches_ssz_vector() { + let bytes = vec![0u8; 56]; + let vector_err = Vector::::from_ssz_bytes(&bytes).unwrap_err(); + let ssz_err = SszVector::::from_ssz_bytes(&bytes).unwrap_err(); + assert_eq!(vector_err, ssz_err); + assert_eq!( + vector_err, + DecodeError::InvalidFixedLength { + expected: 6, + got: 7 + } + ); + } + + /// Encoding, decoding and hashing agree with `SszVector` for both a + /// packed element type (`u64`) and a composite one (`Pair`). + #[test] + fn ssz_bytes_and_root_match_ssz_vector_for_packed_and_composite_elements() { + let packed_values: Vec = (0..37).collect(); + let packed = Vector::::try_from(packed_values.clone()).unwrap(); + let packed_model = model::(&packed_values); + assert_eq!(packed.to_ssz(), packed_model.to_ssz()); + assert_eq!(root(&packed), root(&packed_model)); + assert_eq!( + Vector::::from_ssz_bytes(&packed.to_ssz()).unwrap(), + packed + ); + + let composite_values: Vec = (0..5).map(|i| Pair { a: i, b: i * 2 }).collect(); + let composite = + Vector::>::try_from(composite_values.clone()).unwrap(); + let composite_model = model::(&composite_values); + assert_eq!(composite.to_ssz(), composite_model.to_ssz()); + assert_eq!(root(&composite), root(&composite_model)); + assert_eq!( + Vector::>::from_ssz_bytes(&composite.to_ssz()).unwrap(), + composite + ); + } +} diff --git a/crates/common/ssz-tree/tests/model.proptest-regressions b/crates/common/ssz-tree/tests/model.proptest-regressions new file mode 100644 index 000000000..ae6340f59 --- /dev/null +++ b/crates/common/ssz-tree/tests/model.proptest-regressions @@ -0,0 +1,8 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 8d160d7d350785c21c025eed006bf5f4280829ed5b1a4e5af6d8c034f87d667c # shrinks to bytes = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] +cc 80d7ee2b3006486af61512f87f438602684eadd705b81769142afd41f790e265 # shrinks to bytes = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 70, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 164, 229, 139, 164, 126, 184, 153, 8, 38, 104, 181, 217, 188, 247, 198, 155, 53, 125, 21, 122, 214, 225, 90, 232, 212, 56, 22, 160, 62, 19, 130, 181, 95, 8, 140, 44, 67, 156, 30, 254, 161, 85, 89, 208, 219, 89, 176, 97, 49, 78, 233, 198, 1, 110, 161, 96, 128, 26, 157, 189, 160, 78, 2, 77, 103, 88, 198, 82, 121, 23, 187, 186, 94, 72, 187, 232, 80, 251, 228, 117, 24, 185, 193, 203, 161, 136, 94, 18, 39, 37, 198, 168, 217, 244, 224, 189, 95, 82] diff --git a/crates/common/ssz-tree/tests/model.rs b/crates/common/ssz-tree/tests/model.rs new file mode 100644 index 000000000..e09a5f1b6 --- /dev/null +++ b/crates/common/ssz-tree/tests/model.rs @@ -0,0 +1,523 @@ +//! Random write sequences on `List` and `Vector`, checked after every step +//! against `libssz_types::SszList` / `SszVector` holding the same elements: +//! length, every element, iteration, `hash_tree_root` and SSZ bytes must +//! match exactly. +//! +//! Also checks decode parity against the same references on arbitrary and +//! mutated bytes, and that `rebase_on` shares every subtree two states have +//! in common. + +use std::collections::BTreeMap; +use std::fmt::Debug; + +use ethlambda_ssz_tree::{List, UpdateMap, Value, VecMap, Vector}; +use libssz::{SszDecode as _, SszEncode as _}; +use libssz_derive::{HashTreeRoot, SszDecode, SszEncode}; +use libssz_merkle::{HashTreeRoot, Sha2Hasher}; +use libssz_types::{SszList, SszVector}; +use proptest::collection::vec; +use proptest::prelude::*; + +/// A fixed-size composite element. +#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode, HashTreeRoot)] +struct Item { + id: u64, + data: [u8; 32], +} + +/// A variable-size composite element, which exercises the offset table. +#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode, HashTreeRoot)] +struct Blob { + id: u64, + bytes: SszList, +} + +fn item() -> impl Strategy + Clone { + (any::(), any::<[u8; 32]>()).prop_map(|(id, data)| Item { id, data }) +} + +fn blob() -> impl Strategy + Clone { + (any::(), vec(any::(), 0..64)).prop_map(|(id, bytes)| Blob { + id, + bytes: bytes.try_into().unwrap(), + }) +} + +#[derive(Debug, Clone)] +enum Op { + Push(T), + Set(usize, T), + Apply, +} + +fn ops( + value: impl Strategy + Clone + 'static, +) -> impl Strategy>> { + let op = prop_oneof![ + 3 => value.clone().prop_map(Op::Push), + 3 => (any::(), value).prop_map(|(index, value)| Op::Set(index, value)), + 1 => Just(Op::Apply), + ]; + vec(op, 0..120) +} + +fn root(value: &V) -> [u8; 32] { + HashTreeRoot::hash_tree_root(value, &Sha2Hasher) +} + +fn check_list_matches( + list: &List, + model: &[T], +) -> Result<(), TestCaseError> +where + T: Value + Debug, + U: UpdateMap, +{ + let reference = SszList::::try_from(model.to_vec()).unwrap(); + prop_assert_eq!(list.len(), model.len()); + for (index, value) in model.iter().enumerate() { + prop_assert_eq!(list.get(index), Some(value)); + } + prop_assert_eq!(list.get(model.len()), None); + prop_assert_eq!(list.to_vec(), model.to_vec()); + prop_assert_eq!(root(list), root(&reference)); + let bytes = reference.to_ssz(); + prop_assert_eq!(list.to_ssz(), bytes.clone()); + let decoded = List::::from_ssz_bytes(&bytes).unwrap(); + prop_assert_eq!(root(&decoded), root(&reference)); + Ok(()) +} + +fn run_list(initial: Vec, ops: Vec>) -> Result<(), TestCaseError> +where + T: Value + Debug, + U: UpdateMap, +{ + let mut list = List::::try_from(initial.clone()).unwrap(); + let mut model = initial; + for op in ops { + match op { + Op::Push(value) => { + let fits = model.len() < N; + prop_assert_eq!(list.push(value.clone()).is_ok(), fits); + if fits { + model.push(value); + } + } + Op::Set(index, value) => { + if model.is_empty() { + prop_assert!(list.get_mut(0).is_none()); + continue; + } + let index = index % model.len(); + *list.get_mut(index).unwrap() = value.clone(); + model[index] = value; + } + Op::Apply => list.apply_updates(), + } + check_list_matches(&list, &model)?; + } + list.apply_updates(); + check_list_matches(&list, &model) +} + +fn run_vector( + initial: Vec, + writes: Vec<(usize, T, bool)>, +) -> Result<(), TestCaseError> +where + T: Value + Debug, + U: UpdateMap, +{ + let mut vector = Vector::::try_from(initial.clone()).unwrap(); + let mut model = initial; + for (index, value, apply) in writes { + let index = index % N; + vector[index] = value.clone(); + model[index] = value; + if apply { + vector.apply_updates(); + } + let reference = SszVector::::try_from(model.clone()).unwrap(); + prop_assert_eq!(vector.to_vec(), model.clone()); + prop_assert_eq!(root(&vector), root(&reference)); + prop_assert_eq!(vector.to_ssz(), reference.to_ssz()); + } + Ok(()) +} + +proptest! { + #[test] + fn u64_list(initial in vec(any::(), 0..40), ops in ops(any::())) { + run_list::>(initial, ops)?; + } + + #[test] + fn u8_list(initial in vec(any::(), 0..100), ops in ops(any::())) { + run_list::>(initial, ops)?; + } + + #[test] + fn root_list(initial in vec(any::<[u8; 32]>(), 0..15), ops in ops(any::<[u8; 32]>())) { + run_list::<[u8; 32], 20, BTreeMap>(initial, ops)?; + } + + #[test] + fn item_list(initial in vec(item(), 0..20), ops in ops(item())) { + run_list::>(initial, ops)?; + } + + #[test] + fn blob_list(initial in vec(blob(), 0..6), ops in ops(blob())) { + run_list::>(initial, ops)?; + } + + #[test] + fn u64_list_at_the_registry_limit(initial in vec(any::(), 0..20), ops in ops(any::())) { + run_list::>(initial, ops)?; + } + + /// A leaf holds 512 u64s: this spans several, with the last partial. + #[test] + fn u64_list_spanning_leaves( + initial in vec(any::(), 0..1600), + ops in ops(any::()), + ) { + run_list::>(initial, ops)?; + } + + /// A leaf holds 64 items: 40 bytes each, a page fits 102, rounded down to a + /// power of two. + #[test] + fn item_list_spanning_leaves(initial in vec(item(), 0..400), ops in ops(item())) { + run_list::>(initial, ops)?; + } + + #[test] + fn u64_vector( + initial in vec(any::(), 37), + writes in vec((any::(), any::(), any::()), 0..60), + ) { + run_vector::>(initial, writes)?; + } + + #[test] + fn item_vector( + initial in vec(item(), 5), + writes in vec((any::(), item(), any::()), 0..30), + ) { + run_vector::>(initial, writes)?; + } +} + +// ── Decode parity on arbitrary bytes ── +// +// The crate decodes fixed-size elements with its own checks rather than +// libssz's `decode_list_with_max`, so these properties pin it to libssz: +// for any input, `List`/`Vector` and `SszList`/`SszVector` must both accept +// with the same elements, or both reject with the same `DecodeError`. + +/// One tweak to an otherwise valid encoding. +#[derive(Debug, Clone)] +enum Mutation { + /// Leaves the bytes untouched. + None, + /// XORs the byte at `index % len` with a nonzero value. + Flip(usize, u8), + /// Truncates to `len % (original_len + 1)` bytes. + Truncate(usize), + /// Appends extra bytes past the end. + Append(Vec), +} + +fn mutation() -> impl Strategy + Clone { + prop_oneof![ + 1 => Just(Mutation::None), + 3 => (any::(), 1u8..=u8::MAX).prop_map(|(index, xor)| Mutation::Flip(index, xor)), + 3 => any::().prop_map(Mutation::Truncate), + 3 => vec(any::(), 1..8).prop_map(Mutation::Append), + ] +} + +fn apply_mutation(mut bytes: Vec, mutation: Mutation) -> Vec { + match mutation { + Mutation::None => bytes, + Mutation::Flip(index, xor) => { + if !bytes.is_empty() { + let index = index % bytes.len(); + bytes[index] ^= xor; + } + bytes + } + Mutation::Truncate(len) => { + let len = len % (bytes.len() + 1); + bytes.truncate(len); + bytes + } + Mutation::Append(extra) => { + bytes.extend(extra); + bytes + } + } +} + +/// Arbitrary bytes, mostly derived from a valid encoding of `values` (so +/// offset and length checks are actually reached) but sometimes wholly +/// random. +fn maybe_valid_bytes( + values: impl Strategy> + Clone, +) -> impl Strategy> { + prop_oneof![ + 3 => vec(any::(), 0..=600), + 7 => (values, mutation()).prop_map(|(values, mutation)| { + apply_mutation(values.to_ssz(), mutation) + }), + ] +} + +/// `List::::from_ssz_bytes` and `SszList::::from_ssz_bytes` must +/// agree on `bytes`: both `Ok` with the same elements, or both `Err` with the +/// same `DecodeError`. +fn list_decode_parity(bytes: &[u8]) -> Result<(), TestCaseError> +where + T: Value + Debug, +{ + let tree_result = List::::from_ssz_bytes(bytes); + let ssz_result = SszList::::from_ssz_bytes(bytes); + match (tree_result, ssz_result) { + (Ok(tree), Ok(reference)) => prop_assert_eq!(tree.to_vec(), reference.to_vec()), + (Err(tree_err), Err(ssz_err)) => prop_assert_eq!(tree_err, ssz_err), + (tree_result, ssz_result) => prop_assert!( + false, + "list decode parity mismatch for {bytes:?}: tree={tree_result:?} ssz_list={ssz_result:?}" + ), + } + Ok(()) +} + +/// `Vector::::from_ssz_bytes` and `SszVector::::from_ssz_bytes` +/// must agree on `bytes`, the same as [`list_decode_parity`]. +fn vector_decode_parity(bytes: &[u8]) -> Result<(), TestCaseError> +where + T: Value + Debug, +{ + let tree_result = Vector::::from_ssz_bytes(bytes); + let ssz_result = SszVector::::from_ssz_bytes(bytes); + match (tree_result, ssz_result) { + (Ok(tree), Ok(reference)) => prop_assert_eq!(tree.to_vec(), reference.to_vec()), + (Err(tree_err), Err(ssz_err)) => prop_assert_eq!(tree_err, ssz_err), + (tree_result, ssz_result) => prop_assert!( + false, + "vector decode parity mismatch for {bytes:?}: tree={tree_result:?} ssz_vector={ssz_result:?}" + ), + } + Ok(()) +} + +proptest! { + #[test] + fn u64_list_decode_parity(bytes in maybe_valid_bytes(vec(any::(), 0..=15))) { + list_decode_parity::(&bytes)?; + } + + #[test] + fn item_list_decode_parity(bytes in maybe_valid_bytes(vec(item(), 0..=13))) { + list_decode_parity::(&bytes)?; + } + + #[test] + fn blob_list_decode_parity(bytes in maybe_valid_bytes(vec(blob(), 0..=9))) { + list_decode_parity::(&bytes)?; + } + + #[test] + fn u64_vector_decode_parity(bytes in maybe_valid_bytes(vec(any::(), 0..=11))) { + vector_decode_parity::(&bytes)?; + } + + #[test] + fn item_vector_decode_parity(bytes in maybe_valid_bytes(vec(item(), 0..=10))) { + vector_decode_parity::(&bytes)?; + } +} + +// ── Rebase: contents and roots survive, equal lists end up shared ── + +/// Applies `ops` to `list`, keeping `model` in sync. Unlike `run_list`, makes +/// no assertions: only the final state matters to the rebase property. +fn apply_ops(list: &mut List, model: &mut Vec, ops: Vec>) +where + T: Value, + U: UpdateMap, +{ + for op in ops { + match op { + Op::Push(value) => { + if list.push(value.clone()).is_ok() { + model.push(value); + } + } + Op::Set(index, value) => { + if !model.is_empty() { + let index = index % model.len(); + *list.get_mut(index).unwrap() = value.clone(); + model[index] = value; + } + } + Op::Apply => list.apply_updates(), + } + } +} + +/// After `orig.rebase_on(base)`: `orig`'s elements are unchanged and its root +/// matches a fresh `SszList` model of them; `base` is untouched (elements and +/// root); and if the two hold equal elements, `orig` shares `base`'s tree. +fn check_rebase( + orig: &mut List, + orig_model: &[T], + base: &List, + base_model: &[T], +) -> Result<(), TestCaseError> +where + T: Value + Debug, + U: UpdateMap, +{ + orig.rebase_on(base); + + prop_assert_eq!(orig.to_vec(), orig_model.to_vec()); + let orig_reference = SszList::::try_from(orig_model.to_vec()).unwrap(); + prop_assert_eq!(root(orig), root(&orig_reference)); + + prop_assert_eq!(base.to_vec(), base_model.to_vec()); + let base_reference = SszList::::try_from(base_model.to_vec()).unwrap(); + prop_assert_eq!(root(base), root(&base_reference)); + + if orig_model == base_model { + prop_assert!(orig.ptr_eq(base)); + } + Ok(()) +} + +/// `orig` derived from `base` by a random edit sequence, `base` at least as +/// long as `orig`'s prefix (`shared_prefix` ends up `base`'s length or less). +fn rebase_grown( + base_values: Vec, + ops: Vec>, + hash_base_first: bool, + hash_orig_first: bool, +) -> Result<(), TestCaseError> +where + T: Value + Debug, + U: UpdateMap, +{ + let base = List::::try_from(base_values.clone()).unwrap(); + if hash_base_first { + root(&base); + } + + let mut orig = base.clone(); + let mut orig_model = base_values.clone(); + apply_ops(&mut orig, &mut orig_model, ops); + orig.apply_updates(); + + // Round-trip through SSZ bytes: a freshly decoded tree shares nothing + // with `base`'s allocation, so any sharing the rebase produces comes from + // its own content-equality walk. + let bytes = orig.to_ssz(); + let mut orig = List::::from_ssz_bytes(&bytes).unwrap(); + if hash_orig_first { + root(&orig); + } + + check_rebase(&mut orig, &orig_model, &base, &base_values) +} + +/// `base` derived from `orig` by pushing more elements onto a copy, so `orig` +/// is shorter than `base` and `shared_prefix` ends up `orig`'s own length. +fn rebase_shrunk( + orig_values: Vec, + extra: Vec, + hash_orig_first: bool, + hash_base_first: bool, +) -> Result<(), TestCaseError> +where + T: Value + Debug, + U: UpdateMap, +{ + let built = List::::try_from(orig_values.clone()).unwrap(); + let bytes = built.to_ssz(); + let mut orig = List::::from_ssz_bytes(&bytes).unwrap(); + if hash_orig_first { + root(&orig); + } + + let mut base = List::::try_from(orig_values.clone()).unwrap(); + let mut base_model = orig_values.clone(); + for value in extra { + if base.push(value.clone()).is_ok() { + base_model.push(value); + } + } + base.apply_updates(); + if hash_base_first { + root(&base); + } + + check_rebase(&mut orig, &orig_values, &base, &base_model) +} + +fn zero_prone_u64() -> impl Strategy + Clone { + prop_oneof![Just(0u64), any::()] +} + +proptest! { + #[test] + fn u64_rebase_grown( + base_values in vec(zero_prone_u64(), 0..40), + ops in ops(zero_prone_u64()), + hash_base_first in any::(), + hash_orig_first in any::(), + ) { + rebase_grown::>(base_values, ops, hash_base_first, hash_orig_first)?; + } + + #[test] + fn u64_rebase_shrunk( + orig_values in vec(zero_prone_u64(), 0..30), + extra in vec(zero_prone_u64(), 0..30), + hash_orig_first in any::(), + hash_base_first in any::(), + ) { + rebase_shrunk::>(orig_values, extra, hash_orig_first, hash_base_first)?; + } + + #[test] + fn u64_rebase_grown_spanning_leaves( + base_values in vec(zero_prone_u64(), 0..1400), + ops in ops(zero_prone_u64()), + hash_base_first in any::(), + hash_orig_first in any::(), + ) { + rebase_grown::>(base_values, ops, hash_base_first, hash_orig_first)?; + } + + #[test] + fn item_rebase_grown( + base_values in vec(item(), 0..20), + ops in ops(item()), + hash_base_first in any::(), + hash_orig_first in any::(), + ) { + rebase_grown::>(base_values, ops, hash_base_first, hash_orig_first)?; + } + + #[test] + fn item_rebase_shrunk( + orig_values in vec(item(), 0..15), + extra in vec(item(), 0..15), + hash_orig_first in any::(), + hash_base_first in any::(), + ) { + rebase_shrunk::>(orig_values, extra, hash_orig_first, hash_base_first)?; + } +} diff --git a/crates/common/types/Cargo.toml b/crates/common/types/Cargo.toml index c42eb375f..46d2aca20 100644 --- a/crates/common/types/Cargo.toml +++ b/crates/common/types/Cargo.toml @@ -9,6 +9,14 @@ repository.workspace = true rust-version.workspace = true version.workspace = true +[features] +# Compile the beacon containers against the minimal preset instead of mainnet. +# +# Container shapes are compile-time constants (SSZ list and vector bounds), so +# the preset cannot be selected at runtime. Mainnet is the implicit default. +# `ethlambda-state-transition`'s feature of the same name forwards to this one. +preset-minimal = [] + [dependencies] thiserror.workspace = true serde.workspace = true @@ -18,6 +26,9 @@ libssz.workspace = true libssz-derive.workspace = true libssz-merkle.workspace = true libssz-types.workspace = true +sha2.workspace = true + +ethlambda-ssz-tree.workspace = true [dev-dependencies] serde_json.workspace = true diff --git a/crates/common/types/src/attestation.rs b/crates/common/types/src/attestation.rs index 13d895abc..7d610dc07 100644 --- a/crates/common/types/src/attestation.rs +++ b/crates/common/types/src/attestation.rs @@ -101,7 +101,7 @@ pub fn blank_xmss_signature() -> XmssSignature { } /// Aggregated attestation consisting of participation bits and message. -#[derive(Debug, Clone, Serialize, SszEncode, SszDecode, HashTreeRoot)] +#[derive(Debug, Clone, PartialEq, Serialize, SszEncode, SszDecode, HashTreeRoot)] pub struct AggregatedAttestation { /// Bitfield indicating which validators participated in the aggregation. #[serde(serialize_with = "serialize_aggregation_bits")] diff --git a/crates/common/types/src/beacon/committees.rs b/crates/common/types/src/beacon/committees.rs new file mode 100644 index 000000000..ba6d1606f --- /dev/null +++ b/crates/common/types/src/beacon/committees.rs @@ -0,0 +1,283 @@ +//! [`EpochCommittees`]: one epoch's committees, already shuffled and sliced. +//! +//! The type lives here, in `ethlambda-types`, rather than in +//! `ethlambda-state-transition` where it used to, because `ethlambda-storage`'s +//! committee cache needs to name the type it caches, and the dependency only +//! runs one way: storage depends on types, state transition depends on +//! storage, so storage cannot depend on state transition. Deriving one of +//! these from a `BeaconState` needs the shuffle computation +//! (`get_active_validator_indices`, `get_seed`, `shuffle_list`), which stays +//! in `ethlambda-state-transition`; see that crate's +//! `beacon::helpers::accessors::build_epoch_committees`, this type's only +//! real constructor outside tests. + +use crate::beacon::error::{Error, Result, verify}; +use crate::beacon::preset; +use crate::beacon::primitives::{CommitteeIndex, Epoch, Slot, ValidatorIndex}; +use crate::beacon::signing::compute_epoch_at_slot; + +/// Everything a caller needs for one `(state, epoch)` pair's committees, +/// computed once so that deriving every committee of that epoch costs one +/// active-set scan and one shuffle between them all, rather than one of each +/// per committee. +/// +/// Electra's `get_attesting_indices` needs one committee per bit set in one +/// attestation's `committee_bits`, up to `MAX_COMMITTEES_PER_SLOT` of them, +/// and a block carries up to `MAX_ATTESTATIONS_ELECTRA` attestations; +/// `stf::electra::process_attestation` walks the same committees again to +/// check the aggregation-bit lengths, and fork choice walks them a third time +/// when it replays the block's attestations into the latest-message store. +/// Derived one at a time, each of those committees costs a scan of the whole +/// validator registry plus a `SHUFFLE_ROUND_COUNT`-round shuffle *per member*. +/// Shared through one of these, they cost one scan and one whole-epoch +/// shuffle for the lot. +/// +/// # Why the members are stored already shuffled +/// +/// `ethlambda-state-transition`'s `compute_committee` derives a committee by +/// shuffling each of its positions individually, which repeats the same +/// rounds of hashing once per member. That crate's `shuffle_list` shuffles +/// the whole active set in one pass instead, in place, at which point the +/// committee at any `(slot, index)` is a contiguous slice of it, so +/// [`Self::committee`] hashes nothing at all and allocates nothing beyond the +/// caller's own copy. +/// +/// That is also why building one of these is worth it only when several +/// committees will follow: the whole-epoch permutation moves every active +/// validator through every round, where one committee's per-member shuffle +/// touches that committee's members alone, so a single lookup costs more +/// this way than through `ethlambda-state-transition`'s `get_beacon_committee`, +/// which keeps the per-member derivation for exactly that case. A caller that +/// wants more than one committee of an epoch should hold a `CommitteeCache` +/// (`ethlambda-storage`) instead. +/// +/// # Why the active set is not memoized on `epoch` or `seed` alone +/// +/// `get_active_validator_indices` reads `activation_epoch` and `exit_epoch` +/// off every validator in `state.validators()`, so it is a function of the +/// state's registry, not of `epoch` or `seed` alone. Two different states can +/// share an epoch number, or even a seed (it comes from a RANDAO mix fixed +/// before either state's fork point, so two sibling branches diverging +/// afterward share it exactly) while disagreeing on which validators are +/// active. That is precisely the situation fork choice holds concurrent +/// states for, and precisely what the spec fixtures construct on purpose. A +/// cross-call cache therefore has to key on the state's *history*, which is +/// what `ethlambda-storage`'s `CommitteeCache` does, keyed by a +/// `ShufflingKey` that `ethlambda-state-transition` derives from that +/// history. +pub struct EpochCommittees { + /// The epoch these committees belong to, which [`Self::committee`] holds + /// every `slot` it is asked about against. + epoch: Epoch, + /// The epoch's active validators, in shuffled order: position `p` of the + /// epoch-wide permutation holds `shuffled[p]`. A committee is a + /// contiguous run of this, which is what makes [`Self::committee`] a + /// slice rather than a computation. + shuffled: Vec, + committees_per_slot: u64, +} + +impl EpochCommittees { + /// Assembles an `EpochCommittees` from its already-computed parts. + /// + /// The derivation from a `BeaconState` (scanning its active set for + /// `epoch`, deriving the committee count and shuffle seed from it, and + /// shuffling that set in place) lives in `ethlambda-state-transition`'s + /// `beacon::helpers::accessors::build_epoch_committees`: this crate + /// cannot perform it, since the shuffle needs that crate's hashing and a + /// full `BeaconState`. This constructor exists so that function has + /// somewhere to put the result, and so tests of [`Self::committee`]'s own + /// bounds-checking can build one directly from synthetic parts, with no + /// state at all. + pub fn new(epoch: Epoch, shuffled: Vec, committees_per_slot: u64) -> Self { + Self { + epoch, + shuffled, + committees_per_slot, + } + } + + /// How many committees each slot of this epoch has. The same value + /// `ethlambda-state-transition`'s `get_committee_count_per_slot` would + /// return, read off this type instead of rescanning the registry for it. + pub fn committees_per_slot(&self) -> u64 { + self.committees_per_slot + } + + /// The committee at `slot` with `index`. + /// + /// The same members `ethlambda-state-transition`'s `get_beacon_committee` + /// returns, in the same order, as a slice of the stored permutation + /// rather than a fresh `Vec`. + /// + /// Rejects a `slot` outside the epoch this was built for. The per-member + /// derivation cannot get that wrong, since it derives the epoch from the + /// slot; this type is built for an epoch its caller names separately, and + /// is shared across calls through `CommitteeCache`, so a mismatch would + /// otherwise slice another epoch's shuffle at this slot's offset and hand + /// back a wrong committee instead of an error. + /// + /// Rejects an out-of-range `index` rather than returning an empty or + /// truncated slice, which is the verdict the per-member derivation + /// reaches too: it would ask `compute_shuffled_index` for a position at + /// or past the active-set size, and that fails its own + /// `index < index_count` assertion. That includes an `index` large enough + /// to overflow the committee number; see [`committee_number`]. + pub fn committee(&self, slot: Slot, index: CommitteeIndex) -> Result<&[ValidatorIndex]> { + verify( + compute_epoch_at_slot(slot) == self.epoch, + "compute_epoch_at_slot(slot) == epoch", + )?; + + // No `count > 0` check, unlike the per-member `compute_committee`: + // `committees_per_slot` comes from `committee_count_per_slot`, which + // never returns less than one, so `count` is at least + // `SLOTS_PER_EPOCH` and the divisions below cannot divide by zero. + let count = self.committees_per_slot * preset::SLOTS_PER_EPOCH; + let committee_index = committee_number(slot, self.committees_per_slot, index)?; + verify(committee_index < count, "index < count")?; + + let total = self.shuffled.len() as u64; + let start = (total * committee_index) / count; + let end = (total * (committee_index + 1)) / count; + Ok(&self.shuffled[start as usize..end as usize]) + } +} + +/// Written by hand rather than derived: the shuffling is one entry per active +/// validator, about 2.4M of them on mainnet, and a derived `Debug` would print +/// every one. What a reader wants from these is the shape, which is the two +/// numbers below. +impl std::fmt::Debug for EpochCommittees { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("EpochCommittees") + .field("active_validators", &self.shuffled.len()) + .field("committees_per_slot", &self.committees_per_slot) + .finish() + } +} + +/// Which of its epoch's committees `index` at `slot` names: +/// `(slot % SLOTS_PER_EPOCH) * committees_per_slot + index`, the position +/// the specification's `get_beacon_committee` hands `compute_committee`. +/// +/// `pub` despite being conceptually an implementation detail of +/// [`EpochCommittees::committee`]: `ethlambda-state-transition`'s +/// `get_beacon_committee` needs the exact same position formula for its own, +/// independent per-member derivation (see that function's doc for why it +/// stays independent rather than building on this type), and that crate +/// cannot reach a private item of this one. +/// +/// The addition is checked because nothing upstream bounds `index`: fork +/// choice's `on_attestation` reaches here with a gossip attestation's own +/// `data.index`. The specification's `uint64` arithmetic raises on that +/// overflow, where a release build would wrap it into a valid committee +/// number and return a real committee. The product needs no check, being at +/// most `SLOTS_PER_EPOCH * MAX_COMMITTEES_PER_SLOT`. +pub fn committee_number( + slot: Slot, + committees_per_slot: u64, + index: CommitteeIndex, +) -> Result { + ((slot % preset::SLOTS_PER_EPOCH) * committees_per_slot) + .checked_add(index) + .ok_or(Error::ArithmeticOverflow( + "(slot % SLOTS_PER_EPOCH) * committees_per_slot + index", + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::beacon::signing::compute_start_slot_at_epoch; + + /// The epoch these tests build synthetic committees for. Arbitrary, since + /// nothing here derives from a real state; just non-zero so a slot + /// arithmetic mistake could not accidentally land on the right answer by + /// hitting epoch 0. + const EPOCH: Epoch = 9; + + /// A committee count big enough that a slot has more than one committee, + /// so the position arithmetic these tests exercise is not degenerate. + const PER_SLOT: u64 = 4; + + /// Enough shuffled validators that every committee across the epoch gets + /// at least one member, with an uneven split so the rounding in + /// [`EpochCommittees::committee`] is exercised the same way + /// `ethlambda-state-transition`'s own tests exercise it. + fn synthetic_committees() -> EpochCommittees { + let total = PER_SLOT * preset::SLOTS_PER_EPOCH * 3 + 1; + let shuffled = (0..total).collect(); + EpochCommittees::new(EPOCH, shuffled, PER_SLOT) + } + + /// Which committee indices [`EpochCommittees::committee`] rejects, and + /// which it leaves to its caller. + /// + /// An index at or past `committees_per_slot` is *not* rejected here: the + /// committee number it lands on is still inside the epoch's split, so it + /// names a later slot's committee rather than nothing at all. What must + /// fail is an index that runs off the end of the epoch. + #[test] + fn a_committee_index_past_the_epoch_is_rejected() { + let committees = synthetic_committees(); + let slot = compute_start_slot_at_epoch(EPOCH); + + assert!( + committees.committee(slot, PER_SLOT).is_ok(), + "an index past this slot's committees is the caller's check, not this one's" + ); + assert!( + committees + .committee(slot, PER_SLOT * preset::SLOTS_PER_EPOCH) + .is_err(), + "an index past the whole epoch's committees has nothing to slice" + ); + } + + /// An `index` chosen so that the committee number wraps to exactly zero: + /// unchecked, a release build would return the epoch's first committee for + /// it, where the specification's `uint64` arithmetic raises. Nothing + /// upstream bounds `index` on fork choice's gossip path, so this is an + /// attacker's choice to make, and the derivation must refuse it. + #[test] + fn a_committee_number_that_would_wrap_is_rejected() { + let committees = synthetic_committees(); + let last_slot = compute_start_slot_at_epoch(EPOCH) + preset::SLOTS_PER_EPOCH - 1; + let wraps_to_zero = 0u64.wrapping_sub((preset::SLOTS_PER_EPOCH - 1) * PER_SLOT); + + assert!(committees.committee(last_slot, wraps_to_zero).is_err()); + } + + /// A slot outside the epoch an [`EpochCommittees`] was built for is an + /// error, not a slice of the wrong epoch's shuffle at that slot's offset. + #[test] + fn a_slot_from_another_epoch_is_rejected() { + let committees = synthetic_committees(); + let next_epoch_slot = compute_start_slot_at_epoch(EPOCH + 1); + let previous_epoch_slot = compute_start_slot_at_epoch(EPOCH) - 1; + + assert!(committees.committee(next_epoch_slot, 0).is_err()); + assert!(committees.committee(previous_epoch_slot, 0).is_err()); + } + + /// Across a whole epoch, every member of the synthetic shuffle must be + /// assigned exactly one committee slot, since the epoch's committees are + /// one permutation split up; this is the property + /// [`EpochCommittees::committee`]'s slicing exists to preserve. + #[test] + fn committees_cover_every_shuffled_member_once_per_epoch() { + let committees = synthetic_committees(); + let total = PER_SLOT * preset::SLOTS_PER_EPOCH * 3 + 1; + + let mut all = Vec::new(); + for slot_offset in 0..preset::SLOTS_PER_EPOCH { + let slot = compute_start_slot_at_epoch(EPOCH) + slot_offset; + for index in 0..PER_SLOT { + all.extend_from_slice(committees.committee(slot, index).unwrap()); + } + } + all.sort_unstable(); + assert_eq!(all, (0..total).collect::>()); + } +} diff --git a/crates/common/types/src/beacon/config.rs b/crates/common/types/src/beacon/config.rs new file mode 100644 index 000000000..9e0b03bcf --- /dev/null +++ b/crates/common/types/src/beacon/config.rs @@ -0,0 +1,1538 @@ +//! Runtime chain configuration: the values `configs/mainnet.yaml` and +//! `configs/minimal.yaml` set per network, as opposed to constants (fixed by +//! the specification, see [`crate::beacon::constants`]) or preset values (compile-time +//! container bounds, see [`crate::beacon::preset`]). +//! +//! Fork *scheduling* lives here rather than at compile time specifically +//! because the `transition` fixture suite needs to move a fork's activation +//! epoch per test case; there is no other reason a fork version or epoch +//! could not have been a preset. Everything else in [`Config`] is here simply +//! because the specification itself calls it configuration. +//! +//! # What is left out +//! +//! - **Forks this build cannot process.** `GLOAS_*`, `HEZE_*` and the timing +//! values that arrived with them, plus `GAS_LIMIT_SCHEDULE`, which is +//! gloas-era and is a list rather than a scalar. A fork version stored here +//! would give [`Config::fork_at_epoch`] a fork [`crate::beacon::fork::ForkName`] +//! has no variant for, so these are reported as unknown keys instead. +//! +//! `PRESET_BASE` and `CONFIG_NAME` used to be left out as well, because they +//! are strings and this struct is SSZ-encoded into the database. They are here +//! now, as bounded [`ConfigName`]s, so that `/eth/v1/config/spec` reads every +//! value it reports from the one `Config` the store holds, rather than having +//! the name threaded to it separately from startup. +//! +//! Networking values and the deposit contract identity used to be left out too, +//! on the grounds that the state transition never reads them. They are here now +//! because they have a second reader: `/eth/v1/config/spec` must echo every key +//! a `config.yaml` carries, and a key with no typed home would be reported as +//! unknown on every startup of every valid configuration, which would bury a +//! real typo among forty legitimate warnings. +//! +//! The genesis-section values are all included. `GENESIS_FORK_VERSION` is fork +//! scheduling rather than genesis construction, since [`Config::fork_version`] +//! reads it on every phase0-era signature. The other three +//! (`MIN_GENESIS_ACTIVE_VALIDATOR_COUNT`, `MIN_GENESIS_TIME`, `GENESIS_DELAY`) +//! are read only while building a genesis state from Eth1 deposit history, and +//! never again once that state exists, but the beacon STF's `genesis` module +//! needs them and the `genesis` fixture suite checks them. + +use libssz_derive::{SszDecode, SszEncode}; +use libssz_types::SszList; + +use crate::beacon::constants; +use crate::beacon::fork::ForkName; +use crate::beacon::lean_fork_unreachable; +use crate::beacon::primitives::{Epoch, ExecutionBlockHash, Gwei, U256, Uint256, Version}; +use crate::chain_config::ChainConfig; +use crate::constants::INTERVALS_PER_SLOT; + +/// One entry in fulu's blob schedule: from `epoch` onward (until a later +/// entry takes over), a block may carry up to `max_blobs_per_block` blobs. +/// +/// Modeled as a plain struct rather than a `(Epoch, u64)` tuple so that +/// [`Config::max_blobs_per_block`]'s search reads as "find the entry", not +/// "find the pair". +#[derive( + Debug, Clone, Copy, PartialEq, Eq, SszEncode, SszDecode, serde::Deserialize, serde::Serialize, +)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub struct BlobScheduleEntry { + /// The first epoch this entry applies to. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub epoch: Epoch, + /// The blob count limit from `epoch` onward. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub max_blobs_per_block: u64, +} + +/// How many blob-schedule entries a persisted [`Config`] can carry. +/// +/// A storage bound, not a consensus one: SSZ needs a bounded list and the real +/// schedule holds roughly one entry per fork, so this is generous. Raising it +/// changes the on-disk encoding, which is why the database carries a format +/// version. +pub const MAX_BLOB_SCHEDULE_ENTRIES: usize = 32; + +/// How many bytes a [`ConfigName`] can hold. +/// +/// A storage bound, not a consensus one, for the same reason as +/// [`MAX_BLOB_SCHEDULE_ENTRIES`]: the specification leaves both names +/// unbounded, and the ones in use (`mainnet`, `minimal`, `sepolia`, `hoodi`, +/// kurtosis's `testnet`) are a few bytes each. +pub const MAX_CONFIG_NAME_LENGTH: usize = 256; + +/// A name a `config.yaml` carries: its `CONFIG_NAME` or `PRESET_BASE`. +/// +/// Text of at most [`MAX_CONFIG_NAME_LENGTH`] bytes, which every constructor +/// checks. SSZ-encoded as a `List[uint8, MAX_CONFIG_NAME_LENGTH]`, since +/// [`Config`] is persisted to the database; (de)serialized as a plain string, +/// which is how both the file and `/eth/v1/config/spec` write it. +/// +/// Held as a `String` rather than as the byte list it encodes to, so that +/// [`Self::as_str`] borrows instead of re-checking UTF-8 on every call. The SSZ +/// impls are written out for that reason: a derive would need the field to be +/// the list. +#[derive(Clone, Default, PartialEq, Eq)] +pub struct ConfigName(String); + +/// The quoted text: a resume that refuses a changed `PRESET_BASE`, or warns +/// about a changed `CONFIG_NAME`, prints both through `{:?}`. +impl std::fmt::Debug for ConfigName { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}", self.0) + } +} + +/// A name longer than [`MAX_CONFIG_NAME_LENGTH`]. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[error("{length} bytes is longer than the {MAX_CONFIG_NAME_LENGTH} a config name may hold")] +pub struct ConfigNameTooLong { + pub length: usize, +} + +impl ConfigName { + /// A name known to fit, such as a built-in network's. + /// + /// # Panics + /// + /// If `name` is longer than [`MAX_CONFIG_NAME_LENGTH`]. + fn fixed(name: &str) -> Self { + Self::try_from(name).expect("a built-in name fits the bound") + } + + /// The name as text. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl TryFrom<&str> for ConfigName { + type Error = ConfigNameTooLong; + + fn try_from(name: &str) -> Result { + if name.len() > MAX_CONFIG_NAME_LENGTH { + return Err(ConfigNameTooLong { length: name.len() }); + } + Ok(Self(name.to_owned())) + } +} + +impl std::fmt::Display for ConfigName { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +/// The name's bytes, which is how `SszList` +/// encodes them too: a list of a fixed-size basic type is its elements +/// concatenated, with no length prefix of its own. +impl libssz::SszEncode for ConfigName { + fn is_fixed_size() -> bool { + false + } + + fn fixed_size() -> usize { + 0 + } + + fn encoded_len(&self) -> usize { + self.0.len() + } + + fn ssz_append(&self, buf: &mut Vec) { + buf.extend_from_slice(self.0.as_bytes()); + } +} + +impl libssz::SszDecode for ConfigName { + fn is_fixed_size() -> bool { + false + } + + fn fixed_size() -> usize { + 0 + } + + /// Decoded through the list type, so an over-long name is refused with + /// the list's own error. + /// + /// Lossy rather than fallible on bytes that are not UTF-8. Every + /// constructor takes a `&str`, so only a corrupt database can hold such + /// bytes, and `DecodeError` has no variant that describes them. A repaired + /// `PRESET_BASE` still fails the resume comparison against the file's, + /// and a repaired `CONFIG_NAME` shows up in the warning about it. + fn from_ssz_bytes(bytes: &[u8]) -> Result { + let list = SszList::::from_ssz_bytes(bytes)?; + Ok(Self(String::from_utf8_lossy(&list).into_owned())) + } +} + +impl serde::Serialize for ConfigName { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.0) + } +} + +impl<'de> serde::Deserialize<'de> for ConfigName { + fn deserialize>(deserializer: D) -> Result { + let name = ::deserialize(deserializer)?; + Self::try_from(name.as_str()).map_err(serde::de::Error::custom) + } +} + +/// The runtime configuration for one network: fork scheduling plus every +/// other value the state transition and fork choice read at runtime rather +/// than at compile time. +/// +/// Construct one with [`Config::mainnet`], [`Config::minimal`], or +/// [`Config::active`]; adjust a single fork's activation epoch with +/// [`Config::with_fork_epoch`] for fixture-driven tests that need one. +#[derive( + Debug, Clone, PartialEq, Eq, SszEncode, SszDecode, serde::Deserialize, serde::Serialize, +)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE", default)] +pub struct Config { + // -- Identity --------------------------------------------------------- + /// `PRESET_BASE`: which preset the file was written for. + /// + /// Startup refuses a file whose value differs from the compiled preset, so + /// on a running node this always names [`crate::beacon::preset::Preset::ACTIVE`]. + /// + /// Defaults to empty rather than to mainnet's value when the file omits + /// it: the startup check has to fail on an absent key rather than guess. + #[serde(default)] + pub preset_base: ConfigName, + /// `CONFIG_NAME`: the network's name, for logging and + /// `/eth/v1/config/spec`. Empty when the file omits it. + #[serde(default)] + pub config_name: ConfigName, + + // -- Genesis construction --------------------------------------------- + /// How many active validators the chain needs before it may start. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub min_genesis_active_validator_count: u64, + /// The earliest wall-clock time the chain may start at, whatever the Eth1 + /// deposit history says. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub min_genesis_time: u64, + /// How long after the Eth1 block that satisfies the genesis conditions the + /// chain actually starts. + /// + /// The delay exists so that validators who deposited just before the + /// threshold was crossed still have time to get their nodes running. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub genesis_delay: u64, + /// The wall-clock second the chain's slot 0 began, which every slot + /// boundary is computed from. + /// + /// Distinct from [`Self::min_genesis_time`], which is only the earliest + /// time the deposit-driven genesis rules would have permitted: on mainnet + /// that is 1606824000 against an actual genesis of 1606824023. Reusing + /// that one for the clock would put every slot boundary 23 seconds off. + /// + /// On a beacon chain this is read off the anchor state at bootstrap. On a + /// lean chain it comes from the genesis config file. + #[serde(skip)] + pub genesis_time: u64, + + // -- Fork scheduling -------------------------------------------------- + /// The `Fork.current_version` a phase0 block or attestation signs under, + /// and the value every later fork's version is a successor to. Also + /// mixed into `compute_fork_data_root` when computing the genesis + /// validators root's domain, alongside the all-zero genesis validators + /// root, at chain start. + #[serde(with = "crate::beacon::serde_helpers::hex_array")] + pub genesis_fork_version: Version, + /// The `Fork.current_version` an altair block or attestation signs under. + #[serde(with = "crate::beacon::serde_helpers::hex_array")] + pub altair_fork_version: Version, + /// The epoch altair activates at, or [`constants::FAR_FUTURE_EPOCH`] if it + /// is not scheduled on this network. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub altair_fork_epoch: Epoch, + /// The `Fork.current_version` a bellatrix block or attestation signs + /// under. + #[serde(with = "crate::beacon::serde_helpers::hex_array")] + pub bellatrix_fork_version: Version, + /// The epoch bellatrix (the Merge) activates at, or + /// [`constants::FAR_FUTURE_EPOCH`] if it is not scheduled. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub bellatrix_fork_epoch: Epoch, + /// The `Fork.current_version` a capella block or attestation signs under. + #[serde(with = "crate::beacon::serde_helpers::hex_array")] + pub capella_fork_version: Version, + /// The epoch capella activates at, or [`constants::FAR_FUTURE_EPOCH`] if + /// it is not scheduled. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub capella_fork_epoch: Epoch, + /// The `Fork.current_version` a deneb block or attestation signs under. + #[serde(with = "crate::beacon::serde_helpers::hex_array")] + pub deneb_fork_version: Version, + /// The epoch deneb activates at, or [`constants::FAR_FUTURE_EPOCH`] if it + /// is not scheduled. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub deneb_fork_epoch: Epoch, + /// The `Fork.current_version` an electra block or attestation signs + /// under. + #[serde(with = "crate::beacon::serde_helpers::hex_array")] + pub electra_fork_version: Version, + /// The epoch electra activates at, or [`constants::FAR_FUTURE_EPOCH`] if + /// it is not scheduled. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub electra_fork_epoch: Epoch, + /// The `Fork.current_version` a fulu block or attestation signs under. + #[serde(with = "crate::beacon::serde_helpers::hex_array")] + pub fulu_fork_version: Version, + /// The epoch fulu activates at, or [`constants::FAR_FUTURE_EPOCH`] if it + /// is not scheduled. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub fulu_fork_epoch: Epoch, + + // -- Time parameters --------------------------------------------------- + /// Wall-clock seconds per slot. Deprecated in favor of + /// [`Self::slot_duration_ms`] for anything needing sub-second precision, + /// but still how `compute_time_at_slot` and the fork choice store's + /// `genesis_time`-to-slot arithmetic convert between a slot number and a + /// wall-clock time. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub seconds_per_slot: u64, + /// Milliseconds per slot. What the fork choice store's timeliness + /// checks (`get_attestation_due_ms` and friends) actually divide the + /// `*_due_bps` fields below by; equal to `seconds_per_slot * 1000` on + /// every network this crate ships a constructor for, but tracked + /// separately because the specification does. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub slot_duration_ms: u64, + /// The assumed seconds per execution-layer block, used to convert + /// [`Self::eth1_follow_distance`] (a block count) into a voting-period + /// safety margin. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub seconds_per_eth1_block: u64, + /// Epochs a validator must wait after its exit is processed before its + /// balance becomes withdrawable. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub min_validator_withdrawability_delay: Epoch, + /// Epochs a validator must be active before it is eligible to propose, + /// perform voluntary exits, or (from electra) initiate a consolidation. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub shard_committee_period: Epoch, + /// Execution-layer blocks a state's Eth1 vote must lag the execution + /// chain's head by, so that every node's view of "current" Eth1 data + /// agrees despite network latency and minor reorgs. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub eth1_follow_distance: u64, + /// Basis points of [`Self::slot_duration_ms`] by which an attestation is + /// due; read by the fork choice store's `get_attestation_due_ms`. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub attestation_due_bps: u64, + /// Basis points of [`Self::slot_duration_ms`] by which an aggregate + /// attestation is due; read by `get_aggregate_due_ms`. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub aggregate_due_bps: u64, + /// Basis points of [`Self::slot_duration_ms`] past which a proposer must + /// no longer attempt a late-block reorg; read by + /// `get_proposer_reorg_cutoff_ms`. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub proposer_reorg_cutoff_bps: u64, + /// Basis points of [`Self::slot_duration_ms`] by which a sync committee + /// message is due (altair); read by `get_sync_message_due_ms`. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub sync_message_due_bps: u64, + /// Basis points of [`Self::slot_duration_ms`] by which a sync committee + /// contribution is due (altair); read by `get_contribution_due_ms`. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub contribution_due_bps: u64, + + // -- Validator cycle ----------------------------------------------------- + /// Score points added to a validator's inactivity score for each epoch it + /// is offline (or the chain is leaking) without a timely target vote. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub inactivity_score_bias: u64, + /// Score points subtracted from a validator's inactivity score for each + /// epoch it casts a timely target vote while the chain is not leaking. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub inactivity_score_recovery_rate: u64, + /// Effective balance floor below which a validator is force-exited at the + /// next opportunity, regardless of its own wishes. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub ejection_balance: Gwei, + /// The minimum validators allowed to enter the activation/exit queue in + /// one epoch, regardless of the active validator set's size. Prevents the + /// churn limit from collapsing to zero on a small validator set. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub min_per_epoch_churn_limit: u64, + /// Active validators per unit of per-epoch activation/exit churn: the + /// churn limit before electra is `active_validator_count / + /// churn_limit_quotient`, floored at [`Self::min_per_epoch_churn_limit`]. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub churn_limit_quotient: u64, + /// Deneb: an additional cap on the activation churn limit specifically + /// (separate from the combined activation/exit limit above), so that + /// activations cannot alone consume the whole per-epoch churn budget. + /// Superseded by [`Self::max_per_epoch_activation_exit_churn_limit`] from + /// electra onward, but the specification keeps both names rather than + /// reusing one. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub max_per_epoch_activation_churn_limit: u64, + /// Electra: the churn limit is now denominated in Gwei rather than a + /// validator count (`get_balance_churn_limit`), and this is its floor, + /// replacing [`Self::min_per_epoch_churn_limit`] from electra onward. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub min_per_epoch_churn_limit_electra: Gwei, + /// Electra: the ceiling on the portion of the (Gwei-denominated) churn + /// limit dedicated to activations and exits, as opposed to + /// consolidations (`get_activation_exit_churn_limit`). + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub max_per_epoch_activation_exit_churn_limit: Gwei, + + // -- Fork choice --------------------------------------------------------- + /// Percentage boost, relative to a single committee's weight, given to a + /// block proposed on time when comparing it against competitors for head. + /// Deters "balancing" attacks that rely on splitting the vote right at a + /// slot boundary. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub proposer_score_boost: u64, + /// Percentage of committee weight the current head must be below the + /// parent's competing child by for a proposer to consider reorging it out. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub reorg_head_weight_threshold: u64, + /// Percentage of committee weight the parent block must exceed for a + /// proposer to consider reorging its late child out. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub reorg_parent_weight_threshold: u64, + /// How many epochs finality is allowed to lag before a proposer refuses to + /// attempt a reorg at all, regardless of the weight thresholds above. + /// Reorgs are a liveness optimization; this bounds how much they may risk + /// finality progress to pursue it. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub reorg_max_epochs_since_finalization: Epoch, + + // -- Transition (bellatrix) ----------------------------------------------- + /// The proof-of-work total difficulty at or above which a PoW block + /// becomes a valid terminal block for the Merge transition. + /// [`Uint256`]-sized because total difficulty accumulates over the + /// entire PoW chain's history and long since overflowed 64 bits. + #[serde(deserialize_with = "deserialize_terminal_total_difficulty")] + pub terminal_total_difficulty: Uint256, + /// A specific PoW block hash that overrides [`Self::terminal_total_difficulty`] + /// as the terminal block, if set to anything other than the zero hash. + /// Existed as an emergency override in case total-difficulty tracking + /// disagreed across clients near the Merge; every shipped network left it + /// unset. + pub terminal_block_hash: ExecutionBlockHash, + /// The epoch at or after which [`Self::terminal_block_hash`], if set, is + /// honored. Guards against an old override value being replayed before + /// the network is ready for it. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub terminal_block_hash_activation_epoch: Epoch, + + // -- Blob limits ----------------------------------------------------------- + /// Deneb's fixed cap on `blob_kzg_commitments` per block, in effect from + /// deneb until electra raises it. + #[serde( + rename = "MAX_BLOBS_PER_BLOCK", + with = "crate::beacon::serde_helpers::quoted_or_bare" + )] + pub max_blobs_per_block_deneb: u64, + /// Electra's fixed cap on `blob_kzg_commitments` per block. Also the value + /// [`Self::max_blobs_per_block`] falls back to for any epoch fulu's blob + /// schedule does not (yet) cover, matching `get_blob_parameters`'s own + /// fallback of `BlobParameters(ELECTRA_FORK_EPOCH, + /// MAX_BLOBS_PER_BLOCK_ELECTRA)`. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub max_blobs_per_block_electra: u64, + /// Fulu's blob schedule (EIP7892): a possibly-empty list of `(epoch, + /// limit)` entries, kept sorted ascending by epoch, that lets the blob + /// count limit change again after electra without a new hard fork per + /// change. Read through [`Config::max_blobs_per_block`] rather than + /// directly. + #[serde( + deserialize_with = "deserialize_blob_schedule", + serialize_with = "crate::beacon::serde_helpers::seq::serialize" + )] + pub blob_schedule: SszList, + + // -- Networking -------------------------------------------------------- + // These describe the wire rather than the state transition, so nothing in + // this crate reads them. They are here because a `config.yaml` carries + // them, `/eth/v1/config/spec` has to echo them, and a field with no typed + // home would otherwise be reported as an unknown key on every startup of + // every valid configuration. Where the node runs on a compile-time + // constant instead (here and in the PeerDAS custody group below), the + // binary's `network::check_constants` refuses a network that sets another + // value, so the endpoint never reports one the node does not use. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub attestation_propagation_slot_range: u64, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub attestation_subnet_count: u64, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub attestation_subnet_extra_bits: u64, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub blob_sidecar_subnet_count: u64, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub blob_sidecar_subnet_count_electra: u64, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub data_column_sidecar_subnet_count: u64, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub epochs_per_subnet_subscription: u64, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub max_payload_size: u64, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub max_request_blocks: u64, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub max_request_blocks_deneb: u64, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub max_request_payloads: u64, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub maximum_gossip_clock_disparity: u64, + #[serde(with = "crate::beacon::serde_helpers::hex_array")] + pub message_domain_invalid_snappy: [u8; 4], + #[serde(with = "crate::beacon::serde_helpers::hex_array")] + pub message_domain_valid_snappy: [u8; 4], + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub min_epochs_for_blob_sidecars_requests: u64, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub min_epochs_for_data_column_sidecars_requests: u64, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub subnets_per_node: u64, + + // -- Deposit contract -------------------------------------------------- + // Which Eth1 chain and contract a validator client watches for deposits. + // The state transition only processes deposits already in a block, so it + // never looks the contract up; `/eth/v1/config/deposit_contract` serves + // these. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub deposit_chain_id: u64, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub deposit_network_id: u64, + #[serde(with = "crate::beacon::serde_helpers::hex_array")] + pub deposit_contract_address: [u8; 20], + + // -- PeerDAS custody --------------------------------------------------- + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub balance_per_additional_custody_group: u64, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub custody_requirement: u64, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub number_of_custody_groups: u64, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub samples_per_slot: u64, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub validator_custody_requirement: u64, + + // -- Other runtime values ---------------------------------------------- + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub consolidation_churn_limit_quotient: u64, + + // -- Networking (added after an incomplete initial key list) ----------- + // These five are additional keys mainnet's own published `config.yaml` + // carries; missed initially because the key list this struct was + // checked against came from a genesis generator's example rather than + // the published file itself. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub attestation_subnet_prefix_bits: u64, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub max_request_blob_sidecars: u64, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub max_request_blob_sidecars_electra: u64, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub max_request_data_column_sidecars: u64, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub min_epochs_for_block_requests: u64, +} + +/// Mainnet's values, which is what an absent key in a `config.yaml` falls back +/// to. +/// +/// Deserialization is deliberately permissive: no client surveyed rejects a +/// configuration for a missing key, and a network that predates a field should +/// still load. Mainnet is the right fallback because every other network is +/// described as a deviation from it. +impl Default for Config { + fn default() -> Self { + Self::mainnet() + } +} + +/// Deserializes `TERMINAL_TOTAL_DIFFICULTY`. +/// +/// The one integer field [`crate::beacon::serde_helpers::quoted_or_bare`] +/// cannot cover: [`Uint256`] has no `FromStr` impl (only the inherent +/// [`Uint256::from_dec_str`], kept because nothing on the shipping path parses +/// one otherwise), so this reimplements `quoted_or_bare`'s "take the scalar as +/// a string either way" trick against that inherent parser instead. +fn deserialize_terminal_total_difficulty<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let text = ::deserialize(deserializer)?; + Uint256::from_dec_str(text.trim()).map_err(serde::de::Error::custom) +} + +/// Deserializes `BLOB_SCHEDULE`. +/// +/// The YAML shape is a list of mappings (`{EPOCH, MAX_BLOBS_PER_BLOCK}`), not a +/// scalar, so neither [`crate::beacon::serde_helpers::quoted_or_bare`] nor +/// [`crate::beacon::serde_helpers::hex_array`] applies: this reads the list as +/// a plain `Vec` (each entry deserializing its own two +/// scalar fields through `quoted_or_bare`) and then converts it into the +/// bounded [`SszList`], erroring clearly if the file names more entries than +/// [`MAX_BLOB_SCHEDULE_ENTRIES`] allows. +fn deserialize_blob_schedule<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let entries = as serde::Deserialize>::deserialize(deserializer)?; + entries.try_into().map_err(|err| { + serde::de::Error::custom(format!( + "BLOB_SCHEDULE carries more than {MAX_BLOB_SCHEDULE_ENTRIES} entries: {err:?}" + )) + }) +} + +/// Mainnet's `TERMINAL_TOTAL_DIFFICULTY`. +/// +/// A `const` rather than `Uint256::from_dec_str(..).expect(..)` inside +/// [`Config::mainnet`], so the digits are converted once at compile time instead +/// of on every construction, and a typo is a build failure rather than a panic. +const MAINNET_TERMINAL_TOTAL_DIFFICULTY: Uint256 = + Uint256::from_u128(58_750_000_000_000_000_000_000); + +/// Minimal's `TERMINAL_TOTAL_DIFFICULTY`, 2^256 - 2^10. +/// +/// Past 128 bits, so written as the little-endian bytes [`Uint256`] stores +/// rather than as decimal digits. Those bytes are unreadable by construction, so +/// a test below pins this and [`MAINNET_TERMINAL_TOTAL_DIFFICULTY`] against the +/// decimal strings the configuration files actually carry. +const MINIMAL_TERMINAL_TOTAL_DIFFICULTY: Uint256 = { + let mut bytes = [0xff; 32]; + bytes[0] = 0x00; + bytes[1] = 0xfc; + U256(bytes) +}; + +impl Config { + /// The configuration matching Ethereum mainnet, as of the pinned + /// specification version's `configs/mainnet.yaml`. + pub fn mainnet() -> Self { + Config { + preset_base: ConfigName::fixed("mainnet"), + config_name: ConfigName::fixed("mainnet"), + min_genesis_active_validator_count: 16_384, + min_genesis_time: 1_606_824_000, + genesis_delay: 604_800, + genesis_time: 1_606_824_023, + genesis_fork_version: [0x00, 0x00, 0x00, 0x00], + altair_fork_version: [0x01, 0x00, 0x00, 0x00], + altair_fork_epoch: 74_240, + bellatrix_fork_version: [0x02, 0x00, 0x00, 0x00], + bellatrix_fork_epoch: 144_896, + capella_fork_version: [0x03, 0x00, 0x00, 0x00], + capella_fork_epoch: 194_048, + deneb_fork_version: [0x04, 0x00, 0x00, 0x00], + deneb_fork_epoch: 269_568, + electra_fork_version: [0x05, 0x00, 0x00, 0x00], + electra_fork_epoch: 364_032, + fulu_fork_version: [0x06, 0x00, 0x00, 0x00], + fulu_fork_epoch: 411_392, + + seconds_per_slot: 12, + slot_duration_ms: 12_000, + seconds_per_eth1_block: 14, + min_validator_withdrawability_delay: 256, + shard_committee_period: 256, + eth1_follow_distance: 2_048, + attestation_due_bps: 3_333, + aggregate_due_bps: 6_667, + proposer_reorg_cutoff_bps: 1_667, + sync_message_due_bps: 3_333, + contribution_due_bps: 6_667, + + inactivity_score_bias: 4, + inactivity_score_recovery_rate: 16, + ejection_balance: 16_000_000_000, + min_per_epoch_churn_limit: 4, + churn_limit_quotient: 65_536, + max_per_epoch_activation_churn_limit: 8, + min_per_epoch_churn_limit_electra: 128_000_000_000, + max_per_epoch_activation_exit_churn_limit: 256_000_000_000, + + proposer_score_boost: 40, + reorg_head_weight_threshold: 20, + reorg_parent_weight_threshold: 160, + reorg_max_epochs_since_finalization: 2, + + // Reached September 15, 2022 (the Merge); mainnet has been on + // proof of stake ever since, so this and the two fields below + // never trigger again in practice, but are still read by any + // faithful implementation of `validate_merge_block`. + terminal_total_difficulty: MAINNET_TERMINAL_TOTAL_DIFFICULTY, + terminal_block_hash: ExecutionBlockHash::ZERO, + terminal_block_hash_activation_epoch: constants::FAR_FUTURE_EPOCH, + + max_blobs_per_block_deneb: 6, + max_blobs_per_block_electra: 9, + blob_schedule: vec![ + BlobScheduleEntry { + epoch: 412_672, + max_blobs_per_block: 15, + }, + BlobScheduleEntry { + epoch: 419_072, + max_blobs_per_block: 21, + }, + ] + .try_into() + .expect("mainnet blob schedule within bound"), + + attestation_propagation_slot_range: 32, + attestation_subnet_count: 64, + attestation_subnet_extra_bits: 0, + blob_sidecar_subnet_count: 6, + blob_sidecar_subnet_count_electra: 9, + data_column_sidecar_subnet_count: 128, + epochs_per_subnet_subscription: 256, + max_payload_size: 10_485_760, + max_request_blocks: 1_024, + max_request_blocks_deneb: 128, + max_request_payloads: 128, + maximum_gossip_clock_disparity: 500, + message_domain_invalid_snappy: [0x00, 0x00, 0x00, 0x00], + message_domain_valid_snappy: [0x01, 0x00, 0x00, 0x00], + min_epochs_for_blob_sidecars_requests: 4_096, + min_epochs_for_data_column_sidecars_requests: 4_096, + subnets_per_node: 2, + + deposit_chain_id: 1, + deposit_network_id: 1, + deposit_contract_address: [ + 0x00, 0x00, 0x00, 0x00, 0x21, 0x9a, 0xb5, 0x40, 0x35, 0x6c, 0xbb, 0x83, 0x9c, 0xbe, + 0x05, 0x30, 0x3d, 0x77, 0x05, 0xfa, + ], + + balance_per_additional_custody_group: 32_000_000_000, + custody_requirement: 4, + number_of_custody_groups: 128, + samples_per_slot: 8, + validator_custody_requirement: 8, + + consolidation_churn_limit_quotient: 65_536, + + attestation_subnet_prefix_bits: 6, + max_request_blob_sidecars: 768, + max_request_blob_sidecars_electra: 1_152, + max_request_data_column_sidecars: 16_384, + min_epochs_for_block_requests: 33_024, + } + } + + /// The configuration matching the specification's `minimal` preset, as of + /// the pinned specification version's `configs/minimal.yaml`. + /// + /// Every fork after phase0 defaults to + /// [`constants::FAR_FUTURE_EPOCH`] here: `minimal` is a base for spec + /// fixtures, not a network of its own, and each fixture's `meta.yaml` + /// picks which single fork boundary it wants to exercise via + /// [`Config::with_fork_epoch`] rather than inheriting a fixed schedule. + pub fn minimal() -> Self { + Config { + preset_base: ConfigName::fixed("minimal"), + config_name: ConfigName::fixed("minimal"), + min_genesis_active_validator_count: 64, + min_genesis_time: 1_578_009_600, + genesis_delay: 300, + // `minimal` is a base for spec fixtures, not a network of its + // own: no real chain ever started under it, so there is no real + // wall-clock second to record here. + genesis_time: 0, + genesis_fork_version: [0x00, 0x00, 0x00, 0x01], + altair_fork_version: [0x01, 0x00, 0x00, 0x01], + altair_fork_epoch: constants::FAR_FUTURE_EPOCH, + bellatrix_fork_version: [0x02, 0x00, 0x00, 0x01], + bellatrix_fork_epoch: constants::FAR_FUTURE_EPOCH, + capella_fork_version: [0x03, 0x00, 0x00, 0x01], + capella_fork_epoch: constants::FAR_FUTURE_EPOCH, + deneb_fork_version: [0x04, 0x00, 0x00, 0x01], + deneb_fork_epoch: constants::FAR_FUTURE_EPOCH, + electra_fork_version: [0x05, 0x00, 0x00, 0x01], + electra_fork_epoch: constants::FAR_FUTURE_EPOCH, + fulu_fork_version: [0x06, 0x00, 0x00, 0x01], + fulu_fork_epoch: constants::FAR_FUTURE_EPOCH, + + seconds_per_slot: 6, + slot_duration_ms: 6_000, + seconds_per_eth1_block: 14, + min_validator_withdrawability_delay: 256, + shard_committee_period: 64, + eth1_follow_distance: 16, + attestation_due_bps: 3_333, + aggregate_due_bps: 6_667, + proposer_reorg_cutoff_bps: 1_667, + sync_message_due_bps: 3_333, + contribution_due_bps: 6_667, + + inactivity_score_bias: 4, + inactivity_score_recovery_rate: 16, + ejection_balance: 16_000_000_000, + min_per_epoch_churn_limit: 2, + churn_limit_quotient: 32, + max_per_epoch_activation_churn_limit: 4, + min_per_epoch_churn_limit_electra: 64_000_000_000, + max_per_epoch_activation_exit_churn_limit: 128_000_000_000, + + proposer_score_boost: 40, + reorg_head_weight_threshold: 20, + reorg_parent_weight_threshold: 160, + reorg_max_epochs_since_finalization: 2, + + // configs/minimal.yaml sets this to 2**256 - 2**10: large enough + // that no spec test's simulated PoW chain reaches it. + terminal_total_difficulty: MINIMAL_TERMINAL_TOTAL_DIFFICULTY, + terminal_block_hash: ExecutionBlockHash::ZERO, + terminal_block_hash_activation_epoch: constants::FAR_FUTURE_EPOCH, + + max_blobs_per_block_deneb: 6, + max_blobs_per_block_electra: 9, + blob_schedule: SszList::new(), + + attestation_propagation_slot_range: 32, + attestation_subnet_count: 64, + attestation_subnet_extra_bits: 0, + blob_sidecar_subnet_count: 6, + blob_sidecar_subnet_count_electra: 9, + data_column_sidecar_subnet_count: 128, + epochs_per_subnet_subscription: 256, + max_payload_size: 10_485_760, + max_request_blocks: 1_024, + max_request_blocks_deneb: 128, + max_request_payloads: 128, + maximum_gossip_clock_disparity: 500, + message_domain_invalid_snappy: [0x00, 0x00, 0x00, 0x00], + message_domain_valid_snappy: [0x01, 0x00, 0x00, 0x00], + min_epochs_for_blob_sidecars_requests: 4_096, + min_epochs_for_data_column_sidecars_requests: 4_096, + subnets_per_node: 2, + + deposit_chain_id: 1, + deposit_network_id: 1, + deposit_contract_address: [ + 0x00, 0x00, 0x00, 0x00, 0x21, 0x9a, 0xb5, 0x40, 0x35, 0x6c, 0xbb, 0x83, 0x9c, 0xbe, + 0x05, 0x30, 0x3d, 0x77, 0x05, 0xfa, + ], + + balance_per_additional_custody_group: 32_000_000_000, + custody_requirement: 4, + number_of_custody_groups: 128, + samples_per_slot: 8, + validator_custody_requirement: 8, + + consolidation_churn_limit_quotient: 65_536, + + attestation_subnet_prefix_bits: 6, + max_request_blob_sidecars: 768, + max_request_blob_sidecars_electra: 1_152, + max_request_data_column_sidecars: 16_384, + min_epochs_for_block_requests: 33_024, + } + } + + /// The configuration a lean chain runs on: a real `genesis_time`, the slot + /// duration its network config file sets, and a placeholder for everything + /// else. + /// + /// A lean chain has no beacon fork schedule, no Eth1 deposit contract and + /// no execution layer, but it is stored through the same `Metadata["config"]` + /// row as a beacon chain so that one accessor serves both. The placeholders + /// are chosen so that a beacon-shaped gate reading this by mistake fails + /// closed: every fork epoch is `FAR_FUTURE_EPOCH`, so no fork ever reads as + /// activated, rather than epoch 0, which would read as "activated at + /// genesis" for all seven of them. + pub fn lean(genesis_time: u64, slot_duration_ms: u64) -> Self { + Self { + // A lean `config.yaml` has no `PRESET_BASE` or `CONFIG_NAME` key, + // and a lean chain has no beacon preset, so there is neither name + // to carry. + preset_base: ConfigName::default(), + config_name: ConfigName::default(), + genesis_time, + slot_duration_ms, + // Truncated on a cadence that is not a whole number of seconds. + // Nothing on the lean path reads it: lean schedules every duty off + // `slot_duration_ms`, and the second-resolution field exists for + // the beacon spec's own `compute_time_at_slot`. + seconds_per_slot: slot_duration_ms / 1_000, + altair_fork_epoch: constants::FAR_FUTURE_EPOCH, + bellatrix_fork_epoch: constants::FAR_FUTURE_EPOCH, + capella_fork_epoch: constants::FAR_FUTURE_EPOCH, + deneb_fork_epoch: constants::FAR_FUTURE_EPOCH, + electra_fork_epoch: constants::FAR_FUTURE_EPOCH, + fulu_fork_epoch: constants::FAR_FUTURE_EPOCH, + ..Config::mainnet() + } + } + + /// Genesis as a millisecond timestamp, the zero point every tick + /// computation measures from. + pub fn genesis_time_ms(&self) -> u64 { + self.genesis_time * 1_000 + } + + /// Interval duration in milliseconds. + /// + /// Exact on a lean chain: [`crate::genesis::GenesisConfig`] rejects a slot + /// duration that is not a multiple of [`INTERVALS_PER_SLOT`]. + pub fn milliseconds_per_interval(&self) -> u64 { + self.slot_duration_ms / INTERVALS_PER_SLOT + } + + /// This configuration's time grid, the part a lean node schedules duties + /// off. + /// + /// A [`ChainConfig`] rather than a borrow of `self`: it is two `u64`s and + /// `Copy`, so a caller can hold it across the `&mut Store` the tick + /// pipeline takes, and the metrics helpers can keep taking it by value. + pub fn time_grid(&self) -> ChainConfig { + ChainConfig::new(self.genesis_time, self.slot_duration_ms) + } + + /// The configuration matching the compiled-in preset: [`Config::minimal`] + /// when this crate is built with the `preset-minimal` feature, + /// [`Config::mainnet`] otherwise. + /// + /// For code that already knows its preset at compile time (unlike the + /// `transition` fixture suite, which needs to pick a configuration, and + /// possibly override a fork epoch, per test case). + pub fn active() -> Self { + if cfg!(feature = "preset-minimal") { + Self::minimal() + } else { + Self::mainnet() + } + } + + /// The fork active at `epoch`: the newest fork whose activation epoch is + /// both scheduled (not [`constants::FAR_FUTURE_EPOCH`]) and at or before + /// `epoch`. + /// + /// The "scheduled" check matters because an unscheduled fork's epoch + /// field holds [`constants::FAR_FUTURE_EPOCH`], which is a real, huge + /// `Epoch` value, not a `None`. Comparing epochs naively (newest fork + /// whose epoch is `<= epoch`, full stop) would treat that sentinel as a + /// legitimate activation epoch and could only ever be beaten by querying + /// an even larger epoch, so an unscheduled fork would still eventually + /// "activate" once the chain ran long enough. Filtering out + /// [`constants::FAR_FUTURE_EPOCH`] first is what makes "not scheduled" + /// mean "never", as intended. + /// + /// Phase0 is always scheduled (its epoch is + /// [`crate::beacon::constants::GENESIS_EPOCH`], never the sentinel), so this + /// always finds at least phase0 and never needs to fail. + pub fn fork_at_epoch(&self, epoch: Epoch) -> ForkName { + ForkName::ALL + .into_iter() + .rev() + .find(|&fork| { + let scheduled_at = self.fork_epoch(fork); + scheduled_at != constants::FAR_FUTURE_EPOCH && scheduled_at <= epoch + }) + .unwrap_or(ForkName::Phase0) + } + + /// The `Fork.current_version` value blocks and attestations of `fork` + /// sign under. + pub fn fork_version(&self, fork: ForkName) -> Version { + match fork { + ForkName::Phase0 => self.genesis_fork_version, + ForkName::Altair => self.altair_fork_version, + ForkName::Bellatrix => self.bellatrix_fork_version, + ForkName::Capella => self.capella_fork_version, + ForkName::Deneb => self.deneb_fork_version, + ForkName::Electra => self.electra_fork_version, + ForkName::Fulu => self.fulu_fork_version, + ForkName::Lean => lean_fork_unreachable("Config::fork_version"), + } + } + + /// The epoch `fork` activates at, or [`constants::FAR_FUTURE_EPOCH`] if it + /// is not scheduled on this configuration. Phase0 always returns + /// [`constants::GENESIS_EPOCH`]: it is the chain's starting fork, not a + /// configurable activation. + pub fn fork_epoch(&self, fork: ForkName) -> Epoch { + match fork { + ForkName::Phase0 => constants::GENESIS_EPOCH, + ForkName::Altair => self.altair_fork_epoch, + ForkName::Bellatrix => self.bellatrix_fork_epoch, + ForkName::Capella => self.capella_fork_epoch, + ForkName::Deneb => self.deneb_fork_epoch, + ForkName::Electra => self.electra_fork_epoch, + ForkName::Fulu => self.fulu_fork_epoch, + ForkName::Lean => lean_fork_unreachable("Config::fork_epoch"), + } + } + + /// Returns a copy of this configuration with `fork`'s activation epoch + /// set to `epoch`, leaving every other fork's schedule untouched. + /// + /// For the `transition` fixture suite, which starts from + /// [`Config::minimal`] (where every fork after phase0 defaults to + /// unscheduled) and overrides exactly the one boundary each test case + /// exercises. + /// + /// Phase0's activation is not stored as a field (it is always + /// [`constants::GENESIS_EPOCH`], see [`Config::fork_epoch`]), so passing + /// `ForkName::Phase0` here has no effect. + pub fn with_fork_epoch(mut self, fork: ForkName, epoch: Epoch) -> Self { + match fork { + ForkName::Phase0 => {} + ForkName::Altair => self.altair_fork_epoch = epoch, + ForkName::Bellatrix => self.bellatrix_fork_epoch = epoch, + ForkName::Capella => self.capella_fork_epoch = epoch, + ForkName::Deneb => self.deneb_fork_epoch = epoch, + ForkName::Electra => self.electra_fork_epoch = epoch, + ForkName::Fulu => self.fulu_fork_epoch = epoch, + ForkName::Lean => lean_fork_unreachable("Config::with_fork_epoch"), + } + self + } + + /// Override one fork's version, for a network whose schedule differs from + /// mainnet's. Phase0's version is `genesis_fork_version` and is set there. + pub fn with_fork_version(mut self, fork: ForkName, version: Version) -> Self { + match fork { + ForkName::Phase0 => self.genesis_fork_version = version, + ForkName::Altair => self.altair_fork_version = version, + ForkName::Bellatrix => self.bellatrix_fork_version = version, + ForkName::Capella => self.capella_fork_version = version, + ForkName::Deneb => self.deneb_fork_version = version, + ForkName::Electra => self.electra_fork_version = version, + ForkName::Fulu => self.fulu_fork_version = version, + // Matches `fork_version`'s own arm: reaching this means a caller + // dispatched on the wrong chain, which is a bug in the caller. + ForkName::Lean => lean_fork_unreachable("Config::with_fork_version"), + } + self + } + + /// The blob parameters in effect at `epoch`, as + /// `(epoch, max_blobs_per_block)`, from fulu's [`Self::blob_schedule`]. + /// + /// This is `get_blob_parameters`: the schedule is searched from its + /// latest entry backward for the first one whose epoch is at or before + /// `epoch`; if none matches (the schedule is empty, as in + /// [`Config::minimal`]'s default, or every entry is still in the future), + /// this falls back to electra's own activation and + /// [`Self::max_blobs_per_block_electra`], exactly as the specification's + /// own `get_blob_parameters` falls back to `MAX_BLOBS_PER_BLOCK_ELECTRA`. + /// + /// The pair's *epoch* matters as much as its limit, since + /// [`crate::beacon::fork_digest::compute_fork_digest`] hashes both: that is + /// why this returns the pair and [`Self::max_blobs_per_block`] is the thin + /// half of it, rather than the search being written once per caller. + pub fn blob_parameters(&self, epoch: Epoch) -> (Epoch, u64) { + self.blob_schedule + .iter() + .rev() + .find(|entry| entry.epoch <= epoch) + .map(|entry| (entry.epoch, entry.max_blobs_per_block)) + .unwrap_or((self.electra_fork_epoch, self.max_blobs_per_block_electra)) + } + + /// The blob count limit for a block proposed in `epoch`. See + /// [`Self::blob_parameters`]. + /// + /// This is fulu's helper: a deneb- or electra-only block's blob count is + /// bounded by [`Self::max_blobs_per_block_deneb`] or + /// [`Self::max_blobs_per_block_electra`] directly instead, matching how + /// the specification only introduces `get_blob_parameters` at fulu. + pub fn max_blobs_per_block(&self, epoch: Epoch) -> u64 { + self.blob_parameters(epoch).1 + } +} + +#[cfg(test)] +mod tests { + use libssz::{SszDecode as _, SszEncode as _}; + + use super::*; + + #[test] + fn the_terminal_total_difficulties_match_the_config_files() { + assert_eq!( + Config::mainnet().terminal_total_difficulty, + Uint256::from_dec_str("58750000000000000000000").unwrap(), + ); + assert_eq!( + Config::minimal().terminal_total_difficulty, + Uint256::from_dec_str( + "115792089237316195423570985008687907853269984665640564039457584007913129638912", + ) + .unwrap(), + ); + } + + #[test] + fn fork_at_epoch_matches_mainnet_boundaries() { + let config = Config::mainnet(); + // At each boundary: the epoch just before it still reports the + // previous fork, and the boundary epoch itself already reports the + // new one. + let boundaries = [ + (config.altair_fork_epoch, ForkName::Phase0, ForkName::Altair), + ( + config.bellatrix_fork_epoch, + ForkName::Altair, + ForkName::Bellatrix, + ), + ( + config.capella_fork_epoch, + ForkName::Bellatrix, + ForkName::Capella, + ), + (config.deneb_fork_epoch, ForkName::Capella, ForkName::Deneb), + ( + config.electra_fork_epoch, + ForkName::Deneb, + ForkName::Electra, + ), + (config.fulu_fork_epoch, ForkName::Electra, ForkName::Fulu), + ]; + for (boundary, before, at_and_after) in boundaries { + assert_eq!(config.fork_at_epoch(boundary - 1), before); + assert_eq!(config.fork_at_epoch(boundary), at_and_after); + } + } + + #[test] + fn unscheduled_forks_are_never_returned() { + // Minimal's stock configuration leaves every fork after phase0 at + // FAR_FUTURE_EPOCH. Querying any epoch, including the largest + // possible one, must still resolve to phase0 rather than treating + // the sentinel as a legitimate (if enormous) activation epoch. + let config = Config::minimal(); + assert_eq!(config.fork_at_epoch(0), ForkName::Phase0); + assert_eq!(config.fork_at_epoch(Epoch::MAX), ForkName::Phase0); + } + + #[test] + fn with_fork_epoch_shifts_a_single_boundary() { + let config = Config::minimal().with_fork_epoch(ForkName::Altair, 10); + assert_eq!(config.fork_at_epoch(9), ForkName::Phase0); + assert_eq!(config.fork_at_epoch(10), ForkName::Altair); + // Every later fork is still unscheduled, so a far-future epoch still + // resolves to the one fork that was actually overridden. + assert_eq!(config.fork_at_epoch(1_000_000), ForkName::Altair); + } + + #[test] + fn max_blobs_per_block_selects_the_active_schedule_entry() { + let config = Config::mainnet(); + let first = config.blob_schedule[0]; + let second = config.blob_schedule[1]; + + assert_eq!( + config.max_blobs_per_block(first.epoch - 1), + config.max_blobs_per_block_electra + ); + assert_eq!( + config.max_blobs_per_block(first.epoch), + first.max_blobs_per_block + ); + assert_eq!( + config.max_blobs_per_block(second.epoch - 1), + first.max_blobs_per_block + ); + assert_eq!( + config.max_blobs_per_block(second.epoch), + second.max_blobs_per_block + ); + assert_eq!( + config.max_blobs_per_block(second.epoch + 1_000), + second.max_blobs_per_block + ); + } + + #[test] + fn minimal_has_no_blob_schedule_and_falls_back_to_electra() { + let config = Config::minimal(); + assert!(config.blob_schedule.is_empty()); + assert_eq!( + config.max_blobs_per_block(0), + config.max_blobs_per_block_electra + ); + } + + #[test] + fn a_config_round_trips_through_ssz() { + let config = Config::mainnet(); + let bytes = config.to_ssz(); + assert_eq!( + Config::from_ssz_bytes(&bytes).expect("valid config"), + config + ); + } + + #[test] + fn a_lean_config_carries_its_time_grid_and_nothing_else_meaningful() { + let config = Config::lean(1_770_407_233, 4_000); + assert_eq!(config.genesis_time, 1_770_407_233); + assert_eq!(config.slot_duration_ms, 4_000); + assert_eq!(config.seconds_per_slot, 4); + + // Every fork epoch is FAR_FUTURE_EPOCH: a lean chain has no beacon + // fork schedule, and a placeholder that reads as "scheduled at epoch + // 0" would make a beacon-shaped gate fire on a lean chain. + assert_eq!(config.altair_fork_epoch, constants::FAR_FUTURE_EPOCH); + assert_eq!(config.fulu_fork_epoch, constants::FAR_FUTURE_EPOCH); + } + + #[test] + fn a_lean_config_round_trips_through_ssz() { + let config = Config::lean(7, 4_000); + let bytes = config.to_ssz(); + assert_eq!( + Config::from_ssz_bytes(&bytes).expect("valid config"), + config + ); + } + + #[test] + fn genesis_time_is_not_min_genesis_time() { + // Different quantities, and mainnet is the case that proves it: + // min_genesis_time is the earliest the deposit-driven rules permit, + // 23 seconds before the chain actually started. Reusing it for the + // clock would put every slot boundary 23 seconds off. + let config = Config::mainnet(); + assert_eq!(config.min_genesis_time, 1_606_824_000); + assert_eq!(config.genesis_time, 1_606_824_023); + assert_ne!(config.genesis_time, config.min_genesis_time); + } + + #[test] + fn mainnets_own_config_file_parses_to_the_built_in_config() { + // The fork schedule, slot timing and churn values in eth-clients' + // published file must be exactly what `Config::mainnet` hardcodes. If + // they ever diverge, one of the two is wrong. + let text = include_str!("../../../../../bin/ethlambda/assets/mainnet/config.yaml"); + let parsed: Config = serde_yaml_ng::from_str(text).expect("mainnet config.yaml parses"); + let built_in = Config::mainnet(); + + assert_eq!(parsed.genesis_fork_version, built_in.genesis_fork_version); + assert_eq!(parsed.altair_fork_epoch, built_in.altair_fork_epoch); + assert_eq!(parsed.electra_fork_epoch, built_in.electra_fork_epoch); + assert_eq!(parsed.fulu_fork_epoch, built_in.fulu_fork_epoch); + assert_eq!(parsed.seconds_per_slot, built_in.seconds_per_slot); + assert_eq!(parsed.churn_limit_quotient, built_in.churn_limit_quotient); + assert_eq!(parsed.ejection_balance, built_in.ejection_balance); + + // Every equality above holds for an empty document too, since + // `Config`'s serde default is `Config::mainnet()` itself: this is a + // drift check between our hardcoded values and eth-clients', not + // proof the file is read at all. Prove that separately by editing one + // line the file carries and checking the parsed value follows the + // edit rather than staying at the default. + let perturbed_text = text.replacen( + "CHURN_LIMIT_QUOTIENT: 65536", + "CHURN_LIMIT_QUOTIENT: 12345", + 1, + ); + assert_ne!( + perturbed_text, text, + "fixture no longer carries CHURN_LIMIT_QUOTIENT in the expected form" + ); + let perturbed: Config = serde_yaml_ng::from_str(&perturbed_text).unwrap(); + assert_eq!(perturbed.churn_limit_quotient, 12_345); + } + + #[test] + fn genesis_time_does_not_come_from_the_config_file() { + // Whatever serde does for a skipped field under a container-level + // default, the one thing that must hold is that the file's own + // MIN_GENESIS_TIME never becomes genesis_time: mainnet's differ by 23 + // seconds, and using the wrong one moves every slot boundary. A later + // task fills this from the genesis state. + let text = include_str!("../../../../../bin/ethlambda/assets/mainnet/config.yaml"); + let parsed: Config = serde_yaml_ng::from_str(text).unwrap(); + assert_ne!( + parsed.genesis_time, 1_606_824_000, + "MIN_GENESIS_TIME leaked in" + ); + + // The assertion above holds for an empty document too: `genesis_time` + // is `#[serde(skip)]` and always takes the container default, + // regardless of what the file says. Prove this document is actually + // being parsed by perturbing MIN_GENESIS_TIME and checking it lands + // in `min_genesis_time` while `genesis_time` -- unreachable from any + // config key -- stays exactly where it started. + let perturbed_text = text.replacen( + "MIN_GENESIS_TIME: 1606824000", + "MIN_GENESIS_TIME: 999999999", + 1, + ); + assert_ne!( + perturbed_text, text, + "fixture no longer carries MIN_GENESIS_TIME in the expected form" + ); + let perturbed: Config = serde_yaml_ng::from_str(&perturbed_text).unwrap(); + assert_eq!(perturbed.min_genesis_time, 999_999_999); + assert_eq!(perturbed.genesis_time, parsed.genesis_time); + } + + #[test] + fn the_networking_and_deposit_keys_come_from_the_file() { + let text = include_str!("../../../../../bin/ethlambda/assets/mainnet/config.yaml"); + let parsed: Config = serde_yaml_ng::from_str(text).unwrap(); + + assert_eq!(parsed.attestation_subnet_count, 64); + assert_eq!(parsed.subnets_per_node, 2); + assert_eq!(parsed.max_payload_size, 10_485_760); + assert_eq!(parsed.max_request_blocks, 1_024); + assert_eq!(parsed.max_request_blocks_deneb, 128); + assert_eq!(parsed.maximum_gossip_clock_disparity, 500); + assert_eq!(parsed.message_domain_valid_snappy, [0x01, 0x00, 0x00, 0x00]); + assert_eq!( + parsed.message_domain_invalid_snappy, + [0x00, 0x00, 0x00, 0x00] + ); + assert_eq!(parsed.deposit_chain_id, 1); + assert_eq!(parsed.deposit_network_id, 1); + assert_eq!( + parsed.deposit_contract_address, + hex::decode("00000000219ab540356cBB839Cbe05303d7705Fa") + .unwrap() + .as_slice() + ); + assert_eq!(parsed.custody_requirement, 4); + assert_eq!(parsed.number_of_custody_groups, 128); + assert_eq!(parsed.samples_per_slot, 8); + assert_eq!(parsed.validator_custody_requirement, 8); + assert_eq!(parsed.balance_per_additional_custody_group, 32_000_000_000); + + // Every equality above holds for an empty document too, since each of + // these fields' serde default is mainnet's own value, which is + // exactly what the file carries. Prove the file is actually driving + // the parse: perturb one field from each group above (networking, + // deposit contract, custody) and check the parsed value follows the + // file rather than the default. + let networking_text = text.replacen( + "ATTESTATION_SUBNET_COUNT: 64", + "ATTESTATION_SUBNET_COUNT: 32", + 1, + ); + assert_ne!( + networking_text, text, + "fixture no longer carries ATTESTATION_SUBNET_COUNT in the expected form" + ); + let networking: Config = serde_yaml_ng::from_str(&networking_text).unwrap(); + assert_eq!(networking.attestation_subnet_count, 32); + + let deposit_text = text.replacen("DEPOSIT_CHAIN_ID: 1", "DEPOSIT_CHAIN_ID: 7", 1); + assert_ne!( + deposit_text, text, + "fixture no longer carries DEPOSIT_CHAIN_ID in the expected form" + ); + let deposit: Config = serde_yaml_ng::from_str(&deposit_text).unwrap(); + assert_eq!(deposit.deposit_chain_id, 7); + + let custody_text = text.replacen("CUSTODY_REQUIREMENT: 4", "CUSTODY_REQUIREMENT: 6", 1); + assert_ne!( + custody_text, text, + "fixture no longer carries CUSTODY_REQUIREMENT in the expected form" + ); + let custody: Config = serde_yaml_ng::from_str(&custody_text).unwrap(); + assert_eq!(custody.custody_requirement, 6); + } + + #[test] + fn a_key_absent_from_the_file_falls_back_to_mainnet() { + // mainnet's own config.yaml carries no MAX_REQUEST_PAYLOADS. The default + // has to fill it rather than the parse failing. + let text = include_str!("../../../../../bin/ethlambda/assets/mainnet/config.yaml"); + let parsed: Config = serde_yaml_ng::from_str(text).unwrap(); + assert_eq!( + parsed.max_request_payloads, + Config::mainnet().max_request_payloads + ); + + // The assertion above holds for an empty document too: there is + // nothing in the fixture for MAX_REQUEST_PAYLOADS to differ from. + // Prove this document is actually parsed, not silently treated as + // empty, by perturbing an unrelated key and checking it takes effect + // alongside the still-absent one's default. + let perturbed_text = text.replacen("SUBNETS_PER_NODE: 2", "SUBNETS_PER_NODE: 5", 1); + assert_ne!( + perturbed_text, text, + "fixture no longer carries SUBNETS_PER_NODE in the expected form" + ); + let perturbed: Config = serde_yaml_ng::from_str(&perturbed_text).unwrap(); + assert_eq!(perturbed.subnets_per_node, 5); + assert_eq!( + perturbed.max_request_payloads, + Config::mainnet().max_request_payloads, + "MAX_REQUEST_PAYLOADS is still absent; it must keep defaulting" + ); + } + + #[test] + fn the_names_come_from_the_file_and_default_to_empty() { + let text = include_str!("../../../../../bin/ethlambda/assets/mainnet/config.yaml"); + let parsed: Config = serde_yaml_ng::from_str(text).unwrap(); + assert_eq!(parsed.preset_base.as_str(), "mainnet"); + assert_eq!(parsed.config_name.as_str(), "mainnet"); + + // Not mainnet's values, which every other absent key falls back to: + // an absent PRESET_BASE has to fail the startup preset check rather + // than pass it by default. + let bare: Config = serde_yaml_ng::from_str("SECONDS_PER_SLOT: 12").unwrap(); + assert_eq!(bare.preset_base, ConfigName::default()); + assert_eq!(bare.config_name, ConfigName::default()); + } + + #[test] + fn a_config_name_is_bounded_and_round_trips() { + let longest = "n".repeat(MAX_CONFIG_NAME_LENGTH); + let name = ConfigName::try_from(longest.as_str()).unwrap(); + assert_eq!(name.as_str(), longest); + assert_eq!(ConfigName::from_ssz_bytes(&name.to_ssz()).unwrap(), name); + assert_eq!( + serde_json::to_value(&name).unwrap(), + serde_json::Value::String(longest.clone()) + ); + + let too_long = format!("{longest}n"); + assert_eq!( + ConfigName::try_from(too_long.as_str()), + Err(ConfigNameTooLong { + length: MAX_CONFIG_NAME_LENGTH + 1 + }) + ); + let yaml = format!("CONFIG_NAME: {too_long}"); + let err = serde_yaml_ng::from_str::(&yaml).unwrap_err(); + assert!(err.to_string().contains("config name"), "got {err}"); + } + + /// The hand-written SSZ impls must encode exactly what the byte list they + /// replaced did, or every `DB_VERSION` 4 directory written before them + /// would decode into the wrong fields. + #[test] + fn a_config_name_encodes_as_the_byte_list_it_replaced() { + let name = ConfigName::fixed("ethlambda-devnet"); + let list: SszList = + b"ethlambda-devnet".to_vec().try_into().unwrap(); + assert_eq!(name.to_ssz(), list.to_ssz()); + assert_eq!(ConfigName::from_ssz_bytes(&list.to_ssz()).unwrap(), name); + + let over_long = vec![b'n'; MAX_CONFIG_NAME_LENGTH + 1]; + assert!(ConfigName::from_ssz_bytes(&over_long).is_err()); + + // Only a corrupt database holds these, and the name survives as text. + let not_utf8 = ConfigName::from_ssz_bytes(&[b'a', 0xff]).unwrap(); + assert_eq!(not_utf8.as_str(), "a\u{fffd}"); + } + + #[test] + fn the_blob_schedule_parses_from_the_file() { + let text = include_str!("../../../../../bin/ethlambda/assets/mainnet/config.yaml"); + let parsed: Config = serde_yaml_ng::from_str(text).unwrap(); + assert_eq!(parsed.blob_schedule, Config::mainnet().blob_schedule); + + // The equality above holds for an empty document too: `blob_schedule`'s + // serde default is mainnet's own schedule. Perturb one entry's limit + // and check the parsed schedule follows the file instead of staying + // at the default. + let perturbed_text = text.replacen("MAX_BLOBS_PER_BLOCK: 15", "MAX_BLOBS_PER_BLOCK: 99", 1); + assert_ne!( + perturbed_text, text, + "fixture no longer carries the first BLOB_SCHEDULE entry in the expected form" + ); + let perturbed: Config = serde_yaml_ng::from_str(&perturbed_text).unwrap(); + assert_eq!(perturbed.blob_schedule[0].max_blobs_per_block, 99); + assert_ne!(perturbed.blob_schedule, Config::mainnet().blob_schedule); + } + + #[test] + fn the_spec_config_serializes_in_the_beacon_apis_encoding() { + let json = serde_json::to_value(Config::mainnet()).expect("serializes"); + + // Keys are SCREAMING_SNAKE_CASE, as /eth/v1/config/spec reports them. + assert!(json.get("SECONDS_PER_SLOT").is_some(), "got keys: {json}"); + + // Every integer is a quoted decimal string, not a bare number. + assert_eq!(json["SECONDS_PER_SLOT"], "12"); + assert!(json["DEPOSIT_CHAIN_ID"].is_string()); + assert!(json["ELECTRA_FORK_EPOCH"].is_string()); + + // Byte strings are 0x-prefixed hex. + assert!( + json["GENESIS_FORK_VERSION"] + .as_str() + .unwrap() + .starts_with("0x"), + "got {}", + json["GENESIS_FORK_VERSION"] + ); + assert!( + json["DEPOSIT_CONTRACT_ADDRESS"] + .as_str() + .unwrap() + .starts_with("0x"), + "got {}", + json["DEPOSIT_CONTRACT_ADDRESS"] + ); + assert!( + json["TERMINAL_BLOCK_HASH"] + .as_str() + .unwrap() + .starts_with("0x"), + "got {}", + json["TERMINAL_BLOCK_HASH"] + ); + + // Uint256 is quoted DECIMAL in this API, not hex. + let ttd = json["TERMINAL_TOTAL_DIFFICULTY"].as_str().expect("quoted"); + assert!(!ttd.starts_with("0x"), "got {ttd}"); + assert!(ttd.chars().all(|c| c.is_ascii_digit()), "got {ttd}"); + + // genesis_time is #[serde(skip)]: it is not a config.yaml key and + // /eth/v1/beacon/genesis is where it is reported. + assert!(json.get("GENESIS_TIME").is_none()); + } + + #[test] + fn the_blob_schedule_serializes_as_a_list_of_quoted_entries() { + let mut config = Config::mainnet(); + config.blob_schedule = SszList::try_from(vec![BlobScheduleEntry { + epoch: 100, + max_blobs_per_block: 9, + }]) + .expect("within capacity"); + + let json = serde_json::to_value(&config).expect("serializes"); + let entry = &json["BLOB_SCHEDULE"][0]; + assert_eq!(entry["EPOCH"], "100"); + assert_eq!(entry["MAX_BLOBS_PER_BLOCK"], "9"); + } +} diff --git a/crates/common/types/src/beacon/constants.rs b/crates/common/types/src/beacon/constants.rs new file mode 100644 index 000000000..cee085c01 --- /dev/null +++ b/crates/common/types/src/beacon/constants.rs @@ -0,0 +1,429 @@ +//! Spec constants: values the specification fixes outright, as opposed to +//! preset values (compile-time, see [`crate::beacon::preset`]) or configuration +//! values (runtime, per network, see [`crate::beacon::config`]). +//! +//! A value lives here only if the spec's own `.md` files list it under a +//! "Constants" heading (or, for domain types, wherever the fork first +//! introduces them; several forks file their `DOMAIN_*` additions under +//! "Custom types" instead, but they are the same kind of value as phase0's). +//! +//! One constant from phase0's table is deliberately not modeled: +//! `ENDIANNESS` ('little'). The specification needs to name it because Python +//! integer-to-bytes conversions take an explicit byte order argument; Rust +//! does not have that ambiguity; `to_le_bytes`/`from_le_bytes` (used +//! throughout this crate and by the SSZ codec) already commit to little-endian +//! in the function name, so there is no value for this constant to hold. +//! +//! A handful of values breaks the "Constants heading" rule the other way: +//! the das-core and p2p-interface custody settings below live here even +//! though their `.md` files list them under a "Configs" heading, because they +//! are identical across both shipped configs and a `Config` field would +//! perturb the persisted encoding; see [`NUMBER_OF_CUSTODY_GROUPS`]'s own +//! comment for the detail. + +use crate::beacon::primitives::{DomainType, Epoch, Gwei, Slot}; + +// --------------------------------------------------------------------------- +// Misc (phase0) +// --------------------------------------------------------------------------- + +/// The slot of the genesis block. Always zero: slots count up from genesis, +/// never down, so this is also the smallest valid `Slot`. +pub const GENESIS_SLOT: Slot = 0; + +/// The epoch containing [`GENESIS_SLOT`]. +pub const GENESIS_EPOCH: Epoch = 0; + +/// A sentinel meaning "this has not happened (yet)". +/// +/// Validator lifecycle fields (`activation_eligibility_epoch`, `exit_epoch`, +/// `withdrawable_epoch`, ...) hold this until the corresponding event is +/// scheduled. Comparisons like `validator.exit_epoch == FAR_FUTURE_EPOCH` +/// are how the spec asks "has this validator exited". [`crate::beacon::config`] reuses +/// the same sentinel for fork epochs that have not been scheduled, so that +/// `Config::fork_at_epoch` can skip them the same way. +pub const FAR_FUTURE_EPOCH: Epoch = Epoch::MAX; + +/// The number of ways a single epoch's reward budget is split among +/// validators: proposer inclusion plus one share per attestation component +/// tracked before altair (source, target, head). Altair replaces the +/// three-way split with [`PARTICIPATION_FLAG_WEIGHTS`], but phase0's base +/// reward computation still divides by this constant. +pub const BASE_REWARDS_PER_EPOCH: u64 = 4; + +/// The depth of the deposit contract's incremental merkle tree. Bounds the +/// length of a `Deposit`'s merkle proof (`DEPOSIT_CONTRACT_TREE_DEPTH + 1` +/// entries, the `+ 1` covers the mix-in of the deposit count), hence `usize`. +pub const DEPOSIT_CONTRACT_TREE_DEPTH: usize = 32; + +/// The number of bits in `BeaconState.justification_bits`: one bit per each of +/// the four most recent epochs, tracking whether it was justified. Bounds a +/// `Bitvector`, hence `usize`. +pub const JUSTIFICATION_BITS_LENGTH: usize = 4; + +/// `2**64 - 1`. Used only by the beacon STF's `helpers::math::integer_squareroot` +/// as the boundary past which the doubling-based Newton's method +/// bound is replaced by a precomputed answer; unrelated to +/// [`FAR_FUTURE_EPOCH`] despite the identical bit pattern. +pub const UINT64_MAX: u64 = u64::MAX; + +/// `floor(sqrt(UINT64_MAX))`. The precomputed answer `integer_squareroot` +/// returns for an input of exactly [`UINT64_MAX`], where the general +/// algorithm's intermediate arithmetic would otherwise overflow. +pub const UINT64_MAX_SQRT: u64 = 4_294_967_295; + +// --------------------------------------------------------------------------- +// Withdrawal prefixes +// --------------------------------------------------------------------------- +// +// The first byte of `Validator.withdrawal_credentials` selects how the rest +// of the 32 bytes are interpreted. Typed `u8`: each is compared against a +// single byte read out of a `Bytes32`, never SSZ-encoded on its own. + +/// Marks credentials as a raw BLS withdrawal pubkey hash: withdrawals are +/// disabled until the validator submits a `BLSToExecutionChange` upgrading to +/// [`ETH1_ADDRESS_WITHDRAWAL_PREFIX`]. +pub const BLS_WITHDRAWAL_PREFIX: u8 = 0x00; + +/// Marks credentials as wrapping an execution-layer address: withdrawals (from +/// capella onward) pay out to that address, and the validator is capped at +/// `MAX_EFFECTIVE_BALANCE` (a preset value) until it upgrades further to +/// [`COMPOUNDING_WITHDRAWAL_PREFIX`]. +pub const ETH1_ADDRESS_WITHDRAWAL_PREFIX: u8 = 0x01; + +/// Marks credentials as compounding (electra:EIP7251): balance above +/// `MIN_ACTIVATION_BALANCE` (a preset value) stays effective instead of +/// triggering an automatic partial withdrawal. +pub const COMPOUNDING_WITHDRAWAL_PREFIX: u8 = 0x02; + +// --------------------------------------------------------------------------- +// Domain types +// --------------------------------------------------------------------------- +// +// A `DomainType` names one kind of signable message. `get_domain` mixes one +// of these with a fork version and the genesis validators root to produce the +// `Domain` that actually goes into a signing root, so the same message signed +// under two different forks (or two different chains) produces unrelated +// signatures. + +/// Domain for a `BeaconBlock` proposal signature. +pub const DOMAIN_BEACON_PROPOSER: DomainType = [0x00, 0x00, 0x00, 0x00]; +/// Domain for an `AttestationData` signature. +pub const DOMAIN_BEACON_ATTESTER: DomainType = [0x01, 0x00, 0x00, 0x00]; +/// Domain for the per-epoch RANDAO reveal. +pub const DOMAIN_RANDAO: DomainType = [0x02, 0x00, 0x00, 0x00]; +/// Domain for a `DepositMessage`. Unlike the others, deposit signatures are +/// verified with a fixed genesis-independent domain (`compute_domain` called +/// with no fork data), since a deposit must be valid before the chain it +/// targets has even started. +pub const DOMAIN_DEPOSIT: DomainType = [0x03, 0x00, 0x00, 0x00]; +/// Domain for a `VoluntaryExit`. +pub const DOMAIN_VOLUNTARY_EXIT: DomainType = [0x04, 0x00, 0x00, 0x00]; +/// Domain for an aggregator's slot selection proof. +pub const DOMAIN_SELECTION_PROOF: DomainType = [0x05, 0x00, 0x00, 0x00]; +/// Domain for an `AggregateAndProof`. +pub const DOMAIN_AGGREGATE_AND_PROOF: DomainType = [0x06, 0x00, 0x00, 0x00]; +/// Reserves the non-zero bits of the `DomainType` bitspace for +/// application-specific domains outside the specification. Every +/// specification-defined `DomainType` masks to zero against this; a +/// `DomainType` that does not is reserved for other uses (for example +/// re-purposing beacon chain signatures on an application layer) and this +/// crate never produces or expects one. +pub const DOMAIN_APPLICATION_MASK: DomainType = [0x00, 0x00, 0x00, 0x01]; +/// Domain for a `SyncCommitteeMessage` (altair). +pub const DOMAIN_SYNC_COMMITTEE: DomainType = [0x07, 0x00, 0x00, 0x00]; +/// Domain for a sync committee aggregator's selection proof (altair). +pub const DOMAIN_SYNC_COMMITTEE_SELECTION_PROOF: DomainType = [0x08, 0x00, 0x00, 0x00]; +/// Domain for a `ContributionAndProof` (altair). +pub const DOMAIN_CONTRIBUTION_AND_PROOF: DomainType = [0x09, 0x00, 0x00, 0x00]; +/// Domain for a `BLSToExecutionChange` (capella). Signed with the validator's +/// original BLS withdrawal key, proving ownership before switching +/// [`BLS_WITHDRAWAL_PREFIX`] credentials over to +/// [`ETH1_ADDRESS_WITHDRAWAL_PREFIX`]. +pub const DOMAIN_BLS_TO_EXECUTION_CHANGE: DomainType = [0x0a, 0x00, 0x00, 0x00]; + +// --------------------------------------------------------------------------- +// Participation flags and incentivization weights (altair) +// --------------------------------------------------------------------------- +// +// Altair replaces phase0's `PendingAttestation` bookkeeping with three +// per-validator, per-epoch bits (a `ParticipationFlags`), recording whether an +// attestation had the right source, target, and head, and how promptly it +// landed. `TIMELY_*_FLAG_INDEX` is the bit position within that +// `ParticipationFlags` byte for each of the three; `usize` because it is used +// as a shift amount and as an index, never SSZ-encoded on its own. + +/// Bit index recording a timely, correct attestation source. +pub const TIMELY_SOURCE_FLAG_INDEX: usize = 0; +/// Bit index recording a timely, correct attestation target. +pub const TIMELY_TARGET_FLAG_INDEX: usize = 1; +/// Bit index recording a timely, correct attestation head vote. +pub const TIMELY_HEAD_FLAG_INDEX: usize = 2; + +/// Reward share for a timely, correct source vote, out of [`WEIGHT_DENOMINATOR`]. +pub const TIMELY_SOURCE_WEIGHT: u64 = 14; +/// Reward share for a timely, correct target vote, out of [`WEIGHT_DENOMINATOR`]. +pub const TIMELY_TARGET_WEIGHT: u64 = 26; +/// Reward share for a timely, correct head vote, out of [`WEIGHT_DENOMINATOR`]. +pub const TIMELY_HEAD_WEIGHT: u64 = 14; +/// Reward share for participating in the current sync committee, out of +/// [`WEIGHT_DENOMINATOR`]. +pub const SYNC_REWARD_WEIGHT: u64 = 2; +/// Reward share paid to the block proposer for including attestations and +/// sync committee contributions, out of [`WEIGHT_DENOMINATOR`]. +pub const PROPOSER_WEIGHT: u64 = 8; +/// The denominator every `*_WEIGHT` constant is a numerator over. The weights +/// sum to this value exactly, so together they partition the whole reward. +pub const WEIGHT_DENOMINATOR: u64 = 64; + +/// [`TIMELY_SOURCE_WEIGHT`], [`TIMELY_TARGET_WEIGHT`], and +/// [`TIMELY_HEAD_WEIGHT`], indexed by [`TIMELY_SOURCE_FLAG_INDEX`], +/// [`TIMELY_TARGET_FLAG_INDEX`], and [`TIMELY_HEAD_FLAG_INDEX`] +/// respectively. `get_flag_index_deltas` walks this alongside the three flag +/// indices so the reward computation for each flag reads as one shared loop +/// rather than three near-identical copies. +pub const PARTICIPATION_FLAG_WEIGHTS: [u64; 3] = [ + TIMELY_SOURCE_WEIGHT, + TIMELY_TARGET_WEIGHT, + TIMELY_HEAD_WEIGHT, +]; + +// --------------------------------------------------------------------------- +// Fork choice (phase0) +// --------------------------------------------------------------------------- + +/// The number of sub-slot ticks the fork choice store used to divide a slot +/// into (attest, aggregate, and the next slot's boundary). Deprecated in +/// favor of the millisecond-precision basis-point timings +/// (`ATTESTATION_DUE_BPS` and friends, in [`crate::beacon::config`]), but the +/// specification still lists it, and older fixtures may reference it. +pub const INTERVALS_PER_SLOT: u64 = 3; + +/// The denominator the `*_DUE_BPS` configuration values (in [`crate::beacon::config`]) +/// are numerators over, i.e. ten thousand basis points to a whole slot. +/// `get_slot_component_duration_ms` divides by this to turn a basis-point +/// share into a millisecond offset into the slot. +pub const BASIS_POINTS: u64 = 10_000; + +/// How far behind the wall clock a block must be before it may be imported +/// optimistically on age alone. +/// +/// `optimistic-sync.md`'s constant of the same name. The specification requires +/// it to be operator-configurable, which is what +/// `--safe-slots-to-import-optimistically` is for; this is the default that +/// flag seeds. +/// +/// It only ever gates a *merge transition* block, since any descendant of one +/// satisfies `is_optimistic_candidate_block`'s first condition instead. A +/// checkpoint-synced mainnet follower anchors far past the merge and never +/// meets one. +pub const SAFE_SLOTS_TO_IMPORT_OPTIMISTICALLY: u64 = 128; + +// --------------------------------------------------------------------------- +// Validator guide (phase0 and altair validator.md) +// --------------------------------------------------------------------------- +// +// These govern how a validator behaves rather than what the chain agrees +// about, and only the last sizes a container. They live here, not with the +// code that acts on them, because `/eth/v1/config/spec` reports all three. + +/// How many aggregators the protocol aims for per attestation committee. +/// `is_aggregator` selects a validator when its selection proof hashes to zero +/// modulo `committee_length / TARGET_AGGREGATORS_PER_COMMITTEE`. +/// +/// It also fixes the volume this node receives on +/// `beacon_aggregate_and_proof`: `MAX_COMMITTEES_PER_SLOT` committees each +/// selecting this many aggregators is the per-slot upper bound. +pub const TARGET_AGGREGATORS_PER_COMMITTEE: u64 = 16; + +/// The sync committee counterpart of [`TARGET_AGGREGATORS_PER_COMMITTEE`]: +/// how many aggregators the protocol aims for per sync subcommittee. Nothing +/// in this build aggregates sync committee messages, so the spec endpoint is +/// its only reader. +pub const TARGET_AGGREGATORS_PER_SYNC_SUBCOMMITTEE: u64 = 16; + +/// How many gossip subnets sync committee messages are split across, one +/// subcommittee of `SYNC_COMMITTEE_SIZE / SYNC_COMMITTEE_SUBNET_COUNT` members +/// each. Sizes `SyncSubcommitteeBits` and `MetaData.syncnets`, hence `usize`. +pub const SYNC_COMMITTEE_SUBNET_COUNT: usize = 4; + +// --------------------------------------------------------------------------- +// Blob (deneb) +// --------------------------------------------------------------------------- + +/// The size of one BLS scalar field element in bytes. +/// +/// A blob is a vector of field elements, so this is the factor relating a field +/// element count to a byte length: `BYTES_PER_BLOB` and `BYTES_PER_CELL` in +/// [`crate::beacon::preset`] are both derived from it. Fixed by the specification rather +/// than by preset, since it follows from the size of the BLS12-381 scalar field +/// and not from any network parameter. Bounds a `ByteVector`, hence `usize`. +pub const BYTES_PER_FIELD_ELEMENT: usize = 32; + +/// The version byte prepended to `hash(kzg_commitment)[1:]` to form a blob's +/// versioned hash. Lets the execution layer's transaction format distinguish +/// a KZG-backed versioned hash from other hash-derived identifiers it might +/// introduce later. +pub const VERSIONED_HASH_VERSION_KZG: u8 = 0x01; + +// --------------------------------------------------------------------------- +// Misc (electra) +// --------------------------------------------------------------------------- + +/// Sentinel for `BeaconState.deposit_requests_start_index`: "no execution +/// layer deposit request has set the start index yet". Deposits before this +/// point in the deposit contract's log are still processed from +/// `Eth1Data` votes; once an execution layer `DepositRequest` is seen, the +/// state records its index here and processes deposits from execution layer +/// requests onward instead. Typed `u64` to match the field itself, a plain +/// index counter rather than an `Epoch`. +pub const UNSET_DEPOSIT_REQUESTS_START_INDEX: u64 = u64::MAX; + +/// The amount that signals "withdraw this validator's entire balance" +/// (electra:EIP7002) rather than a partial withdrawal of the given amount. +/// Typed [`Gwei`], matching the `WithdrawalRequest.amount` field it is +/// compared against; the specification's own constants table writes it as a +/// plain `uint64`, but every place that reads it treats it as an amount. +pub const FULL_EXIT_REQUEST_AMOUNT: Gwei = 0; + +/// The first byte of an execution layer request list entry (electra:EIP7685) +/// that identifies it as a deposit request. `u8`: a single tag byte compared +/// directly against the request's type byte, never SSZ-encoded on its own. +pub const DEPOSIT_REQUEST_TYPE: u8 = 0x00; +/// The first byte identifying an execution layer request as a withdrawal +/// request. +pub const WITHDRAWAL_REQUEST_TYPE: u8 = 0x01; +/// The first byte identifying an execution layer request as a consolidation +/// request. +pub const CONSOLIDATION_REQUEST_TYPE: u8 = 0x02; + +// --------------------------------------------------------------------------- +// Custody (fulu, das-core) +// --------------------------------------------------------------------------- + +/// How many custody groups the columns of the extended data matrix are divided +/// into. +/// +/// Equal to `preset::NUMBER_OF_COLUMNS`, so one group is one column. That +/// equality is not assumed anywhere: `compute_columns_for_custody_group` +/// divides the two, and stays correct if a future network separates them. +/// +/// A `configs/*.yaml` value in the specification, but identical across both +/// shipped configs, so the node runs on this constant. `Config` carries the +/// file's value too, for `/eth/v1/config/spec` to report, and startup refuses a +/// network whose value differs from this one, so the two cannot disagree on a +/// running node. The other custody and networking values below follow the +/// same rule. +pub const NUMBER_OF_CUSTODY_GROUPS: u64 = 128; + +/// How many gossip subnets carry data column sidecars: the modulus in +/// `column_index % DATA_COLUMN_SIDECAR_SUBNET_COUNT`, which assigns a column +/// to its gossip topic. See [`NUMBER_OF_CUSTODY_GROUPS`] for why this is a +/// constant. +pub const DATA_COLUMN_SIDECAR_SUBNET_COUNT: u64 = 128; + +/// The floor on how many custody groups a node samples each slot, whatever it +/// custodies. A node's sampling size is the larger of this and its own custody +/// group count, so a minimal-custody node still samples this many. +pub const SAMPLES_PER_SLOT: u64 = 8; + +/// The custody group count an honest node advertises and serves at minimum. +/// Peers may reject a peer advertising less. +pub const CUSTODY_REQUIREMENT: u64 = 4; + +/// The epoch depth beyond which a node MAY refuse to serve data column +/// sidecars, on the grounds that it may have pruned anything older. This node +/// has no pruner, so the refusal never applies: it serves everything it has +/// ever custodied, regardless of age. +pub const MIN_EPOCHS_FOR_DATA_COLUMN_SIDECARS_REQUESTS: u64 = 4096; + +// --------------------------------------------------------------------------- +// Gossip validation (phase0, p2p-interface) +// --------------------------------------------------------------------------- + +/// How far ahead of a node's own clock a gossiped message's slot may sit and +/// still be accepted rather than rejected outright, in milliseconds. +/// +/// A `configs/*.yaml` value in the specification, identical across both +/// shipped configs, so it is a constant here and startup refuses a network +/// that sets another, as with [`NUMBER_OF_CUSTODY_GROUPS`]. +/// +/// Milliseconds rather than [`core::time::Duration`], matching every other +/// value in this module: a caller that wants a `Duration` wraps this one +/// value at its own call site rather than this module carrying a type its +/// neighbours have no use for. +pub const MAXIMUM_GOSSIP_CLOCK_DISPARITY: u64 = 500; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn participation_flag_weights_sum_to_the_denominator() { + // The specification calls this out explicitly ("the sum of the + // weights equal WEIGHT_DENOMINATOR"): together with SYNC_REWARD_WEIGHT + // and PROPOSER_WEIGHT, the three flag weights must partition the + // whole reward with nothing left over and nothing double-counted. + let total: u64 = + PARTICIPATION_FLAG_WEIGHTS.iter().sum::() + SYNC_REWARD_WEIGHT + PROPOSER_WEIGHT; + assert_eq!(total, WEIGHT_DENOMINATOR); + } + + #[test] + fn domain_application_mask_is_reserved_correctly() { + // Every specification-defined domain type must mask to zero: none of + // them are "application" domains. + for domain in [ + DOMAIN_BEACON_PROPOSER, + DOMAIN_BEACON_ATTESTER, + DOMAIN_RANDAO, + DOMAIN_DEPOSIT, + DOMAIN_VOLUNTARY_EXIT, + DOMAIN_SELECTION_PROOF, + DOMAIN_AGGREGATE_AND_PROOF, + DOMAIN_SYNC_COMMITTEE, + DOMAIN_SYNC_COMMITTEE_SELECTION_PROOF, + DOMAIN_CONTRIBUTION_AND_PROOF, + DOMAIN_BLS_TO_EXECUTION_CHANGE, + ] { + let masked = [ + domain[0] & DOMAIN_APPLICATION_MASK[0], + domain[1] & DOMAIN_APPLICATION_MASK[1], + domain[2] & DOMAIN_APPLICATION_MASK[2], + domain[3] & DOMAIN_APPLICATION_MASK[3], + ]; + assert_eq!(masked, [0, 0, 0, 0]); + } + } + + #[test] + fn custody_settings_match_both_shipped_configs() { + // Identical in configs/mainnet.yaml and configs/minimal.yaml, which is + // why these are constants rather than Config fields: a Config field + // would change the SSZ encoding persisted under Metadata["config"] and + // force every existing data directory through a DB_VERSION bump. + assert_eq!(NUMBER_OF_CUSTODY_GROUPS, 128); + assert_eq!(DATA_COLUMN_SIDECAR_SUBNET_COUNT, 128); + assert_eq!(SAMPLES_PER_SLOT, 8); + assert_eq!(CUSTODY_REQUIREMENT, 4); + assert_eq!(MIN_EPOCHS_FOR_DATA_COLUMN_SIDECARS_REQUESTS, 4096); + } + + #[test] + fn gossip_clock_disparity_matches_both_shipped_configs() { + // Same precedent as the custody settings above: identical in + // configs/mainnet.yaml and configs/minimal.yaml. + assert_eq!(MAXIMUM_GOSSIP_CLOCK_DISPARITY, 500); + } + + #[test] + fn number_of_columns_is_a_multiple_of_custody_groups() { + // `compute_columns_for_custody_group` divides these two to map a + // custody group onto its columns; the mapping is only well-formed if + // the division is exact. + assert_eq!( + crate::beacon::preset::NUMBER_OF_COLUMNS as u64 % NUMBER_OF_CUSTODY_GROUPS, + 0 + ); + } +} diff --git a/crates/common/types/src/beacon/containers/altair.rs b/crates/common/types/src/beacon/containers/altair.rs new file mode 100644 index 000000000..d968c1133 --- /dev/null +++ b/crates/common/types/src/beacon/containers/altair.rs @@ -0,0 +1,380 @@ +//! Containers whose shape is specific to altair. +//! +//! Altair's headline change is sync committees: a small, rotating subset of the +//! validator set that signs every block so a light client can follow the +//! chain's head from a single, cheaply verifiable aggregate rather than +//! replaying full state transitions. [`SyncAggregate`] carries the per-block +//! result into [`BeaconBlockBody`], [`SyncCommittee`] carries the current and +//! next committees into [`BeaconState`], and the remaining containers here are +//! the off-chain messages a sync committee member and its aggregator exchange +//! over gossip to produce one: [`SyncCommitteeMessage`], +//! [`SyncCommitteeContribution`], [`ContributionAndProof`], +//! [`SignedContributionAndProof`], and [`SyncAggregatorSelectionData`]. Those +//! five are transcribed from `validator.md` rather than `beacon-chain.md`, +//! since the specification splits sync committee duties into their own +//! document the same way it does phase0's attestation duties. +//! +//! The other change is incentive accounting: altair replaces phase0's +//! accumulate-then-replay attestations with per-validator participation flags +//! recorded as each attestation is processed. Concretely, [`BeaconState`] drops +//! `previous_epoch_attestations` and `current_epoch_attestations` and puts +//! `previous_epoch_participation` and `current_epoch_participation` in the exact +//! same two slots, so this is a type change in place, not an appended pair. +//! Altair then appends `inactivity_scores` and the two sync committee fields +//! after `finalized_checkpoint`. +//! +//! Attestations themselves ([`super::phase0::Attestation`], +//! [`super::phase0::AttesterSlashing`]) are unchanged in altair, so this module +//! imports them rather than redefining them. Electra is where they next change +//! shape; do not go looking for altair-specific versions of them. + +use libssz_derive::{HashTreeRoot, SszDecode, SszEncode}; +use libssz_types::{SszBitvector, SszList, SszVector}; + +use super::phase0::{Attestation, AttesterSlashing}; +use super::shared::{ + Balances, BlockRoots, EpochParticipation, Eth1DataVotes, HistoricalRoots, InactivityScores, + JustificationBits, RandaoMixes, Slashings, StateRoots, Validators, +}; +use super::shared::{ + BeaconBlockHeader, Checkpoint, Deposit, Eth1Data, Fork, ProposerSlashing, SignedVoluntaryExit, +}; +use crate::beacon::primitives::{BlsPubkey, BlsSignature, Bytes32, Root, Slot, ValidatorIndex}; +use crate::beacon::{constants, preset}; + +/// One bit per sync committee member, recording who contributed to a +/// [`SyncAggregate`]. +pub type SyncCommitteeBits = SszBitvector<{ preset::SYNC_COMMITTEE_SIZE }>; + +/// The public keys making up a sync committee, one per seat. +/// +/// May contain duplicates: `get_next_sync_committee_indices` draws seats with +/// replacement weighted by effective balance, so one validator can hold more +/// than one seat in the same committee. +pub type SyncCommitteePubkeys = SszVector; + +/// One bit per member of a single sync subcommittee (one +/// `SYNC_COMMITTEE_SUBNET_COUNT`th of a full sync committee), recording who +/// contributed to one [`SyncCommitteeContribution`]. +pub type SyncSubcommitteeBits = + SszBitvector<{ preset::SYNC_COMMITTEE_SIZE / constants::SYNC_COMMITTEE_SUBNET_COUNT }>; + +// --------------------------------------------------------------------------- +// Sync committees +// --------------------------------------------------------------------------- + +/// A block's summary of sync committee participation: who signed, and the +/// resulting aggregate signature. +/// +/// Carried in every altair-and-later [`BeaconBlockBody`], since every block +/// needs one regardless of how many attestations or other operations it +/// includes. +#[derive( + Debug, Clone, Default, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot, +)] +pub struct SyncAggregate { + #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex::serialize")] + pub sync_committee_bits: SyncCommitteeBits, + /// The aggregate of every signature from a member set in + /// `sync_committee_bits`, over the previous slot's block root. + pub sync_committee_signature: BlsSignature, +} + +/// The committee currently responsible for signing sync aggregates, plus its +/// combined key precomputed for the common case where every member +/// participates. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +pub struct SyncCommittee { + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub pubkeys: SyncCommitteePubkeys, + /// The aggregate of every key in `pubkeys`, so `process_sync_aggregate` does + /// not have to re-aggregate from scratch when the whole committee signs. + pub aggregate_pubkey: BlsPubkey, +} + +// --------------------------------------------------------------------------- +// Blocks +// --------------------------------------------------------------------------- + +/// The contents of a block: phase0's operations, plus the sync committee's +/// contribution to this block. +#[derive( + Debug, Clone, Default, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot, +)] +pub struct BeaconBlockBody { + /// The proposer's contribution to the chain's randomness, which is a + /// signature over the current epoch and so cannot be chosen freely. + pub randao_reveal: BlsSignature, + /// The proposer's vote on the execution chain's deposit state. + pub eth1_data: Eth1Data, + /// Arbitrary proposer-chosen bytes, which consensus never reads. + pub graffiti: Bytes32, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub proposer_slashings: SszList, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub attester_slashings: SszList, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub attestations: SszList, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub deposits: SszList, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub voluntary_exits: SszList, + /// The aggregated sync committee signature over the previous slot's block + /// root, plus which members contributed. This is what lets a light client + /// trust the chain's head without processing every block: it only has to + /// check that a supermajority of a known committee signed. + pub sync_aggregate: SyncAggregate, +} + +/// A block. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +pub struct BeaconBlock { + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub slot: Slot, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub proposer_index: ValidatorIndex, + pub parent_root: Root, + /// The root of the state after this block is applied, which the state + /// transition recomputes and compares. + pub state_root: Root, + pub body: BeaconBlockBody, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +pub struct SignedBeaconBlock { + pub message: BeaconBlock, + pub signature: BlsSignature, +} + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +/// The altair beacon state: 24 fields, in the specification's order. +/// +/// Field order is load-bearing. SSZ encoding and merkleization both follow +/// declaration order, so reordering or omitting a field silently produces a +/// wrong `hash_tree_root`. +/// +/// Fields through `slashings` are identical to phase0's, field for field. From +/// there, altair replaces phase0's `previous_epoch_attestations` and +/// `current_epoch_attestations` (`List`) with +/// `previous_epoch_participation` and `current_epoch_participation` +/// (`List`) in those same two positions, keeps +/// `justification_bits` through `finalized_checkpoint` unchanged, and appends +/// `inactivity_scores`, `current_sync_committee`, and `next_sync_committee`. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +pub struct BeaconState { + // -- Versioning -- + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub genesis_time: u64, + /// The root of the genesis validator registry, which separates this chain + /// from any other running the same fork schedule. + pub genesis_validators_root: Root, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub slot: Slot, + pub fork: Fork, + + // -- History -- + /// The most recent block's header, with `state_root` left zero until the + /// slot advances, since a block cannot commit to the root of the state + /// containing it. + pub latest_block_header: BeaconBlockHeader, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub block_roots: BlockRoots, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub state_roots: StateRoots, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub historical_roots: HistoricalRoots, + + // -- Eth1 -- + pub eth1_data: Eth1Data, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub eth1_data_votes: Eth1DataVotes, + /// How many deposits from the contract have been processed, which is where + /// the next one will be read from. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub eth1_deposit_index: u64, + + // -- Registry -- + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub validators: Validators, + #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + pub balances: Balances, + + // -- Randomness -- + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub randao_mixes: RandaoMixes, + + // -- Slashings -- + #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + pub slashings: Slashings, + + // -- Participation -- + /// Per-validator participation flags for the previous epoch, positionally + /// parallel to `validators`. Occupies the position phase0 gives + /// `previous_epoch_attestations`: altair scores an attestation the moment + /// it is processed instead of deferring to the epoch boundary, so there is + /// no longer a backlog of whole attestations to keep around, only a flag + /// per validator per epoch. + #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + pub previous_epoch_participation: EpochParticipation, + /// Flags for the current epoch, which become `previous_epoch_participation` + /// at the next epoch boundary. + #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + pub current_epoch_participation: EpochParticipation, + + // -- Finality -- + #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex::serialize")] + pub justification_bits: JustificationBits, + pub previous_justified_checkpoint: Checkpoint, + pub current_justified_checkpoint: Checkpoint, + pub finalized_checkpoint: Checkpoint, + + // -- Inactivity -- + /// Per-validator inactivity score, positionally parallel to `validators`. + /// Rises for a validator that misses the timely-target flag during a + /// non-finalizing epoch and falls otherwise, which is what lets the + /// inactivity leak single out validators who are actually offline rather + /// than penalizing everyone during a stall. + #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + pub inactivity_scores: InactivityScores, + + // -- Sync committees -- + /// The committee currently signing sync aggregates. + pub current_sync_committee: SyncCommittee, + /// The committee that takes over from `current_sync_committee` at the next + /// sync committee period boundary. Precomputing it one period ahead is what + /// lets a light client know the next committee before it needs it. + pub next_sync_committee: SyncCommittee, +} + +// --------------------------------------------------------------------------- +// Validator-side containers +// --------------------------------------------------------------------------- +// +// These five are gossiped between sync committee members and their +// aggregators, never stored in the state or a block body, and are transcribed +// from `validator.md` rather than `beacon-chain.md`. + +/// One sync committee member's vote for a slot's block root, before +/// aggregation. +/// +/// The sync committee analogue of a phase0 attestation, but unaggregated: a +/// committee member gossips one of these every slot, and an aggregator +/// combines a subcommittee's worth into a [`SyncCommitteeContribution`]. +#[derive( + Debug, Clone, Default, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot, +)] +pub struct SyncCommitteeMessage { + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub slot: Slot, + pub beacon_block_root: Root, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub validator_index: ValidatorIndex, + pub signature: BlsSignature, +} + +/// An aggregator's combination of one subcommittee's [`SyncCommitteeMessage`]s +/// for a slot. +/// +/// Scoped to `subcommittee_index` rather than the whole committee, because +/// `SYNC_COMMITTEE_SUBNET_COUNT` aggregators work in parallel on disjoint +/// slices of the committee, the same way phase0 attestation aggregation is +/// scoped to one committee rather than the whole active set. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +pub struct SyncCommitteeContribution { + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub slot: Slot, + pub beacon_block_root: Root, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub subcommittee_index: u64, + #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex::serialize")] + pub aggregation_bits: SyncSubcommitteeBits, + /// The aggregate signature of every member set in `aggregation_bits`, over + /// `beacon_block_root`. + pub signature: BlsSignature, +} + +/// A [`SyncCommitteeContribution`] together with proof that its aggregator was +/// selected to produce it. +/// +/// The sync committee analogue of phase0's `AggregateAndProof`. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +pub struct ContributionAndProof { + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub aggregator_index: ValidatorIndex, + pub contribution: SyncCommitteeContribution, + /// The aggregator's signature over the selection data, which is what makes + /// selection verifiable rather than self-declared. + pub selection_proof: BlsSignature, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +pub struct SignedContributionAndProof { + pub message: ContributionAndProof, + pub signature: BlsSignature, +} + +/// What a prospective sync committee aggregator signs to prove it was +/// selected, before it has anything to aggregate yet. +/// +/// Separate from [`ContributionAndProof::selection_proof`]'s signature target +/// only in name: this is the unsigned message that signature covers. +#[derive( + Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot, +)] +pub struct SyncAggregatorSelectionData { + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub slot: Slot, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub subcommittee_index: u64, +} + +#[cfg(test)] +mod tests { + use libssz::{SszDecode as _, SszEncode as _}; + + use super::*; + + #[test] + fn sync_committee_and_aggregate_are_fixed_size() { + // A sync committee is a vector of pubkeys plus one more pubkey, and a + // sync aggregate is a bitvector plus a signature: nothing + // variable-length in either, unlike the block body and state that + // carry them. + assert!(::is_fixed_size()); + assert!(::is_fixed_size()); + } + + #[test] + fn variable_length_containers_carry_offsets() { + // The state and the body each hold at least one list, so both begin + // their encoding with offsets rather than a fixed layout, and the + // block inherits that from its body. + assert!(!::is_fixed_size()); + assert!(!::is_fixed_size()); + assert!(!::is_fixed_size()); + } + + #[test] + fn block_body_round_trips_while_empty() { + // An empty body is the common case for a skipped-operation slot, and it + // exercises every offset in the encoding with zero-length payloads, + // plus the fixed-size sync aggregate altair adds alongside them. + let body = BeaconBlockBody { + randao_reveal: BlsSignature::default(), + eth1_data: Eth1Data::default(), + graffiti: Bytes32::ZERO, + proposer_slashings: Default::default(), + attester_slashings: Default::default(), + attestations: Default::default(), + deposits: Default::default(), + voluntary_exits: Default::default(), + sync_aggregate: SyncAggregate::default(), + }; + + let bytes = body.to_ssz(); + assert_eq!(BeaconBlockBody::from_ssz_bytes(&bytes).unwrap(), body); + } +} diff --git a/crates/common/types/src/beacon/containers/bellatrix.rs b/crates/common/types/src/beacon/containers/bellatrix.rs new file mode 100644 index 000000000..6e802e8ea --- /dev/null +++ b/crates/common/types/src/beacon/containers/bellatrix.rs @@ -0,0 +1,423 @@ +//! Containers whose shape is specific to bellatrix. +//! +//! Bellatrix is the merge: block production moves from proof-of-work mining on +//! the execution side to proposal by the beacon chain's validators, and the +//! execution chain's block becomes an opaque payload the beacon block carries +//! rather than a chain validated on its own. Concretely, [`BeaconBlockBody`] +//! appends `execution_payload`, an [`ExecutionPayload`], and [`BeaconState`] +//! appends `latest_execution_payload_header`, an [`ExecutionPayloadHeader`]: +//! the header is what lets a later payload be checked against the one before +//! it (its `parent_hash` must chain to the header's `block_hash`) without the +//! state having to keep the whole payload, transactions included, around. +//! +//! [`ExecutionPayloadHeader`] is otherwise identical to [`ExecutionPayload`]: +//! it replaces `transactions` with `transactions_root`, the same substitution +//! [`super::shared::BeaconBlockHeader`] makes for a beacon block's body. +//! +//! [`PowBlock`] is unrelated to either: it is transcribed from +//! `fork-choice.md` rather than `beacon-chain.md`, and exists only to let fork +//! choice check a candidate terminal proof-of-work block's total difficulty +//! against `TERMINAL_TOTAL_DIFFICULTY` while validating the merge transition +//! block, the one time consensus has to reason about a chain it does not +//! itself produce. +//! +//! Everything else bellatrix touches, sync committees, attestations, and the +//! rest of the block body and state, is unchanged from altair, so this module +//! imports rather than redefines it. + +use libssz_derive::{HashTreeRoot, SszDecode, SszEncode}; +use libssz_types::{SszList, SszVector}; + +use super::altair::{SyncAggregate, SyncCommittee}; +use super::phase0::{Attestation, AttesterSlashing}; +use super::shared::{ + Balances, BeaconBlockHeader, BlockRoots, Checkpoint, Deposit, EpochParticipation, Eth1Data, + Eth1DataVotes, Fork, HistoricalRoots, InactivityScores, JustificationBits, ProposerSlashing, + RandaoMixes, SignedVoluntaryExit, Slashings, StateRoots, Validators, +}; +use crate::beacon::preset; +use crate::beacon::primitives::{ + BlsSignature, Bytes32, ExecutionAddress, ExecutionBlockHash, Root, Slot, Uint256, + ValidatorIndex, +}; + +// --------------------------------------------------------------------------- +// Execution payload collection aliases +// --------------------------------------------------------------------------- +// +// A const-generic argument that is a path needs braces, so these read +// `{ preset::X }` rather than `preset::X`. + +/// One execution-layer transaction, opaque to consensus. +/// +/// The specification's `ByteList[MAX_BYTES_PER_TRANSACTION]` rather than a +/// structured type, since consensus never decodes a transaction; it only +/// carries the bytes the execution engine will. +pub type Transaction = SszList; + +/// The transactions in one [`ExecutionPayload`], in execution order. +pub type Transactions = SszList; + +/// Arbitrary proposer-chosen bytes on the execution side of a payload, the +/// execution analogue of a beacon block's `graffiti`. +pub type ExtraData = SszList; + +/// A Bloom filter summarizing this payload's transaction logs, fixed-length +/// because it is a filter rather than a list of entries. +pub type LogsBloom = SszVector; + +// --------------------------------------------------------------------------- +// Execution payload +// --------------------------------------------------------------------------- + +/// An execution block, carried inside a [`BeaconBlockBody`] rather than +/// gossiped and validated on its own chain. +/// +/// Field order and naming otherwise follow the execution block header; the +/// specification notes that `fee_recipient`, `prev_randao`, and +/// `block_number` correspond to `beneficiary`, `difficulty`, and `number` in +/// the yellow paper, carried over under new names now that consensus, not +/// proof-of-work mining, produces them. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +pub struct ExecutionPayload { + pub parent_hash: ExecutionBlockHash, + /// Where this block's fees are paid; `beneficiary` in the yellow paper. + pub fee_recipient: ExecutionAddress, + /// The execution layer's post-state root, unrelated to the beacon block's + /// own `state_root`. + pub state_root: Bytes32, + pub receipts_root: Bytes32, + #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex::serialize")] + pub logs_bloom: LogsBloom, + /// The randomness the beacon chain exposes to the EVM for this block; + /// `difficulty` in the yellow paper before the merge repurposed the + /// field, since proof-of-work difficulty no longer exists. + pub prev_randao: Bytes32, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub block_number: u64, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub gas_limit: u64, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub gas_used: u64, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub timestamp: u64, + #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex::serialize")] + pub extra_data: ExtraData, + /// This block's EIP-1559 base fee, a `uint256` because the execution + /// layer's fee market is not bounded to fit a `uint64`. + pub base_fee_per_gas: Uint256, + /// This payload's own hash, which the next payload's `parent_hash` must + /// equal. + pub block_hash: ExecutionBlockHash, + #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex_seq::serialize")] + pub transactions: Transactions, +} + +/// An [`ExecutionPayload`] with its transaction list replaced by a merkle +/// root, so [`BeaconState::latest_execution_payload_header`] can commit to +/// the previous payload without the state growing with every transaction +/// ever included. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +pub struct ExecutionPayloadHeader { + pub parent_hash: ExecutionBlockHash, + pub fee_recipient: ExecutionAddress, + pub state_root: Bytes32, + pub receipts_root: Bytes32, + #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex::serialize")] + pub logs_bloom: LogsBloom, + pub prev_randao: Bytes32, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub block_number: u64, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub gas_limit: u64, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub gas_used: u64, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub timestamp: u64, + #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex::serialize")] + pub extra_data: ExtraData, + pub base_fee_per_gas: Uint256, + /// The hash of the execution block this header summarizes. + pub block_hash: ExecutionBlockHash, + /// The root of the full transaction list [`ExecutionPayload::transactions`] + /// would have carried, so the header stays a fixed shape regardless of + /// how many transactions the block had. + pub transactions_root: Root, +} + +// --------------------------------------------------------------------------- +// Blocks +// --------------------------------------------------------------------------- + +/// The contents of a block: altair's operations, plus this slot's execution +/// payload. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +pub struct BeaconBlockBody { + /// The proposer's contribution to the chain's randomness, which is a + /// signature over the current epoch and so cannot be chosen freely. + pub randao_reveal: BlsSignature, + /// The proposer's vote on the execution chain's deposit state. + pub eth1_data: Eth1Data, + /// Arbitrary proposer-chosen bytes, which consensus never reads. + pub graffiti: Bytes32, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub proposer_slashings: SszList, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub attester_slashings: SszList, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub attestations: SszList, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub deposits: SszList, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub voluntary_exits: SszList, + /// The aggregated sync committee signature over the previous slot's block + /// root, plus which members contributed. + pub sync_aggregate: SyncAggregate, + /// This slot's execution block, carried rather than referenced, since a + /// beacon block and the execution block it produces are proposed and + /// gossiped together. + pub execution_payload: ExecutionPayload, +} + +impl BeaconBlockBody { + /// An empty body: no operations of any kind, and an all-zero execution + /// payload. + /// + /// Not `#[derive(Default)]`, unlike phase0's and altair's bodies (which + /// have no execution payload to build): `execution_payload.logs_bloom` is + /// an [`SszVector`], and unlike a list, a vector can never validly be + /// empty, so libssz gives it no `Default` impl. Its all-zero value is + /// built explicitly at its exact length instead, the same construction + /// `state_transition::beacon::upgrade`'s + /// `empty_bellatrix_execution_payload_header` uses for the header shape + /// of the same payload. + pub fn empty() -> Self { + Self { + randao_reveal: Default::default(), + eth1_data: Default::default(), + graffiti: Default::default(), + proposer_slashings: Default::default(), + attester_slashings: Default::default(), + attestations: Default::default(), + deposits: Default::default(), + voluntary_exits: Default::default(), + sync_aggregate: Default::default(), + execution_payload: ExecutionPayload { + parent_hash: ExecutionBlockHash::ZERO, + fee_recipient: ExecutionAddress::ZERO, + state_root: Bytes32::ZERO, + receipts_root: Bytes32::ZERO, + logs_bloom: LogsBloom::try_from(vec![0u8; preset::BYTES_PER_LOGS_BLOOM]) + .expect("BYTES_PER_LOGS_BLOOM zeros fit LogsBloom's exact length"), + prev_randao: Bytes32::ZERO, + block_number: 0, + gas_limit: 0, + gas_used: 0, + timestamp: 0, + extra_data: Default::default(), + base_fee_per_gas: Uint256::ZERO, + block_hash: ExecutionBlockHash::ZERO, + transactions: Default::default(), + }, + } + } +} + +/// A block. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +pub struct BeaconBlock { + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub slot: Slot, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub proposer_index: ValidatorIndex, + pub parent_root: Root, + /// The root of the state after this block is applied, which the state + /// transition recomputes and compares. + pub state_root: Root, + pub body: BeaconBlockBody, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +pub struct SignedBeaconBlock { + pub message: BeaconBlock, + pub signature: BlsSignature, +} + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +/// The bellatrix beacon state: 25 fields, in the specification's order. +/// +/// Field order is load-bearing. SSZ encoding and merkleization both follow +/// declaration order, so reordering or omitting a field silently produces a +/// wrong `hash_tree_root`. +/// +/// Every field through `next_sync_committee` is identical to altair's, field +/// for field. Bellatrix appends `latest_execution_payload_header`, which is +/// what lets `process_execution_payload` check a proposed block's payload +/// chains to the one actually applied, without the state keeping a full +/// payload's transactions around. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +pub struct BeaconState { + // -- Versioning -- + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub genesis_time: u64, + /// The root of the genesis validator registry, which separates this chain + /// from any other running the same fork schedule. + pub genesis_validators_root: Root, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub slot: Slot, + pub fork: Fork, + + // -- History -- + /// The most recent block's header, with `state_root` left zero until the + /// slot advances, since a block cannot commit to the root of the state + /// containing it. + pub latest_block_header: BeaconBlockHeader, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub block_roots: BlockRoots, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub state_roots: StateRoots, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub historical_roots: HistoricalRoots, + + // -- Eth1 -- + pub eth1_data: Eth1Data, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub eth1_data_votes: Eth1DataVotes, + /// How many deposits from the contract have been processed, which is where + /// the next one will be read from. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub eth1_deposit_index: u64, + + // -- Registry -- + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub validators: Validators, + #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + pub balances: Balances, + + // -- Randomness -- + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub randao_mixes: RandaoMixes, + + // -- Slashings -- + #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + pub slashings: Slashings, + + // -- Participation -- + /// Per-validator participation flags for the previous epoch, positionally + /// parallel to `validators`. + #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + pub previous_epoch_participation: EpochParticipation, + /// Flags for the current epoch, which become `previous_epoch_participation` + /// at the next epoch boundary. + #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + pub current_epoch_participation: EpochParticipation, + + // -- Finality -- + #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex::serialize")] + pub justification_bits: JustificationBits, + pub previous_justified_checkpoint: Checkpoint, + pub current_justified_checkpoint: Checkpoint, + pub finalized_checkpoint: Checkpoint, + + // -- Inactivity -- + /// Per-validator inactivity score, positionally parallel to `validators`. + #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + pub inactivity_scores: InactivityScores, + + // -- Sync committees -- + /// The committee currently signing sync aggregates. + pub current_sync_committee: SyncCommittee, + /// The committee that takes over from `current_sync_committee` at the next + /// sync committee period boundary. + pub next_sync_committee: SyncCommittee, + + // -- Execution -- + /// A commitment to the most recently applied execution payload, so a + /// later block's payload can be checked against it without the state + /// keeping the whole payload around. + pub latest_execution_payload_header: ExecutionPayloadHeader, +} + +// --------------------------------------------------------------------------- +// Fork choice +// --------------------------------------------------------------------------- +// +// Transcribed from `fork-choice.md` rather than `beacon-chain.md`, since it +// belongs to the merge transition handshake rather than to ordinary block +// processing. + +/// One execution-layer (proof-of-work) block, as reported by +/// `get_pow_block`. +/// +/// Fork choice's `is_valid_terminal_pow_block` uses this to check a candidate +/// merge transition block's total difficulty against +/// `TERMINAL_TOTAL_DIFFICULTY`, and its parent's difficulty against the same +/// bound, which is what pins the merge to one specific proof-of-work block +/// rather than any block heavy enough on its own. +#[derive( + Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot, +)] +pub struct PowBlock { + pub block_hash: ExecutionBlockHash, + pub parent_hash: ExecutionBlockHash, + /// The cumulative proof-of-work difficulty of the chain up to and + /// including this block, which is what `TERMINAL_TOTAL_DIFFICULTY` + /// bounds. + pub total_difficulty: Uint256, +} + +#[cfg(test)] +mod tests { + use libssz::{SszDecode as _, SszEncode as _}; + + use super::*; + + #[test] + fn sync_committee_and_pow_block_are_fixed_size() { + // A sync committee is unchanged from altair, and a pow block is three + // fixed-width fields: neither carries anything variable-length, unlike + // the execution payload, block, and state that surround them. + assert!(::is_fixed_size()); + assert!(::is_fixed_size()); + } + + #[test] + fn variable_length_containers_carry_offsets() { + // Both the payload and its header hold `extra_data`, a list, so even + // the header (whose `transactions_root` is fixed-size) begins its + // encoding with offsets. The state and body inherit variability the + // same way altair's do, now compounded by the payload they carry. + assert!(!::is_fixed_size()); + assert!(!::is_fixed_size()); + assert!(!::is_fixed_size()); + assert!(!::is_fixed_size()); + assert!(!::is_fixed_size()); + } + + #[test] + fn execution_payload_round_trips_while_empty() { + // `BeaconBlockBody::empty()` is the one place that builds an + // otherwise-empty payload with a correctly sized, all-zero + // `logs_bloom`, since `LogsBloom` is a fixed-length vector rather than + // a list and so has no `Default`. + let payload = BeaconBlockBody::empty().execution_payload; + + let bytes = payload.to_ssz(); + assert_eq!(ExecutionPayload::from_ssz_bytes(&bytes).unwrap(), payload); + } + + #[test] + fn block_body_round_trips_while_empty() { + // An empty body is the common case for a skipped-operation slot, and it + // exercises every offset in the encoding with zero-length payloads, + // plus the fixed-size sync aggregate and the execution payload + // bellatrix adds alongside them. + let body = BeaconBlockBody::empty(); + + let bytes = body.to_ssz(); + assert_eq!(BeaconBlockBody::from_ssz_bytes(&bytes).unwrap(), body); + } +} diff --git a/crates/common/types/src/beacon/containers/capella.rs b/crates/common/types/src/beacon/containers/capella.rs new file mode 100644 index 000000000..8df81fb10 --- /dev/null +++ b/crates/common/types/src/beacon/containers/capella.rs @@ -0,0 +1,471 @@ +//! Containers whose shape is specific to capella. +//! +//! Capella's headline change is validator withdrawals: until this fork, a +//! validator's stake could shrink (via slashing or penalties) but never leave +//! the consensus layer, since there was nowhere for it to go. Capella gives it +//! somewhere to go by having every block sweep a bounded slice of the +//! validator registry for anyone who is fully or partially withdrawable and +//! pay them out on the execution side. Concretely: [`ExecutionPayload`] and +//! [`ExecutionPayloadHeader`] gain a `withdrawals`/`withdrawals_root` field so +//! the payout is part of the execution block, [`Withdrawal`] is the payout +//! itself, and [`BeaconState`] gains `next_withdrawal_index` and +//! `next_withdrawal_validator_index` as the sweep's persistent cursor. Keeping +//! a cursor rather than rescanning the whole registry every block is what +//! bounds the sweep's cost regardless of how large the registry grows. +//! [`BLSToExecutionChange`] and [`SignedBLSToExecutionChange`] are the other +//! new operation: a one-time switch from a raw BLS withdrawal credential to +//! an execution address, which is what makes a validator eligible for the +//! sweep in the first place. +//! +//! The other change is `historical_summaries`, imported unchanged from +//! [`super::shared`] rather than redefined here: `historical_roots` is frozen +//! in place at its bellatrix position and slot, and `historical_summaries` +//! takes over accumulating new history from this fork on. The specification +//! notes the two are `hash_tree_root`-compatible (a [`super::shared::HistoricalSummary`] +//! has the same two fields as phase0's `HistoricalBatch`), which is what lets a +//! verifier that only knows one of the two forms still check a historical +//! proof against either. +//! +//! [`ExtraData`], [`LogsBloom`], and [`Transactions`] are unchanged from +//! bellatrix, so this module imports them rather than redefining them. +//! [`ExecutionPayload`] and [`ExecutionPayloadHeader`] themselves are not +//! imported, since every field of both is repeated here with `withdrawals` +//! (respectively `withdrawals_root`) appended, and a derive needs the whole +//! field list in one struct. + +use libssz_derive::{HashTreeRoot, SszDecode, SszEncode}; +use libssz_types::SszList; + +use super::altair::{SyncAggregate, SyncCommittee}; +use super::bellatrix::{ExtraData, LogsBloom, Transactions}; +use super::phase0::{Attestation, AttesterSlashing}; +use super::shared::{ + Balances, BeaconBlockHeader, BlockRoots, Checkpoint, Deposit, EpochParticipation, Eth1Data, + Eth1DataVotes, Fork, HistoricalRoots, HistoricalSummaries, InactivityScores, JustificationBits, + ProposerSlashing, RandaoMixes, SignedVoluntaryExit, Slashings, StateRoots, Validators, +}; +use crate::beacon::preset; +use crate::beacon::primitives::{ + BlsPubkey, BlsSignature, Bytes32, ExecutionAddress, ExecutionBlockHash, Gwei, Root, Slot, + Uint256, ValidatorIndex, WithdrawalIndex, +}; + +/// Withdrawals a block applies, bounded the same way every other operation +/// list is. +/// +/// Unlike the other lists in [`BeaconBlockBody`], a proposer does not choose +/// these: `process_withdrawals` recomputes the expected set from the sweep +/// cursor and rejects a block whose `withdrawals` does not match exactly. +pub type Withdrawals = SszList; + +// --------------------------------------------------------------------------- +// New containers +// --------------------------------------------------------------------------- + +/// One validator's payout, included in an [`ExecutionPayload`] and applied by +/// decreasing the validator's balance by `amount`. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +pub struct Withdrawal { + /// This withdrawal's position in the chain-wide withdrawal sequence, + /// monotonically increasing and never reused. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub index: WithdrawalIndex, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub validator_index: ValidatorIndex, + /// Where the payout is sent, taken from the low bytes of the validator's + /// eth1 withdrawal credentials. + pub address: ExecutionAddress, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub amount: Gwei, +} + +/// A validator's one-time request to switch its withdrawal credentials from a +/// raw BLS public key hash to an execution address. +/// +/// Before this operation, a validator's withdrawal credentials commit only to +/// a BLS key, which the execution layer has no way to pay out to. Processing +/// it is what makes a validator eligible for the withdrawal sweep at all: the +/// sweep only considers credentials already in the eth1 form this operation +/// produces. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +pub struct BLSToExecutionChange { + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub validator_index: ValidatorIndex, + /// The key whose hash the validator's current withdrawal credentials must + /// match, proving whoever submits this message actually controls them. + pub from_bls_pubkey: BlsPubkey, + pub to_execution_address: ExecutionAddress, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +pub struct SignedBLSToExecutionChange { + pub message: BLSToExecutionChange, + pub signature: BlsSignature, +} + +// --------------------------------------------------------------------------- +// Execution payload +// --------------------------------------------------------------------------- + +/// The execution layer's block contents, carried inside [`BeaconBlockBody`]. +/// +/// Bellatrix's payload, with `withdrawals` appended: an execution block can +/// now retire validator balances directly, so the payload has to carry the +/// withdrawals it applies alongside the transactions it applies. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +pub struct ExecutionPayload { + pub parent_hash: ExecutionBlockHash, + pub fee_recipient: ExecutionAddress, + /// The execution layer's own state root, unrelated to the enclosing + /// [`BeaconBlock`]'s `state_root`: the two layers keep separate state and + /// neither commits to the other's. + pub state_root: Bytes32, + pub receipts_root: Bytes32, + #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex::serialize")] + pub logs_bloom: LogsBloom, + /// The randao mix consensus supplied for this slot, which the execution + /// layer must be given so it can be verified against + /// `get_randao_mix(state, get_current_epoch(state))`. + pub prev_randao: Bytes32, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub block_number: u64, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub gas_limit: u64, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub gas_used: u64, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub timestamp: u64, + /// Arbitrary bytes the execution client attaches to the block; consensus + /// never reads them. + #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex::serialize")] + pub extra_data: ExtraData, + pub base_fee_per_gas: Uint256, + pub block_hash: ExecutionBlockHash, + #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex_seq::serialize")] + pub transactions: Transactions, + /// The payouts this block applies, computed deterministically by + /// `get_expected_withdrawals` from the state's sweep cursor rather than + /// chosen by the proposer. + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub withdrawals: Withdrawals, +} + +/// What the state retains of an [`ExecutionPayload`] after processing it: the +/// same fields, but with `transactions` and `withdrawals` replaced by their +/// roots so the state does not have to keep every payload in full forever. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +pub struct ExecutionPayloadHeader { + pub parent_hash: ExecutionBlockHash, + pub fee_recipient: ExecutionAddress, + pub state_root: Bytes32, + pub receipts_root: Bytes32, + #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex::serialize")] + pub logs_bloom: LogsBloom, + pub prev_randao: Bytes32, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub block_number: u64, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub gas_limit: u64, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub gas_used: u64, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub timestamp: u64, + #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex::serialize")] + pub extra_data: ExtraData, + pub base_fee_per_gas: Uint256, + pub block_hash: ExecutionBlockHash, + /// The merkle root of the corresponding [`ExecutionPayload::transactions`]. + pub transactions_root: Root, + /// The merkle root of the corresponding [`ExecutionPayload::withdrawals`]. + pub withdrawals_root: Root, +} + +// --------------------------------------------------------------------------- +// Blocks +// --------------------------------------------------------------------------- + +/// The contents of a block: bellatrix's operations, plus a validator's +/// withdrawal credential switch. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +pub struct BeaconBlockBody { + /// The proposer's contribution to the chain's randomness, which is a + /// signature over the current epoch and so cannot be chosen freely. + pub randao_reveal: BlsSignature, + /// The proposer's vote on the execution chain's deposit state. + pub eth1_data: Eth1Data, + /// Arbitrary proposer-chosen bytes, which consensus never reads. + pub graffiti: Bytes32, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub proposer_slashings: SszList, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub attester_slashings: SszList, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub attestations: SszList, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub deposits: SszList, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub voluntary_exits: SszList, + /// The aggregated sync committee signature over the previous slot's block + /// root, plus which members contributed. + pub sync_aggregate: SyncAggregate, + /// The execution layer block this beacon block wraps. + pub execution_payload: ExecutionPayload, + /// A validator's one-time withdrawal credential switch, capella's new + /// operation type. `process_operations` runs it last, the same position it + /// holds here, so it never affects this same slot's withdrawal sweep: that + /// sweep already ran, against whatever credentials were in effect before + /// this block. + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub bls_to_execution_changes: + SszList, +} + +impl BeaconBlockBody { + /// An empty body: no operations of any kind, and an all-zero execution + /// payload. + /// + /// Not `#[derive(Default)]`: `execution_payload.logs_bloom` is an + /// [`SszVector`](libssz_types::SszVector), and unlike a list, a vector can + /// never validly be empty, so libssz gives it no `Default` impl. Its + /// all-zero value is built explicitly at its exact length instead. + pub fn empty() -> Self { + Self { + randao_reveal: Default::default(), + eth1_data: Default::default(), + graffiti: Default::default(), + proposer_slashings: Default::default(), + attester_slashings: Default::default(), + attestations: Default::default(), + deposits: Default::default(), + voluntary_exits: Default::default(), + sync_aggregate: Default::default(), + execution_payload: ExecutionPayload { + parent_hash: ExecutionBlockHash::ZERO, + fee_recipient: ExecutionAddress::ZERO, + state_root: Bytes32::ZERO, + receipts_root: Bytes32::ZERO, + logs_bloom: LogsBloom::try_from(vec![0u8; preset::BYTES_PER_LOGS_BLOOM]) + .expect("BYTES_PER_LOGS_BLOOM zeros fit LogsBloom's exact length"), + prev_randao: Bytes32::ZERO, + block_number: 0, + gas_limit: 0, + gas_used: 0, + timestamp: 0, + extra_data: Default::default(), + base_fee_per_gas: Uint256::ZERO, + block_hash: ExecutionBlockHash::ZERO, + transactions: Default::default(), + withdrawals: Default::default(), + }, + bls_to_execution_changes: Default::default(), + } + } +} + +/// A block. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +pub struct BeaconBlock { + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub slot: Slot, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub proposer_index: ValidatorIndex, + pub parent_root: Root, + /// The root of the state after this block is applied, which the state + /// transition recomputes and compares. + pub state_root: Root, + pub body: BeaconBlockBody, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +pub struct SignedBeaconBlock { + pub message: BeaconBlock, + pub signature: BlsSignature, +} + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +/// The capella beacon state: 28 fields, in the specification's order. +/// +/// Field order is load-bearing. SSZ encoding and merkleization both follow +/// declaration order, so reordering or omitting a field silently produces a +/// wrong `hash_tree_root`. +/// +/// Fields through `next_sync_committee` are identical to bellatrix's, field for +/// field, including `historical_roots`: it keeps its bellatrix position but is +/// frozen from this fork on, since `historical_summaries` (appended below) is +/// where new history accumulates instead. `latest_execution_payload_header` +/// also keeps its name and position, but changes type: it is this module's own +/// [`ExecutionPayloadHeader`], with `withdrawals_root` appended, not +/// bellatrix's. Capella then appends `next_withdrawal_index`, +/// `next_withdrawal_validator_index`, and `historical_summaries`. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +pub struct BeaconState { + // -- Versioning -- + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub genesis_time: u64, + /// The root of the genesis validator registry, which separates this chain + /// from any other running the same fork schedule. + pub genesis_validators_root: Root, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub slot: Slot, + pub fork: Fork, + + // -- History -- + /// The most recent block's header, with `state_root` left zero until the + /// slot advances, since a block cannot commit to the root of the state + /// containing it. + pub latest_block_header: BeaconBlockHeader, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub block_roots: BlockRoots, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub state_roots: StateRoots, + /// Frozen as of this fork: no longer appended to. See + /// [`Self::historical_summaries`]. + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub historical_roots: HistoricalRoots, + + // -- Eth1 -- + pub eth1_data: Eth1Data, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub eth1_data_votes: Eth1DataVotes, + /// How many deposits from the contract have been processed, which is where + /// the next one will be read from. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub eth1_deposit_index: u64, + + // -- Registry -- + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub validators: Validators, + #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + pub balances: Balances, + + // -- Randomness -- + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub randao_mixes: RandaoMixes, + + // -- Slashings -- + #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + pub slashings: Slashings, + + // -- Participation -- + /// Per-validator participation flags for the previous epoch, positionally + /// parallel to `validators`. + #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + pub previous_epoch_participation: EpochParticipation, + /// Flags for the current epoch, which become `previous_epoch_participation` + /// at the next epoch boundary. + #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + pub current_epoch_participation: EpochParticipation, + + // -- Finality -- + #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex::serialize")] + pub justification_bits: JustificationBits, + pub previous_justified_checkpoint: Checkpoint, + pub current_justified_checkpoint: Checkpoint, + pub finalized_checkpoint: Checkpoint, + + // -- Inactivity -- + /// Per-validator inactivity score, positionally parallel to `validators`. + #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + pub inactivity_scores: InactivityScores, + + // -- Sync committees -- + /// The committee currently signing sync aggregates. + pub current_sync_committee: SyncCommittee, + /// The committee that takes over from `current_sync_committee` at the next + /// sync committee period boundary. + pub next_sync_committee: SyncCommittee, + + // -- Execution -- + /// The most recently processed execution payload, kept as a header rather + /// than in full. + pub latest_execution_payload_header: ExecutionPayloadHeader, + + // -- Withdrawals -- + /// The index the next withdrawal will use. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub next_withdrawal_index: WithdrawalIndex, + /// Where the next withdrawal sweep resumes, wrapping around the validator + /// registry. Advancing a persistent cursor rather than rescanning from + /// index zero every block is what bounds `get_expected_withdrawals`' work + /// regardless of how large the registry grows. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub next_withdrawal_validator_index: ValidatorIndex, + + // -- History (continued) -- + /// Accumulated block/state root commitments, one appended per historical + /// root period, replacing the growth of `historical_roots` as of this + /// fork. See [`super::shared::HistoricalSummary`] for why the two forms + /// are `hash_tree_root`-compatible. + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub historical_summaries: HistoricalSummaries, +} + +#[cfg(test)] +mod tests { + use libssz::{SszDecode as _, SszEncode as _}; + + use super::*; + + #[test] + fn withdrawal_round_trips_through_ssz() { + let withdrawal = Withdrawal { + index: 5, + validator_index: 9, + address: ExecutionAddress::repeat_byte(0xab), + amount: 32_000_000_000, + }; + + let bytes = withdrawal.to_ssz(); + assert_eq!(Withdrawal::from_ssz_bytes(&bytes).unwrap(), withdrawal); + } + + #[test] + fn signed_bls_to_execution_change_round_trips_through_ssz() { + let signed_change = SignedBLSToExecutionChange { + message: BLSToExecutionChange { + validator_index: 3, + from_bls_pubkey: BlsPubkey([1; 48]), + to_execution_address: ExecutionAddress::repeat_byte(0xcd), + }, + signature: BlsSignature([2; 96]), + }; + + let bytes = signed_change.to_ssz(); + assert_eq!( + SignedBLSToExecutionChange::from_ssz_bytes(&bytes).unwrap(), + signed_change + ); + } + + #[test] + fn fixed_and_variable_length_containers() { + // Withdrawal and the BLS-to-execution-change pair hold nothing + // variable-length, unlike the payload, body, block, and state that + // carry lists. + assert!(::is_fixed_size()); + assert!(::is_fixed_size()); + assert!(::is_fixed_size()); + + // Both payload containers carry at least one list (`extra_data`, at a + // minimum), so both begin their encoding with offsets. + assert!(!::is_fixed_size()); + assert!(!::is_fixed_size()); + assert!(!::is_fixed_size()); + assert!(!::is_fixed_size()); + assert!(!::is_fixed_size()); + } + + #[test] + fn block_body_round_trips_while_empty() { + // An empty body is the common case for a skipped-operation slot, and it + // exercises every offset in the encoding with zero-length payloads, + // including the nested offsets inside `execution_payload`. + let body = BeaconBlockBody::empty(); + + let bytes = body.to_ssz(); + assert_eq!(BeaconBlockBody::from_ssz_bytes(&bytes).unwrap(), body); + } +} diff --git a/crates/common/types/src/beacon/containers/deneb.rs b/crates/common/types/src/beacon/containers/deneb.rs new file mode 100644 index 000000000..9425b4223 --- /dev/null +++ b/crates/common/types/src/beacon/containers/deneb.rs @@ -0,0 +1,485 @@ +//! Containers whose shape is specific to deneb. +//! +//! Deneb's headline change is data blobs: temporary storage for rollup data +//! that consensus commits to but never processes itself. A blob is far larger +//! than everything else a block carries, so the specification never puts one +//! in the block. Instead the block commits only to a KZG commitment per blob, +//! appended to [`BeaconBlockBody`] as `blob_kzg_commitments`, and each blob is +//! propagated separately as a [`BlobSidecar`] over its own gossip subnet, +//! carrying enough of the block's header to prove the sidecar's commitment +//! really is the one the block committed to. [`BlobIdentifier`] is the +//! request-side counterpart: naming one blob of one block without shipping +//! the blob, for the request-response protocol that backfills sidecars gossip +//! missed. +//! +//! [`ExecutionPayload`] and [`ExecutionPayloadHeader`] both gain +//! `blob_gas_used` and `excess_blob_gas`, deneb's per-block blob fee-market +//! accounting: EIP-4844's analogue of EIP-1559's base fee, scoped to blob +//! space instead of execution gas. [`BeaconState`] otherwise keeps +//! [`super::capella::BeaconState`]'s shape, field for field: only the +//! container held in `latest_execution_payload_header` changes. +//! +//! [`BlobIdentifier`] and [`BlobSidecar`] are transcribed from +//! `p2p-interface.md` rather than `beacon-chain.md`, since the specification +//! defines the wire-level blob types in the networking document rather than +//! the state transition document. Neither is ever stored in the state or a +//! block body; a block only ever holds the commitments the sidecars are +//! checked against. + +use libssz_derive::{HashTreeRoot, SszDecode, SszEncode}; +use libssz_types::{SszList, SszVector}; + +use super::altair::{SyncAggregate, SyncCommittee}; +use super::bellatrix::{ExtraData, LogsBloom, Transactions}; +use super::capella::{SignedBLSToExecutionChange, Withdrawals}; +use super::phase0::{Attestation, AttesterSlashing}; +use super::shared::{ + Balances, BeaconBlockHeader, BlockRoots, Checkpoint, Deposit, EpochParticipation, Eth1Data, + Eth1DataVotes, Fork, HistoricalRoots, HistoricalSummaries, InactivityScores, JustificationBits, + ProposerSlashing, RandaoMixes, SignedBeaconBlockHeader, SignedVoluntaryExit, Slashings, + StateRoots, Validators, +}; +use crate::beacon::preset; +use crate::beacon::primitives::{ + BlobIndex, BlsSignature, Bytes32, ExecutionAddress, ExecutionBlockHash, KzgCommitment, + KzgProof, Root, Slot, Uint256, ValidatorIndex, WithdrawalIndex, +}; + +// --------------------------------------------------------------------------- +// Collection aliases +// --------------------------------------------------------------------------- + +/// Raw blob bytes, as propagated in a [`BlobSidecar`] rather than in the block +/// itself. +/// +/// Bounded by `BYTES_PER_BLOB`, which is large enough that a derived +/// `Default` would zero that many bytes on every construction for no reason. +/// That is why [`BlobSidecar`], the only container that holds one, does not +/// derive `Default`. +pub type Blob = SszVector; + +/// The KZG commitments a block makes to its blobs, one per blob, carried in +/// [`BeaconBlockBody::blob_kzg_commitments`] instead of the blobs themselves. +pub type KzgCommitments = SszList; + +/// The merkle path proving a [`BlobSidecar`]'s commitment sits at its claimed +/// index in the block body's `blob_kzg_commitments`, which is what lets a +/// sidecar be checked against a block header without holding the rest of +/// that block's body. +pub type BlobKzgCommitmentInclusionProof = + SszVector; + +// --------------------------------------------------------------------------- +// Execution payload +// --------------------------------------------------------------------------- + +/// The execution layer's contribution to a block: [`super::capella::ExecutionPayload`]'s +/// fields, with deneb's blob gas accounting appended. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +pub struct ExecutionPayload { + pub parent_hash: ExecutionBlockHash, + pub fee_recipient: ExecutionAddress, + pub state_root: Bytes32, + pub receipts_root: Bytes32, + #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex::serialize")] + pub logs_bloom: LogsBloom, + pub prev_randao: Bytes32, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub block_number: u64, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub gas_limit: u64, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub gas_used: u64, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub timestamp: u64, + #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex::serialize")] + pub extra_data: ExtraData, + pub base_fee_per_gas: Uint256, + pub block_hash: ExecutionBlockHash, + #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex_seq::serialize")] + pub transactions: Transactions, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub withdrawals: Withdrawals, + /// How much blob gas this block's blob transactions consumed. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub blob_gas_used: u64, + /// The blob gas market's excess entering this block. Plays the same role + /// for blob space that the base fee's excess plays for execution gas: it + /// sets the blob base fee the next block's transactions pay, so blob + /// space is priced by an independent market from execution gas. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub excess_blob_gas: u64, +} + +/// [`ExecutionPayload`] with the bulky fields replaced by their roots, which +/// is what the state retains once a payload is no longer the newest one. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +pub struct ExecutionPayloadHeader { + pub parent_hash: ExecutionBlockHash, + pub fee_recipient: ExecutionAddress, + pub state_root: Bytes32, + pub receipts_root: Bytes32, + #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex::serialize")] + pub logs_bloom: LogsBloom, + pub prev_randao: Bytes32, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub block_number: u64, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub gas_limit: u64, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub gas_used: u64, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub timestamp: u64, + #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex::serialize")] + pub extra_data: ExtraData, + pub base_fee_per_gas: Uint256, + pub block_hash: ExecutionBlockHash, + pub transactions_root: Root, + pub withdrawals_root: Root, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub blob_gas_used: u64, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub excess_blob_gas: u64, +} + +// --------------------------------------------------------------------------- +// Blocks +// --------------------------------------------------------------------------- + +/// The contents of a block: capella's operations, with the blob commitments +/// deneb adds appended. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +pub struct BeaconBlockBody { + /// The proposer's contribution to the chain's randomness, which is a + /// signature over the current epoch and so cannot be chosen freely. + pub randao_reveal: BlsSignature, + /// The proposer's vote on the execution chain's deposit state. + pub eth1_data: Eth1Data, + /// Arbitrary proposer-chosen bytes, which consensus never reads. + pub graffiti: Bytes32, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub proposer_slashings: SszList, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub attester_slashings: SszList, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub attestations: SszList, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub deposits: SszList, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub voluntary_exits: SszList, + /// The aggregated sync committee signature over the previous slot's block + /// root, plus which members contributed. + pub sync_aggregate: SyncAggregate, + pub execution_payload: ExecutionPayload, + /// Capella's withdrawal-credential-change operations, unchanged in deneb: + /// each [`SignedBLSToExecutionChange`] wraps a + /// [`super::capella::BLSToExecutionChange`] with a signature proving its + /// holder controls the credential being changed. + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub bls_to_execution_changes: + SszList, + /// One KZG commitment per blob this block's proposer chose to include. + /// Never the blobs themselves: those are propagated separately as + /// [`BlobSidecar`]s, which is what keeps a block's own size independent + /// of how much blob data it references. + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub blob_kzg_commitments: KzgCommitments, +} + +impl BeaconBlockBody { + /// An empty body: no operations of any kind, and an all-zero execution + /// payload. + /// + /// Not `#[derive(Default)]`: `execution_payload.logs_bloom` is an + /// [`SszVector`](libssz_types::SszVector), and unlike a list, a vector can + /// never validly be empty, so libssz gives it no `Default` impl. Its + /// all-zero value is built explicitly at its exact length instead. + pub fn empty() -> Self { + Self { + randao_reveal: Default::default(), + eth1_data: Default::default(), + graffiti: Default::default(), + proposer_slashings: Default::default(), + attester_slashings: Default::default(), + attestations: Default::default(), + deposits: Default::default(), + voluntary_exits: Default::default(), + sync_aggregate: Default::default(), + execution_payload: ExecutionPayload { + parent_hash: ExecutionBlockHash::ZERO, + fee_recipient: ExecutionAddress::ZERO, + state_root: Bytes32::ZERO, + receipts_root: Bytes32::ZERO, + logs_bloom: LogsBloom::try_from(vec![0u8; preset::BYTES_PER_LOGS_BLOOM]) + .expect("BYTES_PER_LOGS_BLOOM zeros fit LogsBloom's exact length"), + prev_randao: Bytes32::ZERO, + block_number: 0, + gas_limit: 0, + gas_used: 0, + timestamp: 0, + extra_data: Default::default(), + base_fee_per_gas: Uint256::ZERO, + block_hash: ExecutionBlockHash::ZERO, + transactions: Default::default(), + withdrawals: Default::default(), + blob_gas_used: 0, + excess_blob_gas: 0, + }, + bls_to_execution_changes: Default::default(), + blob_kzg_commitments: Default::default(), + } + } +} + +/// A block. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +pub struct BeaconBlock { + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub slot: Slot, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub proposer_index: ValidatorIndex, + pub parent_root: Root, + /// The root of the state after this block is applied, which the state + /// transition recomputes and compares. + pub state_root: Root, + pub body: BeaconBlockBody, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +pub struct SignedBeaconBlock { + pub message: BeaconBlock, + pub signature: BlsSignature, +} + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +/// The deneb beacon state: 28 fields, in the specification's order. +/// +/// Field order is load-bearing. SSZ encoding and merkleization both follow +/// declaration order, so reordering or omitting a field silently produces a +/// wrong `hash_tree_root`. +/// +/// Identical to [`super::capella::BeaconState`], field for field: only the +/// type held in `latest_execution_payload_header` changes, to deneb's +/// [`ExecutionPayloadHeader`]. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +pub struct BeaconState { + // -- Versioning -- + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub genesis_time: u64, + /// The root of the genesis validator registry, which separates this chain + /// from any other running the same fork schedule. + pub genesis_validators_root: Root, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub slot: Slot, + pub fork: Fork, + + // -- History -- + /// The most recent block's header, with `state_root` left zero until the + /// slot advances, since a block cannot commit to the root of the state + /// containing it. + pub latest_block_header: BeaconBlockHeader, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub block_roots: BlockRoots, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub state_roots: StateRoots, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub historical_roots: HistoricalRoots, + + // -- Eth1 -- + pub eth1_data: Eth1Data, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub eth1_data_votes: Eth1DataVotes, + /// How many deposits from the contract have been processed, which is + /// where the next one will be read from. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub eth1_deposit_index: u64, + + // -- Registry -- + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub validators: Validators, + #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + pub balances: Balances, + + // -- Randomness -- + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub randao_mixes: RandaoMixes, + + // -- Slashings -- + #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + pub slashings: Slashings, + + // -- Participation -- + /// Per-validator participation flags for the previous epoch, positionally + /// parallel to `validators`. + #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + pub previous_epoch_participation: EpochParticipation, + /// Flags for the current epoch, which become + /// `previous_epoch_participation` at the next epoch boundary. + #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + pub current_epoch_participation: EpochParticipation, + + // -- Finality -- + #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex::serialize")] + pub justification_bits: JustificationBits, + pub previous_justified_checkpoint: Checkpoint, + pub current_justified_checkpoint: Checkpoint, + pub finalized_checkpoint: Checkpoint, + + // -- Inactivity -- + /// Per-validator inactivity score, positionally parallel to `validators`. + #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + pub inactivity_scores: InactivityScores, + + // -- Sync committees -- + /// The committee currently signing sync aggregates. + pub current_sync_committee: SyncCommittee, + /// The committee that takes over from `current_sync_committee` at the + /// next sync committee period boundary. + pub next_sync_committee: SyncCommittee, + + // -- Execution -- + /// The most recent execution payload's header, replacing the whole + /// payload with its roots once the block containing it is no longer new. + pub latest_execution_payload_header: ExecutionPayloadHeader, + + // -- Withdrawals -- + /// The index the next withdrawal will be assigned, so consecutive + /// withdrawals get consecutive indices even though which validators are + /// due one changes from slot to slot. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub next_withdrawal_index: WithdrawalIndex, + /// Where the validator sweep for withdrawals resumes next slot. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub next_withdrawal_validator_index: ValidatorIndex, + + // -- History -- + /// Capella's replacement for whole [`super::shared::HistoricalBatch`] + /// roots: one summary per historical window, appended the same way. + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub historical_summaries: HistoricalSummaries, +} + +// --------------------------------------------------------------------------- +// Blob sidecars +// --------------------------------------------------------------------------- +// +// Both containers are transcribed from `p2p-interface.md` rather than +// `beacon-chain.md`: the specification defines the wire-level blob types in +// the networking document, and neither is ever stored in the state or a +// block body. + +/// A request for one blob of one block, without shipping the blob itself. +/// +/// The unit the `BlobSidecarsByRoot` request-response protocol asks for: a +/// peer that already knows a block's root and which blob index it is missing +/// names exactly that, rather than the slot-range-based +/// `BlobSidecarsByRange` protocol or a fresh gossip subscription. +#[derive( + Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot, +)] +pub struct BlobIdentifier { + pub block_root: Root, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub index: BlobIndex, +} + +/// One blob, plus everything needed to check it belongs to a specific block +/// without holding the rest of that block's body. +/// +/// `signed_block_header` and `kzg_commitment_inclusion_proof` are what make a +/// sidecar self-verifying: a node that only ever receives this sidecar, never +/// the full [`BeaconBlockBody`], can still check `kzg_commitment` against the +/// block's `body_root` via the merkle proof, and the header itself against +/// the proposer's signature. +/// +/// Does not derive `Default`: `blob` is a [`Blob`], bounded by +/// `BYTES_PER_BLOB`, so a derived default would zero that many bytes for no +/// reason every time a placeholder value is needed. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +pub struct BlobSidecar { + /// This blob's position among the block's `blob_kzg_commitments`, which + /// is also its leaf index for `kzg_commitment_inclusion_proof`. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub index: BlobIndex, + #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex::serialize")] + pub blob: Blob, + pub kzg_commitment: KzgCommitment, + /// A KZG proof that `blob` evaluates to `kzg_commitment`, checkable + /// without recomputing the commitment from the whole blob. + pub kzg_proof: KzgProof, + /// The header of the block this blob belongs to, signed by its proposer, + /// which is what `kzg_commitment_inclusion_proof` terminates at. + pub signed_block_header: SignedBeaconBlockHeader, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub kzg_commitment_inclusion_proof: BlobKzgCommitmentInclusionProof, +} + +#[cfg(test)] +mod tests { + use libssz::{SszDecode as _, SszEncode as _}; + + use super::*; + + #[test] + fn blob_identifier_round_trips_through_ssz() { + let identifier = BlobIdentifier { + block_root: Root::repeat_byte(9), + index: 3, + }; + + let bytes = identifier.to_ssz(); + assert_eq!(BlobIdentifier::from_ssz_bytes(&bytes).unwrap(), identifier); + } + + #[test] + fn blob_identifier_is_fixed_size() { + // A root and an index, with nothing variable-length, so the encoding + // has one length for every value. + assert!(::is_fixed_size()); + assert_eq!(::fixed_size(), 32 + 8); + } + + #[test] + fn kzg_commitments_list_round_trips_through_ssz() { + // Exercises the collection alias directly, without needing a whole + // block body around it. + let commitments: KzgCommitments = vec![KzgCommitment([1; 48]), KzgCommitment([2; 48])] + .try_into() + .unwrap(); + + let bytes = commitments.to_ssz(); + assert_eq!( + KzgCommitments::from_ssz_bytes(&bytes).unwrap().into_inner(), + commitments.into_inner() + ); + } + + #[test] + fn blob_sidecar_is_fixed_size_despite_carrying_a_blob() { + // Every field, including `blob` and the inclusion proof, is a fixed- + // length vector of fixed-size elements, so the whole sidecar encodes + // with no offsets. Checked at the type level rather than by building + // an instance, since constructing a full `Blob` (bounded by + // `BYTES_PER_BLOB`) is unnecessary work for a fact the types already + // guarantee. + assert!(::is_fixed_size()); + assert!(::is_fixed_size()); + assert!(::is_fixed_size()); + } + + #[test] + fn variable_length_containers_carry_offsets() { + // Each of these holds at least one list, so its encoding begins with + // offsets rather than being a fixed layout. `ExecutionPayloadHeader` + // is variable-size too, despite looking header-shaped: `extra_data` + // is a byte list, not a vector. + assert!(!::is_fixed_size()); + assert!(!::is_fixed_size()); + assert!(!::is_fixed_size()); + assert!(!::is_fixed_size()); + assert!(!::is_fixed_size()); + } +} diff --git a/crates/common/types/src/beacon/containers/electra.rs b/crates/common/types/src/beacon/containers/electra.rs new file mode 100644 index 000000000..af6a3eb90 --- /dev/null +++ b/crates/common/types/src/beacon/containers/electra.rs @@ -0,0 +1,767 @@ +//! Containers whose shape is specific to electra. +//! +//! Electra bundles several EIPs, and each explains a cluster of the containers +//! below. +//! +//! Through deneb, an attestation names one committee +//! (`AttestationData.index`) and its aggregation bitfield is one bit per +//! member of that single committee. EIP-7549 moves the committee index out of +//! `AttestationData` and into a new [`Attestation::committee_bits`] field +//! naming every committee the attestation covers, so [`AggregationBits`] now +//! has to be wide enough to hold a bit for every attester across every +//! committee in a slot rather than one committee's worth. That reshaping is +//! also why [`preset::MAX_ATTESTATIONS_ELECTRA`] is far smaller than phase0's +//! `MAX_ATTESTATIONS`: one electra attestation now does the work of a whole +//! slot's committees, so a block needs far fewer of them to cover the same +//! validator set. Pre-electra, an unaggregated gossip vote reused +//! `Attestation` itself with one bit set; that no longer works once the +//! bitfield spans the whole slot, which is why [`SingleAttestation`] exists as +//! a separate, explicitly-indexed container. +//! +//! EIP-7251 lets a validator's effective balance grow past +//! `MAX_EFFECTIVE_BALANCE` (given a compounding withdrawal credential), which +//! turns deposits, exits, and consolidations from operations bounded by a +//! count of validators into operations that have to be bounded by balance +//! instead: one very large validator's deposit, exit, or consolidation could +//! otherwise move as much stake in a single slot as thousands of ordinary +//! ones. [`PendingDeposit`], [`PendingPartialWithdrawal`], and +//! [`PendingConsolidation`] are the state's queues for exactly that, each +//! drained a bounded amount per epoch or slot rather than applied the instant +//! they are known about. +//! +//! EIP-6110 and EIP-7002 let the execution layer request a deposit or +//! withdrawal directly, instead of consensus replaying the deposit contract's +//! event log or a validator signing a voluntary exit; EIP-7251 extends the +//! same mechanism to consolidations. [`ExecutionRequests`] is the per-block +//! envelope the execution payload carries all three request kinds in. +//! +//! [`BeaconState`]'s field count crosses a power of two at electra, so its +//! merkle tree gains a level relative to deneb's; see `docs/beacon_stf.md` +//! for the generalized-index table this implies. + +use libssz_derive::{HashTreeRoot, SszDecode, SszEncode}; +use libssz_types::{SszBitlist, SszBitvector, SszList}; + +use super::altair::{SyncAggregate, SyncCommittee}; +use super::bellatrix::LogsBloom; +use super::capella::SignedBLSToExecutionChange; +use super::deneb::{ExecutionPayload, ExecutionPayloadHeader, KzgCommitments}; +use super::shared::{ + AttestationData, Balances, BeaconBlockHeader, BlockRoots, Checkpoint, Deposit, + EpochParticipation, Eth1Data, Eth1DataVotes, Fork, HistoricalRoots, HistoricalSummaries, + InactivityScores, JustificationBits, ProposerSlashing, RandaoMixes, SignedVoluntaryExit, + Slashings, StateRoots, Validators, +}; +use crate::beacon::preset; +use crate::beacon::primitives::{ + BlsPubkey, BlsSignature, Bytes32, CommitteeIndex, Epoch, ExecutionAddress, ExecutionBlockHash, + Gwei, Root, Slot, Uint256, ValidatorIndex, WithdrawalIndex, +}; + +// --------------------------------------------------------------------------- +// Collection aliases +// --------------------------------------------------------------------------- + +/// The attesters covered by one aggregate attestation: one bit per member of +/// every committee named in [`CommitteeBits`], rather than one committee's +/// worth as in every fork before electra. +/// +/// Bounded by `MAX_VALIDATORS_PER_SLOT`, the product of `MAX_COMMITTEES_PER_SLOT` +/// and `MAX_VALIDATORS_PER_COMMITTEE`, since one attestation can now span +/// every committee in the slot. +pub type AggregationBits = SszBitlist<{ preset::MAX_VALIDATORS_PER_SLOT }>; + +/// Which of a slot's committees an [`Attestation`] covers, one bit per +/// committee index. +/// +/// `get_committee_indices` reads this to recover the ordered list of +/// committees, and [`AggregationBits`] is the concatenation of each named +/// committee's member bits in that same ascending order, so `committee_bits` +/// is what tells a reader where one committee's segment ends and the next +/// begins. +pub type CommitteeBits = SszBitvector<{ preset::MAX_COMMITTEES_PER_SLOT }>; + +/// The attesters covered by one aggregate, named explicitly rather than as a +/// bitfield, which is the form signature verification needs. +/// +/// Bounded the same way as [`AggregationBits`], for the same reason: one +/// [`IndexedAttestation`] can now name attesters from every committee in a +/// slot. +pub type AttestingIndices = SszList; + +/// Attestations included in a block. +/// +/// Bounded by `MAX_ATTESTATIONS_ELECTRA`, far smaller than phase0's +/// `MAX_ATTESTATIONS`: one electra attestation now covers a whole slot's +/// committees, so a block needs far fewer of them to cover the same +/// validator set. +pub type Attestations = SszList; + +/// Evidence of conflicting attestations included in a block. +/// +/// Bounded by `MAX_ATTESTER_SLASHINGS_ELECTRA`, tightened from phase0's +/// `MAX_ATTESTER_SLASHINGS`: one electra [`IndexedAttestation`] can now name +/// every attester in a slot, so a single slashing's evidence is +/// proportionally larger to include. +pub type AttesterSlashings = SszList; + +/// Deposits queued in the state, not yet credited to the validator registry. +pub type PendingDeposits = SszList; + +/// Partial withdrawals queued in the state, not yet paid out. +pub type PendingPartialWithdrawals = + SszList; + +/// Validator consolidations queued in the state, not yet applied. +pub type PendingConsolidations = + SszList; + +// --------------------------------------------------------------------------- +// Attestations +// --------------------------------------------------------------------------- + +/// An aggregate attestation, as gossiped and as included in a block. +/// +/// EIP-7549 moves the committee index out of `AttestationData` and into +/// [`Attestation::committee_bits`], so one attestation can now name every +/// committee in a slot rather than just one. From electra on, `data.index` is +/// required to be zero: `committee_bits` is the only source of which +/// committees an attestation covers, and `data` is otherwise shared unchanged +/// with every earlier fork. +#[derive( + Debug, + Clone, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + SszEncode, + SszDecode, + HashTreeRoot, +)] +pub struct Attestation { + #[serde(with = "crate::beacon::serde_helpers::ssz_hex")] + pub aggregation_bits: AggregationBits, + pub data: AttestationData, + /// The aggregate signature of every attester set across every committee + /// named in `committee_bits`, over `data`. + pub signature: BlsSignature, + /// Which committees `aggregation_bits` covers. [`AggregationBits`] is the + /// concatenation of each named committee's member bits, in ascending + /// committee-index order. + #[serde(with = "crate::beacon::serde_helpers::ssz_hex")] + pub committee_bits: CommitteeBits, +} + +/// An attestation with its attesters named rather than bit-encoded. +/// +/// Signature verification needs the public keys, which needs the indices, so +/// the state transition converts an [`Attestation`] into this form before +/// checking it. `attesting_indices` now spans every committee an +/// [`Attestation`] covers, following the same widening as [`AggregationBits`]. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +pub struct IndexedAttestation { + /// The attesters, which the specification requires to be sorted and + /// unique. + #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + pub attesting_indices: AttestingIndices, + pub data: AttestationData, + pub signature: BlsSignature, +} + +/// Evidence that a set of validators made two conflicting attestations. +/// +/// The container's own shape is unchanged from phase0; what changed is +/// [`IndexedAttestation`] itself, which now names attesters from every +/// committee in a slot instead of one. That is also why this is bounded by +/// `MAX_ATTESTER_SLASHINGS_ELECTRA` rather than phase0's larger +/// `MAX_ATTESTER_SLASHINGS`: evidence spanning a whole slot is proportionally +/// more expensive to include. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +pub struct AttesterSlashing { + pub attestation_1: IndexedAttestation, + pub attestation_2: IndexedAttestation, +} + +/// One attester's unaggregated vote, gossiped on a per-committee attestation +/// subnet before an aggregator folds it into an [`Attestation`]. +/// +/// Pre-electra, an unaggregated vote reused [`Attestation`] itself with +/// exactly one bit set in `aggregation_bits`, since that bitfield was already +/// scoped to a single committee. From electra on, `aggregation_bits` spans +/// every committee in the slot, so a lone attester's bit position no longer +/// says which committee it belongs to on its own. `SingleAttestation` carries +/// `committee_index` and `attester_index` explicitly instead, and an +/// aggregator combines every `SingleAttestation` sharing the same `data` into +/// one widened [`Attestation`]. +#[derive( + Debug, + Clone, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + SszEncode, + SszDecode, + HashTreeRoot, +)] +pub struct SingleAttestation { + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub committee_index: CommitteeIndex, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub attester_index: ValidatorIndex, + pub data: AttestationData, + pub signature: BlsSignature, +} + +// --------------------------------------------------------------------------- +// Execution layer triggered requests +// --------------------------------------------------------------------------- +// +// EIP-6110, EIP-7002, and EIP-7251 let the execution layer request a deposit, +// withdrawal, or consolidation directly, without consensus waiting to replay +// the deposit contract's event log or a validator signing a voluntary exit. +// `ExecutionRequests` is the per-block envelope the execution payload carries +// these three request kinds in. + +/// An execution-layer-triggered deposit (EIP-6110). +/// +/// Carries the same fields a `Deposit`'s underlying data does, plus `index`: +/// unlike a contract-log deposit, a request arrives already ordered by the +/// execution layer, so there is no merkle proof to check, only a position to +/// record. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +pub struct DepositRequest { + pub pubkey: BlsPubkey, + pub withdrawal_credentials: Bytes32, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub amount: Gwei, + pub signature: BlsSignature, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub index: u64, +} + +/// An execution-layer-triggered exit or partial withdrawal (EIP-7002). +/// +/// `source_address` is the execution-layer account that requested it, which +/// `process_withdrawal_request` checks against the validator's withdrawal +/// credentials before honoring the request. An `amount` of +/// `FULL_EXIT_REQUEST_AMOUNT` signals a full exit rather than a partial +/// withdrawal of that amount. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +pub struct WithdrawalRequest { + pub source_address: ExecutionAddress, + pub validator_pubkey: BlsPubkey, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub amount: Gwei, +} + +/// An execution-layer-triggered validator consolidation (EIP-7251): a request +/// to merge `source_pubkey`'s balance into `target_pubkey`'s and exit the +/// source, which is how a validator raises its effective balance past +/// `MAX_EFFECTIVE_BALANCE` without a fresh deposit. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +pub struct ConsolidationRequest { + pub source_address: ExecutionAddress, + pub source_pubkey: BlsPubkey, + pub target_pubkey: BlsPubkey, +} + +/// The execution payload's envelope for every execution-layer-triggered +/// request in this block, grouped by kind. +#[derive( + Debug, Clone, Default, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot, +)] +pub struct ExecutionRequests { + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub deposits: SszList, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub withdrawals: SszList, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub consolidations: + SszList, +} + +// --------------------------------------------------------------------------- +// Pending balance queues +// --------------------------------------------------------------------------- +// +// EIP-7251 lets a validator's effective balance grow past +// `MAX_EFFECTIVE_BALANCE`, so the churn that used to be bounded by a count of +// validators now has to be bounded by balance instead. These three +// containers are the state's queues for deposits, partial withdrawals, and +// consolidations that are known about but not yet applied, each drained a +// bounded amount per epoch or slot so no single operation outruns the churn +// limit. + +/// A deposit recorded in the state, not yet credited to the validator +/// registry. +/// +/// Carries the same fields a `Deposit`'s data does, plus `slot`: unlike a +/// `Deposit`, a pending deposit did not arrive with a merkle proof against the +/// deposit contract, so the state has to remember when it was queued instead. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +pub struct PendingDeposit { + pub pubkey: BlsPubkey, + pub withdrawal_credentials: Bytes32, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub amount: Gwei, + pub signature: BlsSignature, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub slot: Slot, +} + +/// A partial withdrawal recorded in the state, not yet paid out. +/// +/// Queued rather than applied immediately so +/// `MAX_PENDING_PARTIALS_PER_WITHDRAWALS_SWEEP` can bound how many of these +/// `get_expected_withdrawals` drains in one slot. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +pub struct PendingPartialWithdrawal { + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub validator_index: ValidatorIndex, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub amount: Gwei, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub withdrawable_epoch: Epoch, +} + +/// A validator consolidation recorded in the state, not yet applied. +/// +/// `source_index` exits once `target_index` absorbs its balance, which is why +/// only the two indices need to be kept: everything else about the merge +/// follows from the validators' own records at the epoch it is processed. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +pub struct PendingConsolidation { + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub source_index: ValidatorIndex, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub target_index: ValidatorIndex, +} + +// --------------------------------------------------------------------------- +// Blocks +// --------------------------------------------------------------------------- + +/// The contents of a block: deneb's operations, plus the execution-layer- +/// triggered requests this block's payload carries. +/// +/// Unchanged from deneb except for three things: `attester_slashings` and +/// `attestations` are now bounded (and, for attestations, shaped) +/// differently, and `execution_requests` is appended at the end. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +pub struct BeaconBlockBody { + /// The proposer's contribution to the chain's randomness, which is a + /// signature over the current epoch and so cannot be chosen freely. + pub randao_reveal: BlsSignature, + /// The proposer's vote on the execution chain's deposit state. + pub eth1_data: Eth1Data, + /// Arbitrary proposer-chosen bytes, which consensus never reads. + pub graffiti: Bytes32, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub proposer_slashings: SszList, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub attester_slashings: AttesterSlashings, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub attestations: Attestations, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub deposits: SszList, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub voluntary_exits: SszList, + /// The aggregated sync committee signature over the previous slot's block + /// root, plus which members contributed. + pub sync_aggregate: SyncAggregate, + pub execution_payload: ExecutionPayload, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub bls_to_execution_changes: + SszList, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub blob_kzg_commitments: KzgCommitments, + /// The execution-layer-triggered deposit, withdrawal, and consolidation + /// requests carried by this block's payload. The one field electra adds + /// to the body. + pub execution_requests: ExecutionRequests, +} + +impl BeaconBlockBody { + /// An empty body: no operations of any kind, and an all-zero execution + /// payload. Also what fulu's identically-shaped body uses, since + /// [`super::SignedBeaconBlock::Fulu`] wraps this same struct. + /// + /// Not `#[derive(Default)]`: `execution_payload.logs_bloom` is an + /// [`SszVector`](libssz_types::SszVector), and unlike a list, a vector can + /// never validly be empty, so libssz gives it no `Default` impl. Its + /// all-zero value is built explicitly at its exact length instead. + pub fn empty() -> Self { + Self { + randao_reveal: Default::default(), + eth1_data: Default::default(), + graffiti: Default::default(), + proposer_slashings: Default::default(), + attester_slashings: Default::default(), + attestations: Default::default(), + deposits: Default::default(), + voluntary_exits: Default::default(), + sync_aggregate: Default::default(), + execution_payload: ExecutionPayload { + parent_hash: ExecutionBlockHash::ZERO, + fee_recipient: ExecutionAddress::ZERO, + state_root: Bytes32::ZERO, + receipts_root: Bytes32::ZERO, + logs_bloom: LogsBloom::try_from(vec![0u8; preset::BYTES_PER_LOGS_BLOOM]) + .expect("BYTES_PER_LOGS_BLOOM zeros fit LogsBloom's exact length"), + prev_randao: Bytes32::ZERO, + block_number: 0, + gas_limit: 0, + gas_used: 0, + timestamp: 0, + extra_data: Default::default(), + base_fee_per_gas: Uint256::ZERO, + block_hash: ExecutionBlockHash::ZERO, + transactions: Default::default(), + withdrawals: Default::default(), + blob_gas_used: 0, + excess_blob_gas: 0, + }, + bls_to_execution_changes: Default::default(), + blob_kzg_commitments: Default::default(), + execution_requests: Default::default(), + } + } +} + +/// A block. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +pub struct BeaconBlock { + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub slot: Slot, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub proposer_index: ValidatorIndex, + pub parent_root: Root, + /// The root of the state after this block is applied, which the state + /// transition recomputes and compares. + pub state_root: Root, + pub body: BeaconBlockBody, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +pub struct SignedBeaconBlock { + pub message: BeaconBlock, + pub signature: BlsSignature, +} + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +/// The electra beacon state: 37 fields, in the specification's order. +/// +/// Field order is load-bearing. SSZ encoding and merkleization both follow +/// declaration order, so reordering or omitting a field silently produces a +/// wrong `hash_tree_root`. +/// +/// Fields through `historical_summaries` are identical to deneb's, field for +/// field (28 of them). Electra appends nine more, serving two EIPs: +/// `deposit_requests_start_index` (EIP-6110) is where the state switches from +/// crediting deposits off `Eth1Data` votes to crediting them off +/// execution-layer `DepositRequest`s, and the remaining eight (EIP-7251) are +/// the balance-churn accounting and the three pending queues that let a +/// validator's effective balance grow past `MAX_EFFECTIVE_BALANCE` without +/// letting a single large validator's deposit, exit, or consolidation move +/// more stake in one slot than the churn limit allows. +/// +/// Crossing 37 fields also crosses a power of two, so this state's merkle +/// tree gains a level relative to deneb's; see `docs/beacon_stf.md` for the +/// generalized-index table this implies. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +pub struct BeaconState { + // -- Versioning -- + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub genesis_time: u64, + /// The root of the genesis validator registry, which separates this chain + /// from any other running the same fork schedule. + pub genesis_validators_root: Root, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub slot: Slot, + pub fork: Fork, + + // -- History -- + /// The most recent block's header, with `state_root` left zero until the + /// slot advances, since a block cannot commit to the root of the state + /// containing it. + pub latest_block_header: BeaconBlockHeader, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub block_roots: BlockRoots, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub state_roots: StateRoots, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub historical_roots: HistoricalRoots, + + // -- Eth1 -- + pub eth1_data: Eth1Data, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub eth1_data_votes: Eth1DataVotes, + /// How many deposits from the contract have been processed, which is + /// where the next one will be read from. Superseded for new deposits once + /// `deposit_requests_start_index` is set, but kept for deposits still in + /// flight from before that point. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub eth1_deposit_index: u64, + + // -- Registry -- + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub validators: Validators, + #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + pub balances: Balances, + + // -- Randomness -- + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub randao_mixes: RandaoMixes, + + // -- Slashings -- + #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + pub slashings: Slashings, + + // -- Participation -- + #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + pub previous_epoch_participation: EpochParticipation, + #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + pub current_epoch_participation: EpochParticipation, + + // -- Finality -- + #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex::serialize")] + pub justification_bits: JustificationBits, + pub previous_justified_checkpoint: Checkpoint, + pub current_justified_checkpoint: Checkpoint, + pub finalized_checkpoint: Checkpoint, + + // -- Inactivity -- + #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + pub inactivity_scores: InactivityScores, + + // -- Sync committees -- + /// The committee currently signing sync aggregates. + pub current_sync_committee: SyncCommittee, + /// The committee that takes over from `current_sync_committee` at the + /// next sync committee period boundary. + pub next_sync_committee: SyncCommittee, + + // -- Execution -- + pub latest_execution_payload_header: ExecutionPayloadHeader, + + // -- Withdrawals -- + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub next_withdrawal_index: WithdrawalIndex, + /// The next validator index `get_expected_withdrawals`'s registry sweep + /// resumes from, so the sweep makes bounded progress across the whole + /// registry over many slots instead of restarting from zero each time. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub next_withdrawal_validator_index: ValidatorIndex, + + // -- Deep history -- + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub historical_summaries: HistoricalSummaries, + + // -- Deposit requests (EIP-6110) -- + /// The execution-layer deposit request index at which the state switched + /// from crediting deposits off `Eth1Data` votes to crediting them off + /// `DepositRequest`s directly. Holds `UNSET_DEPOSIT_REQUESTS_START_INDEX` + /// until the first `DepositRequest` is seen. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub deposit_requests_start_index: u64, + + // -- Balance churn (EIP-7251) -- + /// How much of this epoch's deposit balance churn limit remains unused, + /// so a deposit that would exceed it is queued in `pending_deposits` + /// instead of activating immediately. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub deposit_balance_to_consume: Gwei, + /// How much of this epoch's exit balance churn limit remains unused, the + /// exit-side counterpart of `deposit_balance_to_consume`. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub exit_balance_to_consume: Gwei, + /// The earliest epoch an exit initiated now could take effect, advanced + /// by `compute_exit_epoch_and_update_churn` as exits consume the churn + /// limit faster than it refills. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub earliest_exit_epoch: Epoch, + /// How much of this epoch's consolidation churn limit remains unused. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub consolidation_balance_to_consume: Gwei, + /// The earliest epoch a consolidation initiated now could take effect, + /// the consolidation-side counterpart of `earliest_exit_epoch`. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub earliest_consolidation_epoch: Epoch, + + // -- Pending queues (EIP-7251) -- + /// Deposits known but not yet credited to the validator registry, + /// drained a bounded amount per epoch by `process_pending_deposits`. + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub pending_deposits: PendingDeposits, + /// Partial withdrawals known but not yet paid out, drained a bounded + /// amount per slot by `get_expected_withdrawals`. + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub pending_partial_withdrawals: PendingPartialWithdrawals, + /// Consolidations known but not yet applied, drained a bounded amount per + /// epoch by `process_pending_consolidations`. + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub pending_consolidations: PendingConsolidations, +} + +// --------------------------------------------------------------------------- +// Validator-side containers +// --------------------------------------------------------------------------- + +/// An aggregate together with proof that its aggregator was selected to +/// produce it. +/// +/// Unchanged in shape from phase0: `aggregate` simply carries electra's wider +/// [`Attestation`] now. +#[derive( + Debug, + Clone, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + SszEncode, + SszDecode, + HashTreeRoot, +)] +pub struct AggregateAndProof { + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub aggregator_index: ValidatorIndex, + pub aggregate: Attestation, + /// The aggregator's signature over the slot, which is what makes + /// selection verifiable rather than self-declared. + pub selection_proof: BlsSignature, +} + +#[derive( + Debug, + Clone, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + SszEncode, + SszDecode, + HashTreeRoot, +)] +pub struct SignedAggregateAndProof { + pub message: AggregateAndProof, + pub signature: BlsSignature, +} + +#[cfg(test)] +mod tests { + use libssz::{SszDecode as _, SszEncode as _}; + + use super::*; + + #[test] + fn attestation_aggregation_bits_span_every_named_committee() { + // Two committees named in committee_bits (indices 0 and 2), each + // contributing a slice of aggregation_bits: this is the electra-only + // shape no earlier fork has, since every earlier fork's + // aggregation_bits covers exactly one committee. + let mut committee_bits = CommitteeBits::default(); + committee_bits.set(0, true).unwrap(); + committee_bits.set(2, true).unwrap(); + + let mut aggregation_bits = AggregationBits::with_length(6).unwrap(); + aggregation_bits.set(0, true).unwrap(); + aggregation_bits.set(4, true).unwrap(); + + let attestation = Attestation { + aggregation_bits, + data: AttestationData::default(), + signature: BlsSignature::default(), + committee_bits, + }; + + let bytes = attestation.to_ssz(); + assert_eq!(Attestation::from_ssz_bytes(&bytes).unwrap(), attestation); + } + + #[test] + fn pending_deposit_round_trips_through_ssz() { + let deposit = PendingDeposit { + pubkey: BlsPubkey([9; 48]), + withdrawal_credentials: Bytes32::repeat_byte(1), + amount: 32_000_000_000, + signature: BlsSignature::default(), + slot: 100, + }; + + let bytes = deposit.to_ssz(); + assert_eq!(PendingDeposit::from_ssz_bytes(&bytes).unwrap(), deposit); + } + + #[test] + fn single_attestation_reads_the_beacon_api_json_shape() { + // What a validator client POSTs to `/eth/v2/beacon/pool/attestations`: + // every integer quoted, every byte string 0x-hex. + let json = serde_json::json!({ + "committee_index": "3", + "attester_index": "77", + "data": { + "slot": "65", + "index": "0", + "beacon_block_root": format!("0x{}", "11".repeat(32)), + "source": { "epoch": "1", "root": format!("0x{}", "22".repeat(32)) }, + "target": { "epoch": "2", "root": format!("0x{}", "33".repeat(32)) }, + }, + "signature": format!("0x{}", "44".repeat(96)), + }); + + let attestation: SingleAttestation = serde_json::from_value(json.clone()).unwrap(); + + assert_eq!(attestation.committee_index, 3); + assert_eq!(attestation.attester_index, 77); + assert_eq!(attestation.data.slot, 65); + assert_eq!(attestation.data.target.epoch, 2); + assert_eq!(attestation.data.target.root, Root::repeat_byte(0x33)); + assert_eq!(attestation.signature, BlsSignature([0x44; 96])); + // And it serializes back to the same document. + assert_eq!(serde_json::to_value(&attestation).unwrap(), json); + } + + #[test] + fn single_attestation_rejects_a_short_signature() { + let json = serde_json::json!({ + "committee_index": "0", + "attester_index": "0", + "data": AttestationData::default(), + "signature": format!("0x{}", "44".repeat(95)), + }); + assert!(serde_json::from_value::(json).is_err()); + } + + #[test] + fn new_fixed_size_containers_have_no_offsets() { + // Every field of each is itself fixed-length, so none of these carry + // SSZ offsets, unlike Attestation, IndexedAttestation, and + // ExecutionRequests, which each hold at least one list or bitlist. + assert!(::is_fixed_size()); + assert!(::is_fixed_size()); + assert!(::is_fixed_size()); + assert!(::is_fixed_size()); + assert!(::is_fixed_size()); + assert!(::is_fixed_size()); + assert!(::is_fixed_size()); + } + + #[test] + fn variable_length_containers_carry_offsets() { + assert!(!::is_fixed_size()); + assert!(!::is_fixed_size()); + assert!(!::is_fixed_size()); + assert!(!::is_fixed_size()); + assert!(!::is_fixed_size()); + assert!(!::is_fixed_size()); + assert!(!::is_fixed_size()); + assert!(!::is_fixed_size()); + assert!(!::is_fixed_size()); + } +} diff --git a/crates/common/types/src/beacon/containers/fulu.rs b/crates/common/types/src/beacon/containers/fulu.rs new file mode 100644 index 000000000..73b5b8b10 --- /dev/null +++ b/crates/common/types/src/beacon/containers/fulu.rs @@ -0,0 +1,397 @@ +//! Containers whose shape is specific to fulu. +//! +//! Fulu's headline change is how blob data reaches the network. Through +//! electra, verifying a block's blobs means downloading every one of them in +//! full, which stops scaling as the per-block blob count grows. Fulu instead +//! erasure-codes each blob into a wide row of an extended data matrix and +//! slices the matrix into columns, so a node can sample a handful of columns +//! and gain the same statistical confidence that all the data behind them is +//! available, without ever holding the whole matrix itself. [`DataColumnSidecar`] +//! is what a node gossips and serves per column; [`MatrixEntry`] is the +//! row-and-column-addressed cell the matrix is built from, the form +//! `compute_matrix` and `recover_matrix` (`das-core.md`) operate on rather than +//! the per-column grouping a sidecar presents; [`DataColumnsByRootIdentifier`] +//! is how a request-response peer asks for specific columns of a specific +//! block. `BeaconBlockBody`, `BeaconBlock`, `SignedBeaconBlock`, +//! `ExecutionPayload`, and `ExecutionPayloadHeader` are unchanged from electra: +//! a block still commits to the same `blob_kzg_commitments` it always has, +//! since sampling changes how the data behind those commitments travels over +//! the network, not what the block itself contains. This module defines none +//! of those five; state transition code should import them from +//! [`super::electra`] and [`super::deneb`] instead. +//! +//! The other change is [`BeaconState::proposer_lookahead`]. Every fork through +//! electra computes each slot's proposer on demand from the active set and the +//! shuffling seed, so anything that needs to know a proposer ahead of time has +//! to redo that computation itself. Fulu instead has the state precompute a +//! window of upcoming proposers at each epoch boundary, so +//! `get_beacon_proposer_index` becomes a lookup into that window rather than a +//! shuffle. + +use libssz_derive::{HashTreeRoot, SszDecode, SszEncode}; +use libssz_types::{SszList, SszVector}; + +use super::altair::SyncCommittee; +use super::deneb::{ExecutionPayloadHeader, KzgCommitments}; +use super::electra::{PendingConsolidations, PendingDeposits, PendingPartialWithdrawals}; +use super::shared::{ + Balances, BlockRoots, EpochParticipation, Eth1DataVotes, HistoricalRoots, HistoricalSummaries, + InactivityScores, JustificationBits, RandaoMixes, Slashings, StateRoots, Validators, +}; +use super::shared::{BeaconBlockHeader, Checkpoint, Eth1Data, Fork, SignedBeaconBlockHeader}; +use crate::beacon::preset; +use crate::beacon::primitives::{ + Bytes32, ColumnIndex, Epoch, Gwei, KzgProof, Root, Slot, ValidatorIndex, WithdrawalIndex, +}; + +// --------------------------------------------------------------------------- +// Collection aliases +// --------------------------------------------------------------------------- + +/// The window of upcoming proposer indices `BeaconState::proposer_lookahead` +/// precomputes: the rest of the current epoch plus `MIN_SEED_LOOKAHEAD` full +/// epochs beyond it. +pub type ProposerLookahead = SszVector; + +/// One cell of the extended data matrix: a fixed-size slice of a blob's +/// Reed-Solomon extension, small enough that a node can fetch and verify one +/// without fetching the blob it came from. +pub type Cell = SszVector; + +/// One data column: the same-indexed cell from every blob in a block, which is +/// what a [`DataColumnSidecar`] actually carries. +/// +/// Bounded by `MAX_BLOB_COMMITMENTS_PER_BLOCK` rather than a column-specific +/// constant, since a column holds exactly one cell per blob in the block, and +/// a block can hold at most that many blobs. +pub type DataColumn = SszList; + +/// The KZG proofs accompanying a [`DataColumn`], one per cell in the same +/// order, so `verify_data_column_sidecar_kzg_proofs` can batch-verify the +/// column against `DataColumnSidecar::kzg_commitments` without a separate +/// lookup. +pub type KzgProofs = SszList; + +/// The merkle path proving a [`DataColumnSidecar`]'s `kzg_commitments` sit at +/// their claimed position in the block body. +/// +/// Shallower than deneb's per-commitment inclusion proof, because this one +/// proves the root of the whole `blob_kzg_commitments` list at once rather +/// than one leaf: every sidecar of the same block shares the same commitment +/// list, so proving the list once and repeating it in each sidecar is cheaper +/// than a per-commitment proof would be. +pub type KzgCommitmentsInclusionProof = + SszVector; + +/// The column indices a [`DataColumnsByRootIdentifier`] request asks for. +pub type ColumnIndices = SszList; + +/// A row identifier in the extended data matrix, naming which blob in the +/// block a [`MatrixEntry`] belongs to. +/// +/// `das-core.md` lists this as a custom type alongside +/// [`crate::beacon::primitives::ColumnIndex`], but only [`MatrixEntry`] needs it, so it +/// is defined here instead of in `crate::beacon::primitives`. +pub type RowIndex = u64; + +// --------------------------------------------------------------------------- +// Data availability sampling +// --------------------------------------------------------------------------- + +/// One column's worth of one block's blob data, as gossiped and served over +/// request-response. +/// +/// Self-verifying without a separate fetch of the block: `column`, +/// `kzg_commitments`, and `kzg_proofs` are enough to check the cells against +/// the commitments directly, and `kzg_commitments_inclusion_proof` is enough +/// to check that those commitments are the ones the block actually committed +/// to, via `signed_block_header`. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +pub struct DataColumnSidecar { + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub index: ColumnIndex, + /// A [`DataColumn`] is a `SszList`, and `Cell` is itself a + /// foreign `SszVector` with no `Serialize` of its own, so this + /// needs the per-element hex adapter rather than [`seq`](crate::beacon::serde_helpers::seq) + /// (which requires each element to already implement `Serialize`) or + /// [`ssz_hex`](crate::beacon::serde_helpers::ssz_hex) (which would hex-encode + /// the whole list as one string instead of one string per cell). + #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex_seq::serialize")] + pub column: DataColumn, + /// The block's full `blob_kzg_commitments`, repeated in every one of that + /// block's sidecars rather than fetched separately, which is what lets a + /// sidecar be checked on its own. + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub kzg_commitments: KzgCommitments, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub kzg_proofs: KzgProofs, + pub signed_block_header: SignedBeaconBlockHeader, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub kzg_commitments_inclusion_proof: KzgCommitmentsInclusionProof, +} + +/// One cell of the extended data matrix, addressed by which blob it belongs to +/// and which column it sits in. +/// +/// What [`DataColumnSidecar::column`] is assembled from: a sidecar groups +/// every blob's cell at one column index together, while a `MatrixEntry` +/// names a single cell independent of that grouping, which is the form +/// `compute_matrix` and `recover_matrix` operate on when demonstrating how a +/// node might store and reconstruct the matrix. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +pub struct MatrixEntry { + /// One byte string, not a sequence of them: [`Cell`] is a foreign + /// `SszVector` with no `Serialize` of its own, so this is exactly + /// what [`ssz_hex`](crate::beacon::serde_helpers::ssz_hex) exists for, + /// unlike [`DataColumnSidecar::column`], which holds a whole list of + /// cells. + #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex::serialize")] + pub cell: Cell, + pub kzg_proof: KzgProof, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub column_index: ColumnIndex, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub row_index: RowIndex, +} + +/// A request for specific columns of a specific block, used by the +/// `DataColumnSidecarsByRoot` request-response protocol. +#[derive( + Debug, Clone, Default, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot, +)] +pub struct DataColumnsByRootIdentifier { + pub block_root: Root, + #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + pub columns: ColumnIndices, +} + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +/// The fulu beacon state: electra's 37 fields plus `proposer_lookahead`, 38 +/// total, in the specification's order. +/// +/// Field order is load-bearing. SSZ encoding and merkleization both follow +/// declaration order, so reordering or omitting a field silently produces a +/// wrong `hash_tree_root`. +/// +/// Every field through `pending_consolidations` is identical to electra's, +/// field for field; `proposer_lookahead` is appended after it rather than +/// inserted anywhere earlier, so nothing electra already committed to shifts +/// position. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +pub struct BeaconState { + // -- Versioning -- + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub genesis_time: u64, + /// The root of the genesis validator registry, which separates this chain + /// from any other running the same fork schedule. + pub genesis_validators_root: Root, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub slot: Slot, + pub fork: Fork, + + // -- History -- + /// The most recent block's header, with `state_root` left zero until the + /// slot advances, since a block cannot commit to the root of the state + /// containing it. + pub latest_block_header: BeaconBlockHeader, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub block_roots: BlockRoots, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub state_roots: StateRoots, + /// Frozen since capella: further history is committed to by + /// `historical_summaries` instead. Kept rather than removed so a fulu + /// state's shape stays hash-tree-root-compatible with every root + /// committed while this field was still growing. + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub historical_roots: HistoricalRoots, + + // -- Eth1 -- + pub eth1_data: Eth1Data, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub eth1_data_votes: Eth1DataVotes, + /// How many deposits from the contract have been processed, which is + /// where the next one will be read from. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub eth1_deposit_index: u64, + + // -- Registry -- + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub validators: Validators, + #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + pub balances: Balances, + + // -- Randomness -- + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub randao_mixes: RandaoMixes, + + // -- Slashings -- + #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + pub slashings: Slashings, + + // -- Participation -- + /// Per-validator participation flags for the previous epoch, positionally + /// parallel to `validators`. See altair for why this replaces phase0's + /// accumulated attestations. + #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + pub previous_epoch_participation: EpochParticipation, + /// Flags for the current epoch, which become `previous_epoch_participation` + /// at the next epoch boundary. + #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + pub current_epoch_participation: EpochParticipation, + + // -- Finality -- + #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex::serialize")] + pub justification_bits: JustificationBits, + pub previous_justified_checkpoint: Checkpoint, + pub current_justified_checkpoint: Checkpoint, + pub finalized_checkpoint: Checkpoint, + + // -- Inactivity -- + /// Per-validator inactivity score, positionally parallel to `validators`. + #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + pub inactivity_scores: InactivityScores, + + // -- Sync committees -- + /// The committee currently signing sync aggregates. + pub current_sync_committee: SyncCommittee, + /// The committee that takes over from `current_sync_committee` at the + /// next sync committee period boundary. + pub next_sync_committee: SyncCommittee, + + // -- Execution -- + /// The most recently applied execution payload, retained as a header so + /// the beacon state never has to hold a full payload's transactions and + /// withdrawals, which consensus never reads back out of the state once + /// the payload has been applied. + pub latest_execution_payload_header: ExecutionPayloadHeader, + + // -- Withdrawals -- + /// Where the withdrawal sweep across `validators` last stopped, so + /// `get_expected_withdrawals` resumes from here each slot instead of + /// rescanning the registry from the start. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub next_withdrawal_index: WithdrawalIndex, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub next_withdrawal_validator_index: ValidatorIndex, + + // -- History (capella) -- + /// The commitment to history from capella onward, appended to instead of + /// `historical_roots` once that field was frozen. + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub historical_summaries: HistoricalSummaries, + + // -- Deposits, exits, and consolidations (electra) -- + /// The deposit contract log index at which the state switched from + /// crediting `Eth1Data` votes to crediting execution layer deposit + /// requests directly, or `UNSET_DEPOSIT_REQUESTS_START_INDEX` before the + /// first request arrives. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub deposit_requests_start_index: u64, + /// Deposit churn left over from the current epoch's activation queue, + /// carried into the next epoch rather than wasted. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub deposit_balance_to_consume: Gwei, + /// Exit churn left over from the current epoch's exit queue, carried + /// forward the same way as `deposit_balance_to_consume`. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub exit_balance_to_consume: Gwei, + /// The earliest epoch the exit queue has not yet exhausted its churn for. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub earliest_exit_epoch: Epoch, + /// Consolidation churn left over from the current epoch's consolidation + /// queue. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub consolidation_balance_to_consume: Gwei, + /// The earliest epoch the consolidation queue has not yet exhausted its + /// churn for. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub earliest_consolidation_epoch: Epoch, + /// Deposits credited on the execution layer but not yet applied to the + /// registry, drained a bounded number at a time each epoch. + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub pending_deposits: PendingDeposits, + /// Partial withdrawals requested but not yet paid out. + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub pending_partial_withdrawals: PendingPartialWithdrawals, + /// Validator consolidations requested but not yet applied. + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub pending_consolidations: PendingConsolidations, + + // -- Proposer lookahead (fulu) -- + /// The proposer for every slot from the start of the current epoch + /// through `MIN_SEED_LOOKAHEAD` full epochs ahead, precomputed at each + /// epoch boundary by `process_proposer_lookahead` so that + /// `get_beacon_proposer_index` becomes a lookup into this vector rather + /// than a shuffle computed on demand. + #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + pub proposer_lookahead: ProposerLookahead, +} + +#[cfg(test)] +mod tests { + use libssz::{SszDecode as _, SszEncode as _}; + + use super::*; + + #[test] + fn beacon_state_and_data_column_sidecar_are_variable_size() { + // Both hold at least one list, so both begin their encoding with + // offsets rather than a fixed layout. Checked at the type level, since + // building a full state or a full sidecar is not needed to know this. + assert!(!::is_fixed_size()); + assert!(!::is_fixed_size()); + // A request identifier holds a list of columns too. + assert!(!::is_fixed_size()); + } + + #[test] + fn matrix_entry_is_fixed_size() { + // A cell, a proof, and two indices: nothing variable-length, unlike + // the sidecar that groups many matrix entries together per column. + assert!(::is_fixed_size()); + } + + #[test] + fn matrix_entry_round_trips_through_ssz() { + // One cell is small enough to build directly in a test, unlike a full + // data column, which would need one cell per blob in a block. + let cell = Cell::try_from(vec![7u8; preset::BYTES_PER_CELL]).unwrap(); + let entry = MatrixEntry { + cell, + kzg_proof: KzgProof([1; crate::beacon::primitives::KZG_POINT_SIZE]), + column_index: 3, + row_index: 0, + }; + + let bytes = entry.to_ssz(); + assert_eq!(MatrixEntry::from_ssz_bytes(&bytes).unwrap(), entry); + } + + #[test] + fn data_columns_by_root_identifier_round_trips_with_some_columns() { + let identifier = DataColumnsByRootIdentifier { + block_root: Root::repeat_byte(9), + columns: ColumnIndices::try_from(vec![0, 1, 42]).unwrap(), + }; + + let bytes = identifier.to_ssz(); + assert_eq!( + DataColumnsByRootIdentifier::from_ssz_bytes(&bytes).unwrap(), + identifier + ); + } + + #[test] + fn data_columns_by_root_identifier_round_trips_while_empty() { + let identifier = DataColumnsByRootIdentifier::default(); + + let bytes = identifier.to_ssz(); + assert_eq!( + DataColumnsByRootIdentifier::from_ssz_bytes(&bytes).unwrap(), + identifier + ); + } +} diff --git a/crates/common/types/src/beacon/containers/mod.rs b/crates/common/types/src/beacon/containers/mod.rs new file mode 100644 index 000000000..40a240d99 --- /dev/null +++ b/crates/common/types/src/beacon/containers/mod.rs @@ -0,0 +1,1539 @@ +//! The specification's containers. +//! +//! Containers whose shape is the same in every fork are defined once, in +//! [`shared`]. Containers that change are defined once per fork, in a module per +//! fork, and wrapped in an enum here. +//! +//! # Why an enum over per-fork structs +//! +//! Each per-fork struct derives its SSZ encoding, decoding, and merkleization. +//! That is the point: the per-fork field lists are not a growing tail, so +//! hand-written fork-conditional codecs would have to reproduce a lot of detail +//! that a derive gets from the struct definition. +//! +//! - phase0's `previous_epoch_attestations` and `current_epoch_attestations` do +//! not exist from altair on. They are replaced in position by +//! `previous_epoch_participation` and `current_epoch_participation`, which have +//! a different type. +//! - `latest_execution_payload_header` keeps its name from bellatrix on, but is a +//! different container in bellatrix, capella, and deneb; electra and fulu keep +//! deneb's shape unchanged. +//! - The state's field count crosses a power of two at electra, so its merkle +//! tree is five levels deep through deneb and six from electra on. The same +//! logical field has a different generalized index in different forks. +//! - [`SignedBeaconBlock::Fulu`] is the reverse case: fulu changes no field of a +//! block at all, so its variant wraps [`electra::SignedBeaconBlock`] rather +//! than a `fulu` type that would otherwise be a copy of it. See that variant's +//! doc for why it still needs to be its own variant rather than folded into +//! `Electra`. +//! +//! # Reading the state without matching on the fork +//! +//! About twenty of the state's fields exist unchanged in every fork. The +//! `shared_state_accessors` macro generates their accessors from one list, and +//! that list is this crate's statement of which fields are fork-invariant: if a +//! future fork changes one, it leaves the list and gains an explicit match at +//! each use site. +//! +//! State transition functions therefore read through accessors and match on the +//! fork only where the specification itself changes behavior, so a match arm can +//! be reviewed against the spec's own diff. + +pub mod altair; +pub mod bellatrix; +pub mod capella; +pub mod deneb; +pub mod electra; +pub mod fulu; +pub mod phase0; +pub mod shared; + +pub use shared::*; + +use libssz::{SszDecode as _, SszEncode as _}; + +use crate::beacon::error::{Error, Result}; +use crate::beacon::fork::ForkName; +use crate::beacon::primitives::{ + BlsSignature, Bytes32, CommitteeIndex, Epoch, ExecutionBlockHash, Gwei, HashTreeRoot as _, + Root, Slot, ValidatorIndex, WithdrawalIndex, +}; +use crate::beacon::{beacon_value_unreachable, lean_block_unreachable, lean_state_unreachable}; + +/// Runs `$body` against whichever fork's state this is. +/// +/// For the reads every beacon fork answers the same way: the arm list lives here +/// once instead of once per accessor, so a new fork is one line in this macro and +/// one in [`BeaconState::fork_name`] rather than one line in each of twenty match +/// ladders. `$function` names the accessor for the lean arm's panic only. +macro_rules! dispatch_state { + ($self:expr, $function:expr, |$state:ident| $body:expr) => { + match $self { + BeaconState::Phase0($state) => $body, + BeaconState::Altair($state) => $body, + BeaconState::Bellatrix($state) => $body, + BeaconState::Capella($state) => $body, + BeaconState::Deneb($state) => $body, + BeaconState::Electra($state) => $body, + BeaconState::Fulu($state) => $body, + BeaconState::Lean(_) => lean_state_unreachable($function), + } + }; +} + +/// Runs `$body` against whichever state this is, the lean one included. +/// +/// The counterpart to `dispatch_state!` for the two operations that are not +/// beacon-specific at all. Every variant is an SSZ container, lean's as much as +/// any fork's, so encoding one and merkleizing one mean the same thing whichever +/// chain it belongs to, and answering them for lean is what keeps +/// [`BeaconState::from_ssz`] from handing out a value its own encoder rejects. +/// +/// No `$function` parameter, because no arm panics. `$body` has to typecheck for +/// all eight: `to_ssz` does because every variant derives `SszEncode`, and +/// `hash_tree_root` because [`crate::beacon::primitives::HashTreeRoot`] is +/// blanket-implemented over `libssz_merkle::HashTreeRoot` and is the only trait +/// of that name in scope here, so lean's state answers it too, with the same +/// bytes its own `crate::primitives::HashTreeRoot` would produce. +macro_rules! dispatch_state_including_lean { + ($self:expr, |$state:ident| $body:expr) => { + match $self { + BeaconState::Phase0($state) => $body, + BeaconState::Altair($state) => $body, + BeaconState::Bellatrix($state) => $body, + BeaconState::Capella($state) => $body, + BeaconState::Deneb($state) => $body, + BeaconState::Electra($state) => $body, + BeaconState::Fulu($state) => $body, + BeaconState::Lean($state) => $body, + } + }; +} + +/// Runs `$body` against the forks that carry a field, and names the ones that +/// predate it. +/// +/// Same purpose as `dispatch_state!`, for a field the specification introduces +/// partway along the fork schedule: `carried_by` is the forks whose state has it, +/// `absent_from` the forks that answer [`Error::UnsupportedForFork`]. Both lists +/// are spelled out rather than one being derived from the other, so that a new +/// fork does not silently join either side. +macro_rules! dispatch_state_from { + ( + $self:expr, $function:expr, |$state:ident| $body:expr, + carried_by: [$($fork:ident),+ $(,)?], + absent_from: [$($absent:ident),+ $(,)?], + ) => { + match $self { + $(BeaconState::$fork($state) => Ok($body),)+ + $(BeaconState::$absent(_) => Err(Error::UnsupportedForFork { + function: $function, + fork: ForkName::$absent, + }),)+ + BeaconState::Lean(_) => lean_state_unreachable($function), + } + }; +} + +/// Runs `$body` against whichever fork's block this is. +/// +/// The block-shaped counterpart to `dispatch_state!`: `$function` names the +/// accessor for the lean arm's panic, the same way it does there. +macro_rules! dispatch_block { + ($self:expr, $function:expr, |$block:ident| $body:expr) => { + match $self { + SignedBeaconBlock::Phase0($block) => $body, + SignedBeaconBlock::Altair($block) => $body, + SignedBeaconBlock::Bellatrix($block) => $body, + SignedBeaconBlock::Capella($block) => $body, + SignedBeaconBlock::Deneb($block) => $body, + SignedBeaconBlock::Electra($block) => $body, + SignedBeaconBlock::Fulu($block) => $body, + SignedBeaconBlock::Lean(_) => lean_block_unreachable($function), + } + }; +} + +/// Runs `$body` against whichever block this is, the lean one included. +/// +/// The counterpart to `dispatch_block!` for the accessors every variant can +/// answer for real. Lean's `Block` declares `slot`, `proposer_index`, +/// `parent_root` and `state_root` under exactly those names and matching +/// types, which is why the `message:`/`outer:` split in +/// `signed_beacon_block_accessors!` is the same line as "answerable for lean". +/// +/// No `$function` parameter, because no arm panics. +macro_rules! dispatch_block_including_lean { + ($self:expr, |$block:ident| $body:expr) => { + match $self { + SignedBeaconBlock::Phase0($block) => $body, + SignedBeaconBlock::Altair($block) => $body, + SignedBeaconBlock::Bellatrix($block) => $body, + SignedBeaconBlock::Capella($block) => $body, + SignedBeaconBlock::Deneb($block) => $body, + SignedBeaconBlock::Electra($block) => $body, + SignedBeaconBlock::Fulu($block) => $body, + SignedBeaconBlock::Lean($block) => $body, + } + }; +} + +/// The beacon state, in whichever fork's shape it currently has, plus the lean +/// state. +/// +/// [`BeaconState::Lean`] is not a Beacon Chain shape. It is here so that one +/// `BlockChainServer` can dispatch on a single state type. Every accessor that +/// reads a *beacon* field treats it as unreachable, and the enforced boundary is +/// the single `match` at the top of each handler. The four operations that are +/// not beacon-specific answer it for real: [`BeaconState::fork_name`], +/// [`BeaconState::from_ssz`], [`BeaconState::to_ssz`] and +/// [`BeaconState::hash_tree_root`], the last three because every variant is an +/// SSZ container whatever chain it came from. +#[derive(Debug, Clone, PartialEq)] +pub enum BeaconState { + Phase0(phase0::BeaconState), + Altair(altair::BeaconState), + Bellatrix(bellatrix::BeaconState), + Capella(capella::BeaconState), + Deneb(deneb::BeaconState), + Electra(electra::BeaconState), + Fulu(fulu::BeaconState), + Lean(crate::state::State), +} + +/// Hand-written rather than derived, unlike its sibling +/// [`SignedBeaconBlock`]'s `#[serde(untagged)]`. +/// +/// Every beacon-fork variant still has to serialize as exactly the inner +/// value, with no tag added: the fork travels in the Beacon API response +/// envelope, as a `version` field and an `Eth-Consensus-Version` header, the +/// same reasoning [`SignedBeaconBlock`]'s derive relies on. What differs is +/// [`BeaconState::Lean`]. `SignedBeaconBlock::Lean` is real, servable JSON — +/// `/lean/v0/blocks/finalized` answers it — but lean's *state* stays +/// SSZ-only by design: `/lean/v0/states/finalized` serves SSZ and always +/// will, so `crate::state::State` deliberately has no `Serialize` impl, and +/// `#[derive(Serialize)]` here could not compile without inventing one. +/// +/// So this impl lets every beacon fork serialize normally and turns the +/// `Lean` arm into a serde error instead of a tag or a fabricated encoding. +/// That mirrors how the lean/beacon split is enforced everywhere else in +/// this crate: at the boundary, not in the type system — +/// [`BeaconState::expect_lean`] panics, `dispatch_state!`'s `Lean` arm panics +/// for beacon-only accessors, and this is the same boundary reached through +/// serde instead of a direct call. A panic would be wrong here specifically +/// because serialization is fallible in the caller's vocabulary already (an +/// axum handler already has to handle a `serde_json::to_value` failure), so +/// an `Err` is the gentler member of that family rather than a new one. +/// +/// If a future refactor "simplifies" this back into a derive, it will hit +/// the same missing-`Serialize`-on-`State` wall this impl exists to route +/// around, on purpose. +impl serde::Serialize for BeaconState { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + match self { + BeaconState::Phase0(state) => state.serialize(serializer), + BeaconState::Altair(state) => state.serialize(serializer), + BeaconState::Bellatrix(state) => state.serialize(serializer), + BeaconState::Capella(state) => state.serialize(serializer), + BeaconState::Deneb(state) => state.serialize(serializer), + BeaconState::Electra(state) => state.serialize(serializer), + BeaconState::Fulu(state) => state.serialize(serializer), + BeaconState::Lean(_) => Err(serde::ser::Error::custom( + "a lean state has no JSON encoding: /lean/v0/states/finalized serves SSZ only", + )), + } + } +} + +impl BeaconState { + /// The lean [`State`](crate::state::State) this value wraps. + /// + /// The mirror image of `dispatch_state!`'s `Lean` arm. That arm fires when a + /// lean state reaches a beacon accessor; this one fires when a beacon state + /// reaches a caller that only ever runs against a lean store, which every + /// reader on the `/lean/v0` surface and in the lean state transition is. + /// + /// Such a caller would otherwise write the peel out by hand, so this owns it + /// once: a data directory holds one chain for its whole life (see the storage + /// crate's `Chain`), which is what makes the other arm unreachable rather + /// than an error worth returning. + /// + /// `#[track_caller]` so the panic still reports the call site, the way the + /// `let ... else { unreachable!() }` written inline there would have. + #[track_caller] + pub fn expect_lean(&self) -> &crate::state::State { + match self { + BeaconState::Lean(state) => state, + other => beacon_value_unreachable("state", other.fork_name()), + } + } + + /// The fork whose rules and shape apply to this state. + pub fn fork_name(&self) -> ForkName { + match self { + BeaconState::Phase0(_) => ForkName::Phase0, + BeaconState::Altair(_) => ForkName::Altair, + BeaconState::Bellatrix(_) => ForkName::Bellatrix, + BeaconState::Capella(_) => ForkName::Capella, + BeaconState::Deneb(_) => ForkName::Deneb, + BeaconState::Electra(_) => ForkName::Electra, + BeaconState::Fulu(_) => ForkName::Fulu, + BeaconState::Lean(_) => ForkName::Lean, + } + } + + /// Byte offset of `slot` in an encoded beacon `BeaconState`. + /// + /// `genesis_time` (u64) and `genesis_validators_root` (Root) are both + /// fixed-size and lead the container at every fork, so `slot` follows + /// them at a constant offset with no variable-length offset to resolve + /// first. + const SLOT_OFFSET: usize = 8 + 32; + + /// The `slot` of an encoded beacon state, without decoding the rest. + /// + /// The inverse problem to [`BeaconState::from_ssz`]: that one is told the + /// fork, this one recovers the value a caller works the fork out from. + /// Checkpoint sync needs it because SSZ carries no type tag and the state + /// arrives as bytes off an HTTP response. + /// + /// Reads the *beacon* layout. [`BeaconState::Lean`] opens with `config` + /// instead, so lean bytes yield a meaningless number here rather than an + /// error; every caller already knows which chain it is talking to. + pub fn slot_from_ssz(bytes: &[u8]) -> Result { + let end = Self::SLOT_OFFSET + 8; + let slot_bytes = + bytes + .get(Self::SLOT_OFFSET..end) + .ok_or(libssz::DecodeError::InvalidByteLength { + expected: end, + got: bytes.len(), + })?; + Ok(Slot::from_ssz_bytes(slot_bytes)?) + } + + /// Decodes a state of a known fork. + /// + /// The fork cannot be recovered from the bytes, since SSZ carries no type + /// tag, so it comes from context: the caller's configuration, or the fixture + /// directory being run. Every fork this crate implements has a shape, so + /// unlike other fork-dispatching functions in this crate, there is no + /// `Error::UnsupportedForFork` arm to fall through to here. + pub fn from_ssz(fork: ForkName, bytes: &[u8]) -> Result { + match fork { + ForkName::Phase0 => Ok(BeaconState::Phase0(phase0::BeaconState::from_ssz_bytes( + bytes, + )?)), + ForkName::Altair => Ok(BeaconState::Altair(altair::BeaconState::from_ssz_bytes( + bytes, + )?)), + ForkName::Bellatrix => Ok(BeaconState::Bellatrix( + bellatrix::BeaconState::from_ssz_bytes(bytes)?, + )), + ForkName::Capella => Ok(BeaconState::Capella(capella::BeaconState::from_ssz_bytes( + bytes, + )?)), + ForkName::Deneb => Ok(BeaconState::Deneb(deneb::BeaconState::from_ssz_bytes( + bytes, + )?)), + ForkName::Electra => Ok(BeaconState::Electra(electra::BeaconState::from_ssz_bytes( + bytes, + )?)), + ForkName::Fulu => Ok(BeaconState::Fulu(fulu::BeaconState::from_ssz_bytes(bytes)?)), + ForkName::Lean => Ok(BeaconState::Lean(crate::state::State::from_ssz_bytes( + bytes, + )?)), + } + } + + /// Encodes the state. + /// + /// Answers for [`BeaconState::Lean`] rather than treating it as unreachable, + /// unlike the accessors that read a beacon field: this is the inverse of + /// [`BeaconState::from_ssz`], which builds that variant, so refusing here + /// would make decoding a state and re-encoding it panic. + pub fn to_ssz(&self) -> Vec { + dispatch_state_including_lean!(self, |state| state.to_ssz()) + } + + /// The state's merkle root, which a block's `state_root` must equal. + /// + /// Answers for [`BeaconState::Lean`] too, for the reason + /// [`BeaconState::to_ssz`] gives. The digest is lean's own state root: both + /// of this crate's `HashTreeRoot` traits merkleize through `libssz_merkle` + /// with the same hasher, and only the wrapper type around the bytes differs. + pub fn hash_tree_root(&self) -> Root { + dispatch_state_including_lean!(self, |state| state.hash_tree_root()) + } + + /// This state's own merkle root, cached in `latest_block_header` if a + /// writer put it there and merkleized through + /// [`BeaconState::hash_tree_root`] otherwise. + /// + /// The cached field is the root of the state applying its block produced, + /// so it describes this state only while the state is still inside that + /// block's slot; one slot on it names the older one. The specification + /// fills it on the way out of that slot, so it never satisfies both halves + /// at once and every fixture state merkleizes here. + /// + /// The value reaches `state.state_roots`, a consensus input, so a wrong one + /// forks silently. Only a root this node computed, or checked against one + /// it computed, may be written: see `beacon::fork_choice::on_block` and + /// `get_forkchoice_store`, the two writers. + /// + /// Panics on [`BeaconState::Lean`], like every other beacon accessor here. + pub fn compute_state_root(&self) -> Root { + let header = self.latest_block_header(); + if self.slot() == header.slot && !header.state_root.is_zero() { + header.state_root + } else { + self.hash_tree_root() + } + } +} + +/// Generates read and write accessors for state fields that every fork shares. +/// +/// The `copy` and `reference` lists are this crate's statement of which state +/// fields are fork-invariant. A fork that changes one of them moves it out of the +/// list and gains an explicit match at each use site. +/// +/// Two field lists rather than one, because returning a reference to a `u64` +/// would make the state transition noisier than it needs to be: `copy` fields are +/// returned by value, `reference` fields by reference. Both also get a `_mut` +/// accessor, and both names are given explicitly, since `macro_rules!` cannot +/// concatenate identifiers on stable Rust. +/// +/// The arms come from `dispatch_state!`, which is also why the variant list is +/// not a parameter of this macro: `macro_rules!` zips two repetitions at the same +/// nesting depth rather than nesting them, so a `variants: [...]` list would be +/// iterated in lockstep with the field list instead of once per field. Calling +/// one macro from the other sidesteps that and keeps the fork list in one place. +macro_rules! shared_state_accessors { + ( + copy: [$(($field:ident, $field_mut:ident, $ty:ty)),* $(,)?], + reference: [$(($ref_field:ident, $ref_field_mut:ident, $ref_ty:ty)),* $(,)?], + ) => { + impl BeaconState { + $( + pub fn $field(&self) -> $ty { + dispatch_state!(self, stringify!($field), |state| state.$field) + } + + pub fn $field_mut(&mut self) -> &mut $ty { + dispatch_state!(self, stringify!($field_mut), |state| &mut state.$field) + } + )* + + $( + pub fn $ref_field(&self) -> &$ref_ty { + dispatch_state!(self, stringify!($ref_field), |state| &state.$ref_field) + } + + pub fn $ref_field_mut(&mut self) -> &mut $ref_ty { + dispatch_state!( + self, + stringify!($ref_field_mut), + |state| &mut state.$ref_field + ) + } + )* + } + }; +} + +shared_state_accessors!( + copy: [ + (slot, slot_mut, Slot), + (eth1_deposit_index, eth1_deposit_index_mut, u64), + (previous_justified_checkpoint, previous_justified_checkpoint_mut, Checkpoint), + (current_justified_checkpoint, current_justified_checkpoint_mut, Checkpoint), + (finalized_checkpoint, finalized_checkpoint_mut, Checkpoint), + ], + reference: [ + (fork, fork_mut, Fork), + (latest_block_header, latest_block_header_mut, BeaconBlockHeader), + (block_roots, block_roots_mut, BlockRoots), + (state_roots, state_roots_mut, StateRoots), + (historical_roots, historical_roots_mut, HistoricalRoots), + (eth1_data, eth1_data_mut, Eth1Data), + (eth1_data_votes, eth1_data_votes_mut, Eth1DataVotes), + (validators, validators_mut, Validators), + (balances, balances_mut, Balances), + (randao_mixes, randao_mixes_mut, RandaoMixes), + (slashings, slashings_mut, Slashings), + (justification_bits, justification_bits_mut, JustificationBits), + ], +); + +impl BeaconState { + /// The genesis time of the chain this state belongs to. + /// + /// Answers for lean as well as every beacon fork. It is a genesis + /// identity rather than a beacon field, and lean keeps it in + /// `state.config.genesis_time` rather than at the top level. Widened for + /// the same reason `dispatch_state_including_lean!` widens `to_ssz` and + /// `hash_tree_root`: one comparison then recognizes either chain's own + /// state, which is what lets checkpoint sync and the resume path share + /// an implementation. + pub fn genesis_time(&self) -> u64 { + match self { + BeaconState::Lean(state) => state.config.genesis_time, + beacon => dispatch_state!(beacon, "genesis_time", |state| state.genesis_time), + } + } + + /// The root committing to the genesis validator registry. + /// + /// Lean has no such field. Its registry is fixed at genesis, nothing in + /// the state transition mutates it, the same invariant `StateDiff` relies + /// on when it omits `validators`, so the root of the registry at any + /// slot is the root it had at genesis, which is the quantity beacon + /// stores. That equivalence is what lets one comparison serve both + /// chains. + /// + /// O(1) on beacon, where it is a stored field, and a merkleization of the + /// registry on lean. Called at startup, not on a hot path. + pub fn genesis_validators_root(&self) -> Root { + match self { + BeaconState::Lean(state) => state.validators.hash_tree_root(), + beacon => dispatch_state!(beacon, "genesis_validators_root", |state| state + .genesis_validators_root), + } + } + + /// Beacon-only, unlike the read above: genesis construction sets this + /// field (`state_transition::beacon::genesis`), and a lean state has no + /// such field to hand out a `&mut` to. + pub fn genesis_time_mut(&mut self) -> &mut u64 { + dispatch_state!(self, "genesis_time_mut", |state| &mut state.genesis_time) + } + + /// Beacon-only, for the reason given on [`BeaconState::genesis_time_mut`]. + pub fn genesis_validators_root_mut(&mut self) -> &mut Root { + dispatch_state!(self, "genesis_validators_root_mut", |state| &mut state + .genesis_validators_root) + } +} + +impl BeaconState { + /// The validator at `index`. + /// + /// A named error rather than an `Option`, since the specification indexes the + /// registry in many places and an out-of-range index is always a fault. + pub fn validator(&self, index: ValidatorIndex) -> Result<&Validator> { + self.validators() + .get(index as usize) + .ok_or(Error::UnknownValidator(index)) + } + + /// The validator at `index`, mutably. + pub fn validator_mut(&mut self, index: ValidatorIndex) -> Result<&mut Validator> { + self.validators_mut() + .get_mut(index as usize) + .ok_or(Error::UnknownValidator(index)) + } + + /// Folds every buffered write into the tree-backed fields (`validators`, + /// `balances`), so the next `hash_tree_root` rehashes only the touched + /// paths and keeps the hashes it computes. + /// + /// Hashing with writes still pending gives the right root but caches + /// nothing for those paths, so the state transition calls this before + /// every state-root computation. A no-op on a lean state, which has no + /// tree-backed fields. + pub fn apply_pending_mutations(&mut self) { + if matches!(self, BeaconState::Lean(_)) { + return; + } + self.validators_mut().apply_updates(); + self.balances_mut().apply_updates(); + } + + /// Whether `validators` or `balances` has a write [`apply_pending_mutations`] + /// has not folded into its tree yet. + /// + /// Always `false` on a lean state, which has no tree-backed fields. Meant + /// for callers that cache an `Arc`: once shared, a state + /// cannot be flushed later, so every root taken through the `Arc` would + /// pay the slow, uncached hashing path if a write were still pending. + /// + /// [`apply_pending_mutations`]: BeaconState::apply_pending_mutations + pub fn has_pending_mutations(&self) -> bool { + if matches!(self, BeaconState::Lean(_)) { + return false; + } + self.validators().has_pending_updates() || self.balances().has_pending_updates() + } + + /// Makes this state's tree-backed fields share every unchanged subtree + /// with `base`'s, so two nearly equal states do not each hold a full copy + /// of the registry. The state's contents do not change, only which + /// allocations back them. + /// + /// Works across forks, since `validators` and `balances` have one type in + /// every fork. A no-op if either state is lean. + pub fn rebase_on(&mut self, base: &BeaconState) { + if matches!(self, BeaconState::Lean(_)) || matches!(base, BeaconState::Lean(_)) { + return; + } + self.validators_mut().rebase_on(base.validators()); + self.balances_mut().rebase_on(base.balances()); + } + + /// The balance of the validator at `index`. + pub fn balance(&self, index: ValidatorIndex) -> Result { + self.balances() + .get(index as usize) + .copied() + .ok_or(Error::UnknownValidator(index)) + } + + /// The randao mix for `epoch`, which the specification indexes modulo the + /// vector length so the vector acts as a ring buffer. + pub fn randao_mix(&self, epoch: Epoch) -> Bytes32 { + let mixes = self.randao_mixes(); + mixes[epoch as usize % mixes.len()] + } + + /// The withdrawal sweep's cursor: how many withdrawals the chain has ever + /// made, and which validator the next sweep resumes from. + /// + /// Both exist from capella on, so they cannot join + /// `shared_state_accessors`' fork-invariant lists, and they are read + /// through here rather than through a per-fork projection to a concrete + /// state struct because the sweep that reads them is genuinely shared: deneb + /// reuses capella's `get_expected_withdrawals` unchanged, and a projection + /// returning `&capella::BeaconState` cannot serve a deneb state at all. That + /// mistake was made once here and cost a runtime `UnsupportedForFork` on + /// every deneb block carrying a withdrawal. + pub fn withdrawal_cursor(&self) -> Result<(WithdrawalIndex, ValidatorIndex)> { + dispatch_state_from!( + self, + "BeaconState::withdrawal_cursor", + |state| ( + state.next_withdrawal_index, + state.next_withdrawal_validator_index, + ), + carried_by: [Capella, Deneb, Electra, Fulu], + absent_from: [Phase0, Altair, Bellatrix], + ) + } + + /// The withdrawal sweep's cursor, mutably. See [`Self::withdrawal_cursor`]. + pub fn withdrawal_cursor_mut(&mut self) -> Result<(&mut WithdrawalIndex, &mut ValidatorIndex)> { + dispatch_state_from!( + self, + "BeaconState::withdrawal_cursor_mut", + |state| ( + &mut state.next_withdrawal_index, + &mut state.next_withdrawal_validator_index, + ), + carried_by: [Capella, Deneb, Electra, Fulu], + absent_from: [Phase0, Altair, Bellatrix], + ) + } + + /// The three per-validator lists that exist from altair on, by reference and + /// all at once. + /// + /// These cannot join `shared_state_accessors`' lists, since phase0 has + /// none of them, and a per-fork projection to a concrete state struct (the + /// way the beacon STF's `helpers::altair::altair_state_ref` reaches them) + /// cannot serve every fork that carries them: bellatrix, capella, deneb, electra, + /// and fulu all keep the identical three fields, but each is a distinct + /// Rust type, so a projection typed to return `&altair::BeaconState` can + /// only ever answer for an altair state. + /// + /// Handed back together rather than one accessor per field for the same + /// reason [`Self::altair_validator_lists_mut`] does: the fork condition + /// that gates all three is identical, so one match serves every caller, + /// including one that only needs one or two of the three and destructures + /// the rest away with `_`. + pub fn altair_validator_lists( + &self, + ) -> Result<(&EpochParticipation, &EpochParticipation, &InactivityScores)> { + dispatch_state_from!( + self, + "BeaconState::altair_validator_lists", + |state| ( + &state.previous_epoch_participation, + &state.current_epoch_participation, + &state.inactivity_scores, + ), + carried_by: [Altair, Bellatrix, Capella, Deneb, Electra, Fulu], + absent_from: [Phase0], + ) + } + + /// The three per-validator lists that exist from altair on, mutably and all + /// at once. See [`Self::altair_validator_lists`] for why this cannot be a + /// per-fork projection instead. + /// + /// Handed back together for two reasons that stack: + /// the beacon STF's `stf::operations::add_validator_to_registry` genuinely + /// needs all three, since they are positionally parallel with `validators` and + /// `balances`, so a validator entering the registry has to grow all five + /// or leave the state internally inconsistent in a way nothing else would + /// notice until a `hash_tree_root` came out wrong; and every caller that + /// needs fewer than three still reaches them through this one accessor, + /// discarding what it does not need, rather than a matching per-field + /// accessor that would need the identical fork match written out again. + /// + /// Borrowing three fields of one struct at once is what the tuple is for. + /// Rust permits it because the fields are disjoint, whereas three successive + /// accessor calls would each borrow the whole enum. + pub fn altair_validator_lists_mut( + &mut self, + ) -> Result<( + &mut EpochParticipation, + &mut EpochParticipation, + &mut InactivityScores, + )> { + dispatch_state_from!( + self, + "BeaconState::altair_validator_lists_mut", + |state| ( + &mut state.previous_epoch_participation, + &mut state.current_epoch_participation, + &mut state.inactivity_scores, + ), + carried_by: [Altair, Bellatrix, Capella, Deneb, Electra, Fulu], + absent_from: [Phase0], + ) + } + + /// The current and next sync committee, by reference. + /// + /// Both exist from altair on, byte-for-byte the same field in every later + /// fork (see, for instance, bellatrix's own state doc), so they cannot + /// join `shared_state_accessors`' lists, since phase0 predates sync + /// committees entirely. A per-fork projection cannot serve here either: + /// the beacon STF's `stf::altair::process_sync_aggregate` is called for every + /// fork from altair through fulu (see that function's own documentation), + /// and a projection typed to return `&altair::BeaconState` can only ever answer + /// for an altair state, not for the bellatrix, capella, deneb, electra, or + /// fulu ones the same call site also has to serve. + pub fn sync_committees(&self) -> Result<(&altair::SyncCommittee, &altair::SyncCommittee)> { + dispatch_state_from!( + self, + "BeaconState::sync_committees", + |state| (&state.current_sync_committee, &state.next_sync_committee), + carried_by: [Altair, Bellatrix, Capella, Deneb, Electra, Fulu], + absent_from: [Phase0], + ) + } + + /// The current and next sync committee, mutably. See + /// [`Self::sync_committees`] for why this cannot be a per-fork projection. + /// + /// Handed back together, rather than as two separate accessors, because + /// the beacon STF's `stf::epoch::altair::process_sync_committee_updates` + /// rotates the pair by replacing one with the other at each sync committee period + /// boundary, which needs both mutable borrows alive for the one + /// `core::mem::replace` that does it. + pub fn sync_committees_mut( + &mut self, + ) -> Result<(&mut altair::SyncCommittee, &mut altair::SyncCommittee)> { + dispatch_state_from!( + self, + "BeaconState::sync_committees_mut", + |state| ( + &mut state.current_sync_committee, + &mut state.next_sync_committee, + ), + carried_by: [Altair, Bellatrix, Capella, Deneb, Electra, Fulu], + absent_from: [Phase0], + ) + } +} + +/// An aggregate attestation with the proof its aggregator was selected, in +/// whichever fork's shape it currently has. +/// +/// Two variants, not one per fork, for the reason +/// [`SignedBeaconBlock::Fulu`] wraps electra's block: every fork through deneb +/// shares [`phase0::SignedAggregateAndProof`] outright, and fulu shares +/// electra's the same way. +/// +/// Here rather than beside the gossip decode in `ethlambda-p2p`, where it was +/// first declared, because the gossip path no longer ends at that decode: an +/// aggregate now travels over `ethlambda-network-api` to the chain actor and +/// into fork choice. That protocol crate depends on this one and on nothing +/// else, deliberately, so a fork-generic container every layer names has to +/// live here, next to [`SignedBeaconBlock`]. +/// +/// The accessors are the pure ones. Turning this into the fork-choice crate's +/// own `Attestation` needs that crate's enum, so it lives there as a +/// `From` implementation rather than as a method here. +#[derive(Debug, Clone, PartialEq)] +pub enum SignedAggregateAndProof { + Phase0(phase0::SignedAggregateAndProof), + Electra(electra::SignedAggregateAndProof), +} + +impl SignedAggregateAndProof { + /// The validator that was selected to aggregate this committee's votes. + pub fn aggregator_index(&self) -> ValidatorIndex { + match self { + Self::Phase0(signed) => signed.message.aggregator_index, + Self::Electra(signed) => signed.message.aggregator_index, + } + } + + /// The slot the aggregated attestation votes at. + pub fn slot(&self) -> Slot { + match self { + Self::Phase0(signed) => signed.message.aggregate.data.slot, + Self::Electra(signed) => signed.message.aggregate.data.slot, + } + } + + /// The fork-invariant half of the aggregate this carries. + pub fn data(&self) -> AttestationData { + match self { + Self::Phase0(signed) => signed.message.aggregate.data, + Self::Electra(signed) => signed.message.aggregate.data, + } + } + + /// The epoch and root the aggregate's `target` checkpoint names. + pub fn target(&self) -> (Epoch, Root) { + let target = self.data().target; + (target.epoch, target.root) + } + + /// The aggregator's signature over the aggregate's slot, which is what + /// makes its selection verifiable rather than self-declared. + pub fn selection_proof(&self) -> BlsSignature { + match self { + Self::Phase0(signed) => signed.message.selection_proof, + Self::Electra(signed) => signed.message.selection_proof, + } + } + + /// The aggregator's signature over the whole `AggregateAndProof`. + pub fn signature(&self) -> BlsSignature { + match self { + Self::Phase0(signed) => signed.signature, + Self::Electra(signed) => signed.signature, + } + } + + /// The one committee index the aggregate names, or `None` if it does not + /// name exactly one. + /// + /// EIP-7549 moved the committee out of `data.index`, which electra + /// requires to be zero, and into a `committee_bits` bitfield. Electra's + /// gossip validation then requires that bitfield to select *exactly* one + /// committee, so answering `None` for both zero and several is not a lost + /// distinction: both are the same rejection, and collapsing them here is + /// what keeps `ethlambda-state-transition`'s `beacon::gossip::aggregate` + /// cheap checks (this crate cannot intra-link into that one) from having + /// to know this enum's two shapes. + /// + /// The `len(aggregation_bits) == len(committee)` check downstream is only + /// meaningful because of that "exactly one": electra's `aggregation_bits` + /// spans every committee `committee_bits` names, so it equals one + /// committee's width precisely when one committee is named. + pub fn committee_index(&self) -> Option { + match self { + Self::Phase0(signed) => Some(signed.message.aggregate.data.index), + Self::Electra(signed) => { + let bits = &signed.message.aggregate.committee_bits; + let mut named = (0..bits.len()).filter(|&index| bits.get(index).unwrap_or(false)); + let first = named.next()?; + // A second named committee disqualifies the aggregate outright. + match named.next() { + None => Some(first as CommitteeIndex), + Some(_) => None, + } + } + } + } + + /// How many attesters the aggregate covers. + /// + /// Counts set bits rather than reporting the bitfield's length: from + /// electra on, `aggregation_bits` spans every committee named in + /// `committee_bits`, so its length says how wide the aggregate could be, + /// not how many validators actually signed. + pub fn attester_count(&self) -> usize { + match self { + Self::Phase0(signed) => signed.message.aggregate.aggregation_bits.count_ones(), + Self::Electra(signed) => signed.message.aggregate.aggregation_bits.count_ones(), + } + } + + /// The aggregation bits, as a plain vector of booleans. + /// + /// The shape the seen-set's superset test needs: it compares one + /// aggregate's coverage against the union of what has already been seen + /// for the same `AttestationData`, and neither bitfield type it could + /// receive supports that directly. + pub fn aggregation_bits(&self) -> Vec { + match self { + Self::Phase0(signed) => { + let bits = &signed.message.aggregate.aggregation_bits; + (0..bits.len()) + .map(|i| bits.get(i).unwrap_or(false)) + .collect() + } + Self::Electra(signed) => { + let bits = &signed.message.aggregate.aggregation_bits; + (0..bits.len()) + .map(|i| bits.get(i).unwrap_or(false)) + .collect() + } + } + } +} + +/// A signed block, in whichever fork's shape it currently has. +/// +/// `Fulu` wraps [`electra::SignedBeaconBlock`] rather than a `fulu` type of its +/// own, deliberately: fulu changes no field of a block (see the [`fulu`] module +/// doc), so there is no `fulu::SignedBeaconBlock`, and this crate must not +/// invent one just to fill out the enum. The variant still has to exist and +/// stay distinct from `Electra`, because fulu does change how a block is +/// processed even though it does not change what a block is: `get_blob_parameters` +/// makes the blob commitment limit `process_operations` checks depend on the +/// epoch rather than being a single fixed preset from electra on. Code that +/// dispatches on fork therefore still needs to be able to tell a fulu block +/// from an electra one, even though both carry the identical +/// `electra::SignedBeaconBlock` payload. +/// +/// `#[serde(untagged)]`: the Beacon API's response envelope carries the fork +/// name as `version` and as the `Eth-Consensus-Version` header, never as a +/// tag inside the block object, so serializing this enum must produce +/// exactly the inner value's JSON with no variant wrapper. +/// [`SignedBeaconBlock::Fulu`] and [`SignedBeaconBlock::Electra`] wrap the +/// identical `electra::SignedBeaconBlock` type, which is exactly why an +/// *envelope* tag is required to distinguish them on the wire and a data tag +/// would be actively wrong: `untagged` serialization always writes the +/// active variant's payload, so this holds even for that pair. +/// +/// Unlike [`BeaconState`]'s enum, a plain derive works here: +/// [`SignedBeaconBlock::Lean`] is real, servable JSON — +/// `/lean/v0/blocks/finalized` answers it — so `crate::block::SignedBlock` +/// does implement `Serialize`, bare integers included. See `BeaconState`'s +/// hand-written impl for the state side of this asymmetry. +#[derive(Debug, Clone, PartialEq, serde::Serialize)] +#[serde(untagged)] +pub enum SignedBeaconBlock { + Phase0(phase0::SignedBeaconBlock), + Altair(altair::SignedBeaconBlock), + Bellatrix(bellatrix::SignedBeaconBlock), + Capella(capella::SignedBeaconBlock), + Deneb(deneb::SignedBeaconBlock), + Electra(electra::SignedBeaconBlock), + /// Fulu's block. See the enum doc for why this wraps + /// [`electra::SignedBeaconBlock`] instead of a `fulu` type. + Fulu(electra::SignedBeaconBlock), + + /// The Lean consensus protocol's block. + /// + /// Not a Beacon Chain shape, and here for the same reason + /// [`BeaconState::Lean`] is: so the storage layer takes one block type and + /// splits inside its methods rather than growing a method per chain. + Lean(crate::block::SignedBlock), +} + +impl SignedBeaconBlock { + /// The lean [`SignedBlock`](crate::block::SignedBlock) this value wraps. + /// + /// The block-shaped counterpart to [`BeaconState::expect_lean`], and the + /// mirror image of `dispatch_block!`'s `Lean` arm. Takes `self` by value, + /// since the callers that peel a block back off go on to own it. + /// + /// For a caller that has some other arm to run instead of panicking, match + /// on [`SignedBeaconBlock::Lean`] directly; this is for the callers whose + /// store is lean by construction. + #[track_caller] + pub fn expect_lean(self) -> crate::block::SignedBlock { + let fork = self.fork_name(); + match self { + SignedBeaconBlock::Lean(block) => block, + _ => beacon_value_unreachable("block", fork), + } + } + + /// This block's own execution payload block hash, if it carries a payload. + /// + /// Written out by hand rather than through `signed_beacon_block_accessors!`, + /// which generates accessors only for fields every fork shares: phase0 and + /// altair predate the merge and have no payload at all, and lean is not a + /// Beacon Chain shape. Those three answer `None`, which is a real answer + /// rather than a failure — `is_execution_block` in the specification's + /// optimistic sync document asks exactly this question and expects `False` + /// for a pre-merge block. + /// + /// Named arms rather than a catch-all `_`, so a fork added to the enum + /// breaks this match instead of silently defaulting to "no payload". + pub fn execution_block_hash(&self) -> Option { + match self { + Self::Phase0(_) | Self::Altair(_) | Self::Lean(_) => None, + Self::Bellatrix(block) => Some(block.message.body.execution_payload.block_hash), + Self::Capella(block) => Some(block.message.body.execution_payload.block_hash), + Self::Deneb(block) => Some(block.message.body.execution_payload.block_hash), + Self::Electra(block) | Self::Fulu(block) => { + Some(block.message.body.execution_payload.block_hash) + } + } + } + + /// How many blob KZG commitments this block's body carries: zero before + /// deneb, which introduced them. + /// + /// The one body field `beacon_block` gossip validation bounds before it + /// consults any state. + pub fn blob_kzg_commitment_count(&self) -> usize { + match self { + Self::Phase0(_) + | Self::Altair(_) + | Self::Bellatrix(_) + | Self::Capella(_) + | Self::Lean(_) => 0, + Self::Deneb(block) => block.message.body.blob_kzg_commitments.len(), + Self::Electra(block) | Self::Fulu(block) => { + block.message.body.blob_kzg_commitments.len() + } + } + } + + /// This block's execution payload timestamp, if it carries a payload. + /// + /// `None` before bellatrix, for the same reason as + /// [`Self::execution_block_hash`]. + pub fn execution_payload_timestamp(&self) -> Option { + match self { + Self::Phase0(_) | Self::Altair(_) | Self::Lean(_) => None, + Self::Bellatrix(block) => Some(block.message.body.execution_payload.timestamp), + Self::Capella(block) => Some(block.message.body.execution_payload.timestamp), + Self::Deneb(block) => Some(block.message.body.execution_payload.timestamp), + Self::Electra(block) | Self::Fulu(block) => { + Some(block.message.body.execution_payload.timestamp) + } + } + } + + /// The fork whose rules apply to this block. + /// + /// Not the same question as "what shape is this value": `Fulu` and + /// `Electra` answer this differently while sharing a shape, which is the + /// whole reason `Fulu` is its own variant rather than being folded into + /// `Electra`. + pub fn fork_name(&self) -> ForkName { + match self { + SignedBeaconBlock::Phase0(_) => ForkName::Phase0, + SignedBeaconBlock::Altair(_) => ForkName::Altair, + SignedBeaconBlock::Bellatrix(_) => ForkName::Bellatrix, + SignedBeaconBlock::Capella(_) => ForkName::Capella, + SignedBeaconBlock::Deneb(_) => ForkName::Deneb, + SignedBeaconBlock::Electra(_) => ForkName::Electra, + SignedBeaconBlock::Fulu(_) => ForkName::Fulu, + SignedBeaconBlock::Lean(_) => ForkName::Lean, + } + } + + /// Decodes a signed block of a known fork. + /// + /// The fork cannot be recovered from the bytes, since SSZ carries no type + /// tag, so it comes from context, the same way [`BeaconState::from_ssz`]'s + /// does. `ForkName::Fulu` decodes as [`electra::SignedBeaconBlock`], since + /// that is the type [`SignedBeaconBlock::Fulu`] wraps. + pub fn from_ssz(fork: ForkName, bytes: &[u8]) -> Result { + match fork { + ForkName::Phase0 => Ok(SignedBeaconBlock::Phase0( + phase0::SignedBeaconBlock::from_ssz_bytes(bytes)?, + )), + ForkName::Altair => Ok(SignedBeaconBlock::Altair( + altair::SignedBeaconBlock::from_ssz_bytes(bytes)?, + )), + ForkName::Bellatrix => Ok(SignedBeaconBlock::Bellatrix( + bellatrix::SignedBeaconBlock::from_ssz_bytes(bytes)?, + )), + ForkName::Capella => Ok(SignedBeaconBlock::Capella( + capella::SignedBeaconBlock::from_ssz_bytes(bytes)?, + )), + ForkName::Deneb => Ok(SignedBeaconBlock::Deneb( + deneb::SignedBeaconBlock::from_ssz_bytes(bytes)?, + )), + ForkName::Electra => Ok(SignedBeaconBlock::Electra( + electra::SignedBeaconBlock::from_ssz_bytes(bytes)?, + )), + ForkName::Fulu => Ok(SignedBeaconBlock::Fulu( + electra::SignedBeaconBlock::from_ssz_bytes(bytes)?, + )), + ForkName::Lean => Ok(SignedBeaconBlock::Lean( + crate::block::SignedBlock::from_ssz_bytes(bytes)?, + )), + } + } + + /// Encodes the signed block. + /// + /// Answers for [`SignedBeaconBlock::Lean`] rather than treating it as + /// unreachable, unlike the accessors that read a beacon field: this is the + /// inverse of [`SignedBeaconBlock::from_ssz`], which builds that variant, + /// so refusing here would make decoding a block and re-encoding it panic. + pub fn to_ssz(&self) -> Vec { + dispatch_block_including_lean!(self, |block| block.to_ssz()) + } + + /// The merkle root of the unsigned `message`, which is what the proposer's + /// `signature` is actually over. + /// + /// Deliberately not named `hash_tree_root`: that name is left free for the + /// root of the whole signed container (message and signature together), + /// which no code in this crate needs yet but which would mean something + /// different from this method if added later. + /// + /// Answers for [`SignedBeaconBlock::Lean`] too, for the reason + /// [`SignedBeaconBlock::to_ssz`] gives: lean's `Block` merkleizes through + /// the same `HashTreeRoot` blanket impl as every beacon fork's `message` + /// does, re-exported under this module's own `Root` alias. + pub fn message_hash_tree_root(&self) -> Root { + dispatch_block_including_lean!(self, |block| block.message.hash_tree_root()) + } + + /// The `hash_tree_root` of this block's body. + /// + /// Not part of `signed_beacon_block_accessors!`, which hands back a field + /// verbatim: every fork stores a different body container, so what a + /// caller wants is the merkle root of whichever one this is, not a value + /// to compare directly. `/eth/v1/beacon/headers/{id}` answers with a + /// `SignedBeaconBlockHeader`, whose `body_root` is the one field the + /// other accessors here cannot produce. + /// + /// Answers for [`SignedBeaconBlock::Lean`] too, for the reason + /// [`SignedBeaconBlock::message_hash_tree_root`] gives: lean's `Block` + /// also has a `body`, which merkleizes through the same `HashTreeRoot` + /// blanket impl as every beacon fork's does. + pub fn body_root(&self) -> Root { + dispatch_block_including_lean!(self, |block| block.message.body.hash_tree_root()) + } +} + +/// Generates read accessors for signed-block fields that every fork shares. +/// +/// A signed block has far fewer share points than [`BeaconState`], and none of +/// them need a `_mut` accessor, since nothing in this crate mutates a decoded +/// block in place. The list is still split in two, the same way +/// `shared_state_accessors`'s is: every field here happens to be `Copy`, so +/// the split is not `copy` versus `reference` but `message` versus `outer`, +/// separating the fields nested under `message` from `signature`, the one +/// field [`SignedBeaconBlock`] carries directly. +/// +/// That split turns out to also be the lean boundary. Lean's `Block` declares +/// `slot`, `proposer_index`, `parent_root` and `state_root` under exactly the +/// `message:` names and types, so those accessors dispatch through +/// `dispatch_block_including_lean!` and answer for lean for real. `signature` +/// has no lean equivalent, since lean signs with a `MultiMessageAggregate` +/// proof rather than a `BlsSignature`, so it stays on the panicking +/// `dispatch_block!`. +macro_rules! signed_beacon_block_accessors { + ( + message: [$(($field:ident, $ty:ty)),* $(,)?], + outer: [$(($outer_field:ident, $outer_ty:ty)),* $(,)?], + ) => { + impl SignedBeaconBlock { + $( + pub fn $field(&self) -> $ty { + dispatch_block_including_lean!(self, |block| block.message.$field) + } + )* + + $( + pub fn $outer_field(&self) -> $outer_ty { + dispatch_block!(self, stringify!($outer_field), |block| block.$outer_field) + } + )* + } + }; +} + +signed_beacon_block_accessors!( + message: [ + (slot, Slot), + (proposer_index, ValidatorIndex), + (parent_root, Root), + (state_root, Root), + ], + outer: [ + (signature, BlsSignature), + ], +); + +#[cfg(test)] +mod tests { + use super::*; + use crate::beacon::preset; + use crate::beacon::primitives::{ExecutionAddress, Uint256}; + + /// Single-validator lean state. The pubkeys are placeholders; nothing here + /// verifies a signature. + fn lean_state(genesis_time: u64, attestation_pubkey: u8) -> crate::state::State { + crate::state::State::from_genesis( + genesis_time, + vec![crate::state::Validator { + attestation_pubkey: [attestation_pubkey; crate::state::PUBLIC_KEY_SIZE], + proposal_pubkey: [2u8; crate::state::PUBLIC_KEY_SIZE], + index: 0, + }], + ) + } + + #[test] + fn a_lean_state_answers_the_genesis_identity_reads() { + let inner = lean_state(1_770_407_233, 1); + let expected_root = inner.validators.hash_tree_root(); + let state = BeaconState::Lean(inner); + + assert_eq!(state.genesis_time(), 1_770_407_233); + assert_eq!(state.genesis_validators_root(), expected_root); + } + + #[test] + fn a_different_lean_registry_gives_a_different_root() { + let one = BeaconState::Lean(lean_state(1_770_407_233, 1)); + let other = BeaconState::Lean(lean_state(1_770_407_233, 9)); + + assert_ne!( + one.genesis_validators_root(), + other.genesis_validators_root() + ); + } + + /// The writes stay beacon-only: a lean state has no + /// `genesis_validators_root` field to hand out a `&mut` to. + #[test] + #[should_panic(expected = "lean state reached a beacon accessor")] + fn the_genesis_validators_root_write_stays_beacon_only() { + let mut state = BeaconState::Lean(lean_state(0, 1)); + let _ = state.genesis_validators_root_mut(); + } + + #[test] + fn a_lean_state_reports_the_lean_fork() { + let state = BeaconState::Lean(crate::state::State::from_genesis(0, Vec::new())); + assert_eq!(state.fork_name(), ForkName::Lean); + } + + #[test] + fn a_lean_state_round_trips_through_the_beacon_enum() { + // `from_ssz` builds the Lean variant, so its inverse has to accept one. + // Before `to_ssz` answered for lean, decoding a state and re-encoding it + // panicked, which is the one asymmetry this enum cannot afford: it is + // exactly what a `BlockChainServer` dispatching on a configured fork + // does. + let lean = crate::state::State::from_genesis(0, Vec::new()); + let bytes = BeaconState::Lean(lean.clone()).to_ssz(); + + let decoded = BeaconState::from_ssz(ForkName::Lean, &bytes).expect("a lean state"); + assert_eq!(decoded, BeaconState::Lean(lean.clone())); + assert_eq!(decoded.to_ssz(), bytes); + } + + #[test] + fn a_lean_state_merkleizes_to_its_own_root() { + // Not merely "does not panic": the digest has to be the one lean's own + // trait produces, since a block's `state_root` is checked against it. + // Only the wrapper type around the bytes differs. Lean's trait is named + // through its full path rather than imported: both are blanket impls + // over `libssz_merkle::HashTreeRoot`, so bringing the second one into + // scope would make the call ambiguous. + let lean = crate::state::State::from_genesis(0, Vec::new()); + let via_beacon = BeaconState::Lean(lean.clone()).hash_tree_root(); + let via_lean = crate::primitives::HashTreeRoot::hash_tree_root(&lean); + + assert_eq!(via_beacon.0, via_lean.0); + } + + #[test] + #[should_panic(expected = "lean state reached a beacon accessor")] + fn a_lean_state_panics_in_a_beacon_accessor() { + // The guarantee is structural, not type-level: BeaconState::Lean is + // constructible anywhere, so this pins the failure mode to a named + // panic rather than a silent wrong answer. + let state = BeaconState::Lean(crate::state::State::from_genesis(0, Vec::new())); + let _ = state.slot(); + } + + #[test] + fn a_lean_block_answers_the_shared_accessors() { + let lean = crate::block::SignedBlock { + message: crate::block::Block { + slot: 9, + proposer_index: 3, + parent_root: crate::primitives::H256::from([1u8; 32]), + state_root: crate::primitives::H256::from([2u8; 32]), + body: Default::default(), + }, + proof: Default::default(), + }; + let block = SignedBeaconBlock::Lean(lean); + + assert_eq!(block.fork_name(), ForkName::Lean); + assert_eq!(block.slot(), 9); + assert_eq!(block.proposer_index(), 3); + assert_eq!( + block.parent_root(), + crate::primitives::H256::from([1u8; 32]) + ); + assert_eq!(block.state_root(), crate::primitives::H256::from([2u8; 32])); + } + + #[test] + fn a_lean_block_reports_its_body_root() { + let block = crate::block::SignedBlock { + message: crate::block::Block { + slot: 1, + proposer_index: 0, + parent_root: crate::primitives::H256::ZERO, + state_root: crate::primitives::H256::ZERO, + body: crate::block::BlockBody::default(), + }, + proof: crate::block::MultiMessageAggregate::default(), + }; + let expected = + crate::primitives::HashTreeRoot::hash_tree_root(&crate::block::BlockBody::default()); + + let wrapped = SignedBeaconBlock::Lean(block); + assert_eq!(wrapped.body_root(), expected); + } + + #[test] + #[should_panic(expected = "lean block reached a beacon accessor")] + fn a_lean_block_has_no_bls_signature() { + // A lean block carries a MultiMessageAggregate proof, not a + // BlsSignature, so this accessor has nothing to answer with. Named + // rather than silent, the same way the state accessors are. + let lean = crate::block::SignedBlock { + message: crate::block::Block { + slot: 0, + proposer_index: 0, + parent_root: crate::primitives::H256::ZERO, + state_root: crate::primitives::H256::ZERO, + body: Default::default(), + }, + proof: Default::default(), + }; + let _ = SignedBeaconBlock::Lean(lean).signature(); + } + + #[test] + fn slot_from_ssz_reads_the_slot_at_its_fixed_offset() { + // The prefix every beacon fork's BeaconState opens with: + // genesis_time (8) + genesis_validators_root (32) + slot (8). + let mut bytes = vec![0u8; 48]; + bytes[..8].copy_from_slice(&1_606_824_023u64.to_le_bytes()); + bytes[8..40].copy_from_slice(&[7u8; 32]); + bytes[40..48].copy_from_slice(&9_876_543u64.to_le_bytes()); + + assert_eq!(BeaconState::slot_from_ssz(&bytes).unwrap(), 9_876_543); + } + + #[test] + fn slot_from_ssz_rejects_a_buffer_too_short_to_hold_one() { + let bytes = vec![0u8; 47]; + assert!(BeaconState::slot_from_ssz(&bytes).is_err()); + } + + // -- execution_block_hash -- + + /// An otherwise-empty phase0 block, built field by field: phase0's + /// containers derive `Debug, Clone, PartialEq, Eq, SszEncode, SszDecode, + /// HashTreeRoot` but not `Default`, unlike their sub-fields. + /// + /// Only phase0 is built here. `execution_block_hash`'s implementation + /// groups `Phase0`, `Altair` and `Lean` into one match arm + /// (`Self::Phase0(_) | Self::Altair(_) | Self::Lean(_) => None`), so an + /// Altair block would only prove something about the enum, not about the + /// method; Lean's `SignedBlock` does not derive `Default` either, which + /// would make it the most expensive of the three to build for no extra + /// coverage. + fn empty_phase0_signed_block() -> phase0::SignedBeaconBlock { + phase0::SignedBeaconBlock { + message: phase0::BeaconBlock { + slot: 0, + proposer_index: 0, + parent_root: Root::default(), + state_root: Root::default(), + body: phase0::BeaconBlockBody { + randao_reveal: BlsSignature::default(), + eth1_data: Eth1Data::default(), + graffiti: Bytes32::default(), + proposer_slashings: Default::default(), + attester_slashings: Default::default(), + attestations: Default::default(), + deposits: Default::default(), + voluntary_exits: Default::default(), + }, + }, + signature: BlsSignature::default(), + } + } + + /// An otherwise-empty electra-shaped block whose execution payload's own + /// `block_hash` is `block_hash`. + /// + /// Also stands in for a fulu block: [`SignedBeaconBlock::Fulu`] wraps + /// this same [`electra::SignedBeaconBlock`] type rather than a + /// fulu-specific one (see that variant's own doc), so this builder is + /// shared rather than duplicated. + fn empty_electra_signed_block(block_hash: ExecutionBlockHash) -> electra::SignedBeaconBlock { + electra::SignedBeaconBlock { + message: electra::BeaconBlock { + slot: 0, + proposer_index: 0, + parent_root: Root::default(), + state_root: Root::default(), + body: electra::BeaconBlockBody { + randao_reveal: BlsSignature::default(), + eth1_data: Eth1Data::default(), + graffiti: Bytes32::default(), + proposer_slashings: Default::default(), + attester_slashings: Default::default(), + attestations: Default::default(), + deposits: Default::default(), + voluntary_exits: Default::default(), + sync_aggregate: altair::SyncAggregate::default(), + execution_payload: deneb::ExecutionPayload { + parent_hash: ExecutionBlockHash::default(), + fee_recipient: ExecutionAddress::default(), + state_root: Bytes32::default(), + receipts_root: Bytes32::default(), + // A fixed-length vector rather than a list, so unlike + // its neighbors it has no `Default`; sized by hand. + logs_bloom: bellatrix::LogsBloom::try_from(vec![ + 0u8; + preset::BYTES_PER_LOGS_BLOOM + ]) + .expect("built at exactly BYTES_PER_LOGS_BLOOM"), + prev_randao: Bytes32::default(), + block_number: 0, + gas_limit: 0, + gas_used: 0, + timestamp: 0, + extra_data: Default::default(), + base_fee_per_gas: Uint256::default(), + block_hash, + transactions: Default::default(), + withdrawals: Default::default(), + blob_gas_used: 0, + excess_blob_gas: 0, + }, + bls_to_execution_changes: Default::default(), + blob_kzg_commitments: Default::default(), + execution_requests: electra::ExecutionRequests { + deposits: Default::default(), + withdrawals: Default::default(), + consolidations: Default::default(), + }, + }, + }, + signature: BlsSignature::default(), + } + } + + #[test] + fn execution_block_hash_is_none_before_the_merge() { + let block = SignedBeaconBlock::Phase0(empty_phase0_signed_block()); + assert_eq!(block.execution_block_hash(), None); + } + + #[test] + fn execution_block_hash_reads_the_electra_payloads_own_hash() { + let expected = ExecutionBlockHash::repeat_byte(7); + let block = SignedBeaconBlock::Electra(empty_electra_signed_block(expected)); + assert_eq!(block.execution_block_hash(), Some(expected)); + } + + #[test] + fn execution_block_hash_reads_the_fulu_payloads_own_hash() { + // Kept separate from the electra test above: `Fulu` wraps + // `electra::SignedBeaconBlock` (an enum quirk explained on + // `SignedBeaconBlock::Fulu`'s own doc), so this pins that the *enum + // variant* reaches the right match arm, not just the wrapped struct. + let expected = ExecutionBlockHash::repeat_byte(7); + let block = SignedBeaconBlock::Fulu(empty_electra_signed_block(expected)); + assert_eq!(block.execution_block_hash(), Some(expected)); + } + + // -- body_root -- + + #[test] + fn a_beacon_blocks_body_root_is_its_bodys_own_merkle_root() { + // Computed independently of `body_root`'s own implementation, through + // the raw `libssz_merkle` trait rather than this crate's convenience + // wrapper, so a body_root that only ever ran on the lean arm would be + // caught here rather than passing by construction. + let signed = empty_phase0_signed_block(); + let body = signed.message.body.clone(); + let expected = crate::primitives::H256(libssz_merkle::HashTreeRoot::hash_tree_root( + &body, + &libssz_merkle::Sha2Hasher, + )); + + let block = SignedBeaconBlock::Phase0(signed); + assert_eq!(block.body_root(), expected); + } + + #[test] + fn a_fulu_block_reports_its_blob_commitments_and_payload_timestamp() { + use crate::beacon::containers::electra; + use crate::beacon::primitives::{KzgCommitment, Root}; + + let mut body = electra::BeaconBlockBody::empty(); + body.execution_payload.timestamp = 1_234; + body.blob_kzg_commitments = vec![KzgCommitment::default(); 3] + .try_into() + .expect("within MAX_BLOB_COMMITMENTS_PER_BLOCK"); + let block = SignedBeaconBlock::Fulu(electra::SignedBeaconBlock { + message: electra::BeaconBlock { + slot: 1, + proposer_index: 0, + parent_root: Root::ZERO, + state_root: Root::ZERO, + body, + }, + signature: Default::default(), + }); + + assert_eq!(block.blob_kzg_commitment_count(), 3); + assert_eq!(block.execution_payload_timestamp(), Some(1_234)); + } + + #[test] + fn a_phase0_block_has_no_blob_commitments_and_no_payload() { + use crate::beacon::containers::phase0; + use crate::beacon::primitives::Root; + + let block = SignedBeaconBlock::Phase0(phase0::SignedBeaconBlock { + message: phase0::BeaconBlock { + slot: 1, + proposer_index: 0, + parent_root: Root::ZERO, + state_root: Root::ZERO, + body: phase0::BeaconBlockBody { + randao_reveal: Default::default(), + eth1_data: Default::default(), + graffiti: Root::ZERO, + proposer_slashings: Default::default(), + attester_slashings: Default::default(), + attestations: Default::default(), + deposits: Default::default(), + voluntary_exits: Default::default(), + }, + }, + signature: Default::default(), + }); + + assert_eq!(block.blob_kzg_commitment_count(), 0); + assert_eq!(block.execution_payload_timestamp(), None); + } +} diff --git a/crates/common/types/src/beacon/containers/phase0.rs b/crates/common/types/src/beacon/containers/phase0.rs new file mode 100644 index 000000000..631dbd8e3 --- /dev/null +++ b/crates/common/types/src/beacon/containers/phase0.rs @@ -0,0 +1,298 @@ +//! Containers whose shape is specific to phase0. +//! +//! The distinguishing feature of phase0's state is that it accumulates whole +//! attestations, in `previous_epoch_attestations` and +//! `current_epoch_attestations`, and replays them at the epoch boundary to work +//! out who voted for what. Altair replaces that with per-validator participation +//! flags recorded as each attestation is processed, which is both cheaper and +//! bounded, and drops these two fields entirely. + +use libssz_derive::{HashTreeRoot, SszDecode, SszEncode}; +use libssz_types::{SszBitlist, SszList}; + +use super::shared::{ + AttestationData, BeaconBlockHeader, Checkpoint, Deposit, Eth1Data, Eth1DataVotes, Fork, + ProposerSlashing, SignedVoluntaryExit, +}; +use super::shared::{ + Balances, BlockRoots, HistoricalRoots, JustificationBits, RandaoMixes, Slashings, StateRoots, + Validators, +}; +use crate::beacon::preset; +use crate::beacon::primitives::{BlsSignature, Bytes32, Root, Slot, ValidatorIndex}; + +/// The attesters covered by one aggregate, as a bit per committee member. +/// +/// Bounded by the largest a single committee can be, since a phase0 attestation +/// covers exactly one committee. Electra widens this to a whole slot's worth of +/// committees. +pub type AggregationBits = SszBitlist<{ preset::MAX_VALIDATORS_PER_COMMITTEE }>; + +/// The attesters covered by one aggregate, named explicitly rather than as a +/// bitfield, which is the form signature verification needs. +pub type AttestingIndices = SszList; + +/// Attestations retained in the state, awaiting the epoch boundary. +pub type PendingAttestations = SszList; + +// --------------------------------------------------------------------------- +// Attestations +// --------------------------------------------------------------------------- + +/// An aggregate attestation, as gossiped and as included in a block. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +pub struct Attestation { + #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex::serialize")] + pub aggregation_bits: AggregationBits, + pub data: AttestationData, + /// The aggregate signature of every attester set in `aggregation_bits`, over + /// `data`. + pub signature: BlsSignature, +} + +/// An attestation with its attesters named rather than bit-encoded. +/// +/// Signature verification needs the public keys, which needs the indices, so the +/// state transition converts an [`Attestation`] into this form before checking +/// it. Slashing evidence is expressed in this form too, since a slashing has to +/// be checkable without the committee that produced it. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +pub struct IndexedAttestation { + /// The attesters, which the specification requires to be sorted and unique. + #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + pub attesting_indices: AttestingIndices, + pub data: AttestationData, + pub signature: BlsSignature, +} + +/// Evidence that a set of validators made two conflicting attestations. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +pub struct AttesterSlashing { + pub attestation_1: IndexedAttestation, + pub attestation_2: IndexedAttestation, +} + +/// An attestation retained in the state until the epoch boundary. +/// +/// Phase0 cannot score an attestation when it arrives, because the reward +/// depends on facts not yet settled, so it stores the attestation along with the +/// two things it will need later and defers the work. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +pub struct PendingAttestation { + #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex::serialize")] + pub aggregation_bits: AggregationBits, + pub data: AttestationData, + /// How many slots passed between the attested slot and the block that + /// included this attestation. Rewards scale inversely with it, which is what + /// pays for prompt attesting. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub inclusion_delay: Slot, + /// Who included it, so that the proposer reward can be paid at the epoch + /// boundary to a proposer identified when the attestation arrived. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub proposer_index: ValidatorIndex, +} + +// --------------------------------------------------------------------------- +// Blocks +// --------------------------------------------------------------------------- + +/// The contents of a block: everything the proposer chose to include. +#[derive( + Debug, Clone, Default, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot, +)] +pub struct BeaconBlockBody { + /// The proposer's contribution to the chain's randomness, which is a + /// signature over the current epoch and so cannot be chosen freely. + pub randao_reveal: BlsSignature, + /// The proposer's vote on the execution chain's deposit state. + pub eth1_data: Eth1Data, + /// Arbitrary proposer-chosen bytes, which consensus never reads. + pub graffiti: Bytes32, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub proposer_slashings: SszList, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub attester_slashings: SszList, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub attestations: SszList, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub deposits: SszList, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub voluntary_exits: SszList, +} + +/// A block. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +pub struct BeaconBlock { + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub slot: Slot, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub proposer_index: ValidatorIndex, + pub parent_root: Root, + /// The root of the state after this block is applied, which the state + /// transition recomputes and compares. + pub state_root: Root, + pub body: BeaconBlockBody, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +pub struct SignedBeaconBlock { + pub message: BeaconBlock, + pub signature: BlsSignature, +} + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +/// The phase0 beacon state: 21 fields, in the specification's order. +/// +/// Field order is load-bearing. SSZ encoding and merkleization both follow +/// declaration order, so reordering or omitting a field silently produces a +/// wrong `hash_tree_root`. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +pub struct BeaconState { + // -- Versioning -- + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub genesis_time: u64, + /// The root of the genesis validator registry, which separates this chain + /// from any other running the same fork schedule. + pub genesis_validators_root: Root, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub slot: Slot, + pub fork: Fork, + + // -- History -- + /// The most recent block's header, with `state_root` left zero until the slot + /// advances, since a block cannot commit to the root of the state containing + /// it. + pub latest_block_header: BeaconBlockHeader, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub block_roots: BlockRoots, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub state_roots: StateRoots, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub historical_roots: HistoricalRoots, + + // -- Eth1 -- + pub eth1_data: Eth1Data, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub eth1_data_votes: Eth1DataVotes, + /// How many deposits from the contract have been processed, which is where + /// the next one will be read from. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub eth1_deposit_index: u64, + + // -- Registry -- + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub validators: Validators, + #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + pub balances: Balances, + + // -- Randomness -- + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub randao_mixes: RandaoMixes, + + // -- Slashings -- + #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + pub slashings: Slashings, + + // -- Attestations -- + /// Attestations for the previous epoch, replayed at the epoch boundary. Only + /// phase0 has these; altair replaces them with participation flags. + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub previous_epoch_attestations: PendingAttestations, + /// Attestations for the current epoch, which become + /// `previous_epoch_attestations` at the next boundary. + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub current_epoch_attestations: PendingAttestations, + + // -- Finality -- + #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex::serialize")] + pub justification_bits: JustificationBits, + pub previous_justified_checkpoint: Checkpoint, + pub current_justified_checkpoint: Checkpoint, + pub finalized_checkpoint: Checkpoint, +} + +// --------------------------------------------------------------------------- +// Validator-side containers +// --------------------------------------------------------------------------- + +/// An aggregate together with proof that its aggregator was selected to produce +/// it. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +pub struct AggregateAndProof { + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub aggregator_index: ValidatorIndex, + pub aggregate: Attestation, + /// The aggregator's signature over the slot, which is what makes selection + /// verifiable rather than self-declared. + pub selection_proof: BlsSignature, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +pub struct SignedAggregateAndProof { + pub message: AggregateAndProof, + pub signature: BlsSignature, +} + +#[cfg(test)] +mod tests { + use libssz::{SszDecode as _, SszEncode as _}; + + use super::*; + + #[test] + fn pending_attestation_round_trips_through_ssz() { + let attestation = PendingAttestation { + aggregation_bits: { + let mut bits = AggregationBits::with_length(5).unwrap(); + bits.set(0, true).unwrap(); + bits.set(3, true).unwrap(); + bits + }, + data: AttestationData::default(), + inclusion_delay: 2, + proposer_index: 9, + }; + + let bytes = attestation.to_ssz(); + assert_eq!( + PendingAttestation::from_ssz_bytes(&bytes).unwrap(), + attestation + ); + } + + #[test] + fn variable_length_containers_carry_offsets() { + // Each of these holds at least one list or bitlist, so its encoding + // begins with offsets rather than being a fixed layout. The state, the + // body, and an attestation are the three that matter. + assert!(!::is_fixed_size()); + assert!(!::is_fixed_size()); + assert!(!::is_fixed_size()); + assert!(!::is_fixed_size()); + assert!(!::is_fixed_size()); + assert!(!::is_fixed_size()); + } + + #[test] + fn block_body_round_trips_while_empty() { + // An empty body is the common case for a skipped-operation slot, and it + // exercises every offset in the encoding with zero-length payloads. + let body = BeaconBlockBody { + randao_reveal: BlsSignature::default(), + eth1_data: Eth1Data::default(), + graffiti: Bytes32::ZERO, + proposer_slashings: Default::default(), + attester_slashings: Default::default(), + attestations: Default::default(), + deposits: Default::default(), + voluntary_exits: Default::default(), + }; + + let bytes = body.to_ssz(); + assert_eq!(BeaconBlockBody::from_ssz_bytes(&bytes).unwrap(), body); + } +} diff --git a/crates/common/types/src/beacon/containers/shared.rs b/crates/common/types/src/beacon/containers/shared.rs new file mode 100644 index 000000000..610a98ffd --- /dev/null +++ b/crates/common/types/src/beacon/containers/shared.rs @@ -0,0 +1,462 @@ +//! Containers whose definition does not change between forks, and the SSZ +//! collection aliases the per-fork modules build on. +//! +//! A container belongs here only if every fork that has it defines it +//! identically. Anything a fork reshapes lives in that fork's module instead, so +//! that a reader looking for "what changed in electra" finds it in one place. +//! +//! This module grows as forks land. It currently covers what phase0 needs. + +use std::collections::BTreeMap; + +use ethlambda_ssz_tree::List; +use libssz_derive::{HashTreeRoot, SszDecode, SszEncode}; +use libssz_types::{SszBitvector, SszList, SszVector}; + +use crate::beacon::constants; +use crate::beacon::preset; +use crate::beacon::primitives::{ + BlsPubkey, BlsSignature, Bytes32, CommitteeIndex, Domain, Epoch, Gwei, ParticipationFlags, + Root, Slot, ValidatorIndex, Version, +}; + +// --------------------------------------------------------------------------- +// Collection aliases +// --------------------------------------------------------------------------- +// +// A const-generic argument that is a path needs braces, so these read +// `{ preset::X }` rather than `preset::X`. + +/// The rolling window of recent block roots the state keeps, indexed by slot +/// modulo its length so it acts as a ring buffer. +pub type BlockRoots = SszVector; + +/// The rolling window of recent state roots, indexed the same way as +/// [`BlockRoots`]. +pub type StateRoots = SszVector; + +/// Accumulated roots of [`HistoricalBatch`], one appended per historical batch, +/// which is how the chain keeps a commitment to history older than the rolling +/// windows without keeping the roots themselves. +pub type HistoricalRoots = SszList; + +/// Eth1 data votes accumulated over one voting period, tallied and then reset. +pub type Eth1DataVotes = SszList; + +/// The validator registry. Append-only: a validator is never removed, only +/// exited, since indices are referenced by attestations and must stay stable. +/// +/// Kept in a persistent Merkle tree ([`List`]) rather than a `Vec`: a state +/// derived from another shares the unchanged part of the registry with it, and +/// re-hashing after a block only rehashes the records the block touched. +/// Writes are buffered in a `BTreeMap`, since they are rare and scattered, +/// until `BeaconState::apply_pending_mutations`. +pub type Validators = + List>; + +/// Balances, positionally parallel to [`Validators`]. +/// +/// Kept separate from the registry rather than as a `Validator` field because it +/// changes every epoch while the rest of a validator's record rarely does, and a +/// separate list means rewards do not redirty the registry's merkle tree. +/// +/// Tree-backed like [`Validators`], with writes buffered densely (the default +/// `VecMap`), since epoch processing writes every balance. +pub type Balances = List; + +/// Past randao mixes, indexed by epoch modulo the vector length, so the state +/// retains a bounded history of the beacon chain's randomness. +pub type RandaoMixes = SszVector; + +/// Slashed balance totals per epoch, indexed by epoch modulo the vector length. +/// Epoch processing reads the whole vector to size the proportional slashing +/// penalty, which is what makes correlated slashings cost more than isolated +/// ones. +pub type Slashings = SszVector; + +/// One bit per recent epoch recording whether it was justified, which is the +/// state that lets finalization look back over several epochs at once. +pub type JustificationBits = SszBitvector<{ constants::JUSTIFICATION_BITS_LENGTH }>; + +/// The merkle path proving a deposit against the deposit contract's root. +/// +/// One longer than the contract's tree depth, since the extra node is the +/// mix-in of the deposit count. +pub type DepositProof = SszVector; + +/// Per-validator participation flags for one epoch, positionally parallel to +/// [`Validators`]. Altair uses this in place of phase0's accumulated +/// attestations. +pub type EpochParticipation = SszList; + +/// Per-validator inactivity scores, positionally parallel to [`Validators`] +/// (altair and later). +pub type InactivityScores = SszList; + +/// Accumulated [`HistoricalSummary`] entries, which replace [`HistoricalRoots`] +/// as the commitment to history from capella onward. +pub type HistoricalSummaries = SszList; + +// --------------------------------------------------------------------------- +// Misc +// --------------------------------------------------------------------------- + +/// Which fork the chain is on, and which one it came from. +/// +/// Both versions are kept because a signature is verified under the fork version +/// in effect when the message was signed, so a message from just before a fork +/// boundary still verifies just after it. +#[derive( + Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot, +)] +pub struct Fork { + /// The version in effect before [`Fork::epoch`]. + #[serde(with = "crate::beacon::serde_helpers::hex_array")] + pub previous_version: Version, + /// The version in effect from [`Fork::epoch`] onward. + #[serde(with = "crate::beacon::serde_helpers::hex_array")] + pub current_version: Version, + /// The epoch at which `current_version` took effect. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub epoch: Epoch, +} + +/// A fork version paired with the chain's genesis validators root, hashed +/// together to produce a signing domain. +/// +/// Including the genesis validators root is what separates two chains running +/// the same fork schedule: a signature from one never verifies on the other. +#[derive( + Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot, +)] +pub struct ForkData { + #[serde(with = "crate::beacon::serde_helpers::hex_array")] + pub current_version: Version, + pub genesis_validators_root: Root, +} + +/// An epoch and the block root at its start: what attestations vote on and what +/// justification and finalization track. +#[derive( + Debug, + Clone, + Copy, + Default, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + SszEncode, + SszDecode, + HashTreeRoot, +)] +pub struct Checkpoint { + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub epoch: Epoch, + /// The root of the first block of [`Checkpoint::epoch`], or of the most + /// recent block before it if that slot was empty. + pub root: Root, +} + +/// A registry entry for one validator. +#[derive( + Debug, Clone, Default, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot, +)] +pub struct Validator { + /// The key attestations and proposals are signed with. + pub pubkey: BlsPubkey, + /// Where a withdrawal pays out. The first byte selects how the rest is + /// interpreted; see the prefixes in [`crate::beacon::constants`]. + pub withdrawal_credentials: Bytes32, + /// The balance actually used for voting weight and rewards, which is the + /// real balance rounded down to a multiple of `EFFECTIVE_BALANCE_INCREMENT` + /// and capped. Rounding with hysteresis is what keeps the merkle tree from + /// being redirtied by every small balance change. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub effective_balance: Gwei, + pub slashed: bool, + /// The epoch the validator's balance first reached the activation minimum, + /// making it a candidate for activation. Distinct from + /// [`Validator::activation_epoch`], which is when the churn limit actually + /// let it in: eligibility is immediate, activation is queued. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub activation_eligibility_epoch: Epoch, + /// The epoch the validator became active. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub activation_epoch: Epoch, + /// The epoch the validator stops being active. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub exit_epoch: Epoch, + /// The epoch the validator's balance may be withdrawn, which lags + /// [`Validator::exit_epoch`] so that slashable offences remain punishable + /// for a while after exit. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub withdrawable_epoch: Epoch, +} + +/// What an attestation actually attests to. +#[derive( + Debug, + Clone, + Copy, + Default, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + SszEncode, + SszDecode, + HashTreeRoot, +)] +pub struct AttestationData { + /// The slot being attested for. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub slot: Slot, + /// Which of the slot's committees the attester belongs to. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub index: CommitteeIndex, + /// The attester's view of the head of the chain, which is the LMD GHOST + /// vote. + pub beacon_block_root: Root, + /// The justified checkpoint the attester builds on, which is the FFG vote's + /// source. + pub source: Checkpoint, + /// The checkpoint the attester is trying to justify, which is the FFG vote's + /// target. + pub target: Checkpoint, +} + +/// The execution chain's deposit state, as voted on by proposers. +#[derive( + Debug, Clone, Default, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot, +)] +pub struct Eth1Data { + /// The deposit contract's merkle root at this point. + pub deposit_root: Root, + /// The total number of deposits the contract has ever seen, not the number + /// still unprocessed. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub deposit_count: u64, + pub block_hash: Root, +} + +/// The parts of an execution layer block a proposer needs in order to vote on +/// [`Eth1Data`]. +/// +/// The specification defines only these three fields and notes the rest are +/// omitted, since nothing in consensus reads them. +#[derive( + Debug, Clone, Default, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot, +)] +pub struct Eth1Block { + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub timestamp: u64, + pub deposit_root: Root, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub deposit_count: u64, +} + +/// A window of block and state roots, whose root is appended to +/// [`HistoricalRoots`] once the window is full. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +pub struct HistoricalBatch { + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub block_roots: BlockRoots, + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub state_roots: StateRoots, +} + +/// A commitment to one historical window, replacing [`HistoricalBatch`] roots +/// from capella onward. +/// +/// Keeping the two roots separately, rather than hashing them together as +/// [`HistoricalBatch`] does, is what lets a light client prove a block root +/// against history without also having the state roots. +#[derive( + Debug, Clone, Default, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot, +)] +pub struct HistoricalSummary { + pub block_summary_root: Root, + pub state_summary_root: Root, +} + +/// A root paired with the domain it is signed under, hashed together to give the +/// message a signature actually covers. +#[derive( + Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot, +)] +pub struct SigningData { + pub object_root: Root, + #[serde(with = "crate::beacon::serde_helpers::hex_array")] + pub domain: Domain, +} + +// --------------------------------------------------------------------------- +// Deposits +// --------------------------------------------------------------------------- + +/// The part of a deposit a depositor signs. +/// +/// Separate from [`DepositData`] precisely because the signature cannot cover +/// itself: the signature in `DepositData` is over the `DepositMessage` with the +/// same fields. +#[derive( + Debug, Clone, Default, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot, +)] +pub struct DepositMessage { + pub pubkey: BlsPubkey, + pub withdrawal_credentials: Bytes32, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub amount: Gwei, +} + +/// A deposit as recorded by the deposit contract. +#[derive( + Debug, Clone, Default, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot, +)] +pub struct DepositData { + pub pubkey: BlsPubkey, + pub withdrawal_credentials: Bytes32, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub amount: Gwei, + /// Proof of possession over the corresponding [`DepositMessage`]. An invalid + /// signature does not make the deposit invalid: it is simply not credited to + /// a new validator, which is why the public key here is never assumed to be a + /// valid curve point. + pub signature: BlsSignature, +} + +/// A deposit together with its merkle proof against the deposit contract root. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +pub struct Deposit { + #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + pub proof: DepositProof, + pub data: DepositData, +} + +// --------------------------------------------------------------------------- +// Block headers and operations +// --------------------------------------------------------------------------- + +/// A block without its body, which is what the state retains and what proposer +/// slashings compare. +#[derive( + Debug, Clone, Default, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot, +)] +pub struct BeaconBlockHeader { + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub slot: Slot, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub proposer_index: ValidatorIndex, + pub parent_root: Root, + /// The root of the state after applying this block. Left zero in the state's + /// own copy of the latest header until the slot advances, since a block + /// cannot commit to the root of the state that contains it. + pub state_root: Root, + /// The root of the body, which is what lets the header stand in for the + /// whole block. + pub body_root: Root, +} + +#[derive( + Debug, Clone, Default, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot, +)] +pub struct SignedBeaconBlockHeader { + pub message: BeaconBlockHeader, + pub signature: BlsSignature, +} + +/// Evidence that a proposer signed two different blocks for the same slot. +#[derive( + Debug, Clone, Default, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot, +)] +pub struct ProposerSlashing { + pub signed_header_1: SignedBeaconBlockHeader, + pub signed_header_2: SignedBeaconBlockHeader, +} + +/// A validator's request to stop validating. +#[derive( + Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot, +)] +pub struct VoluntaryExit { + /// The earliest epoch the exit may be processed at. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub epoch: Epoch, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub validator_index: ValidatorIndex, +} + +#[derive( + Debug, Clone, Default, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot, +)] +pub struct SignedVoluntaryExit { + pub message: VoluntaryExit, + pub signature: BlsSignature, +} + +#[cfg(test)] +mod tests { + use libssz::{SszDecode as _, SszEncode as _}; + + use super::*; + + #[test] + fn checkpoint_is_fixed_size() { + // An epoch and a root, with nothing variable-length, so the encoding has + // one length for every value. + assert!(::is_fixed_size()); + assert_eq!(::fixed_size(), 8 + 32); + } + + #[test] + fn validator_round_trips_through_ssz() { + let validator = Validator { + pubkey: BlsPubkey([3; 48]), + withdrawal_credentials: Bytes32::repeat_byte(7), + effective_balance: 32_000_000_000, + slashed: true, + activation_eligibility_epoch: 1, + activation_epoch: 2, + exit_epoch: 3, + withdrawable_epoch: 4, + }; + + let bytes = validator.to_ssz(); + assert_eq!(Validator::from_ssz_bytes(&bytes).unwrap(), validator); + } + + #[test] + fn deposit_is_fixed_size_despite_holding_a_proof() { + // Every field of a Deposit is itself fixed-length, including the proof + // vector, so the container is fixed-size and carries no offsets. + assert!(::is_fixed_size()); + } + + #[test] + fn every_fork_invariant_container_is_fixed_size() { + // Worth asserting rather than assuming: none of the containers in this + // module has a variable-length field, not even the ones holding + // collections, since those collections are all `Vector`s of fixed-size + // elements. Every variable-length container in phase0 (`Attestation` with + // its bitlist, `BeaconBlockBody` with its operation lists, `BeaconState`) + // is fork-specific and lives elsewhere. So an offset appearing anywhere + // in this module's encodings would mean something changed shape. + assert!(::is_fixed_size()); + assert!(::is_fixed_size()); + assert!(::is_fixed_size()); + assert!(::is_fixed_size()); + assert!(::is_fixed_size()); + assert!(::is_fixed_size()); + assert!(::is_fixed_size()); + assert!(::is_fixed_size()); + assert!(::is_fixed_size()); + assert!(::is_fixed_size()); + assert!(::is_fixed_size()); + assert!(::is_fixed_size()); + assert!(::is_fixed_size()); + assert!(::is_fixed_size()); + assert!(::is_fixed_size()); + assert!(::is_fixed_size()); + } +} diff --git a/crates/common/types/src/beacon/error.rs b/crates/common/types/src/beacon/error.rs new file mode 100644 index 000000000..6db1db528 --- /dev/null +++ b/crates/common/types/src/beacon/error.rs @@ -0,0 +1,70 @@ +//! The crate's error type. +//! +//! The specification expresses validity conditions as `assert` statements. Each +//! one becomes an [`Error::SpecAssert`] carrying the condition it checked, which +//! keeps the enum small while still naming the failing rule in test output. The +//! structured variants exist for conditions worth inspecting programmatically +//! or worth reporting with their values. + +use crate::beacon::fork::ForkName; + +pub type Result = core::result::Result; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// A validity condition from the specification did not hold. The message is + /// the condition, phrased as the spec phrases it. + #[error("spec assertion failed: {0}")] + SpecAssert(&'static str), + + /// A function that the specification introduces in a later fork was called + /// on an earlier state. + #[error("{function} does not exist in {fork}")] + UnsupportedForFork { + function: &'static str, + fork: ForkName, + }, + + #[error("index {index} out of bounds, length {len}")] + IndexOutOfBounds { index: usize, len: usize }, + + #[error("no validator at index {0}")] + UnknownValidator(u64), + + #[error("arithmetic overflow in {0}")] + ArithmeticOverflow(&'static str), + + #[error("signature verification failed for {0}")] + InvalidSignature(&'static str), + + #[error("SSZ decoding failed: {0:?}")] + SszDecode(libssz::DecodeError), + + /// A bounded SSZ collection was given more elements than its type permits. + #[error("SSZ type error: {0:?}")] + SszType(libssz_types::TypeError), +} + +impl From for Error { + fn from(err: libssz::DecodeError) -> Self { + Error::SszDecode(err) + } +} + +impl From for Error { + fn from(err: libssz_types::TypeError) -> Self { + Error::SszType(err) + } +} + +/// Returns `Err(Error::SpecAssert)` unless `condition` holds. +/// +/// Mirrors the specification's `assert` statements, so a reader can match the +/// implementation against the spec line by line. +pub fn verify(condition: bool, what: &'static str) -> Result<()> { + if condition { + Ok(()) + } else { + Err(Error::SpecAssert(what)) + } +} diff --git a/crates/common/types/src/beacon/fork.rs b/crates/common/types/src/beacon/fork.rs new file mode 100644 index 000000000..b9c7fc4ae --- /dev/null +++ b/crates/common/types/src/beacon/fork.rs @@ -0,0 +1,272 @@ +//! Fork identity. +//! +//! The variant order is the fork order, so the derived [`Ord`] is the comparison +//! the state transition uses to gate behavior: `fork >= ForkName::Altair` reads +//! as "altair or later", matching how the specification introduces changes. + +use core::fmt; + +use crate::beacon::preset; + +/// How many slots apart full state snapshots are written for lean, in +/// [`ForkName::snapshot_interval`]. +/// +/// A slot count, not a duration: the reconstruction walk costs the same per +/// slot whatever the configured cadence is. ~68 minutes at the default +/// 4-second slots. +/// +/// Matches what `crates/storage/src/store.rs`'s `SNAPSHOT_ANCHOR_INTERVAL` +/// used before that value moved here, so lean's on-disk layout is unchanged. +/// Snapshots bound a diff-chain reconstruction walk to at most this many +/// steps. +const LEAN_SNAPSHOT_INTERVAL: u64 = 1_024; + +/// How many slots apart full state snapshots are written for the Beacon +/// Chain, in [`ForkName::snapshot_interval`]: one epoch, so a +/// reconstruction fold is bounded at one epoch of blocks plus a single +/// decode. +const BEACON_SNAPSHOT_INTERVAL: u64 = preset::SLOTS_PER_EPOCH; + +/// A named fork of the Beacon Chain, ordered oldest to newest, followed by +/// Lean. +/// +/// The variant order is the fork order, so the derived [`Ord`] is the comparison +/// the state transition uses to gate behavior: `fork >= ForkName::Altair` reads +/// as "altair or later". +/// +/// [`ForkName::Lean`] is last so that every such gate reads as true for a lean +/// state, and is deliberately absent from [`ForkName::ALL`]: lean is not a point +/// on the Beacon Chain's fork timeline, has no spec fixtures, and must never be +/// a target of the beacon STF's `upgrade`-style traversal. See +/// [`ForkName::ALL`]. +/// +/// Forks after fulu exist upstream but are out of scope for this crate. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ForkName { + Phase0, + Altair, + Bellatrix, + Capella, + Deneb, + Electra, + Fulu, + /// The Lean consensus protocol, which this repository implements alongside + /// the Beacon Chain. Not a Beacon Chain fork, and not in [`ForkName::ALL`]. + Lean, +} + +impl ForkName { + /// Every *Beacon Chain* fork this crate implements, in order. + /// + /// [`ForkName::Lean`] is not here: it is not a Beacon Chain fork. Because + /// `parse`, `previous`, `next`, and the spec-fixture harness all search this + /// array, its absence is what makes `parse("lean")` return `None`, keeps + /// `Fulu.next()` at `None`, and stops any fixture directory from resolving + /// to a lean case. + pub const ALL: [ForkName; 7] = [ + ForkName::Phase0, + ForkName::Altair, + ForkName::Bellatrix, + ForkName::Capella, + ForkName::Deneb, + ForkName::Electra, + ForkName::Fulu, + ]; + + /// The lowercase name the specification and its fixture paths use. + pub fn as_str(self) -> &'static str { + match self { + ForkName::Phase0 => "phase0", + ForkName::Altair => "altair", + ForkName::Bellatrix => "bellatrix", + ForkName::Capella => "capella", + ForkName::Deneb => "deneb", + ForkName::Electra => "electra", + ForkName::Fulu => "fulu", + ForkName::Lean => "lean", + } + } + + /// Parses a fork name as written in the specification and its fixture paths. + /// + /// Returns `None` for forks outside this crate's scope, which lets fixture + /// runners skip unsupported directories rather than fail on them. + pub fn parse(name: &str) -> Option { + ForkName::ALL.into_iter().find(|f| f.as_str() == name) + } + + /// The fork immediately before this one, or `None` for phase0. + pub fn previous(self) -> Option { + let index = ForkName::ALL.iter().position(|f| *f == self)?; + index.checked_sub(1).map(|i| ForkName::ALL[i]) + } + + /// The fork immediately after this one, or `None` for the newest. + pub fn next(self) -> Option { + let index = ForkName::ALL.iter().position(|f| *f == self)?; + ForkName::ALL.get(index + 1).copied() + } + + /// The one-byte tag this fork is stored under in a `States` value. + /// + /// Spelled out rather than `self as u8`. The variant order is already + /// load-bearing for the derived [`Ord`] (see this enum's own doc), so + /// deriving the on-disk tag from it too would mean a reorder made for the + /// ordering's sake silently reinterpreted every state already written. + /// + /// [`ForkName::Lean`] takes 255 rather than 7 so that the beacon forks after + /// fulu can keep taking the next free value as they land. + pub const fn selector(self) -> u8 { + match self { + ForkName::Phase0 => 0, + ForkName::Altair => 1, + ForkName::Bellatrix => 2, + ForkName::Capella => 3, + ForkName::Deneb => 4, + ForkName::Electra => 5, + ForkName::Fulu => 6, + ForkName::Lean => 255, + } + } + + /// How many slots apart full state snapshots are written for this fork. + /// + /// A storage tuning parameter, not a consensus one, which is why it lives + /// here rather than on `Config`: putting it in a config a chain agrees on + /// would imply the two had to agree on it. + /// + /// Lean's interval does not survive contact with a ~350 MB beacon state, + /// since the reconstruction fold would apply that many deltas; beacon + /// takes an epoch, so the fold is bounded at one epoch of blocks plus a + /// single decode. + pub const fn snapshot_interval(self) -> u64 { + match self { + ForkName::Lean => LEAN_SNAPSHOT_INTERVAL, + _ => BEACON_SNAPSHOT_INTERVAL, + } + } + + /// The inverse of [`ForkName::selector`]. + /// + /// `None` for a byte this build does not know, which means a corrupt or + /// future-format database rather than anything a caller can recover from. + pub const fn from_selector(byte: u8) -> Option { + match byte { + 0 => Some(ForkName::Phase0), + 1 => Some(ForkName::Altair), + 2 => Some(ForkName::Bellatrix), + 3 => Some(ForkName::Capella), + 4 => Some(ForkName::Deneb), + 5 => Some(ForkName::Electra), + 6 => Some(ForkName::Fulu), + 255 => Some(ForkName::Lean), + _ => None, + } + } +} + +impl fmt::Display for ForkName { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ordering_follows_fork_order() { + assert!(ForkName::Phase0 < ForkName::Altair); + assert!(ForkName::Deneb < ForkName::Electra); + assert!(ForkName::Fulu > ForkName::Phase0); + } + + #[test] + fn parse_round_trips_every_fork() { + for fork in ForkName::ALL { + assert_eq!(ForkName::parse(fork.as_str()), Some(fork)); + } + assert_eq!(ForkName::parse("gloas"), None); + } + + #[test] + fn neighbours_terminate_at_the_ends() { + assert_eq!(ForkName::Phase0.previous(), None); + assert_eq!(ForkName::Fulu.next(), None); + assert_eq!(ForkName::Altair.previous(), Some(ForkName::Phase0)); + assert_eq!(ForkName::Altair.next(), Some(ForkName::Bellatrix)); + } + + #[test] + fn lean_sorts_after_every_beacon_fork() { + // "Lean is the next fork": every `fork >= ForkName::X` gate in the + // state transition reads as true for a lean state. + for fork in ForkName::ALL { + assert!(ForkName::Lean > fork, "Lean must outrank {fork}"); + } + } + + #[test] + fn lean_is_not_a_beacon_fork() { + // ALL drives `parse`, `previous`, `next`, and the fixture harness's + // test-list construction. Lean is outside all four. + assert!(!ForkName::ALL.contains(&ForkName::Lean)); + assert_eq!(ForkName::parse("lean"), None); + assert_eq!(ForkName::Lean.next(), None); + assert_eq!(ForkName::Lean.previous(), None); + } + + #[test] + fn fulu_is_still_the_last_beacon_fork() { + // Guards the reason Lean is kept out of ALL: adding it there would make + // this None into Some(Lean) and let `upgrade` walk off the end. + assert_eq!(ForkName::Fulu.next(), None); + } + + #[test] + fn lean_has_a_name() { + assert_eq!(ForkName::Lean.as_str(), "lean"); + } + + #[test] + fn every_fork_round_trips_through_its_selector() { + for fork in ForkName::ALL { + assert_eq!(ForkName::from_selector(fork.selector()), Some(fork)); + } + // Lean is outside ALL but is exactly the value the tag exists to + // distinguish, so it is checked separately rather than left out. + assert_eq!( + ForkName::from_selector(ForkName::Lean.selector()), + Some(ForkName::Lean) + ); + } + + #[test] + fn the_snapshot_interval_is_per_chain() { + // Lean's interval must match what the storage layer used before this + // was factored out, or lean's on-disk layout silently changes. + assert_eq!(ForkName::Lean.snapshot_interval(), LEAN_SNAPSHOT_INTERVAL); + assert_eq!( + ForkName::Electra.snapshot_interval(), + BEACON_SNAPSHOT_INTERVAL + ); + assert_ne!( + ForkName::Lean.snapshot_interval(), + ForkName::Electra.snapshot_interval() + ); + } + + #[test] + fn selectors_are_pinned_to_their_on_disk_values() { + // These bytes are a storage format: changing one makes every existing + // database decode as the wrong fork. Asserted literally rather than + // derived from the variant order, which the derived Ord already owns. + assert_eq!(ForkName::Phase0.selector(), 0); + assert_eq!(ForkName::Fulu.selector(), 6); + // Lean sits at the top of the byte range so gloas and heze can keep + // taking the next free value after fulu. + assert_eq!(ForkName::Lean.selector(), 255); + assert_eq!(ForkName::from_selector(7), None); + } +} diff --git a/crates/common/types/src/beacon/fork_choice.rs b/crates/common/types/src/beacon/fork_choice.rs new file mode 100644 index 000000000..7002466d1 --- /dev/null +++ b/crates/common/types/src/beacon/fork_choice.rs @@ -0,0 +1,142 @@ +//! Fork-choice-adjacent data that is neither a block nor a state. +//! +//! Two kinds share this file. `LatestMessage` and `PowBlock` are SSZ +//! consensus containers moved out of the `beacon::fork_choice` module of +//! `ethlambda-state-transition`, which re-exports both at their old paths so +//! every use site inside it is unchanged. `PayloadStatusEnum` and +//! `PayloadStatusV1` are plain Engine-API-shaped data with no such former +//! home. All four live here rather than there for the same reason: the +//! DB-backed `ethlambda_storage::Store` holds them, and `ethlambda-storage` +//! cannot depend on `ethlambda-state-transition`, which pulls in `blst` and +//! `c-kzg`. + +use libssz_derive::{HashTreeRoot, SszDecode, SszEncode}; + +use crate::beacon::primitives::{Epoch, ExecutionBlockHash, Root, Uint256}; + +/// One validator's most recent attestation: the epoch it targeted, and the +/// block it attested to (the LMD GHOST vote). +/// +/// `Copy`, matching the specification's `@dataclass(eq=True, frozen=True)`: +/// there is nothing here worth borrowing rather than copying. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LatestMessage { + pub epoch: Epoch, + pub root: Root, +} + +/// The execution chain's own block header, as far as bellatrix's merge +/// transition check needs it: `specs/bellatrix/fork-choice.md`'s `PowBlock`. +/// +/// The specification's own `get_pow_block(hash) -> Optional[PowBlock]` is +/// "implementation and context dependent": a real client would ask its +/// execution engine. The fork choice store's own record of these, populated by +/// the fixture suites' `on_merge_block` step, is what stands in for that. +/// +/// Defined at the top level here, unlike in its former home: this module has no +/// `Result` alias of its own for the `SszDecode` derive's generated code to +/// collide with, so the nested module that used to shield it is gone. +#[derive(Debug, Clone, Copy, PartialEq, Eq, SszEncode, SszDecode, HashTreeRoot)] +pub struct PowBlock { + pub block_hash: Root, + pub parent_hash: Root, + /// The total work behind `block_hash`, compared against + /// [`crate::beacon::config::Config::terminal_total_difficulty`] to decide + /// whether this is the one PoW block the merge transitioned at. + pub total_difficulty: Uint256, +} + +/// The status an execution client returns for a payload. +/// +/// `PayloadStatusV1.status` from the Engine API's `paris.md`. The two aliases +/// `optimistic-sync.md` defines over it are methods rather than a second enum: +/// `INVALIDATED` is `Invalid` or `InvalidBlockHash`, and `NOT_VALIDATED` is +/// `Syncing` or `Accepted`. Naming them here keeps every call site reading the +/// specification's own word for the case it is handling. +/// +/// Named with the `Enum` suffix, rather than plain `PayloadStatus`, so it can +/// sit next to [`PayloadStatusV1`] without the two names clashing; +/// `alloy-rpc-types-engine` pairs the same two names for the same reason, so +/// an EL-integration reader already knows which is which. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PayloadStatusEnum { + Valid, + Invalid, + Syncing, + Accepted, + InvalidBlockHash, +} + +impl PayloadStatusEnum { + /// `optimistic-sync.md`'s `INVALIDATED` alias. + pub fn is_invalidated(self) -> bool { + matches!(self, Self::Invalid | Self::InvalidBlockHash) + } + + /// `optimistic-sync.md`'s `NOT_VALIDATED` alias. + pub fn is_not_validated(self) -> bool { + matches!(self, Self::Syncing | Self::Accepted) + } +} + +/// An execution client's full answer about one payload. +/// +/// `PayloadStatusV1` from the Engine API's `paris.md`. Held in the store's +/// beacon scratch keyed by execution block hash, the same way [`PowBlock`] is +/// held keyed by its own hash and for the same reason: both stand in for a +/// call to an execution client, and the fork choice fixture format seeds both +/// through a step of its own. +/// +/// `validation_error` is kept rather than dropped even though nothing branches +/// on it: it is the only place an execution client explains *why* it rejected +/// a payload, and losing it would make an `INVALID` verdict unattributable in +/// a log. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PayloadStatusV1 { + pub status: PayloadStatusEnum, + /// The most recent valid block hash on the branch, when the client can + /// name one. `None` is the specification's `null`. + pub latest_valid_hash: Option, + pub validation_error: Option, +} + +#[cfg(test)] +mod tests { + use libssz::{SszDecode as _, SszEncode as _}; + + use super::*; + + #[test] + fn a_pow_block_round_trips_through_ssz() { + // The store persists these, so the derive has to survive the move out + // of `ethlambda-state-transition` intact. + let block = PowBlock { + block_hash: Root::repeat_byte(1), + parent_hash: Root::repeat_byte(2), + total_difficulty: Uint256::from(3u64), + }; + let bytes = block.to_ssz(); + assert_eq!( + PowBlock::from_ssz_bytes(&bytes).expect("valid pow block"), + block + ); + } + + #[test] + fn the_two_invalid_statuses_are_the_invalidated_alias() { + assert!(PayloadStatusEnum::Invalid.is_invalidated()); + assert!(PayloadStatusEnum::InvalidBlockHash.is_invalidated()); + assert!(!PayloadStatusEnum::Valid.is_invalidated()); + assert!(!PayloadStatusEnum::Syncing.is_invalidated()); + assert!(!PayloadStatusEnum::Accepted.is_invalidated()); + } + + #[test] + fn the_two_pending_statuses_are_the_not_validated_alias() { + assert!(PayloadStatusEnum::Syncing.is_not_validated()); + assert!(PayloadStatusEnum::Accepted.is_not_validated()); + assert!(!PayloadStatusEnum::Valid.is_not_validated()); + assert!(!PayloadStatusEnum::Invalid.is_not_validated()); + assert!(!PayloadStatusEnum::InvalidBlockHash.is_not_validated()); + } +} diff --git a/crates/common/types/src/beacon/fork_digest.rs b/crates/common/types/src/beacon/fork_digest.rs new file mode 100644 index 000000000..69a42488a --- /dev/null +++ b/crates/common/types/src/beacon/fork_digest.rs @@ -0,0 +1,161 @@ +//! The four bytes that separate one network, fork, and blob schedule from +//! another on the wire. +//! +//! Lives here rather than in `ethlambda-state-transition` because the networking +//! crate needs it and must not depend on the state transition: that crate's +//! `beacon` module pulls in `blst` and `c-kzg`, neither of which a gossip topic +//! name has any business requiring. That module's `helpers::misc` re-exports +//! [`compute_fork_data_root`] at its old path. + +use sha2::{Digest as _, Sha256}; + +use crate::beacon::config::Config; +use crate::beacon::constants; +use crate::beacon::containers::shared::ForkData; +use crate::beacon::fork::ForkName; +use crate::beacon::primitives::{Epoch, ForkDigest, HashTreeRoot as _, Root, Version}; + +/// The root binding a fork version to a chain's genesis validator set. +/// +/// Mixing both into every signing domain and into the fork digest is what keeps +/// a signature, or a gossip topic, from one chain or fork from being valid on +/// another. +pub fn compute_fork_data_root(current_version: Version, genesis_validators_root: Root) -> Root { + ForkData { + current_version, + genesis_validators_root, + } + .hash_tree_root() +} + +/// The four bytes every gossip topic name and the `eth2` ENR entry carry, for a +/// chain with this schedule, this genesis validator set, and this epoch. +/// +/// This is fulu's `compute_fork_digest` (EIP-7892). Before fulu the digest is +/// simply the fork data root's first four bytes. From fulu on, the blob +/// parameters are xored in, so that a blob-parameter-only fork moves the digest +/// and therefore the topic names without needing a new fork version. +pub fn compute_fork_digest( + config: &Config, + genesis_validators_root: Root, + epoch: Epoch, +) -> ForkDigest { + let fork = config.fork_at_epoch(epoch); + let base = compute_fork_data_root(config.fork_version(fork), genesis_validators_root); + + if fork < ForkName::Fulu { + return base.0[..4].try_into().expect("a Root is 32 bytes"); + } + + let (bp_epoch, bp_max_blobs) = config.blob_parameters(epoch); + let mut hasher = Sha256::new(); + hasher.update(bp_epoch.to_le_bytes()); + hasher.update(bp_max_blobs.to_le_bytes()); + let mask = hasher.finalize(); + + core::array::from_fn(|index| base.0[index] ^ mask[index]) +} + +/// The next epoch at which [`compute_fork_digest`] changes, if there is one. +/// +/// Both fork activations and blob-schedule entries qualify: crossing either one +/// strands a running node on topic names no peer is publishing to. Unscheduled +/// forks carry [`constants::FAR_FUTURE_EPOCH`], a real value rather than a +/// `None`, so they are filtered out before the minimum is taken. +pub fn next_fork_boundary(config: &Config, epoch: Epoch) -> Option { + ForkName::ALL + .into_iter() + .map(|fork| config.fork_epoch(fork)) + .chain(config.blob_schedule.iter().map(|entry| entry.epoch)) + .filter(|&boundary| boundary != constants::FAR_FUTURE_EPOCH && boundary > epoch) + .min() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::beacon::config::Config; + use crate::beacon::primitives::Root; + + /// Ethereum mainnet's `genesis_validators_root`. + fn mainnet_gvr() -> Root { + Root::from_slice( + &hex::decode("4b363db94e286120d76eb905340fdd4e54bfe9f06bf33ff6cf5ad27f511bfe95") + .expect("valid hex"), + ) + } + + #[test] + fn mainnet_digests_match_the_ones_observed_on_the_wire() { + // Every value here was read off a live mainnet discv5 crawl and is + // recorded in docs/discovery.md. Both branches of the fulu rule are + // covered: the pre-fulu truncation and the EIP-7892 blob-parameter xor. + let config = Config::mainnet(); + let gvr = mainnet_gvr(); + let cases = [ + // phase0, still advertised by bootnode records never re-published. + (0u64, [0xb5, 0x30, 0x3f, 0x2a]), + // electra. + (364_032u64, [0xad, 0x53, 0x2c, 0xeb]), + // fulu, before the first blob-schedule entry: the parameters fall + // back to (electra_fork_epoch, max_blobs_per_block_electra). + (411_392u64, [0xcc, 0x2c, 0x5c, 0xdb]), + // fulu, first BPO fork. + (412_672u64, [0xcb, 0x0d, 0x1a, 0xcc]), + // fulu, second BPO fork: mainnet's current digest. + (419_072u64, [0x8c, 0x9f, 0x62, 0xfe]), + ]; + for (epoch, expected) in cases { + assert_eq!( + compute_fork_digest(&config, gvr, epoch), + expected, + "digest at epoch {epoch}" + ); + } + } + + #[test] + fn the_digest_holds_between_boundaries() { + // A digest that changed every epoch would mean the node re-subscribed + // constantly; it must only move at a fork or blob-schedule boundary. + let config = Config::mainnet(); + let gvr = mainnet_gvr(); + assert_eq!( + compute_fork_digest(&config, gvr, 419_072), + compute_fork_digest(&config, gvr, 419_072 + 5_000) + ); + assert_ne!( + compute_fork_digest(&config, gvr, 419_071), + compute_fork_digest(&config, gvr, 419_072) + ); + } + + #[test] + fn next_boundary_covers_both_fork_and_blob_schedule_epochs() { + let config = Config::mainnet(); + // A plain fork boundary. + assert_eq!(next_fork_boundary(&config, 0), Some(74_240)); + // A blob-parameter-only fork is a boundary too: it moves the digest. + assert_eq!(next_fork_boundary(&config, 411_392), Some(412_672)); + assert_eq!(next_fork_boundary(&config, 412_672), Some(419_072)); + // Past the last scheduled boundary there is nothing left to warn about. + assert_eq!(next_fork_boundary(&config, 419_072), None); + } + + #[test] + fn far_future_forks_are_not_boundaries() { + // Minimal leaves every fork after phase0 at FAR_FUTURE_EPOCH, which is a + // real, enormous Epoch rather than a None; treating it as a boundary + // would schedule a warning for the heat death of the universe. + assert_eq!(next_fork_boundary(&Config::minimal(), 0), None); + } + + #[test] + fn fork_data_root_binds_the_version_and_the_chain() { + let a = compute_fork_data_root([1, 0, 0, 0], Root::ZERO); + let b = compute_fork_data_root([2, 0, 0, 0], Root::ZERO); + let c = compute_fork_data_root([1, 0, 0, 0], Root::repeat_byte(1)); + assert_ne!(a, b); + assert_ne!(a, c); + } +} diff --git a/crates/common/types/src/beacon/mod.rs b/crates/common/types/src/beacon/mod.rs new file mode 100644 index 000000000..4f51c3ea0 --- /dev/null +++ b/crates/common/types/src/beacon/mod.rs @@ -0,0 +1,153 @@ +//! Beacon Chain types, namespaced away from the lean types alongside them. +//! +//! `ethlambda-types` already has `primitives`, `constants`, and `checkpoint` +//! modules of its own, and lean's `Checkpoint` is a different type from +//! beacon's by the same name. Everything moved out of the beacon state +//! transition lives +//! under this module so both sets can coexist. + +pub mod committees; +pub mod config; +pub mod constants; +pub mod containers; +pub mod error; +pub mod fork; +pub mod fork_choice; +pub mod fork_digest; +pub mod preset; +pub mod primitives; +pub mod serde_helpers; +pub mod signing; + +/// Panics, naming the beacon accessor a lean state reached. +/// +/// The boundary is structural rather than type-level: [`containers::BeaconState`] +/// carries a `Lean` variant, so every beacon-only accessor needs an arm for it. +/// Written once here so the wording, and the diagnosis it points at, cannot +/// drift from one such arm to the next. +/// +/// This is the state-shaped boundary: reaching it means a handler dispatched on +/// the wrong thing. For the fork-shaped one see [`lean_fork_unreachable`], kept +/// separate because the two are crossed by different mistakes. +/// +/// `#[track_caller]` so the panic still reports the arm's own file and line, the +/// way an `unreachable!` written inline there would have. +#[cold] +#[track_caller] +pub(crate) fn lean_state_unreachable(function: &str) -> ! { + unreachable!( + "lean state reached a beacon accessor ({function}); \ + BlockChainServer must dispatch on fork_name() before this point" + ) +} + +/// Panics, naming the Beacon Chain-only function [`fork::ForkName::Lean`] reached. +/// +/// The fork-shaped counterpart to [`lean_state_unreachable`]: lean is not a point +/// on the beacon fork schedule at all, so no state is involved and the fault is +/// the argument the caller passed rather than the state it dispatched on. +#[cold] +#[track_caller] +pub(crate) fn lean_fork_unreachable(function: &str) -> ! { + unreachable!( + "ForkName::Lean reached a Beacon Chain function ({function}); \ + lean is not a point on the beacon fork schedule, so the caller \ + passed a fork it should have dispatched on first" + ) +} + +/// Panics, naming the Beacon Chain-only block accessor a lean block reached. +/// +/// The block-shaped counterpart to [`lean_state_unreachable`]. A lean block is +/// a real variant of [`containers::SignedBeaconBlock`] and answers every +/// accessor whose field it actually has; this is for the one it does not, +/// `signature`, since lean signs with a `MultiMessageAggregate` proof rather +/// than a `BlsSignature`. +#[cold] +#[track_caller] +pub(crate) fn lean_block_unreachable(function: &str) -> ! { + unreachable!( + "lean block reached a beacon accessor ({function}); \ + a lean block has no BLS signature" + ) +} + +/// Panics, naming the beacon fork a lean-only caller reached. +/// +/// The mirror image of [`lean_state_unreachable`] and [`lean_block_unreachable`]: +/// those fire when a lean value meets a beacon accessor, this one when a beacon +/// value meets a caller that only ever runs against a lean store. Both mean a +/// handler dispatched on the wrong thing, so both are `unreachable!` rather than +/// a `Result` no correct caller would see. +/// +/// Written once here, like its counterparts, so the wording cannot drift across +/// the call sites that peel [`containers::BeaconState::Lean`] back off. The +/// fork it actually found is what says which chain's value took the wrong path. +#[cold] +#[track_caller] +pub(crate) fn beacon_value_unreachable(what: &str, fork: fork::ForkName) -> ! { + unreachable!( + "a {fork:?} {what} reached a lean-only caller; \ + a data directory holds one chain for its whole life, so the store's \ + chain tag and the caller disagree" + ) +} + +#[cfg(test)] +mod tests { + /// A type-level assertion that the namespace does not reintroduce a second + /// 32-byte hash: assigning one to the other only compiles while + /// [`primitives::Root`] names the very same type as the lean `H256`, which + /// is what lets a beacon block root reach a store lookup unconverted. + #[test] + fn a_beacon_root_is_the_lean_hash() { + let beacon: super::primitives::Root = super::primitives::Root::ZERO; + let _lean: crate::primitives::H256 = beacon; + } + + #[test] + fn beacon_constants_are_reachable_beside_lean_constants() { + // Both crates define a `constants` module; the namespace keeps them + // apart. `FAR_FUTURE_EPOCH` is the sentinel every unscheduled fork + // epoch carries. + assert_eq!(super::constants::FAR_FUTURE_EPOCH, u64::MAX); + assert_eq!(crate::constants::FORK_DIGEST, "12345678"); + } + + #[test] + fn fork_ordering_is_reachable_from_the_namespace() { + use super::fork::ForkName; + assert!(ForkName::Fulu > ForkName::Phase0); + } + + #[test] + fn preset_slots_per_epoch_matches_the_selected_preset() { + #[cfg(not(feature = "preset-minimal"))] + assert_eq!(super::preset::SLOTS_PER_EPOCH, 32); + #[cfg(feature = "preset-minimal")] + assert_eq!(super::preset::SLOTS_PER_EPOCH, 8); + } + + #[test] + fn mainnet_config_carries_the_fulu_schedule() { + let config = super::config::Config::mainnet(); + assert_eq!(config.fulu_fork_version, [0x06, 0x00, 0x00, 0x00]); + assert_eq!(config.fulu_fork_epoch, 411_392); + // The two blob-parameter-only forks, which perturb the fork digest. + assert_eq!(config.blob_schedule.len(), 2); + assert_eq!(config.blob_schedule[0].epoch, 412_672); + assert_eq!(config.blob_schedule[0].max_blobs_per_block, 15); + assert_eq!(config.blob_schedule[1].epoch, 419_072); + assert_eq!(config.blob_schedule[1].max_blobs_per_block, 21); + } + + #[test] + fn the_beacon_containers_are_reachable_from_the_namespace() { + use super::containers::{BeaconState, phase0}; + + // A type-level assertion: naming the variant constructor as a function + // proves both the enum and the per-fork struct resolve, with nothing to + // construct. No fork's BeaconState derives Default. + let _: fn(phase0::BeaconState) -> BeaconState = BeaconState::Phase0; + } +} diff --git a/crates/common/types/src/beacon/preset.rs b/crates/common/types/src/beacon/preset.rs new file mode 100644 index 000000000..f820f2b1c --- /dev/null +++ b/crates/common/types/src/beacon/preset.rs @@ -0,0 +1,1482 @@ +//! Preset constants: the compile-time-selectable half of the specification's +//! tunables. +//! +//! The specification splits its numeric parameters into two kinds. *Configuration* +//! values (genesis time, fork-activation epochs, network-specific limits) can +//! differ between two networks running the same client binary, so they belong in +//! [`crate::beacon::config`] as runtime data. *Preset* values instead fix the shape of the +//! SSZ containers the state transition operates on: how many slots a historical +//! roots vector holds, how many attestations fit in a block, how many field +//! elements make up a blob. Two networks that disagree on a preset value are +//! running different, mutually unintelligible protocols, because every hash tree +//! root over a preset-bounded list or vector depends on that bound. That is also +//! why the values below are Rust constants rather than fields on a struct: a +//! container's SSZ shape is a type-level property (`SszList`), +//! decided once when the crate is compiled, not something a running node could +//! switch at runtime without also swapping out its state's memory layout. +//! +//! The specification defines exactly two presets, `mainnet` and `minimal` +//! (`minimal` exists to keep spec test fixtures and local devnets fast). This +//! crate mirrors that: [`mainnet`] and [`minimal`] each define every preset +//! constant the specification uses from phase0 through fulu, and the top-level +//! re-export picks one at compile time, gated on the `preset-minimal` feature. +//! Downstream code should import from here (`preset::SLOTS_PER_EPOCH`, not +//! `preset::mainnet::SLOTS_PER_EPOCH`), so that it automatically follows whichever +//! preset the crate was built against. +//! +//! # Why some constants are `usize` and others are `u64` +//! +//! A constant that bounds an SSZ collection (`List`, `Vector`, `Bitlist`, +//! `Bitvector`) in some container, in any fork from phase0 through fulu, is typed +//! `usize` here, because it gets threaded through this crate as a const-generic +//! parameter on that collection's Rust representation, and const generics require +//! `usize`. Every other preset constant (reward/penalty divisors, Gwei amounts, +//! epoch/slot durations that never size a container by themselves) is typed `u64`, +//! matching the `uint64` the specification gives it. A few constants are factors +//! of a container bound without being one themselves (`SLOTS_PER_EPOCH` is the +//! textbook case: it never bounds anything alone, only in products like +//! `SLOTS_PER_EPOCH * EPOCHS_PER_ETH1_VOTING_PERIOD`), so they stay `u64` and the +//! product gets its own `usize` constant instead. +//! +//! # Derived constants +//! +//! Some values a container needs are not literal preset entries at all: the +//! specification writes them as an inline formula over other presets, directly in +//! a container's field-type annotation rather than in the preset YAML. This module +//! gives each of those formulas a name (`MAX_PENDING_ATTESTATIONS`, +//! `SLOTS_PER_ETH1_VOTING_PERIOD`, `BYTES_PER_BLOB`, `BYTES_PER_CELL`, +//! `PROPOSER_LOOKAHEAD_LENGTH`, `MAX_VALIDATORS_PER_SLOT`) and computes it from the +//! constants it depends on, so the relationship is checked by the compiler instead +//! of copied by hand into two places. +//! +//! Constants that the specification instead lists as fixed, preset-independent +//! `Constant`s (for example `JUSTIFICATION_BITS_LENGTH`, `BYTES_PER_FIELD_ELEMENT`, +//! `DEPOSIT_CONTRACT_TREE_DEPTH`) do not appear here even when they bound a +//! container or feed one of the formulas above; they belong to this crate's +//! `constants` module instead, since they cannot vary between `mainnet` and +//! `minimal` in the first place. + +/// The mainnet preset: the values that secure the real Ethereum network. +/// +/// Sourced from `presets/mainnet/{phase0,altair,bellatrix,capella,deneb,electra, +/// fulu}.yaml` in the specification, plus the derived constants documented at the +/// module level. +pub mod mainnet { + // ================================================================ + // Phase0 + // ================================================================ + + // --- Misc --- + + /// How many committees a single slot's active validators are split into. + /// Also bounds `Attestation.committee_bits` from electra onward + /// (`Bitvector`), and is a factor of + /// `MAX_VALIDATORS_PER_SLOT`. + pub const MAX_COMMITTEES_PER_SLOT: usize = 64; + + /// The committee size `get_committee_count_per_slot` aims for when splitting a + /// slot's active validators into committees. Not itself a container bound. + pub const TARGET_COMMITTEE_SIZE: u64 = 128; + + /// Bounds `Attestation.aggregation_bits` (a `Bitlist`) and, pre-electra, + /// `IndexedAttestation.attesting_indices` (a `List`). From + /// electra onward it is instead a factor of `MAX_VALIDATORS_PER_SLOT`, which + /// bounds those same fields. + pub const MAX_VALIDATORS_PER_COMMITTEE: usize = 2048; + + /// Number of swap-or-not shuffle rounds `compute_shuffled_index` performs when + /// deriving committee membership from a seed. + pub const SHUFFLE_ROUND_COUNT: u64 = 90; + + /// Denominator of the balance band, centered on a multiple of + /// `EFFECTIVE_BALANCE_INCREMENT`, that a validator's effective balance must + /// leave before `process_effective_balance_updates` moves it. + pub const HYSTERESIS_QUOTIENT: u64 = 4; + + /// Numerator narrowing the downward half of the hysteresis band relative to + /// `HYSTERESIS_QUOTIENT`, so effective balance falls faster than it rises. + pub const HYSTERESIS_DOWNWARD_MULTIPLIER: u64 = 1; + + /// Numerator widening the upward half of the hysteresis band relative to + /// `HYSTERESIS_QUOTIENT`, damping effective balance increases. + pub const HYSTERESIS_UPWARD_MULTIPLIER: u64 = 5; + + // --- Gwei values --- + + /// Smallest deposit amount `process_deposit` accepts onto the validator + /// registry (deposits below this are recorded but never activate a + /// validator). + pub const MIN_DEPOSIT_AMOUNT: u64 = 1_000_000_000; + + /// Ceiling that reward, penalty, and churn-limit math clamps a validator's + /// effective balance to, pre-electra (electra validators with a compounding + /// withdrawal credential instead use `MAX_EFFECTIVE_BALANCE_ELECTRA`). + pub const MAX_EFFECTIVE_BALANCE: u64 = 32_000_000_000; + + /// Rounding granularity effective balance is truncated to, and the unit that + /// base-reward math scales by. + pub const EFFECTIVE_BALANCE_INCREMENT: u64 = 1_000_000_000; + + // --- Time parameters --- + + /// Minimum number of slots `process_attestation` requires between an + /// attestation's slot and the slot it is included in. + pub const MIN_ATTESTATION_INCLUSION_DELAY: u64 = 1; + + /// Slots per epoch. Never bounds a container by itself; it is a factor of + /// several derived bounds below (`SLOTS_PER_ETH1_VOTING_PERIOD`, + /// `MAX_PENDING_ATTESTATIONS`, `PROPOSER_LOOKAHEAD_LENGTH`). + pub const SLOTS_PER_EPOCH: u64 = 32; + + /// Epochs the RANDAO mix used to seed a shuffling lags behind the epoch it + /// shuffles, so the seed is unpredictable before that epoch starts. Also a + /// factor of `PROPOSER_LOOKAHEAD_LENGTH`. + pub const MIN_SEED_LOOKAHEAD: u64 = 1; + + /// Epochs an exiting validator's withdrawable epoch is delayed past its exit + /// epoch, bounding how far in advance the exit queue can be gamed. + pub const MAX_SEED_LOOKAHEAD: u64 = 4; + + /// Epochs a single ETH1 voting period spans. A factor of + /// `SLOTS_PER_ETH1_VOTING_PERIOD`; not itself a container bound. + pub const EPOCHS_PER_ETH1_VOTING_PERIOD: u64 = 64; + + /// `EPOCHS_PER_ETH1_VOTING_PERIOD * SLOTS_PER_EPOCH`, the specification's + /// formula for the bound on `BeaconState.eth1_data_votes` + /// (`List`), the votes collected + /// during one ETH1 voting period. + pub const SLOTS_PER_ETH1_VOTING_PERIOD: usize = + EPOCHS_PER_ETH1_VOTING_PERIOD as usize * SLOTS_PER_EPOCH as usize; + + /// Bounds `BeaconState.block_roots` and `state_roots`, both + /// `Vector`. + pub const SLOTS_PER_HISTORICAL_ROOT: usize = 8192; + + /// Epochs since the last finality advance before + /// `process_inactivity_updates`-adjacent penalty logic starts leaking an + /// inactive validator's balance. + pub const MIN_EPOCHS_TO_INACTIVITY_PENALTY: u64 = 4; + + // --- State list lengths --- + + /// Bounds `BeaconState.randao_mixes` (`Vector`), the ring buffer of past RANDAO mixes + /// shufflings are seeded from. + pub const EPOCHS_PER_HISTORICAL_VECTOR: usize = 65536; + + /// Bounds `BeaconState.slashings` (`Vector`), + /// the ring buffer `process_slashings` sums to scale the correlated slashing + /// penalty. + pub const EPOCHS_PER_SLASHINGS_VECTOR: usize = 8192; + + /// Bounds `BeaconState.historical_roots`, and from capella onward + /// `historical_summaries` (both `List<_, HISTORICAL_ROOTS_LIMIT>`). + pub const HISTORICAL_ROOTS_LIMIT: usize = 16_777_216; + + /// Bounds every validator-indexed list in `BeaconState`: `validators`, + /// `balances`, and, from altair, `previous_epoch_participation`, + /// `current_epoch_participation`, and `inactivity_scores`. + pub const VALIDATOR_REGISTRY_LIMIT: usize = 1_099_511_627_776; + + // --- Rewards and penalties --- + + /// Numerator of the base reward per increment of effective balance, before + /// dividing by the integer square root of total active balance. + pub const BASE_REWARD_FACTOR: u64 = 64; + + /// Divisor of a slashed validator's effective balance that sets the combined + /// whistleblower/proposer reward `slash_validator` pays out. + pub const WHISTLEBLOWER_REWARD_QUOTIENT: u64 = 512; + + /// Divisor of the whistleblower reward that the block proposer's share is set + /// to; the remainder goes to whoever reported the slashable offense. + pub const PROPOSER_REWARD_QUOTIENT: u64 = 8; + + /// Divisor controlling how fast effective balance leaks during non-finality, + /// pre-altair (altair replaces this with + /// `INACTIVITY_PENALTY_QUOTIENT_ALTAIR`). + pub const INACTIVITY_PENALTY_QUOTIENT: u64 = 67_108_864; + + /// Divisor of effective balance burned immediately on slashing, pre-altair. + pub const MIN_SLASHING_PENALTY_QUOTIENT: u64 = 128; + + /// Multiplier scaling the slashing penalty by the proportion of validators + /// slashed in the same slashings-vector window, pre-altair. + pub const PROPORTIONAL_SLASHING_MULTIPLIER: u64 = 1; + + // --- Max operations per block --- + + /// Bounds `BeaconBlockBody.proposer_slashings` + /// (`List`). + pub const MAX_PROPOSER_SLASHINGS: usize = 16; + + /// Bounds `BeaconBlockBody.attester_slashings`, pre-electra + /// (`List`; electra replaces it with + /// `MAX_ATTESTER_SLASHINGS_ELECTRA`). + pub const MAX_ATTESTER_SLASHINGS: usize = 2; + + /// Bounds `BeaconBlockBody.attestations`, pre-electra + /// (`List`; electra replaces it with + /// `MAX_ATTESTATIONS_ELECTRA`). Also a factor of `MAX_PENDING_ATTESTATIONS`. + pub const MAX_ATTESTATIONS: usize = 128; + + /// `MAX_ATTESTATIONS * SLOTS_PER_EPOCH`, the specification's formula for the + /// bound on `BeaconState.previous_epoch_attestations` and + /// `current_epoch_attestations` (`List`), the phase0 + /// per-epoch attestation backlog that altair replaces with participation + /// flags. + pub const MAX_PENDING_ATTESTATIONS: usize = MAX_ATTESTATIONS * SLOTS_PER_EPOCH as usize; + + /// Bounds `BeaconBlockBody.deposits` (`List`). + pub const MAX_DEPOSITS: usize = 16; + + /// Bounds `BeaconBlockBody.voluntary_exits` + /// (`List`). + pub const MAX_VOLUNTARY_EXITS: usize = 16; + + // ================================================================ + // Altair + // ================================================================ + + // --- Rewards and penalties --- + + /// Altair's replacement for `INACTIVITY_PENALTY_QUOTIENT`, retuned for the + /// participation-flag accounting altair introduces. + pub const INACTIVITY_PENALTY_QUOTIENT_ALTAIR: u64 = 50_331_648; + + /// Altair's replacement for `MIN_SLASHING_PENALTY_QUOTIENT`. + pub const MIN_SLASHING_PENALTY_QUOTIENT_ALTAIR: u64 = 64; + + /// Altair's replacement for `PROPORTIONAL_SLASHING_MULTIPLIER`. + pub const PROPORTIONAL_SLASHING_MULTIPLIER_ALTAIR: u64 = 2; + + // --- Sync committee --- + + /// Bounds `SyncCommittee.pubkeys` (`Vector`) + /// and a sync aggregate's `sync_committee_bits` + /// (`Bitvector`). + pub const SYNC_COMMITTEE_SIZE: usize = 512; + + /// Epochs a sync committee serves before the next one rotates in. A factor of + /// `UPDATE_TIMEOUT`; not itself a container bound. + pub const EPOCHS_PER_SYNC_COMMITTEE_PERIOD: u64 = 256; + + // --- Sync protocol --- + + /// Minimum signer count a sync aggregate must have for light-client update + /// validity checks to accept it. + pub const MIN_SYNC_COMMITTEE_PARTICIPANTS: u64 = 1; + + /// Slots since the light client store's finalized header before + /// `process_light_client_store_force_update` force-applies the best pending + /// update. Equal to `SLOTS_PER_EPOCH * EPOCHS_PER_SYNC_COMMITTEE_PERIOD`. + pub const UPDATE_TIMEOUT: u64 = 8192; + + // ================================================================ + // Bellatrix + // ================================================================ + + // --- Rewards and penalties --- + + /// Bellatrix's replacement for `INACTIVITY_PENALTY_QUOTIENT_ALTAIR`. + pub const INACTIVITY_PENALTY_QUOTIENT_BELLATRIX: u64 = 16_777_216; + + /// Bellatrix's replacement for `MIN_SLASHING_PENALTY_QUOTIENT_ALTAIR`. + pub const MIN_SLASHING_PENALTY_QUOTIENT_BELLATRIX: u64 = 32; + + /// Bellatrix's replacement for `PROPORTIONAL_SLASHING_MULTIPLIER_ALTAIR`. + pub const PROPORTIONAL_SLASHING_MULTIPLIER_BELLATRIX: u64 = 3; + + // --- Execution --- + + /// Bounds the `Transaction` type itself (`ByteList` + /// is `Transaction`'s SSZ definition). + pub const MAX_BYTES_PER_TRANSACTION: usize = 1_073_741_824; + + /// Bounds `ExecutionPayload.transactions` + /// (`List`). + pub const MAX_TRANSACTIONS_PER_PAYLOAD: usize = 1_048_576; + + /// Bounds `ExecutionPayload(Header).logs_bloom` + /// (`ByteVector`). + pub const BYTES_PER_LOGS_BLOOM: usize = 256; + + /// Bounds `ExecutionPayload(Header).extra_data` + /// (`ByteList`). + pub const MAX_EXTRA_DATA_BYTES: usize = 32; + + // ================================================================ + // Capella + // ================================================================ + + // --- Max operations per block --- + + /// Bounds `BeaconBlockBody.bls_to_execution_changes` + /// (`List`). + pub const MAX_BLS_TO_EXECUTION_CHANGES: usize = 16; + + // --- Execution --- + + /// Bounds `ExecutionPayload(Header).withdrawals` + /// (`List`). + pub const MAX_WITHDRAWALS_PER_PAYLOAD: usize = 16; + + // --- Withdrawals processing --- + + /// Cap on how many validators `get_expected_withdrawals` scans per slot while + /// sweeping the registry for withdrawable balances. A loop bound the + /// withdrawal-sweep algorithm uses, not a container length, so it stays + /// `u64`. + pub const MAX_VALIDATORS_PER_WITHDRAWALS_SWEEP: u64 = 16384; + + // ================================================================ + // Deneb + // ================================================================ + + // --- Execution --- + + /// Bounds `BeaconBlockBody.blob_kzg_commitments` + /// (`List`) and, from fulu, + /// `DataColumnSidecar.column` / `kzg_commitments`. + pub const MAX_BLOB_COMMITMENTS_PER_BLOCK: usize = 4096; + + // --- Networking --- + + /// Bounds `BlobSidecar.kzg_commitment_inclusion_proof` + /// (`Vector`), the merkle proof + /// that a commitment sits at its claimed index in `blob_kzg_commitments`. + pub const KZG_COMMITMENT_INCLUSION_PROOF_DEPTH: usize = 17; + + // --- Blob --- + + /// Bounds a blob's polynomial representation + /// (`Vector`), and is a factor of + /// `BYTES_PER_BLOB` and (doubled) fulu's `FIELD_ELEMENTS_PER_EXT_BLOB`. + pub const FIELD_ELEMENTS_PER_BLOB: usize = 4096; + + /// `BYTES_PER_FIELD_ELEMENT * FIELD_ELEMENTS_PER_BLOB`, the specification's + /// formula for the bound on the `Blob` type + /// (`ByteVector`), carried in `BlobSidecar.blob`. + /// + /// Derived rather than transcribed, so the two cannot drift apart. + /// `BYTES_PER_FIELD_ELEMENT` is a fixed spec constant rather than a preset + /// value, so it comes from [`crate::beacon::constants`]. + pub const BYTES_PER_BLOB: usize = + crate::beacon::constants::BYTES_PER_FIELD_ELEMENT * FIELD_ELEMENTS_PER_BLOB; + + // ================================================================ + // Electra + // ================================================================ + + // --- Gwei values --- + + /// Minimum balance a validator must reach before it can activate. Electra's + /// deposit-flow counterpart to `MIN_DEPOSIT_AMOUNT`. + pub const MIN_ACTIVATION_BALANCE: u64 = 32_000_000_000; + + /// Electra's raised ceiling on effective balance for validators with a + /// compounding (0x02) withdrawal credential; validators without one still use + /// `MAX_EFFECTIVE_BALANCE`. + pub const MAX_EFFECTIVE_BALANCE_ELECTRA: u64 = 2_048_000_000_000; + + // --- Rewards and penalties --- + + /// Electra's replacement for `MIN_SLASHING_PENALTY_QUOTIENT_ALTAIR`, tightened + /// alongside the higher `MAX_EFFECTIVE_BALANCE_ELECTRA`. + pub const MIN_SLASHING_PENALTY_QUOTIENT_ELECTRA: u64 = 4096; + + /// Electra's replacement for `WHISTLEBLOWER_REWARD_QUOTIENT`. + pub const WHISTLEBLOWER_REWARD_QUOTIENT_ELECTRA: u64 = 4096; + + // --- State list lengths --- + + /// Bounds `BeaconState.pending_deposits` + /// (`List`), electra's queue of + /// deposits not yet processed into the registry. + pub const PENDING_DEPOSITS_LIMIT: usize = 134_217_728; + + /// Bounds `BeaconState.pending_partial_withdrawals` + /// (`List`). + pub const PENDING_PARTIAL_WITHDRAWALS_LIMIT: usize = 134_217_728; + + /// Bounds `BeaconState.pending_consolidations` + /// (`List`). + pub const PENDING_CONSOLIDATIONS_LIMIT: usize = 262_144; + + // --- Max operations per block --- + + /// Electra's replacement for `MAX_ATTESTER_SLASHINGS`, bounding + /// `BeaconBlockBody.attester_slashings` now that committee bits move + /// attesting indices out of the slashing itself. + pub const MAX_ATTESTER_SLASHINGS_ELECTRA: usize = 1; + + /// Electra's replacement for `MAX_ATTESTATIONS`, lowered because one electra + /// `Attestation` now covers every committee in a slot instead of one. + pub const MAX_ATTESTATIONS_ELECTRA: usize = 8; + + /// `MAX_COMMITTEES_PER_SLOT * MAX_VALIDATORS_PER_COMMITTEE`, the + /// specification's formula for the bound on `Attestation.aggregation_bits` + /// (`Bitlist`) and + /// `IndexedAttestation.attesting_indices` + /// (`List`) from electra onward, + /// since one attestation's bitfield now spans every committee in the slot + /// rather than one. + pub const MAX_VALIDATORS_PER_SLOT: usize = + MAX_COMMITTEES_PER_SLOT * MAX_VALIDATORS_PER_COMMITTEE; + + // --- Execution --- + + /// Bounds `ExecutionRequests.deposits` + /// (`List`). + pub const MAX_DEPOSIT_REQUESTS_PER_PAYLOAD: usize = 8192; + + /// Bounds `ExecutionRequests.withdrawals` + /// (`List`). + pub const MAX_WITHDRAWAL_REQUESTS_PER_PAYLOAD: usize = 16; + + /// Bounds `ExecutionRequests.consolidations` + /// (`List`). + pub const MAX_CONSOLIDATION_REQUESTS_PER_PAYLOAD: usize = 2; + + // --- Withdrawals processing --- + + /// Cap on pending partial withdrawals `get_expected_withdrawals` drains per + /// slot. A loop bound, not a container length, so it stays `u64`. + pub const MAX_PENDING_PARTIALS_PER_WITHDRAWALS_SWEEP: u64 = 8; + + // --- Pending deposits processing --- + + /// Cap on pending deposits `process_pending_deposits` processes per epoch. A + /// loop bound, not a container length, so it stays `u64`. + pub const MAX_PENDING_DEPOSITS_PER_EPOCH: u64 = 16; + + // ================================================================ + // Fulu + // ================================================================ + + // --- Networking --- + + /// Bounds `DataColumnSidecar.kzg_commitments_inclusion_proof` + /// (`Vector`). Shallower than + /// deneb's `KZG_COMMITMENT_INCLUSION_PROOF_DEPTH` because it proves the root + /// of the whole `blob_kzg_commitments` list rather than one leaf. + pub const KZG_COMMITMENTS_INCLUSION_PROOF_DEPTH: usize = 4; + + // --- Blob --- + + /// Bounds a `Cell`'s field-element view + /// (`Vector`), and is a factor of + /// `BYTES_PER_CELL`. + pub const FIELD_ELEMENTS_PER_CELL: usize = 64; + + /// Bounds the Reed-Solomon-extended blob polynomial (`PolynomialCoeff`, a + /// `List`); twice + /// `FIELD_ELEMENTS_PER_BLOB` since the extension doubles the evaluation + /// domain. + pub const FIELD_ELEMENTS_PER_EXT_BLOB: usize = 8192; + + /// Bounds the per-blob cell and proof arrays `compute_cells_and_kzg_proofs` + /// returns (`Vector` and + /// `Vector`). + pub const CELLS_PER_EXT_BLOB: usize = 128; + + /// Bounds `DataColumnsByRootIdentifier.columns` + /// (`List`). One data column corresponds to + /// one index across the extended matrix, so this equals `CELLS_PER_EXT_BLOB`. + pub const NUMBER_OF_COLUMNS: usize = 128; + + /// `FIELD_ELEMENTS_PER_CELL * BYTES_PER_FIELD_ELEMENT`, the specification's + /// formula for the bound on the `Cell` type (`ByteVector`). + /// + /// Derived rather than transcribed, from the same [`crate::beacon::constants`] value + /// `BYTES_PER_BLOB` uses. + pub const BYTES_PER_CELL: usize = + FIELD_ELEMENTS_PER_CELL * crate::beacon::constants::BYTES_PER_FIELD_ELEMENT; + + // --- State list lengths --- + + /// `(MIN_SEED_LOOKAHEAD + 1) * SLOTS_PER_EPOCH`, the specification's formula + /// for the length of `BeaconState.proposer_lookahead` + /// (`Vector`), the vector of + /// precomputed proposer indices fulu adds to the state. + pub const PROPOSER_LOOKAHEAD_LENGTH: usize = + (MIN_SEED_LOOKAHEAD as usize + 1) * SLOTS_PER_EPOCH as usize; +} + +/// The minimal preset: the same shape as [`mainnet`], scaled down so spec test +/// fixtures and local devnets run fast. +/// +/// Sourced from `presets/minimal/{phase0,altair,bellatrix,capella,deneb,electra, +/// fulu}.yaml` in the specification. Several values equal their mainnet +/// counterpart (the specification only "customizes" the ones that matter for +/// making a small network self-consistent); each is still spelled out here rather +/// than reused from [`mainnet`], so the two modules stay independent sources of +/// truth that happen to agree, rather than one silently defining the other. +pub mod minimal { + // ================================================================ + // Phase0 + // ================================================================ + + // --- Misc --- + + /// How many committees a single slot's active validators are split into. + /// Also bounds `Attestation.committee_bits` from electra onward + /// (`Bitvector`), and is a factor of + /// `MAX_VALIDATORS_PER_SLOT`. + pub const MAX_COMMITTEES_PER_SLOT: usize = 4; + + /// The committee size `get_committee_count_per_slot` aims for when splitting a + /// slot's active validators into committees. Not itself a container bound. + pub const TARGET_COMMITTEE_SIZE: u64 = 4; + + /// Bounds `Attestation.aggregation_bits` (a `Bitlist`) and, pre-electra, + /// `IndexedAttestation.attesting_indices` (a `List`). From + /// electra onward it is instead a factor of `MAX_VALIDATORS_PER_SLOT`, which + /// bounds those same fields. + pub const MAX_VALIDATORS_PER_COMMITTEE: usize = 2048; + + /// Number of swap-or-not shuffle rounds `compute_shuffled_index` performs when + /// deriving committee membership from a seed. + pub const SHUFFLE_ROUND_COUNT: u64 = 10; + + /// Denominator of the balance band, centered on a multiple of + /// `EFFECTIVE_BALANCE_INCREMENT`, that a validator's effective balance must + /// leave before `process_effective_balance_updates` moves it. + pub const HYSTERESIS_QUOTIENT: u64 = 4; + + /// Numerator narrowing the downward half of the hysteresis band relative to + /// `HYSTERESIS_QUOTIENT`, so effective balance falls faster than it rises. + pub const HYSTERESIS_DOWNWARD_MULTIPLIER: u64 = 1; + + /// Numerator widening the upward half of the hysteresis band relative to + /// `HYSTERESIS_QUOTIENT`, damping effective balance increases. + pub const HYSTERESIS_UPWARD_MULTIPLIER: u64 = 5; + + // --- Gwei values --- + + /// Smallest deposit amount `process_deposit` accepts onto the validator + /// registry (deposits below this are recorded but never activate a + /// validator). + pub const MIN_DEPOSIT_AMOUNT: u64 = 1_000_000_000; + + /// Ceiling that reward, penalty, and churn-limit math clamps a validator's + /// effective balance to, pre-electra (electra validators with a compounding + /// withdrawal credential instead use `MAX_EFFECTIVE_BALANCE_ELECTRA`). + pub const MAX_EFFECTIVE_BALANCE: u64 = 32_000_000_000; + + /// Rounding granularity effective balance is truncated to, and the unit that + /// base-reward math scales by. + pub const EFFECTIVE_BALANCE_INCREMENT: u64 = 1_000_000_000; + + // --- Time parameters --- + + /// Minimum number of slots `process_attestation` requires between an + /// attestation's slot and the slot it is included in. + pub const MIN_ATTESTATION_INCLUSION_DELAY: u64 = 1; + + /// Slots per epoch. Never bounds a container by itself; it is a factor of + /// several derived bounds below (`SLOTS_PER_ETH1_VOTING_PERIOD`, + /// `MAX_PENDING_ATTESTATIONS`, `PROPOSER_LOOKAHEAD_LENGTH`). + pub const SLOTS_PER_EPOCH: u64 = 8; + + /// Epochs the RANDAO mix used to seed a shuffling lags behind the epoch it + /// shuffles, so the seed is unpredictable before that epoch starts. Also a + /// factor of `PROPOSER_LOOKAHEAD_LENGTH`. + pub const MIN_SEED_LOOKAHEAD: u64 = 1; + + /// Epochs an exiting validator's withdrawable epoch is delayed past its exit + /// epoch, bounding how far in advance the exit queue can be gamed. + pub const MAX_SEED_LOOKAHEAD: u64 = 4; + + /// Epochs a single ETH1 voting period spans. A factor of + /// `SLOTS_PER_ETH1_VOTING_PERIOD`; not itself a container bound. + pub const EPOCHS_PER_ETH1_VOTING_PERIOD: u64 = 4; + + /// `EPOCHS_PER_ETH1_VOTING_PERIOD * SLOTS_PER_EPOCH`, the specification's + /// formula for the bound on `BeaconState.eth1_data_votes` + /// (`List`), the votes collected + /// during one ETH1 voting period. + pub const SLOTS_PER_ETH1_VOTING_PERIOD: usize = + EPOCHS_PER_ETH1_VOTING_PERIOD as usize * SLOTS_PER_EPOCH as usize; + + /// Bounds `BeaconState.block_roots` and `state_roots`, both + /// `Vector`. + pub const SLOTS_PER_HISTORICAL_ROOT: usize = 64; + + /// Epochs since the last finality advance before + /// `process_inactivity_updates`-adjacent penalty logic starts leaking an + /// inactive validator's balance. + pub const MIN_EPOCHS_TO_INACTIVITY_PENALTY: u64 = 4; + + // --- State list lengths --- + + /// Bounds `BeaconState.randao_mixes` (`Vector`), the ring buffer of past RANDAO mixes + /// shufflings are seeded from. + pub const EPOCHS_PER_HISTORICAL_VECTOR: usize = 64; + + /// Bounds `BeaconState.slashings` (`Vector`), + /// the ring buffer `process_slashings` sums to scale the correlated slashing + /// penalty. + pub const EPOCHS_PER_SLASHINGS_VECTOR: usize = 64; + + /// Bounds `BeaconState.historical_roots`, and from capella onward + /// `historical_summaries` (both `List<_, HISTORICAL_ROOTS_LIMIT>`). + pub const HISTORICAL_ROOTS_LIMIT: usize = 16_777_216; + + /// Bounds every validator-indexed list in `BeaconState`: `validators`, + /// `balances`, and, from altair, `previous_epoch_participation`, + /// `current_epoch_participation`, and `inactivity_scores`. + pub const VALIDATOR_REGISTRY_LIMIT: usize = 1_099_511_627_776; + + // --- Rewards and penalties --- + + /// Numerator of the base reward per increment of effective balance, before + /// dividing by the integer square root of total active balance. + pub const BASE_REWARD_FACTOR: u64 = 64; + + /// Divisor of a slashed validator's effective balance that sets the combined + /// whistleblower/proposer reward `slash_validator` pays out. + pub const WHISTLEBLOWER_REWARD_QUOTIENT: u64 = 512; + + /// Divisor of the whistleblower reward that the block proposer's share is set + /// to; the remainder goes to whoever reported the slashable offense. + pub const PROPOSER_REWARD_QUOTIENT: u64 = 8; + + /// Divisor controlling how fast effective balance leaks during non-finality, + /// pre-altair (altair replaces this with + /// `INACTIVITY_PENALTY_QUOTIENT_ALTAIR`). + pub const INACTIVITY_PENALTY_QUOTIENT: u64 = 33_554_432; + + /// Divisor of effective balance burned immediately on slashing, pre-altair. + pub const MIN_SLASHING_PENALTY_QUOTIENT: u64 = 64; + + /// Multiplier scaling the slashing penalty by the proportion of validators + /// slashed in the same slashings-vector window, pre-altair. Set lower than + /// mainnet's for a gentler test-network safety margin. + pub const PROPORTIONAL_SLASHING_MULTIPLIER: u64 = 2; + + // --- Max operations per block --- + + /// Bounds `BeaconBlockBody.proposer_slashings` + /// (`List`). + pub const MAX_PROPOSER_SLASHINGS: usize = 16; + + /// Bounds `BeaconBlockBody.attester_slashings`, pre-electra + /// (`List`; electra replaces it with + /// `MAX_ATTESTER_SLASHINGS_ELECTRA`). + pub const MAX_ATTESTER_SLASHINGS: usize = 2; + + /// Bounds `BeaconBlockBody.attestations`, pre-electra + /// (`List`; electra replaces it with + /// `MAX_ATTESTATIONS_ELECTRA`). Also a factor of `MAX_PENDING_ATTESTATIONS`. + pub const MAX_ATTESTATIONS: usize = 128; + + /// `MAX_ATTESTATIONS * SLOTS_PER_EPOCH`, the specification's formula for the + /// bound on `BeaconState.previous_epoch_attestations` and + /// `current_epoch_attestations` (`List`), the phase0 + /// per-epoch attestation backlog that altair replaces with participation + /// flags. + pub const MAX_PENDING_ATTESTATIONS: usize = MAX_ATTESTATIONS * SLOTS_PER_EPOCH as usize; + + /// Bounds `BeaconBlockBody.deposits` (`List`). + pub const MAX_DEPOSITS: usize = 16; + + /// Bounds `BeaconBlockBody.voluntary_exits` + /// (`List`). + pub const MAX_VOLUNTARY_EXITS: usize = 16; + + // ================================================================ + // Altair + // ================================================================ + + // --- Rewards and penalties --- + + /// Altair's replacement for `INACTIVITY_PENALTY_QUOTIENT`, retuned for the + /// participation-flag accounting altair introduces. + pub const INACTIVITY_PENALTY_QUOTIENT_ALTAIR: u64 = 50_331_648; + + /// Altair's replacement for `MIN_SLASHING_PENALTY_QUOTIENT`. + pub const MIN_SLASHING_PENALTY_QUOTIENT_ALTAIR: u64 = 64; + + /// Altair's replacement for `PROPORTIONAL_SLASHING_MULTIPLIER`. + pub const PROPORTIONAL_SLASHING_MULTIPLIER_ALTAIR: u64 = 2; + + // --- Sync committee --- + + /// Bounds `SyncCommittee.pubkeys` (`Vector`) + /// and a sync aggregate's `sync_committee_bits` + /// (`Bitvector`). + pub const SYNC_COMMITTEE_SIZE: usize = 32; + + /// Epochs a sync committee serves before the next one rotates in. A factor of + /// `UPDATE_TIMEOUT`; not itself a container bound. + pub const EPOCHS_PER_SYNC_COMMITTEE_PERIOD: u64 = 8; + + // --- Sync protocol --- + + /// Minimum signer count a sync aggregate must have for light-client update + /// validity checks to accept it. + pub const MIN_SYNC_COMMITTEE_PARTICIPANTS: u64 = 1; + + /// Slots since the light client store's finalized header before + /// `process_light_client_store_force_update` force-applies the best pending + /// update. Equal to `SLOTS_PER_EPOCH * EPOCHS_PER_SYNC_COMMITTEE_PERIOD`. + pub const UPDATE_TIMEOUT: u64 = 64; + + // ================================================================ + // Bellatrix + // ================================================================ + + // --- Rewards and penalties --- + + /// Bellatrix's replacement for `INACTIVITY_PENALTY_QUOTIENT_ALTAIR`. + pub const INACTIVITY_PENALTY_QUOTIENT_BELLATRIX: u64 = 16_777_216; + + /// Bellatrix's replacement for `MIN_SLASHING_PENALTY_QUOTIENT_ALTAIR`. + pub const MIN_SLASHING_PENALTY_QUOTIENT_BELLATRIX: u64 = 32; + + /// Bellatrix's replacement for `PROPORTIONAL_SLASHING_MULTIPLIER_ALTAIR`. + pub const PROPORTIONAL_SLASHING_MULTIPLIER_BELLATRIX: u64 = 3; + + // --- Execution --- + + /// Bounds the `Transaction` type itself (`ByteList` + /// is `Transaction`'s SSZ definition). + pub const MAX_BYTES_PER_TRANSACTION: usize = 1_073_741_824; + + /// Bounds `ExecutionPayload.transactions` + /// (`List`). + pub const MAX_TRANSACTIONS_PER_PAYLOAD: usize = 1_048_576; + + /// Bounds `ExecutionPayload(Header).logs_bloom` + /// (`ByteVector`). + pub const BYTES_PER_LOGS_BLOOM: usize = 256; + + /// Bounds `ExecutionPayload(Header).extra_data` + /// (`ByteList`). + pub const MAX_EXTRA_DATA_BYTES: usize = 32; + + // ================================================================ + // Capella + // ================================================================ + + // --- Max operations per block --- + + /// Bounds `BeaconBlockBody.bls_to_execution_changes` + /// (`List`). + pub const MAX_BLS_TO_EXECUTION_CHANGES: usize = 16; + + // --- Execution --- + + /// Bounds `ExecutionPayload(Header).withdrawals` + /// (`List`). + pub const MAX_WITHDRAWALS_PER_PAYLOAD: usize = 4; + + // --- Withdrawals processing --- + + /// Cap on how many validators `get_expected_withdrawals` scans per slot while + /// sweeping the registry for withdrawable balances. A loop bound the + /// withdrawal-sweep algorithm uses, not a container length, so it stays + /// `u64`. + pub const MAX_VALIDATORS_PER_WITHDRAWALS_SWEEP: u64 = 16; + + // ================================================================ + // Deneb + // ================================================================ + + // --- Execution --- + + /// Bounds `BeaconBlockBody.blob_kzg_commitments` + /// (`List`) and, from fulu, + /// `DataColumnSidecar.column` / `kzg_commitments`. + pub const MAX_BLOB_COMMITMENTS_PER_BLOCK: usize = 4096; + + // --- Networking --- + + /// Bounds `BlobSidecar.kzg_commitment_inclusion_proof` + /// (`Vector`), the merkle proof + /// that a commitment sits at its claimed index in `blob_kzg_commitments`. + pub const KZG_COMMITMENT_INCLUSION_PROOF_DEPTH: usize = 17; + + // --- Blob --- + + /// Bounds a blob's polynomial representation + /// (`Vector`), and is a factor of + /// `BYTES_PER_BLOB` and (doubled) fulu's `FIELD_ELEMENTS_PER_EXT_BLOB`. Not + /// customized for minimal: a smaller blob would need a different trusted + /// setup, so this preset keeps mainnet's value. + pub const FIELD_ELEMENTS_PER_BLOB: usize = 4096; + + /// `BYTES_PER_FIELD_ELEMENT * FIELD_ELEMENTS_PER_BLOB`, the specification's + /// formula for the bound on the `Blob` type + /// (`ByteVector`), carried in `BlobSidecar.blob`. + /// + /// Derived rather than transcribed, so the two cannot drift apart. + /// `BYTES_PER_FIELD_ELEMENT` is a fixed spec constant rather than a preset + /// value, so it comes from [`crate::beacon::constants`]. + pub const BYTES_PER_BLOB: usize = + crate::beacon::constants::BYTES_PER_FIELD_ELEMENT * FIELD_ELEMENTS_PER_BLOB; + + // ================================================================ + // Electra + // ================================================================ + + // --- Gwei values --- + + /// Minimum balance a validator must reach before it can activate. Electra's + /// deposit-flow counterpart to `MIN_DEPOSIT_AMOUNT`. + pub const MIN_ACTIVATION_BALANCE: u64 = 32_000_000_000; + + /// Electra's raised ceiling on effective balance for validators with a + /// compounding (0x02) withdrawal credential; validators without one still use + /// `MAX_EFFECTIVE_BALANCE`. + pub const MAX_EFFECTIVE_BALANCE_ELECTRA: u64 = 2_048_000_000_000; + + // --- Rewards and penalties --- + + /// Electra's replacement for `MIN_SLASHING_PENALTY_QUOTIENT_ALTAIR`, tightened + /// alongside the higher `MAX_EFFECTIVE_BALANCE_ELECTRA`. + pub const MIN_SLASHING_PENALTY_QUOTIENT_ELECTRA: u64 = 4096; + + /// Electra's replacement for `WHISTLEBLOWER_REWARD_QUOTIENT`. + pub const WHISTLEBLOWER_REWARD_QUOTIENT_ELECTRA: u64 = 4096; + + // --- State list lengths --- + + /// Bounds `BeaconState.pending_deposits` + /// (`List`), electra's queue of + /// deposits not yet processed into the registry. + pub const PENDING_DEPOSITS_LIMIT: usize = 134_217_728; + + /// Bounds `BeaconState.pending_partial_withdrawals` + /// (`List`). + pub const PENDING_PARTIAL_WITHDRAWALS_LIMIT: usize = 64; + + /// Bounds `BeaconState.pending_consolidations` + /// (`List`). + pub const PENDING_CONSOLIDATIONS_LIMIT: usize = 64; + + // --- Max operations per block --- + + /// Electra's replacement for `MAX_ATTESTER_SLASHINGS`, bounding + /// `BeaconBlockBody.attester_slashings` now that committee bits move + /// attesting indices out of the slashing itself. + pub const MAX_ATTESTER_SLASHINGS_ELECTRA: usize = 1; + + /// Electra's replacement for `MAX_ATTESTATIONS`, lowered because one electra + /// `Attestation` now covers every committee in a slot instead of one. + pub const MAX_ATTESTATIONS_ELECTRA: usize = 8; + + /// `MAX_COMMITTEES_PER_SLOT * MAX_VALIDATORS_PER_COMMITTEE`, the + /// specification's formula for the bound on `Attestation.aggregation_bits` + /// (`Bitlist`) and + /// `IndexedAttestation.attesting_indices` + /// (`List`) from electra onward, + /// since one attestation's bitfield now spans every committee in the slot + /// rather than one. + pub const MAX_VALIDATORS_PER_SLOT: usize = + MAX_COMMITTEES_PER_SLOT * MAX_VALIDATORS_PER_COMMITTEE; + + // --- Execution --- + + /// Bounds `ExecutionRequests.deposits` + /// (`List`). + pub const MAX_DEPOSIT_REQUESTS_PER_PAYLOAD: usize = 8192; + + /// Bounds `ExecutionRequests.withdrawals` + /// (`List`). + pub const MAX_WITHDRAWAL_REQUESTS_PER_PAYLOAD: usize = 16; + + /// Bounds `ExecutionRequests.consolidations` + /// (`List`). + pub const MAX_CONSOLIDATION_REQUESTS_PER_PAYLOAD: usize = 2; + + // --- Withdrawals processing --- + + /// Cap on pending partial withdrawals `get_expected_withdrawals` drains per + /// slot. A loop bound, not a container length, so it stays `u64`. + pub const MAX_PENDING_PARTIALS_PER_WITHDRAWALS_SWEEP: u64 = 2; + + // --- Pending deposits processing --- + + /// Cap on pending deposits `process_pending_deposits` processes per epoch. A + /// loop bound, not a container length, so it stays `u64`. + pub const MAX_PENDING_DEPOSITS_PER_EPOCH: u64 = 16; + + // ================================================================ + // Fulu + // ================================================================ + + // --- Networking --- + + /// Bounds `DataColumnSidecar.kzg_commitments_inclusion_proof` + /// (`Vector`). Shallower than + /// deneb's `KZG_COMMITMENT_INCLUSION_PROOF_DEPTH` because it proves the root + /// of the whole `blob_kzg_commitments` list rather than one leaf. + pub const KZG_COMMITMENTS_INCLUSION_PROOF_DEPTH: usize = 4; + + // --- Blob --- + + /// Bounds a `Cell`'s field-element view + /// (`Vector`), and is a factor of + /// `BYTES_PER_CELL`. + pub const FIELD_ELEMENTS_PER_CELL: usize = 64; + + /// Bounds the Reed-Solomon-extended blob polynomial (`PolynomialCoeff`, a + /// `List`); twice + /// `FIELD_ELEMENTS_PER_BLOB` since the extension doubles the evaluation + /// domain. + pub const FIELD_ELEMENTS_PER_EXT_BLOB: usize = 8192; + + /// Bounds the per-blob cell and proof arrays `compute_cells_and_kzg_proofs` + /// returns (`Vector` and + /// `Vector`). + pub const CELLS_PER_EXT_BLOB: usize = 128; + + /// Bounds `DataColumnsByRootIdentifier.columns` + /// (`List`). One data column corresponds to + /// one index across the extended matrix, so this equals `CELLS_PER_EXT_BLOB`. + pub const NUMBER_OF_COLUMNS: usize = 128; + + /// `FIELD_ELEMENTS_PER_CELL * BYTES_PER_FIELD_ELEMENT`, the specification's + /// formula for the bound on the `Cell` type (`ByteVector`). + /// + /// Derived rather than transcribed, from the same [`crate::beacon::constants`] value + /// `BYTES_PER_BLOB` uses. + pub const BYTES_PER_CELL: usize = + FIELD_ELEMENTS_PER_CELL * crate::beacon::constants::BYTES_PER_FIELD_ELEMENT; + + // --- State list lengths --- + + /// `(MIN_SEED_LOOKAHEAD + 1) * SLOTS_PER_EPOCH`, the specification's formula + /// for the length of `BeaconState.proposer_lookahead` + /// (`Vector`), the vector of + /// precomputed proposer indices fulu adds to the state. + pub const PROPOSER_LOOKAHEAD_LENGTH: usize = + (MIN_SEED_LOOKAHEAD as usize + 1) * SLOTS_PER_EPOCH as usize; +} + +#[cfg(not(feature = "preset-minimal"))] +pub use mainnet::*; +#[cfg(feature = "preset-minimal")] +pub use minimal::*; + +/// Which of the two presets the constants above were taken from. +/// +/// The re-export just below the two modules picks one at compile time, so +/// nothing in this crate can ask *which* one it got. A persisted artifact can: +/// every SSZ container in this crate is bounded by these constants, so a state +/// written by one preset's build is a different shape from the same state +/// written by the other's, and a data directory has to say which it holds. +/// [`Preset::ACTIVE`] is what a writer records and a reader compares against. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Preset { + Mainnet, + Minimal, +} + +impl Preset { + /// The preset this build compiles its containers against. + pub const ACTIVE: Preset = if cfg!(feature = "preset-minimal") { + Preset::Minimal + } else { + Preset::Mainnet + }; + + /// The byte this preset is stored under. Spelled out rather than derived + /// from the variant order, because it is a storage format and not a + /// discriminant: reordering the variants must not reinterpret a directory. + pub const fn selector(self) -> u8 { + match self { + Preset::Mainnet => 0, + Preset::Minimal => 1, + } + } + + /// The inverse of [`Preset::selector`]. + pub const fn from_selector(byte: u8) -> Option { + match byte { + 0 => Some(Preset::Mainnet), + 1 => Some(Preset::Minimal), + _ => None, + } + } + + /// The specification's own name for this preset, as it appears in a + /// configuration directory or a fixture tree. + pub const fn name(self) -> &'static str { + match self { + Preset::Mainnet => "mainnet", + Preset::Minimal => "minimal", + } + } +} + +impl core::fmt::Display for Preset { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str(self.name()) + } +} + +/// Preset values that a later fork retunes without changing any container's +/// shape. +/// +/// The specification expresses these as a fresh constant per fork +/// (`MIN_SLASHING_PENALTY_QUOTIENT`, then `..._ALTAIR`, then `..._BELLATRIX`) +/// and redefines the function that reads it, so that each fork's copy of the +/// function reads its own constant. Reproducing that here would mean one copy +/// of `slash_validator` per fork, differing in a single identifier (and, +/// through deneb, one copy of `process_slashings` the same way). Selecting +/// the value by fork instead keeps one copy of each function, and puts the +/// whole fork-to-value mapping in one place where it can be read against the +/// specification's own tables. +/// +/// Electra breaks that pattern for `process_slashings` specifically +/// (EIP-7251): its own copy restructures the division around the constant +/// rather than only swapping the constant in, so from electra on a value +/// selected here is no longer enough on its own to keep one shared copy of +/// that function correct. The beacon STF's +/// `stf::epoch::electra::process_slashings` is a separate function for exactly +/// that reason; see its own doc for the +/// arithmetic. `slash_validator` never breaks the pattern this module +/// describes, so it keeps reading [`retuned::min_slashing_penalty_quotient`] +/// and [`retuned::whistleblower_reward_quotient`] from here through every fork this crate +/// implements. +/// +/// This is the one place in the crate where a preset value is chosen at runtime. +/// It is sound because none of these bound a container: they are divisors and +/// multipliers in balance arithmetic, so nothing about them has to be known at +/// compile time. +/// +/// # The two presets do not agree on the direction of these changes +/// +/// The minimal preset overrides only the *phase0* values and inherits every +/// later fork's from mainnet unchanged. So a value can move one way across a +/// fork boundary under mainnet and the other way under minimal: +/// `INACTIVITY_PENALTY_QUOTIENT` falls from phase0 to altair under mainnet and +/// rises under minimal, and `MIN_SLASHING_PENALTY_QUOTIENT` falls under mainnet +/// but is unchanged under minimal. +/// +/// That is worth knowing before writing anything that assumes these get +/// uniformly harsher over time. They do under mainnet; the tests below therefore +/// pin the fork-to-constant mapping, which holds under both presets, rather than +/// any numeric relationship, which does not. +pub mod retuned { + use crate::beacon::fork::ForkName; + use crate::beacon::lean_fork_unreachable; + + /// How much the summed slashings are scaled by before being capped at the + /// total active balance. + /// + /// Raised at altair and again at bellatrix, so slashing together with a + /// larger fraction of the validator set costs proportionally more than it + /// used to. + pub fn proportional_slashing_multiplier(fork: ForkName) -> u64 { + match fork { + ForkName::Phase0 => super::PROPORTIONAL_SLASHING_MULTIPLIER, + ForkName::Altair => super::PROPORTIONAL_SLASHING_MULTIPLIER_ALTAIR, + ForkName::Bellatrix + | ForkName::Capella + | ForkName::Deneb + | ForkName::Electra + | ForkName::Fulu => super::PROPORTIONAL_SLASHING_MULTIPLIER_BELLATRIX, + ForkName::Lean => lean_fork_unreachable("proportional_slashing_multiplier"), + } + } + + /// The divisor setting the immediate penalty a slashed validator pays, before + /// the epoch boundary's proportional penalty. + /// + /// Lowered through altair and bellatrix, so the immediate penalty grows, and + /// then raised steeply at electra. That reversal is not a relaxation: electra + /// raises the ceiling on a validator's effective balance, so leaving the + /// divisor where bellatrix put it would have made the immediate penalty on a + /// maximally consolidated validator far larger in absolute terms than the + /// penalty bellatrix intended. The divisor grows roughly in step with the + /// balance ceiling. + pub fn min_slashing_penalty_quotient(fork: ForkName) -> u64 { + match fork { + ForkName::Phase0 => super::MIN_SLASHING_PENALTY_QUOTIENT, + ForkName::Altair => super::MIN_SLASHING_PENALTY_QUOTIENT_ALTAIR, + ForkName::Bellatrix | ForkName::Capella | ForkName::Deneb => { + super::MIN_SLASHING_PENALTY_QUOTIENT_BELLATRIX + } + ForkName::Electra | ForkName::Fulu => super::MIN_SLASHING_PENALTY_QUOTIENT_ELECTRA, + ForkName::Lean => lean_fork_unreachable("min_slashing_penalty_quotient"), + } + } + + /// The divisor setting the reporter's cut of a slashed validator's effective + /// balance. + /// + /// Electra raises the divisor, which lowers the reward, because the same + /// fork raises the maximum effective balance: leaving the divisor alone would + /// have made reporting a large validator far more lucrative than reporting a + /// small one. + pub fn whistleblower_reward_quotient(fork: ForkName) -> u64 { + match fork { + ForkName::Electra | ForkName::Fulu => super::WHISTLEBLOWER_REWARD_QUOTIENT_ELECTRA, + ForkName::Phase0 + | ForkName::Altair + | ForkName::Bellatrix + | ForkName::Capella + | ForkName::Deneb => super::WHISTLEBLOWER_REWARD_QUOTIENT, + ForkName::Lean => lean_fork_unreachable("whistleblower_reward_quotient"), + } + } + + /// The divisor setting how fast an inactive validator's balance leaks while + /// the chain is failing to finalize. + /// + /// Altair and later scale the leak by a validator's own `inactivity_scores` + /// entry rather than reading this directly, so in practice only phase0's + /// inactivity penalty consults it. The later arms exist so that a caller + /// asking for a fork's value gets the right answer rather than phase0's. + pub fn inactivity_penalty_quotient(fork: ForkName) -> u64 { + match fork { + ForkName::Phase0 => super::INACTIVITY_PENALTY_QUOTIENT, + ForkName::Altair => super::INACTIVITY_PENALTY_QUOTIENT_ALTAIR, + ForkName::Bellatrix + | ForkName::Capella + | ForkName::Deneb + | ForkName::Electra + | ForkName::Fulu => super::INACTIVITY_PENALTY_QUOTIENT_BELLATRIX, + ForkName::Lean => lean_fork_unreachable("inactivity_penalty_quotient"), + } + } + + #[cfg(test)] + mod tests { + use super::*; + + use super::super::{ + INACTIVITY_PENALTY_QUOTIENT, INACTIVITY_PENALTY_QUOTIENT_ALTAIR, + INACTIVITY_PENALTY_QUOTIENT_BELLATRIX, MIN_SLASHING_PENALTY_QUOTIENT, + MIN_SLASHING_PENALTY_QUOTIENT_ALTAIR, MIN_SLASHING_PENALTY_QUOTIENT_BELLATRIX, + MIN_SLASHING_PENALTY_QUOTIENT_ELECTRA, PROPORTIONAL_SLASHING_MULTIPLIER, + PROPORTIONAL_SLASHING_MULTIPLIER_ALTAIR, PROPORTIONAL_SLASHING_MULTIPLIER_BELLATRIX, + WHISTLEBLOWER_REWARD_QUOTIENT, WHISTLEBLOWER_REWARD_QUOTIENT_ELECTRA, + }; + + /// Pins which constant each fork selects. + /// + /// The failure this guards against is a swapped or misplaced match arm, so + /// the assertions name the constants rather than comparing numbers. An + /// earlier version of this test compared values instead, asserting that + /// slashing gets uniformly harsher across the forks. That holds under + /// mainnet and is false under minimal, which overrides only phase0's + /// values and inherits the rest, so phase0 to altair moves the other way + /// there. Naming the constants is the only formulation that is both + /// preset-independent and able to catch a swapped arm. + #[test] + fn every_fork_selects_its_own_constant() { + let bellatrix_onward = [ + ForkName::Bellatrix, + ForkName::Capella, + ForkName::Deneb, + ForkName::Electra, + ForkName::Fulu, + ]; + + assert_eq!( + proportional_slashing_multiplier(ForkName::Phase0), + PROPORTIONAL_SLASHING_MULTIPLIER + ); + assert_eq!( + proportional_slashing_multiplier(ForkName::Altair), + PROPORTIONAL_SLASHING_MULTIPLIER_ALTAIR + ); + for fork in bellatrix_onward { + assert_eq!( + proportional_slashing_multiplier(fork), + PROPORTIONAL_SLASHING_MULTIPLIER_BELLATRIX, + "{fork} must use bellatrix's slashing multiplier", + ); + } + + assert_eq!( + inactivity_penalty_quotient(ForkName::Phase0), + INACTIVITY_PENALTY_QUOTIENT + ); + assert_eq!( + inactivity_penalty_quotient(ForkName::Altair), + INACTIVITY_PENALTY_QUOTIENT_ALTAIR + ); + for fork in bellatrix_onward { + assert_eq!( + inactivity_penalty_quotient(fork), + INACTIVITY_PENALTY_QUOTIENT_BELLATRIX, + "{fork} must use bellatrix's inactivity penalty quotient", + ); + } + + assert_eq!( + min_slashing_penalty_quotient(ForkName::Phase0), + MIN_SLASHING_PENALTY_QUOTIENT + ); + assert_eq!( + min_slashing_penalty_quotient(ForkName::Altair), + MIN_SLASHING_PENALTY_QUOTIENT_ALTAIR + ); + for fork in [ForkName::Bellatrix, ForkName::Capella, ForkName::Deneb] { + assert_eq!( + min_slashing_penalty_quotient(fork), + MIN_SLASHING_PENALTY_QUOTIENT_BELLATRIX, + "{fork} must use bellatrix's penalty divisor", + ); + } + for fork in [ForkName::Electra, ForkName::Fulu] { + assert_eq!( + min_slashing_penalty_quotient(fork), + MIN_SLASHING_PENALTY_QUOTIENT_ELECTRA, + "{fork} must use electra's penalty divisor", + ); + } + + for fork in [ + ForkName::Phase0, + ForkName::Altair, + ForkName::Bellatrix, + ForkName::Capella, + ForkName::Deneb, + ] { + assert_eq!( + whistleblower_reward_quotient(fork), + WHISTLEBLOWER_REWARD_QUOTIENT, + "{fork} predates electra's whistleblower reward change", + ); + } + for fork in [ForkName::Electra, ForkName::Fulu] { + assert_eq!( + whistleblower_reward_quotient(fork), + WHISTLEBLOWER_REWARD_QUOTIENT_ELECTRA, + "{fork} must use electra's whistleblower reward quotient", + ); + } + } + + /// Every fork must get an answer from every selector. A `match` makes this + /// true by construction today, but the selectors are the kind of code a + /// later edit turns into a lookup with a fallible default. + #[test] + fn no_fork_is_left_without_a_value() { + for fork in ForkName::ALL { + assert_ne!(proportional_slashing_multiplier(fork), 0); + assert_ne!(min_slashing_penalty_quotient(fork), 0); + assert_ne!(whistleblower_reward_quotient(fork), 0); + assert_ne!(inactivity_penalty_quotient(fork), 0); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::{Preset, mainnet, minimal}; + + #[test] + fn selectors_are_pinned_to_their_on_disk_values() { + // These bytes are a storage format: changing one makes every existing + // data directory claim to have been written by the other preset. + assert_eq!(Preset::Mainnet.selector(), 0); + assert_eq!(Preset::Minimal.selector(), 1); + + for preset in [Preset::Mainnet, Preset::Minimal] { + assert_eq!(Preset::from_selector(preset.selector()), Some(preset)); + } + assert_eq!(Preset::from_selector(2), None); + } + + #[test] + fn the_active_preset_matches_the_constants_that_were_re_exported() { + // The point of `ACTIVE` is that it cannot drift from the `pub use` + // above it, which is what a persisted selector is trusted to describe. + let (expected, slots_per_epoch) = match Preset::ACTIVE { + Preset::Mainnet => (Preset::Mainnet, mainnet::SLOTS_PER_EPOCH), + Preset::Minimal => (Preset::Minimal, minimal::SLOTS_PER_EPOCH), + }; + assert_eq!(Preset::ACTIVE, expected); + assert_eq!(super::SLOTS_PER_EPOCH, slots_per_epoch); + } + + /// Values pinned directly from the specification, so a typo or an accidental + /// edit shows up as a failing test rather than a silent divergence from + /// `presets/{mainnet,minimal}/*.yaml`. + #[test] + fn pinned_values() { + assert_eq!(mainnet::SLOTS_PER_EPOCH, 32); + assert_eq!(minimal::SLOTS_PER_EPOCH, 8); + + assert_eq!(mainnet::SYNC_COMMITTEE_SIZE, 512); + assert_eq!(minimal::SYNC_COMMITTEE_SIZE, 32); + + assert_eq!(mainnet::MAX_COMMITTEES_PER_SLOT, 64); + assert_eq!(minimal::MAX_COMMITTEES_PER_SLOT, 4); + + assert_eq!(mainnet::SLOTS_PER_HISTORICAL_ROOT, 8192); + assert_eq!(minimal::SLOTS_PER_HISTORICAL_ROOT, 64); + + assert_eq!(mainnet::FIELD_ELEMENTS_PER_BLOB, 4096); + assert_eq!(minimal::FIELD_ELEMENTS_PER_BLOB, 4096); + + assert_eq!(mainnet::MAX_VALIDATORS_PER_SLOT, 131_072); + assert_eq!(minimal::MAX_VALIDATORS_PER_SLOT, 8192); + } + + /// Every derived constant computed from more than one preset value, checked + /// against the specification's formula spelled out arithmetically rather than + /// copied as a literal. + #[test] + fn derived_values() { + assert_eq!( + mainnet::MAX_PENDING_ATTESTATIONS, + mainnet::MAX_ATTESTATIONS * mainnet::SLOTS_PER_EPOCH as usize + ); + assert_eq!( + minimal::MAX_PENDING_ATTESTATIONS, + minimal::MAX_ATTESTATIONS * minimal::SLOTS_PER_EPOCH as usize + ); + + assert_eq!( + mainnet::SLOTS_PER_ETH1_VOTING_PERIOD, + mainnet::EPOCHS_PER_ETH1_VOTING_PERIOD as usize * mainnet::SLOTS_PER_EPOCH as usize + ); + assert_eq!( + minimal::SLOTS_PER_ETH1_VOTING_PERIOD, + minimal::EPOCHS_PER_ETH1_VOTING_PERIOD as usize * minimal::SLOTS_PER_EPOCH as usize + ); + + assert_eq!( + mainnet::BYTES_PER_BLOB, + 32 * mainnet::FIELD_ELEMENTS_PER_BLOB + ); + assert_eq!( + minimal::BYTES_PER_BLOB, + 32 * minimal::FIELD_ELEMENTS_PER_BLOB + ); + + assert_eq!( + mainnet::BYTES_PER_CELL, + mainnet::FIELD_ELEMENTS_PER_CELL * crate::beacon::constants::BYTES_PER_FIELD_ELEMENT + ); + assert_eq!( + minimal::BYTES_PER_CELL, + minimal::FIELD_ELEMENTS_PER_CELL * crate::beacon::constants::BYTES_PER_FIELD_ELEMENT + ); + + assert_eq!( + mainnet::PROPOSER_LOOKAHEAD_LENGTH, + (mainnet::MIN_SEED_LOOKAHEAD as usize + 1) * mainnet::SLOTS_PER_EPOCH as usize + ); + assert_eq!( + minimal::PROPOSER_LOOKAHEAD_LENGTH, + (minimal::MIN_SEED_LOOKAHEAD as usize + 1) * minimal::SLOTS_PER_EPOCH as usize + ); + + assert_eq!( + mainnet::MAX_VALIDATORS_PER_SLOT, + mainnet::MAX_COMMITTEES_PER_SLOT * mainnet::MAX_VALIDATORS_PER_COMMITTEE + ); + assert_eq!( + minimal::MAX_VALIDATORS_PER_SLOT, + minimal::MAX_COMMITTEES_PER_SLOT * minimal::MAX_VALIDATORS_PER_COMMITTEE + ); + } + + /// Every name this module defines must resolve, at the same type, through + /// both `mainnet::` and `minimal::`. Referencing every constant through both + /// paths turns a name present in one preset but missing (or misspelled) in + /// the other into a compile error here, rather than a runtime surprise for + /// whichever fork first needs the missing side. + #[test] + fn same_names_in_both_presets() { + /// Names one constant at its type through both presets. The pairing is + /// the point, so the macro takes the name once and writes both paths. + macro_rules! in_both { + ($ty:ty: $($name:ident),+ $(,)?) => { + $( + let _: $ty = mainnet::$name; + let _: $ty = minimal::$name; + )+ + }; + } + + in_both!(usize: MAX_COMMITTEES_PER_SLOT); + in_both!(u64: TARGET_COMMITTEE_SIZE); + in_both!(usize: MAX_VALIDATORS_PER_COMMITTEE); + in_both!(u64: + SHUFFLE_ROUND_COUNT, HYSTERESIS_QUOTIENT, HYSTERESIS_DOWNWARD_MULTIPLIER, + HYSTERESIS_UPWARD_MULTIPLIER, + ); + + in_both!(u64: MIN_DEPOSIT_AMOUNT, MAX_EFFECTIVE_BALANCE, EFFECTIVE_BALANCE_INCREMENT); + + in_both!(u64: + MIN_ATTESTATION_INCLUSION_DELAY, SLOTS_PER_EPOCH, MIN_SEED_LOOKAHEAD, + MAX_SEED_LOOKAHEAD, EPOCHS_PER_ETH1_VOTING_PERIOD, + ); + in_both!(usize: SLOTS_PER_ETH1_VOTING_PERIOD, SLOTS_PER_HISTORICAL_ROOT); + in_both!(u64: MIN_EPOCHS_TO_INACTIVITY_PENALTY); + + in_both!(usize: + EPOCHS_PER_HISTORICAL_VECTOR, EPOCHS_PER_SLASHINGS_VECTOR, HISTORICAL_ROOTS_LIMIT, + VALIDATOR_REGISTRY_LIMIT, + ); + + in_both!(u64: + BASE_REWARD_FACTOR, WHISTLEBLOWER_REWARD_QUOTIENT, PROPOSER_REWARD_QUOTIENT, + INACTIVITY_PENALTY_QUOTIENT, MIN_SLASHING_PENALTY_QUOTIENT, + PROPORTIONAL_SLASHING_MULTIPLIER, + ); + + in_both!(usize: + MAX_PROPOSER_SLASHINGS, MAX_ATTESTER_SLASHINGS, MAX_ATTESTATIONS, + MAX_PENDING_ATTESTATIONS, MAX_DEPOSITS, MAX_VOLUNTARY_EXITS, + ); + + in_both!(u64: + INACTIVITY_PENALTY_QUOTIENT_ALTAIR, MIN_SLASHING_PENALTY_QUOTIENT_ALTAIR, + PROPORTIONAL_SLASHING_MULTIPLIER_ALTAIR, + ); + in_both!(usize: SYNC_COMMITTEE_SIZE); + in_both!(u64: + EPOCHS_PER_SYNC_COMMITTEE_PERIOD, MIN_SYNC_COMMITTEE_PARTICIPANTS, UPDATE_TIMEOUT, + ); + + in_both!(u64: + INACTIVITY_PENALTY_QUOTIENT_BELLATRIX, MIN_SLASHING_PENALTY_QUOTIENT_BELLATRIX, + PROPORTIONAL_SLASHING_MULTIPLIER_BELLATRIX, + ); + in_both!(usize: + MAX_BYTES_PER_TRANSACTION, MAX_TRANSACTIONS_PER_PAYLOAD, BYTES_PER_LOGS_BLOOM, + MAX_EXTRA_DATA_BYTES, + ); + + in_both!(usize: MAX_BLS_TO_EXECUTION_CHANGES, MAX_WITHDRAWALS_PER_PAYLOAD); + in_both!(u64: MAX_VALIDATORS_PER_WITHDRAWALS_SWEEP); + + in_both!(usize: + MAX_BLOB_COMMITMENTS_PER_BLOCK, KZG_COMMITMENT_INCLUSION_PROOF_DEPTH, + FIELD_ELEMENTS_PER_BLOB, BYTES_PER_BLOB, + ); + + in_both!(u64: + MIN_ACTIVATION_BALANCE, MAX_EFFECTIVE_BALANCE_ELECTRA, + MIN_SLASHING_PENALTY_QUOTIENT_ELECTRA, WHISTLEBLOWER_REWARD_QUOTIENT_ELECTRA, + ); + in_both!(usize: + PENDING_DEPOSITS_LIMIT, PENDING_PARTIAL_WITHDRAWALS_LIMIT, PENDING_CONSOLIDATIONS_LIMIT, + MAX_ATTESTER_SLASHINGS_ELECTRA, MAX_ATTESTATIONS_ELECTRA, MAX_VALIDATORS_PER_SLOT, + MAX_DEPOSIT_REQUESTS_PER_PAYLOAD, MAX_WITHDRAWAL_REQUESTS_PER_PAYLOAD, + MAX_CONSOLIDATION_REQUESTS_PER_PAYLOAD, + ); + in_both!(u64: MAX_PENDING_PARTIALS_PER_WITHDRAWALS_SWEEP, MAX_PENDING_DEPOSITS_PER_EPOCH); + + in_both!(usize: + KZG_COMMITMENTS_INCLUSION_PROOF_DEPTH, FIELD_ELEMENTS_PER_CELL, + FIELD_ELEMENTS_PER_EXT_BLOB, CELLS_PER_EXT_BLOB, NUMBER_OF_COLUMNS, BYTES_PER_CELL, + PROPOSER_LOOKAHEAD_LENGTH, + ); + } +} diff --git a/crates/common/types/src/beacon/primitives.rs b/crates/common/types/src/beacon/primitives.rs new file mode 100644 index 000000000..daf44ec88 --- /dev/null +++ b/crates/common/types/src/beacon/primitives.rs @@ -0,0 +1,678 @@ +//! The specification's primitive types. +//! +//! Scalar spec types are aliases of `u64` rather than newtypes. The +//! specification does arithmetic on slots, epochs, and balances freely, and +//! wrapping each in its own type would mean either arithmetic trait impls or +//! constant unwrapping, neither of which makes the state transition easier to +//! check against the spec text. +//! +//! Fixed-length byte strings do get newtypes, because confusing a public key +//! with a signature or a commitment is a real mistake that the compiler can +//! catch for free. +//! +//! [`H160`] and [`U256`] are declared here rather than taken from a crate of +//! Ethereum primitives. A Beacon Chain container needs three types of that +//! family: a 32-byte hash, a 20-byte address, and a 256-bit integer. The first +//! is already [`crate::primitives::H256`], so an external crate would save two +//! declarations and cost a second `H256` that every root in the crate has to be +//! converted through. + +use core::{cmp::Ordering, fmt}; + +use libssz_derive::{HashTreeRoot, SszDecode, SszEncode}; + +use super::serde_helpers::HexPrefixed; +pub use crate::primitives::{H256, HashTreeRoot}; + +/// A slot number. +pub type Slot = u64; +/// An epoch number. +pub type Epoch = u64; +/// An index into a slot's committees. +pub type CommitteeIndex = u64; +/// An index into the validator registry. +pub type ValidatorIndex = u64; +/// An amount of Gwei. +pub type Gwei = u64; +/// An index into the withdrawal sequence. +pub type WithdrawalIndex = u64; +/// An index into a block's blobs. +pub type BlobIndex = u64; +/// An index into a block's data columns. +pub type ColumnIndex = u64; + +/// A merkle root or any other 32-byte hash. +/// +/// The same [`H256`] the lean types use, so a beacon block root reaching a +/// store lookup needs no conversion. The two chains disagree about nearly +/// everything else, but a 32-byte SSZ hash is a 32-byte SSZ hash. +pub type Root = H256; +/// 32 bytes with no further meaning attached. +pub type Bytes32 = H256; +/// A 256-bit unsigned integer, SSZ-encoded little-endian. +pub type Uint256 = U256; +/// An execution layer address. +pub type ExecutionAddress = H160; +/// An execution layer block hash. +pub type ExecutionBlockHash = H256; + +/// A fork version. +pub type Version = [u8; 4]; +/// The four-byte prefix that separates signature domains. +pub type DomainType = [u8; 4]; +/// A signing domain: a domain type combined with a fork version and the genesis +/// validators root. +pub type Domain = [u8; 32]; +/// The four bytes identifying a fork on the wire. +pub type ForkDigest = [u8; 4]; + +/// A bitfield of participation flags for one validator, one bit per flag index. +pub type ParticipationFlags = u8; + +/// The number of bytes in an execution layer address. +pub const ADDRESS_SIZE: usize = 20; +/// The number of bytes in a BLS12-381 public key. +pub const BLS_PUBKEY_SIZE: usize = 48; +/// The number of bytes in a BLS12-381 signature. +pub const BLS_SIGNATURE_SIZE: usize = 96; +/// The number of bytes in a KZG commitment or proof, both compressed G1 points. +pub const KZG_POINT_SIZE: usize = 48; + +/// Formats a fixed-length byte string as `Name(0xabcd…)`. +/// +/// Shared by the `Debug` implementations below, which are otherwise identical, so +/// that they cannot drift into printing the same kind of value several ways. The +/// name is passed rather than taken from [`core::any::type_name`] so it stays a +/// literal the compiler checks against the type it sits on. +fn debug_byte_vector(f: &mut fmt::Formatter<'_>, name: &str, bytes: &[u8]) -> fmt::Result { + write!(f, "{name}(0x{})", hex::encode(bytes)) +} + +/// An execution layer address: an SSZ `Vector[uint8, 20]`. +/// +/// [`libssz_merkle::HashTreeRoot`] is written out rather than derived because +/// the derive does not carry `is_basic_type` through, and 20 bytes is the width +/// where that answer matters: a list or vector of *basic* elements packs them +/// contiguously into 32-byte chunks, while one of *composite* elements pads each +/// to a leaf of its own. No container holds a collection of addresses, so the +/// two agree everywhere the question is asked today; writing the answer out is +/// what keeps the first container that does hold one from silently merkleizing +/// the other way. Every other type in this module is 32 bytes wide, where +/// packing and padding coincide. +#[derive(Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, SszEncode, SszDecode)] +#[ssz(transparent)] +pub struct H160(pub [u8; ADDRESS_SIZE]); + +impl H160 { + /// The all-zero address. + pub const ZERO: Self = Self([0; ADDRESS_SIZE]); + + /// Every byte set to `byte`. See [`H256::repeat_byte`]. + pub const fn repeat_byte(byte: u8) -> Self { + Self([byte; ADDRESS_SIZE]) + } + + /// # Panics + /// + /// If `bytes` is not exactly [`ADDRESS_SIZE`] long. + pub fn from_slice(bytes: &[u8]) -> Self { + Self( + bytes + .try_into() + .expect("H160::from_slice requires exactly 20 bytes"), + ) + } + + pub fn as_slice(&self) -> &[u8] { + &self.0 + } +} + +impl libssz_merkle::HashTreeRoot for H160 { + fn hash_tree_root(&self, hasher: &impl libssz_merkle::Sha256Hasher) -> libssz_merkle::Node { + libssz_merkle::HashTreeRoot::hash_tree_root(&self.0, hasher) + } + + fn is_basic_type() -> bool { + <[u8; ADDRESS_SIZE] as libssz_merkle::HashTreeRoot>::is_basic_type() + } +} + +impl From<[u8; ADDRESS_SIZE]> for H160 { + fn from(bytes: [u8; ADDRESS_SIZE]) -> Self { + Self(bytes) + } +} + +impl fmt::Debug for H160 { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + debug_byte_vector(f, "H160", &self.0) + } +} + +/// A 256-bit unsigned integer. +/// +/// Held as the 32 little-endian bytes SSZ encodes it as, so encoding, decoding +/// and merkleizing are the inner array's and cannot disagree with the wire. The +/// consequence is that the stored byte order is the reverse of the numeric one, +/// which is why [`Ord`] is written out below instead of derived. +/// +/// No arithmetic, because the specification never computes on one: +/// `terminal_total_difficulty` is only ever compared against a PoW block's +/// accumulated difficulty, and `base_fee_per_gas` is carried through the +/// execution payload header untouched. +#[derive(Clone, Copy, Default, PartialEq, Eq, Hash, SszEncode, SszDecode, HashTreeRoot)] +#[ssz(transparent)] +pub struct U256(pub [u8; 32]); + +impl U256 { + /// Zero. + pub const ZERO: Self = Self([0; 32]); + /// The largest representable value, `2^256 - 1`. + pub const MAX: Self = Self([0xff; 32]); + + /// The integer `value`, widened. + /// + /// `const` so a configuration constant that fits 128 bits can be written as + /// its decimal digits rather than as bytes. + pub const fn from_u128(value: u128) -> Self { + let mut bytes = [0; 32]; + let low = value.to_le_bytes(); + let mut i = 0; + while i < low.len() { + bytes[i] = low[i]; + i += 1; + } + Self(bytes) + } + + /// Parses a decimal string, the form every specification configuration file + /// writes a `uint256` in. + /// + /// Kept even though nothing on the shipping path parses one: the values that + /// *are* hard-coded get pinned against their decimal digits by a test, and + /// reading them back is how a mistranscribed byte is caught. + pub fn from_dec_str(digits: &str) -> Result { + if digits.is_empty() { + return Err(ParseU256Error::Empty); + } + + let mut bytes = [0u8; 32]; + for byte in digits.bytes() { + let digit = match byte { + b'0'..=b'9' => u16::from(byte - b'0'), + _ => return Err(ParseU256Error::InvalidDigit), + }; + + // `value = value * 10 + digit`, low byte first. Each step is at + // most `255 * 10 + 9`, so the running carry fits the same `u16`. + let mut carry = digit; + for limb in &mut bytes { + let widened = u16::from(*limb) * 10 + carry; + *limb = widened as u8; + carry = widened >> 8; + } + if carry != 0 { + return Err(ParseU256Error::Overflow); + } + } + Ok(Self(bytes)) + } + + /// The value as decimal digits, which is how the Beacon API writes it. + /// + /// A thin wrapper over the [`Display`](fmt::Display) impl above, kept as + /// its own named method for callers reading `crates/common/types` that + /// want an owned decimal `String` without needing to know `U256` + /// implements `Display` to reach for `.to_string()`. The `Serialize` impl + /// bypasses this and goes through `Display` directly via `collect_str`, + /// since going through this method first would allocate the `String` + /// `collect_str` exists to avoid. + pub fn to_decimal_string(&self) -> String { + self.to_string() + } +} + +impl From for U256 { + fn from(value: u64) -> Self { + Self::from_u128(u128::from(value)) + } +} + +impl Ord for U256 { + fn cmp(&self, other: &Self) -> Ordering { + // Most significant byte first, the reverse of how the bytes are + // stored. Deriving this would compare the least significant byte first + // and answer nonsense. + self.0.iter().rev().cmp(other.0.iter().rev()) + } +} + +impl PartialOrd for U256 { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl fmt::LowerHex for U256 { + /// Most significant digit first, with leading zeros trimmed, so the output + /// reads as a number rather than as a byte string. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.0.iter().rposition(|&byte| byte != 0) { + None => f.write_str("0"), + Some(high) => { + write!(f, "{:x}", self.0[high])?; + for &byte in self.0[..high].iter().rev() { + write!(f, "{byte:02x}")?; + } + Ok(()) + } + } + } +} + +impl fmt::Debug for U256 { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "U256(0x{self:x})") + } +} + +impl fmt::Display for U256 { + /// Decimal digits, most significant first: the form the Beacon API writes + /// a `uint256` in (see the [`Serialize`](serde::Serialize) impl below, + /// which is the whole reason this exists rather than only [`fmt::LowerHex`] + /// above). + /// + /// Long division by 10 over the big-endian bytes: the value is 256 bits, + /// so no primitive integer holds it and no `u128` shortcut is correct. + /// `(remainder << 8) | u16::from(byte)` needs `remainder` wide enough to + /// hold that shift: `remainder` is always a base-10 digit (at most 9), so + /// the widest case is `9 << 8 | 255 = 2559`, which fits `u16` with room to + /// spare. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut digits = self.0; + digits.reverse(); // stored little-endian; divide big-endian + let mut out = Vec::new(); + + while digits.iter().any(|byte| *byte != 0) { + let mut remainder = 0u16; + for byte in digits.iter_mut() { + let current = (remainder << 8) | u16::from(*byte); + *byte = (current / 10) as u8; + remainder = current % 10; + } + out.push(b'0' + remainder as u8); + } + + if out.is_empty() { + return f.write_str("0"); + } + out.reverse(); + // `out` holds only ASCII digits `b'0'..=b'9'`, so this cannot fail. + f.write_str(std::str::from_utf8(&out).expect("ascii digits")) + } +} + +impl serde::Serialize for U256 { + /// Always written as a quoted decimal string, the same convention + /// [`super::serde_helpers::quoted_or_bare::serialize`] uses for the `u64` + /// spec aliases. + /// + /// Routes through `collect_str` over the [`Display`](fmt::Display) impl + /// above rather than `serialize_str(&self.to_decimal_string())`: the + /// latter allocates a `String` up front, but serde_json overrides + /// `collect_str` to format straight into its output buffer, so on that + /// (our) backend this allocates nothing. `uint256` fields + /// (`total_difficulty`, `base_fee_per_gas`) appear once per execution + /// payload header, not per validator, but the adapter is free, so there + /// is no reason to pay for a `String` here either. + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.collect_str(self) + } +} + +/// Why a decimal string was not a [`U256`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum ParseU256Error { + #[error("a uint256 needs at least one digit")] + Empty, + #[error("a uint256 is written in decimal digits only")] + InvalidDigit, + #[error("the value does not fit 256 bits")] + Overflow, +} + +// Each of the four types below is an SSZ `Vector[uint8, N]`: it encodes and +// merkleizes as its inner array. `Default` is written out rather than derived +// because the standard library implements it for arrays only up to length 32, +// well short of all four of these, and a derive requires every field's type to +// implement the trait. + +/// A BLS12-381 public key, compressed. +/// +/// Not validated on construction. The specification only requires a key to be a +/// valid curve point where it is used in a signature check, and deposit +/// processing depends on being able to hold a key that never validates. +#[derive(Clone, Copy, PartialEq, Eq, Hash, SszEncode, SszDecode, HashTreeRoot)] +#[ssz(transparent)] +pub struct BlsPubkey(pub [u8; BLS_PUBKEY_SIZE]); + +impl Default for BlsPubkey { + fn default() -> Self { + Self([0; BLS_PUBKEY_SIZE]) + } +} + +impl AsRef<[u8]> for BlsPubkey { + fn as_ref(&self) -> &[u8] { + &self.0 + } +} + +impl fmt::Debug for BlsPubkey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + debug_byte_vector(f, "BlsPubkey", &self.0) + } +} + +/// A BLS12-381 signature, compressed. +#[derive(Clone, Copy, PartialEq, Eq, Hash, SszEncode, SszDecode, HashTreeRoot)] +#[ssz(transparent)] +pub struct BlsSignature(pub [u8; BLS_SIGNATURE_SIZE]); + +impl Default for BlsSignature { + fn default() -> Self { + Self([0; BLS_SIGNATURE_SIZE]) + } +} + +impl AsRef<[u8]> for BlsSignature { + fn as_ref(&self) -> &[u8] { + &self.0 + } +} + +impl fmt::Debug for BlsSignature { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + debug_byte_vector(f, "BlsSignature", &self.0) + } +} + +/// A KZG commitment to a blob. +/// +/// The same width as [`KzgProof`] and as [`BlsPubkey`], and a separate type from +/// both for that reason: an alias would make all three interchangeable. +#[derive(Clone, Copy, PartialEq, Eq, Hash, SszEncode, SszDecode, HashTreeRoot)] +#[ssz(transparent)] +pub struct KzgCommitment(pub [u8; KZG_POINT_SIZE]); + +impl Default for KzgCommitment { + fn default() -> Self { + Self([0; KZG_POINT_SIZE]) + } +} + +impl AsRef<[u8]> for KzgCommitment { + fn as_ref(&self) -> &[u8] { + &self.0 + } +} + +impl fmt::Debug for KzgCommitment { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + debug_byte_vector(f, "KzgCommitment", &self.0) + } +} + +/// A KZG proof. +#[derive(Clone, Copy, PartialEq, Eq, Hash, SszEncode, SszDecode, HashTreeRoot)] +#[ssz(transparent)] +pub struct KzgProof(pub [u8; KZG_POINT_SIZE]); + +impl Default for KzgProof { + fn default() -> Self { + Self([0; KZG_POINT_SIZE]) + } +} + +impl AsRef<[u8]> for KzgProof { + fn as_ref(&self) -> &[u8] { + &self.0 + } +} + +impl fmt::Debug for KzgProof { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + debug_byte_vector(f, "KzgProof", &self.0) + } +} + +/// Implements the Beacon API's `Serialize` for a fixed-width byte newtype: +/// `0x`-prefixed lowercase hex, always, of the inner bytes. +/// +/// A macro over the list rather than five copies of the same four lines, +/// mirroring [`debug_byte_vector`] above: [`H160`], [`BlsPubkey`], +/// [`BlsSignature`], [`KzgCommitment`], and [`KzgProof`] share no trait to hang +/// a blanket impl on that would not also catch [`H256`] (re-exported into this +/// module), which already carries its own hand-written `Serialize` and must +/// keep it untouched. +/// +/// Routes through [`HexPrefixed`] and `collect_str` rather than +/// `serialize_str(&format!("0x{}", hex::encode(&self.0)))`, for the same +/// reason [`super::serde_helpers::hex_array::serialize`] does: the `format!` +/// route allocates twice per call (once in `hex::encode`, once in `format!`), +/// but serde_json overrides `collect_str` to write straight into its output +/// buffer, so on that (our) backend this allocates nothing. These types make +/// every leaf of a signed block or a blob sidecar, so the saving is not +/// theoretical. +macro_rules! impl_hex_serialize { + ($($ty:ty),* $(,)?) => { + $( + impl serde::Serialize for $ty { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.collect_str(&HexPrefixed(self.0.as_slice())) + } + } + )* + }; +} + +impl_hex_serialize!(H160, BlsPubkey, BlsSignature, KzgCommitment, KzgProof); + +/// The `Deserialize` counterpart of [`impl_hex_serialize`], for the types the +/// Beacon API accepts in a request body: a validator's public key (as a +/// validator id), a signature (inside a submitted attestation) and an execution +/// address (a proposer's fee recipient). Hex with or +/// without the `0x` prefix, of exactly the type's width. +macro_rules! impl_hex_deserialize { + ($($ty:ty),* $(,)?) => { + $( + impl<'de> serde::Deserialize<'de> for $ty { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + super::serde_helpers::hex_array::deserialize(deserializer).map(Self) + } + } + )* + }; +} + +impl_hex_deserialize!(BlsPubkey, BlsSignature, H160); + +#[cfg(test)] +mod tests { + use libssz::{SszDecode as _, SszEncode as _}; + use libssz_merkle::Sha2Hasher; + + use super::*; + + /// The two-argument root, spelled out because [`HashTreeRoot`] is in scope + /// here and its own `hash_tree_root` takes no hasher. + fn ssz_root(value: &T) -> libssz_merkle::Node { + libssz_merkle::HashTreeRoot::hash_tree_root(value, &Sha2Hasher) + } + + #[test] + fn byte_vectors_round_trip_through_ssz() { + let key = BlsPubkey([7; BLS_PUBKEY_SIZE]); + let bytes = key.to_ssz(); + assert_eq!(bytes.len(), BLS_PUBKEY_SIZE); + assert_eq!(BlsPubkey::from_ssz_bytes(&bytes).unwrap(), key); + } + + #[test] + fn debug_output_is_hex() { + let proof = KzgProof([0xab; KZG_POINT_SIZE]); + assert!(format!("{proof:?}").starts_with("KzgProof(0xabab")); + } + + #[test] + fn an_address_encodes_as_its_twenty_bytes() { + let address = H160::repeat_byte(0xcd); + assert_eq!(address.to_ssz(), [0xcd; ADDRESS_SIZE]); + assert_eq!(H160::from_ssz_bytes(&address.to_ssz()).unwrap(), address); + } + + /// The property the hand-written [`libssz_merkle::HashTreeRoot`] exists for: + /// a 20-byte value is a basic type, so a collection of them packs rather + /// than padding. Both halves are checked, since the root alone would pass + /// with `is_basic_type` left at its default. + #[test] + fn an_address_is_a_basic_type_rooted_as_its_padded_bytes() { + assert!(::is_basic_type()); + + let address = H160::repeat_byte(0xcd); + let mut expected = [0u8; 32]; + expected[..ADDRESS_SIZE].fill(0xcd); + assert_eq!(ssz_root(&address), expected); + } + + #[test] + fn a_uint256_encodes_little_endian() { + let value = U256::from(258u64); + let bytes = value.to_ssz(); + assert_eq!(bytes.len(), 32); + assert_eq!(&bytes[..3], &[2, 1, 0]); + assert_eq!(U256::from_ssz_bytes(&bytes).unwrap(), value); + } + + /// A `uint256` roots to its own little-endian bytes, unhashed, so the + /// encoding above is also the leaf. + #[test] + fn a_uint256_roots_to_its_encoding() { + let value = U256::from(258u64); + assert_eq!(ssz_root(&value).as_slice(), value.to_ssz().as_slice()); + } + + /// Numeric order, not stored-byte order. `256` and `1` differ only in bytes + /// a derived `Ord` would reach in the wrong order, and would compare the + /// wrong way round: `256`'s low byte is `0` where `1`'s is `1`. + #[test] + fn uint256_compares_numerically() { + assert!(U256::from(256u64) > U256::from(1u64)); + assert!(U256::from(1u64) > U256::ZERO); + assert!(U256::MAX > U256::from(u64::MAX)); + assert_eq!(U256::from(7u64).cmp(&U256::from(7u64)), Ordering::Equal); + } + + #[test] + fn a_decimal_string_parses_to_the_same_value_as_its_digits() { + assert_eq!(U256::from_dec_str("0").unwrap(), U256::ZERO); + assert_eq!(U256::from_dec_str("258").unwrap(), U256::from(258u64)); + assert_eq!( + U256::from_dec_str("340282366920938463463374607431768211455").unwrap(), + U256::from_u128(u128::MAX), + ); + assert_eq!( + U256::from_dec_str( + "115792089237316195423570985008687907853269984665640564039457584007913129639935", + ) + .unwrap(), + U256::MAX, + ); + } + + #[test] + fn a_decimal_string_that_is_not_a_uint256_is_rejected() { + assert_eq!(U256::from_dec_str(""), Err(ParseU256Error::Empty)); + assert_eq!( + U256::from_dec_str("0x10"), + Err(ParseU256Error::InvalidDigit) + ); + assert_eq!( + // 2^256, one past the largest representable value. + U256::from_dec_str( + "115792089237316195423570985008687907853269984665640564039457584007913129639936", + ), + Err(ParseU256Error::Overflow), + ); + } + + #[test] + fn uint256_debug_is_big_endian_hex() { + assert_eq!(format!("{:?}", U256::ZERO), "U256(0x0)"); + assert_eq!(format!("{:?}", U256::from(258u64)), "U256(0x102)"); + } + + #[test] + fn byte_newtypes_serialize_as_prefixed_hex() { + let sig = BlsSignature([0xab; BLS_SIGNATURE_SIZE]); + let json = serde_json::to_string(&sig).unwrap(); + assert!(json.starts_with(r#""0xabab"#), "got {json}"); + assert_eq!(json.len(), BLS_SIGNATURE_SIZE * 2 + 4); // 0x + quotes + + let address = H160([0x11; 20]); + assert_eq!( + serde_json::to_string(&address).unwrap(), + r#""0x1111111111111111111111111111111111111111""# + ); + } + + #[test] + fn uint256_serializes_as_a_quoted_decimal() { + // 32_000_000_000 Gwei, little-endian, the way SSZ stores it. + let mut bytes = [0u8; 32]; + bytes[..8].copy_from_slice(&32_000_000_000u64.to_le_bytes()); + assert_eq!( + serde_json::to_string(&U256(bytes)).unwrap(), + r#""32000000000""# + ); + } + + #[test] + fn a_large_uint256_does_not_lose_digits() { + // 2^128, which no u128 path would round-trip through f64. + let mut bytes = [0u8; 32]; + bytes[16] = 1; + assert_eq!( + serde_json::to_string(&U256(bytes)).unwrap(), + r#""340282366920938463463374607431768211456""# + ); + } + + /// A zero byte in the *middle* of the value (not just trailing) is the case + /// most likely to break a long-division-by-10 implementation: any off-by-one + /// in how the carry propagates across a zero limb either drops digits or + /// inserts a spurious one. `2^136 + 1` sets the lowest byte and byte 17, + /// leaving sixteen zero bytes between two nonzero ones, which the other + /// three cases (a small value, a lone high bit, and their combination) do + /// not exercise: each of those has nonzero bytes only at one end. + #[test] + fn a_uint256_with_an_interior_zero_byte_round_trips_through_decimal() { + let mut bytes = [0u8; 32]; + bytes[0] = 1; + bytes[17] = 1; + assert_eq!( + serde_json::to_string(&U256(bytes)).unwrap(), + r#""87112285931760246646623899502532662132737""# + ); + } +} diff --git a/crates/common/types/src/beacon/serde_helpers.rs b/crates/common/types/src/beacon/serde_helpers.rs new file mode 100644 index 000000000..91599b286 --- /dev/null +++ b/crates/common/types/src/beacon/serde_helpers.rs @@ -0,0 +1,488 @@ +//! The serde adapters the beacon types need, in both directions. +//! +//! Reading covers the two scalar shapes an eth2 `config.yaml` uses; writing +//! covers the Beacon API's JSON encoding. The two are not symmetric, and the +//! asymmetries are documented on each adapter: reading an integer accepts a +//! quoted or bare scalar, writing one always quotes. + +use serde::{Deserialize as _, Deserializer}; + +/// An integer written either quoted or bare. +/// +/// The specification's own configuration files quote large integers so that a +/// JavaScript client does not lose precision reading them, but the convention +/// is not universal and a generator may emit either. Accepting only one form +/// silently leaves the field at its default, which is the failure mode this +/// avoids. +/// +/// Every scalar is taken as a string and parsed, rather than matched against +/// an untagged enum of "string or integer". YAML resolves a bare `0x...` to an +/// integer, and an untagged enum makes serde buffer the value first, which +/// fails outright on `DEPOSIT_CONTRACT_ADDRESS`: twenty bytes of hex overflow +/// `u128` and the buffered value cannot even be constructed. Asking for a +/// string hands us the scalar's own text whether or not it was quoted, which +/// is the same coercion Teku applies and the reason Teku has none of the +/// quoting bugs Prysm worked around. +pub mod quoted_or_bare { + use super::*; + + pub fn deserialize<'de, D, T>(deserializer: D) -> Result + where + D: Deserializer<'de>, + T: std::str::FromStr, + T::Err: std::fmt::Display, + { + let text = String::deserialize(deserializer)?; + text.trim().parse().map_err(serde::de::Error::custom) + } + + /// Always written quoted, whatever form it was read in. + /// + /// The asymmetry with [`deserialize`] is deliberate: a `config.yaml` may + /// quote an integer or not, and both must parse, but every integer in a + /// Beacon API response is a quoted string. Reading is permissive, writing + /// is not. + /// + /// Intended for the unsigned integer aliases (`Slot`, `Epoch`, `Gwei`, + /// `ValidatorIndex`, ...), whose `Display` output already is their wire + /// form. A `Display` type whose text is not its wire form, such as a + /// `bool` or a signed integer, would silently misencode through here. + /// + /// Goes through `collect_str` rather than `serialize_str(&value.to_string())`: + /// `to_string()` always allocates a `String`, but serde_json overrides + /// `collect_str` to format straight into its output buffer, so on that + /// (our) backend this allocates nothing. A field of this type appears + /// once per validator in a `BeaconState`, so the difference is millions + /// of allocations on a mainnet-sized response. + pub fn serialize(value: &T, serializer: S) -> Result + where + S: serde::Serializer, + T: std::fmt::Display, + { + serializer.collect_str(value) + } +} + +/// A fixed-width byte array written as hex, with or without a `0x` prefix. +/// +/// Used for fork versions and the snappy message domains, which are four +/// bytes, and for the deposit contract address, which is twenty. +pub mod hex_array { + use super::*; + + pub fn deserialize<'de, D, const N: usize>(deserializer: D) -> Result<[u8; N], D::Error> + where + D: Deserializer<'de>, + { + let text = String::deserialize(deserializer)?; + let digits = text.trim(); + let digits = digits.strip_prefix("0x").unwrap_or(digits); + + if digits.len() != N * 2 { + return Err(serde::de::Error::custom(format!( + "expected {N} bytes of hex, got {} characters", + digits.len() + ))); + } + + let mut out = [0u8; N]; + hex::decode_to_slice(digits, &mut out).map_err(serde::de::Error::custom)?; + Ok(out) + } + + /// Always written with the `0x` prefix, though [`deserialize`] accepts it + /// either way. + /// + /// Formats through the [`HexPrefixed`] `Display` adapter and + /// `collect_str` rather than `serialize_str(&format!("0x{}", hex::encode(value)))`: + /// the latter allocates twice (once in `hex::encode`, once in `format!`) + /// per call, but serde_json overrides `collect_str` to write straight + /// into its output buffer with no intermediate `String`, so on that (our) + /// backend the adapter allocates nothing. The saving here is small in + /// absolute terms, since the fixed-width byte types this serves + /// (`Version`, `DomainType`, `ForkDigest`, `Domain`) appear a handful of + /// times per state rather than per validator: it is written this way to + /// match its sibling above, so that neither adapter is the one that + /// allocates. + pub fn serialize(value: &[u8; N], serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.collect_str(&HexPrefixed(value.as_slice())) + } +} + +/// A zero-allocation `Display` adapter writing `0x`-prefixed lowercase hex. +/// +/// Module-scoped rather than private to [`hex_array`] so [`ssz_hex`] can +/// format its owned SSZ encoding through the same path without a second +/// allocation. Public so everything else that writes Beacon API hex reuses it +/// too rather than repeating `format!("0x{}", hex::encode(..))`: +/// `beacon::primitives`'s hand-written `Serialize` impls for the fixed-width +/// byte newtypes, the RPC crate's hand-built JSON, and the validator client's +/// request bodies. Call `.to_string()` on it where a `String` is needed. +pub struct HexPrefixed<'a>(pub &'a [u8]); + +impl std::fmt::Display for HexPrefixed<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "0x")?; + self.0.iter().try_for_each(|byte| write!(f, "{byte:02x}")) + } +} + +/// A sequence of integers, each written quoted. +/// +/// Serialize-only: nothing reads one back yet. Takes any `IntoIterator` of +/// `Display` by reference so it serves a `Vec` and an `SszList` +/// alike, which is what the containers need — `libssz-types` has no serde +/// support and is a foreign crate, so its collections cannot carry an impl of +/// their own. +/// +/// Carries the same caveat as [`quoted_or_bare::serialize`]: it is intended +/// for sequences of the unsigned integer aliases (`Slot`, `Epoch`, `Gwei`, +/// `ValidatorIndex`, ...), whose `Display` output already is their wire form. +/// A sequence of some other `Display` type whose text is not its wire form, +/// such as `bool` or a signed integer, would silently misencode through +/// here, element by element. +pub mod quoted_u64_seq { + /// Wraps one `Display` element so [`serde::ser::SerializeSeq::serialize_element`] + /// routes it through `collect_str` instead of `serialize_str(&value.to_string())`: + /// the latter always allocates a `String` per element, but serde_json + /// overrides `collect_str` to format straight into its output buffer, so + /// on that (our) backend this allocates nothing. This runs once per + /// element of fields like `Attestation.attesting_indices`, which reach + /// into the millions across a mainnet `BeaconState`. + struct Quoted(T); + + impl serde::Serialize for Quoted { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.collect_str(&self.0) + } + } + + pub fn serialize(values: C, serializer: S) -> Result + where + S: serde::Serializer, + C: IntoIterator, + C::IntoIter: ExactSizeIterator, + T: std::fmt::Display, + { + use serde::ser::SerializeSeq as _; + + let iter = values.into_iter(); + let mut seq = serializer.serialize_seq(Some(iter.len()))?; + for item in iter { + seq.serialize_element(&Quoted(item))?; + } + seq.end() + } +} + +/// Anything SSZ-encodable, written as `0x`-prefixed hex of that encoding. +/// +/// This is how the Beacon API carries bitfields: `SszBitlist` and +/// `SszBitvector` have no JSON form of their own, and their SSZ encoding — +/// which already carries the length-delimiting bit for a bitlist — is exactly +/// what the specification's hex string holds. +pub mod ssz_hex { + use super::HexPrefixed; + + /// `to_ssz()` allocates the `Vec` holding the encoding; that + /// allocation is genuinely unavoidable for `SszBitlist`, which must set + /// a delimiter bit no existing buffer holds. It is not strictly needed + /// for `SszBitvector` (`as_bytes()` already is the encoding) or a byte + /// list (`SszList` derefs to `[u8]`, which already is the + /// encoding too) — but one shared `SszEncode`/`to_ssz()` path across all + /// three is preferred over hand-picking a zero-allocation route per + /// type, for one allocation that is a handful of bytes, not a + /// per-validator or per-element cost. What this function still avoids is + /// a *second* allocation on top of `to_ssz()`'s: formatting through + /// [`HexPrefixed`] and `collect_str` (serde_json writes straight into + /// its output buffer for that call) means the hex text itself is never + /// materialized as its own `String`. + pub fn serialize(value: &T, serializer: S) -> Result + where + S: serde::Serializer, + T: libssz::SszEncode, + { + serializer.collect_str(&HexPrefixed(&value.to_ssz())) + } + + /// The inverse: hex (with or without `0x`) of the value's SSZ encoding, + /// decoded through `SszDecode`, so a bitlist's delimiter bit and a + /// bitvector's width are checked by the same code that checks them on the + /// wire. + pub fn deserialize<'de, D, T>(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + T: libssz::SszDecode, + { + let text = ::deserialize(deserializer)?; + let digits = text.trim(); + let bytes = hex::decode(digits.strip_prefix("0x").unwrap_or(digits)) + .map_err(serde::de::Error::custom)?; + T::from_ssz_bytes(&bytes) + .map_err(|err| serde::de::Error::custom(format!("invalid SSZ encoding: {err:?}"))) + } +} + +/// A sequence of SSZ-encodable byte strings, each written as its own +/// `0x`-prefixed hex, from a foreign collection. +/// +/// Neither [`seq`] nor [`ssz_hex`] alone produces this shape. [`seq::serialize`] +/// needs each element to already implement `Serialize`, but an element here is +/// itself a foreign `SszList` (from `libssz-types`), which the orphan +/// rule rules out an impl for, the same constraint [`seq`]'s own doc comment +/// describes. [`ssz_hex::serialize`] over the whole field would go the other +/// way: it would hex-encode the *entire list's* SSZ encoding as one string, +/// rather than emitting one hex string per element. This module composes the +/// two: an internal per-element wrapper gives each element `Serialize` by +/// deferring to the same [`HexPrefixed`] formatting [`ssz_hex`] uses, and +/// [`serialize`](self::serialize) drives the sequence the way +/// [`quoted_u64_seq`] drives its own per-element wrapper. +/// +/// `ExecutionPayload.transactions` is the motivating field: a +/// `SszList` where `Transaction` is itself a +/// `SszList`, so the Beacon API's JSON array of `0x`-prefixed +/// transaction hex strings needs exactly this shape. +pub mod ssz_hex_seq { + use libssz::SszEncode as _; + + use super::HexPrefixed; + + /// Wraps one element so [`serde::ser::SerializeSeq::serialize_element`] + /// routes it through `collect_str` instead of allocating a `String` per + /// element the way `serialize_str(&format!("0x{}", hex::encode(...)))` + /// would: serde_json overrides `collect_str` to format straight into its + /// output buffer, so on that (our) backend only `to_ssz()`'s own + /// allocation remains — the same one [`ssz_hex::serialize`] cannot avoid + /// either. + /// + /// Generic over `T: Deref` rather than `T: SszEncode` + /// directly: sequence iteration (below) hands over `&Element`, not + /// `Element`, and `SszEncode` (unlike `serde::Serialize` or `Display`) + /// carries no blanket impl for references, so the bound has to look + /// through the reference instead of requiring one on it. + struct Hex(T); + + impl serde::Serialize for Hex + where + T: std::ops::Deref, + T::Target: libssz::SszEncode, + { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.collect_str(&HexPrefixed(&self.0.to_ssz())) + } + } + + pub fn serialize(values: C, serializer: S) -> Result + where + S: serde::Serializer, + C: IntoIterator, + C::IntoIter: ExactSizeIterator, + T: std::ops::Deref, + T::Target: libssz::SszEncode, + { + use serde::ser::SerializeSeq as _; + + let iter = values.into_iter(); + let mut seq = serializer.serialize_seq(Some(iter.len()))?; + for item in iter { + seq.serialize_element(&Hex(item))?; + } + seq.end() + } +} + +/// A sequence of values that serialize themselves, from a foreign collection. +/// +/// `SszList` and `SszVector` come from `libssz-types`, which has no serde +/// support, so the orphan rule rules out an impl on them and every field of one +/// routes through here instead. +pub mod seq { + use serde::ser::SerializeSeq as _; + + pub fn serialize(values: C, serializer: S) -> Result + where + S: serde::Serializer, + C: IntoIterator, + C::IntoIter: ExactSizeIterator, + T: serde::Serialize, + { + let iter = values.into_iter(); + let mut seq = serializer.serialize_seq(Some(iter.len()))?; + for item in iter { + seq.serialize_element(&item)?; + } + seq.end() + } +} + +#[cfg(test)] +mod tests { + use serde::Deserialize; + + #[derive(Debug, Deserialize)] + struct Sample { + #[serde(deserialize_with = "super::quoted_or_bare::deserialize")] + count: u64, + #[serde(deserialize_with = "super::hex_array::deserialize")] + version: [u8; 4], + } + + #[test] + fn quoted_and_bare_integers_parse_identically() { + let quoted: Sample = serde_yaml_ng::from_str("count: '64'\nversion: '0x01000000'").unwrap(); + let bare: Sample = serde_yaml_ng::from_str("count: 64\nversion: 0x01000000").unwrap(); + assert_eq!(quoted.count, 64); + assert_eq!(bare.count, 64); + assert_eq!(quoted.version, [0x01, 0x00, 0x00, 0x00]); + assert_eq!(bare.version, [0x01, 0x00, 0x00, 0x00]); + } + + #[test] + fn hex_without_prefix_parses() { + let sample: Sample = serde_yaml_ng::from_str("count: 1\nversion: '01000000'").unwrap(); + assert_eq!(sample.version, [0x01, 0x00, 0x00, 0x00]); + } + + #[test] + fn a_hex_value_of_the_wrong_length_is_an_error() { + let err = serde_yaml_ng::from_str::("count: 1\nversion: '0x0100'") + .unwrap_err() + .to_string(); + assert!(err.contains("expected 4 bytes"), "got {err}"); + } + + /// Twenty bytes of unquoted hex, exactly as `eth-clients/mainnet` writes + /// `DEPOSIT_CONTRACT_ADDRESS`. This is the case that rules out reading a + /// scalar through an untagged "string or integer" enum: the value exceeds + /// `u128`, so serde cannot buffer it and the parse fails before any of our + /// code runs. Asking for a `String` sees the scalar's own text instead. + #[test] + fn a_twenty_byte_unquoted_address_parses() { + #[derive(Debug, serde::Deserialize)] + struct Address { + #[serde(deserialize_with = "super::hex_array::deserialize")] + deposit_contract_address: [u8; 20], + } + + let parsed: Address = serde_yaml_ng::from_str( + "deposit_contract_address: 0x00000000219ab540356cBB839Cbe05303d7705Fa", + ) + .expect("an unquoted twenty-byte address parses"); + assert_eq!( + parsed.deposit_contract_address[0..4], + [0x00, 0x00, 0x00, 0x00] + ); + assert_eq!(parsed.deposit_contract_address[19], 0xfa); + } + + #[derive(Debug, serde::Serialize)] + struct Out { + #[serde(with = "super::quoted_or_bare")] + count: u64, + #[serde(with = "super::hex_array")] + version: [u8; 4], + } + + #[test] + fn integers_are_written_quoted() { + let json = serde_json::to_string(&Out { + count: 64, + version: [0x01, 0x00, 0x00, 0x00], + }) + .unwrap(); + assert_eq!(json, r#"{"count":"64","version":"0x01000000"}"#); + } + + #[test] + fn a_written_hex_array_always_carries_the_prefix() { + let json = serde_json::to_string(&Out { + count: 0, + version: [0xde, 0xad, 0xbe, 0xef], + }) + .unwrap(); + assert!(json.contains(r#""0xdeadbeef""#), "got {json}"); + } + + #[derive(Debug, PartialEq, serde::Serialize, serde::Deserialize)] + struct RoundTrip { + #[serde(with = "super::quoted_or_bare")] + count: u64, + } + + /// The module doc's whole reason for quoting is protecting large + /// integers from JavaScript's float precision loss, so the round trip + /// must hold exactly at `u64::MAX`, not just for small values. + #[test] + fn a_quoted_integer_survives_a_round_trip_at_u64_max() { + let original = RoundTrip { count: u64::MAX }; + let json = serde_json::to_string(&original).unwrap(); + assert_eq!(json, r#"{"count":"18446744073709551615"}"#); + let parsed: RoundTrip = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, original); + } + + #[derive(Debug, serde::Serialize)] + struct Coll { + #[serde(serialize_with = "super::quoted_u64_seq::serialize")] + indices: Vec, + #[serde(serialize_with = "super::quoted_u64_seq::serialize")] + list: libssz_types::SszList, + #[serde(serialize_with = "super::ssz_hex::serialize")] + bits: libssz_types::SszBitlist<64>, + } + + #[test] + fn a_list_of_integers_quotes_every_element() { + let value = Coll { + indices: vec![1, 2, 300], + list: libssz_types::SszList::try_from(vec![4, 5, 600]).unwrap(), + bits: libssz_types::SszBitlist::new(), + }; + let json = serde_json::to_value(&value).unwrap(); + assert_eq!(json["indices"], serde_json::json!(["1", "2", "300"])); + // Same adapter, driven through an `SszList` rather than a + // `Vec` — the whole reason `quoted_u64_seq` is generic over + // `IntoIterator` instead of hard-coded to `Vec`. + assert_eq!(json["list"], serde_json::json!(["4", "5", "600"])); + } + + #[test] + fn a_bitfield_is_written_as_hex_of_its_ssz_encoding() { + let mut bits = libssz_types::SszBitlist::<64>::with_length(8).unwrap(); + bits.set(0, true).unwrap(); + let value = Coll { + indices: vec![], + list: libssz_types::SszList::new(), + bits, + }; + let json = serde_json::to_value(&value).unwrap(); + // Data byte 0x01 (bit 0 set) followed by the length-delimiter byte + // 0x01 (the delimiter bit lands at bit index 8, i.e. bit 0 of the + // second byte, since the bitlist encoding is `ceil((len + 1) / 8)` + // bytes wide). + assert_eq!(json["bits"], serde_json::json!("0x0101")); + } + + #[test] + fn an_empty_bitfield_is_just_the_delimiter_byte() { + let value = Coll { + indices: vec![], + list: libssz_types::SszList::new(), + bits: libssz_types::SszBitlist::new(), + }; + let json = serde_json::to_value(&value).unwrap(); + // No data bits at all: the encoding is the lone delimiter bit set in + // an otherwise-empty byte, not an empty string and not an all-zero + // byte. + assert_eq!(json["bits"], serde_json::json!("0x01")); + } +} diff --git a/crates/common/types/src/beacon/signing.rs b/crates/common/types/src/beacon/signing.rs new file mode 100644 index 000000000..48df70464 --- /dev/null +++ b/crates/common/types/src/beacon/signing.rs @@ -0,0 +1,139 @@ +//! Slot and epoch arithmetic, the signing domains built from them, and the +//! signing root that combines a message with one. +//! +//! These helpers depend on nothing but their arguments, so unlike the state +//! accessors they need no `BeaconState`. They live here rather than in +//! `ethlambda-state-transition` so that a consumer which only signs or verifies +//! a message, such as a validator client, can reach them without taking on that +//! crate's `blst`, `c-kzg` and RocksDB dependencies. + +use crate::beacon::constants::DOMAIN_DEPOSIT; +use crate::beacon::containers::shared::{Fork, SigningData}; +use crate::beacon::fork_digest::compute_fork_data_root; +use crate::beacon::preset; +use crate::beacon::primitives::{ + Domain, DomainType, Epoch, HashTreeRoot as _, Root, Slot, Version, +}; + +/// The epoch containing `slot`. +pub fn compute_epoch_at_slot(slot: Slot) -> Epoch { + slot / preset::SLOTS_PER_EPOCH +} + +/// The first slot of `epoch`. +pub fn compute_start_slot_at_epoch(epoch: Epoch) -> Slot { + epoch * preset::SLOTS_PER_EPOCH +} + +/// The signing domain for a message type on a particular fork and chain. +/// +/// The domain is the four-byte domain type followed by the first 28 bytes of the +/// fork data root, so it fits in 32 bytes while still committing to both. +pub fn compute_domain( + domain_type: DomainType, + fork_version: Version, + genesis_validators_root: Root, +) -> Domain { + let fork_data_root = compute_fork_data_root(fork_version, genesis_validators_root); + let mut domain = [0u8; 32]; + domain[..4].copy_from_slice(&domain_type); + domain[4..].copy_from_slice(&fork_data_root.0[..28]); + domain +} + +/// The root a signature is actually computed over: the message's root combined +/// with its domain. +pub fn compute_signing_root(object_root: Root, domain: Domain) -> Root { + SigningData { + object_root, + domain, + } + .hash_tree_root() +} + +/// The fork version in effect at `epoch`, given the state's fork schedule. +/// +/// A message signed just before a fork boundary must still verify just after it, +/// which is why the state keeps the previous version at all. +pub fn fork_version_at_epoch(fork: &Fork, epoch: Epoch) -> Version { + if epoch < fork.epoch { + fork.previous_version + } else { + fork.current_version + } +} + +/// The domain for a deposit signature. +/// +/// Deposits are the one message signed under a genesis-independent domain, since +/// a deposit has to be valid before the chain it funds has started, so it cannot +/// commit to a genesis validators root. +pub fn compute_deposit_domain(genesis_fork_version: Version) -> Domain { + compute_domain(DOMAIN_DEPOSIT, genesis_fork_version, Root::ZERO) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fork_version_switches_at_the_boundary() { + let fork = Fork { + previous_version: [0, 0, 0, 0], + current_version: [1, 0, 0, 0], + epoch: 10, + }; + assert_eq!(fork_version_at_epoch(&fork, 9), [0, 0, 0, 0]); + assert_eq!(fork_version_at_epoch(&fork, 10), [1, 0, 0, 0]); + assert_eq!(fork_version_at_epoch(&fork, 11), [1, 0, 0, 0]); + } + + #[test] + fn domain_carries_the_type_then_the_fork_data_prefix() { + let domain = compute_domain([1, 0, 0, 0], [2, 0, 0, 0], Root::repeat_byte(9)); + assert_eq!(&domain[..4], &[1, 0, 0, 0]); + + let fork_data_root = compute_fork_data_root([2, 0, 0, 0], Root::repeat_byte(9)); + assert_eq!(&domain[4..], &fork_data_root.0[..28]); + } + + #[test] + fn domain_separates_forks_and_chains() { + let a = compute_domain([1, 0, 0, 0], [2, 0, 0, 0], Root::ZERO); + let b = compute_domain([1, 0, 0, 0], [3, 0, 0, 0], Root::ZERO); + let c = compute_domain([1, 0, 0, 0], [2, 0, 0, 0], Root::repeat_byte(1)); + assert_ne!( + a, b, + "a different fork version must give a different domain" + ); + assert_ne!(a, c, "a different chain must give a different domain"); + } + + #[test] + fn signing_root_commits_to_both_the_object_and_the_domain() { + let domain = compute_domain([1, 0, 0, 0], [2, 0, 0, 0], Root::ZERO); + let other_domain = compute_domain([9, 0, 0, 0], [2, 0, 0, 0], Root::ZERO); + let a = compute_signing_root(Root::repeat_byte(1), domain); + let b = compute_signing_root(Root::repeat_byte(2), domain); + let c = compute_signing_root(Root::repeat_byte(1), other_domain); + assert_ne!( + a, b, + "a different object root must give a different signing root" + ); + assert_ne!( + a, c, + "a different domain must give a different signing root" + ); + } + + #[test] + fn epoch_and_start_slot_round_trip() { + let epoch = 42; + let start = compute_start_slot_at_epoch(epoch); + assert_eq!(compute_epoch_at_slot(start), epoch); + assert_eq!( + compute_epoch_at_slot(start + preset::SLOTS_PER_EPOCH - 1), + epoch + ); + } +} diff --git a/crates/common/types/src/block.rs b/crates/common/types/src/block.rs index 5bc21e896..d3e5f7e2a 100644 --- a/crates/common/types/src/block.rs +++ b/crates/common/types/src/block.rs @@ -22,7 +22,7 @@ use primitives::HashTreeRoot as _; /// of how the merged proof is serialised. /// /// -#[derive(Clone, SszEncode, SszDecode)] +#[derive(Clone, PartialEq, Serialize, SszEncode, SszDecode)] pub struct SignedBlock { /// The block being signed. pub message: Block, @@ -56,12 +56,28 @@ pub type ByteList512KiB = ByteList<524_288>; /// a verifier rebuilds each component's set from the surrounding block body /// before decoding. SSZ encoding this container adds the offset required for /// its variable-length field. -#[derive(Debug, Default, Clone, PartialEq, Eq, SszEncode, SszDecode, HashTreeRoot)] +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, SszEncode, SszDecode, HashTreeRoot)] pub struct MultiMessageAggregate { /// Serialized multi-message aggregate proof bytes. + #[serde(serialize_with = "serialize_proof_hex")] pub proof: ByteList512KiB, } +/// Serialize a [`ByteList512KiB`] proof blob as a `0x`-prefixed hex string. +/// +/// This is opaque binary data (a leanVM proof), not a fixed-length +/// identity key, so it follows the `0x`-prefixed convention already used in +/// this crate for that kind of value — block/state roots (`H256`) and the +/// `AggregationBits` bitlist — rather than the un-prefixed convention +/// `state.rs` uses for XMSS validator pubkeys. +fn serialize_proof_hex(proof: &ByteList512KiB, serializer: S) -> Result +where + S: Serializer, +{ + let encoded = format!("0x{}", hex::encode(proof.iter().as_slice())); + serializer.serialize_str(&encoded) +} + impl MultiMessageAggregate { /// Build an aggregate from an already bounded proof byte list. pub fn new(proof: ByteList512KiB) -> Self { @@ -199,7 +215,7 @@ pub struct BlockHeader { } /// A complete block including header and body. -#[derive(Debug, Clone, Serialize, SszEncode, SszDecode, HashTreeRoot)] +#[derive(Debug, Clone, PartialEq, Serialize, SszEncode, SszDecode, HashTreeRoot)] pub struct Block { /// The slot in which the block was proposed. pub slot: u64, @@ -249,7 +265,7 @@ impl Block { /// /// Currently, the main operation is voting. Validators submit attestations which are /// packaged into blocks. -#[derive(Debug, Default, Clone, Serialize, SszEncode, SszDecode, HashTreeRoot)] +#[derive(Debug, Default, Clone, PartialEq, Serialize, SszEncode, SszDecode, HashTreeRoot)] pub struct BlockBody { /// Plain validator attestations carried in the block body. /// @@ -336,4 +352,39 @@ mod tests { assert_eq!(&encoded[4..], proof_bytes); assert_eq!(aggregate.proof_bytes(), proof_bytes); } + + #[test] + fn a_signed_block_serializes_with_bare_integers_and_a_hex_proof() { + let signed = SignedBlock { + message: Block { + slot: 9, + proposer_index: 3, + parent_root: H256([0x11; 32]), + state_root: H256([0x22; 32]), + body: BlockBody::default(), + }, + proof: MultiMessageAggregate::default(), + }; + + let json = serde_json::to_value(&signed).unwrap(); + // Bare, not quoted: this is lean's own encoding and predates the + // beacon surface. `/lean/v0` consumers parse numbers. + assert_eq!(json["message"]["slot"], 9); + assert_eq!(json["message"]["proposer_index"], 3); + + // `MultiMessageAggregate` is a single-field `{ proof: ByteList512KiB }` + // struct wrapping raw leanVM proof bytes. It serializes as an + // object with one hex-encoded string field; the default (empty) proof + // round-trips to a bare "0x". + assert_eq!(json["proof"]["proof"], "0x"); + } + + #[test] + fn multi_message_aggregate_serializes_proof_as_0x_prefixed_hex() { + let aggregate = MultiMessageAggregate::from_bytes(&[0xde, 0xad, 0xbe, 0xef]).unwrap(); + + let json = serde_json::to_value(&aggregate).unwrap(); + + assert_eq!(json["proof"], "0xdeadbeef"); + } } diff --git a/crates/common/types/src/enr.rs b/crates/common/types/src/enr.rs new file mode 100644 index 000000000..0feeb907b --- /dev/null +++ b/crates/common/types/src/enr.rs @@ -0,0 +1,75 @@ +//! ENR entries ethlambda advertises for discv5 peer discovery. +//! +//! Layout follows the beacon-chain phase0 p2p interface spec's discovery +//! domain, so that whatever lean standardizes on later has the best chance of +//! already matching. +//! +//! Note that lean defines no fork schedule and its fork digest is a +//! compile-time constant rather than a genesis-derived value, so every field of +//! [`EnrForkId`] is currently fixed. The `eth2` check therefore separates lean +//! from non-lean, but not one lean devnet from another. + +use libssz_derive::{SszDecode, SszEncode}; + +use crate::constants::FORK_DIGEST; + +/// Fork version of the next planned hard fork. The spec says to set this to the +/// current fork version when no fork is planned; lean has neither. +pub const NEXT_FORK_VERSION: [u8; 4] = [0; 4]; + +/// Sentinel for "no fork is scheduled", per the beacon spec. +pub const FAR_FUTURE_EPOCH: u64 = u64::MAX; + +/// The `eth2` ENR entry: SSZ, 16 bytes, byte-identical to the beacon-chain +/// `ENRForkID` container. +#[derive(Debug, Clone, Copy, PartialEq, Eq, SszEncode, SszDecode)] +pub struct EnrForkId { + pub fork_digest: [u8; 4], + pub next_fork_version: [u8; 4], + pub next_fork_epoch: u64, +} + +impl EnrForkId { + /// This node's fork id. Constant for the lifetime of the process. + pub fn local() -> Self { + Self { + fork_digest: fork_digest(), + next_fork_version: NEXT_FORK_VERSION, + next_fork_epoch: FAR_FUTURE_EPOCH, + } + } +} + +/// [`FORK_DIGEST`] as raw bytes. The constant is the same hex string embedded in +/// every gossipsub topic name, so the ENR and the topics cannot disagree. +pub fn fork_digest() -> [u8; 4] { + u32::from_str_radix(FORK_DIGEST, 16) + .expect("FORK_DIGEST must be 8 hex digits") + .to_be_bytes() +} + +#[cfg(test)] +mod tests { + use super::*; + use libssz::{SszDecode, SszEncode}; + #[test] + fn fork_digest_parses_the_constant() { + assert_eq!(fork_digest(), [0x12, 0x34, 0x56, 0x78]); + } + + #[test] + fn enr_fork_id_is_sixteen_bytes_and_round_trips() { + let id = EnrForkId::local(); + let bytes = id.to_ssz(); + assert_eq!(bytes.len(), 16, "ENRForkID is 4 + 4 + 8 bytes"); + assert_eq!(EnrForkId::from_ssz_bytes(&bytes).unwrap(), id); + } + + #[test] + fn local_fork_id_has_no_planned_fork() { + let id = EnrForkId::local(); + assert_eq!(id.fork_digest, fork_digest()); + assert_eq!(id.next_fork_version, NEXT_FORK_VERSION); + assert_eq!(id.next_fork_epoch, FAR_FUTURE_EPOCH); + } +} diff --git a/crates/common/types/src/genesis.rs b/crates/common/types/src/genesis.rs index 4e91bf02e..5fbd63cd3 100644 --- a/crates/common/types/src/genesis.rs +++ b/crates/common/types/src/genesis.rs @@ -1,9 +1,11 @@ use serde::Deserialize; +use crate::beacon::containers::BeaconState; use crate::chain_config::ChainConfig; use crate::constants::{ DEFAULT_MILLISECONDS_PER_SLOT, INTERVALS_PER_SLOT, MIN_MILLISECONDS_PER_SLOT, }; +use crate::primitives::{H256, HashTreeRoot as _}; use crate::state::{State, Validator, ValidatorPubkeyBytes}; /// Ways a state can fail to belong to the configured genesis. @@ -11,20 +13,18 @@ use crate::state::{State, Validator, ValidatorPubkeyBytes}; /// Raised for any state whose provenance we have not established ourselves: /// one downloaded through checkpoint sync, or one loaded from a data directory /// that may have been written by a different network. +/// +/// Two values identify a genesis on either chain. The registry root subsumes +/// what used to be four separate checks, count, sequential indices, and both +/// pubkeys per validator, because a list's root commits to all of them. #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum GenesisMismatch { #[error("genesis time mismatch: expected {expected}, got {got}")] GenesisTime { expected: u64, got: u64 }, #[error("slot duration mismatch: expected {expected} ms, got {got} ms")] SlotDuration { expected: u64, got: u64 }, - #[error("validator count mismatch: expected {expected}, got {got}")] - ValidatorCount { expected: usize, got: usize }, - #[error( - "validator at position {position} has non-sequential index (expected {position}, got {got})" - )] - NonSequentialIndex { position: usize, got: u64 }, - #[error("validator {index} pubkey mismatch (attestation or proposal key)")] - ValidatorPubkey { index: usize }, + #[error("genesis validators root mismatch: expected {expected}, got {got}")] + GenesisValidatorsRoot { expected: H256, got: H256 }, } /// A single validator entry in the genesis config with dual public keys. @@ -70,20 +70,28 @@ impl GenesisConfig { .collect() } - /// Verify `state` was produced by this genesis. + /// The root committing to this config's validator registry. + /// + /// Built through [`State::from_genesis`] rather than hashing the `Vec` + /// that [`GenesisConfig::validators`] returns. The state holds an SSZ + /// list, whose root mixes in the length and pads to the type's limit, so + /// a `Vec` root is a different value; going through the state guarantees + /// the same type as the one the comparison runs against, rather than + /// naming it here and letting the two drift. /// - /// Compares the genesis time and the full validator registry: count, - /// sequential indices, and both pubkeys per validator. The validator set is - /// fixed at genesis (nothing in the state transition mutates it), so any - /// state of a chain started from this config must carry exactly this - /// registry, whatever slot it sits at. + /// Startup-only, so building a whole state for one field is not worth + /// avoiding. + pub fn genesis_validators_root(&self) -> H256 { + State::from_genesis(self.genesis_time, self.validators()) + .validators + .hash_tree_root() + } + + /// Verify `state` was produced by this genesis. /// - /// This is a network-identity check, not a consistency check: it says - /// nothing about whether the state is internally coherent. Callers that - /// accept a state from an untrusted source pair it with their own sanity - /// checks. - pub fn verify_state(&self, state: &State) -> Result<(), GenesisMismatch> { - verify_state_genesis(state, self.genesis_time, &self.validators()) + /// The parsed-config front door onto [`verify_state_genesis`]. + pub fn verify_state(&self, state: &BeaconState) -> Result<(), GenesisMismatch> { + verify_state_genesis(state, self.genesis_time, self.genesis_validators_root()) } /// Verify a persisted [`ChainConfig`] belongs to this network. @@ -139,45 +147,38 @@ where } /// Verify `state` was produced by the genesis described by `genesis_time` and -/// `expected_validators`. +/// `genesis_validators_root`. /// -/// The implementation behind [`GenesisConfig::verify_state`], for callers that -/// hold the genesis time and validator registry separately rather than as a -/// parsed config. +/// Serves both chains and both entry points: a state loaded from a data +/// directory and one downloaded through checkpoint sync, lean or beacon. Lean +/// has no `genesis_validators_root` field, but its registry never mutates, so +/// the root of the registry it carries is the root it had at genesis, see +/// [`crate::beacon::containers::BeaconState::genesis_validators_root`]. +/// +/// This is a network-identity check, not a consistency check: it says nothing +/// about whether the state is internally coherent. Callers that accept a state +/// from an untrusted source pair it with their own sanity checks. pub fn verify_state_genesis( - state: &State, + state: &BeaconState, genesis_time: u64, - expected_validators: &[Validator], + genesis_validators_root: H256, ) -> Result<(), GenesisMismatch> { - if state.config.genesis_time != genesis_time { + let found_time = state.genesis_time(); + if found_time != genesis_time { return Err(GenesisMismatch::GenesisTime { expected: genesis_time, - got: state.config.genesis_time, + got: found_time, }); } - if state.validators.len() != expected_validators.len() { - return Err(GenesisMismatch::ValidatorCount { - expected: expected_validators.len(), - got: state.validators.len(), + let found_root = state.genesis_validators_root(); + if found_root != genesis_validators_root { + return Err(GenesisMismatch::GenesisValidatorsRoot { + expected: genesis_validators_root, + got: found_root, }); } - let pairs = state.validators.iter().zip(expected_validators.iter()); - for (position, (actual, expected)) in pairs.enumerate() { - if actual.index != position as u64 { - return Err(GenesisMismatch::NonSequentialIndex { - position, - got: actual.index, - }); - } - if actual.attestation_pubkey != expected.attestation_pubkey - || actual.proposal_pubkey != expected.proposal_pubkey - { - return Err(GenesisMismatch::ValidatorPubkey { index: position }); - } - } - Ok(()) } @@ -203,10 +204,7 @@ where #[cfg(test)] mod tests { use super::*; - use crate::{ - primitives::HashTreeRoot as _, - state::{State, Validator}, - }; + use crate::state::{State, Validator}; const ATT_PUBKEY_A: &str = "cd323f232b34ab26d6db7402c886e74ca81cfd3a0c659d2fe022356f25592f7d"; const PROP_PUBKEY_A: &str = "b7b0f72e24801b02bda64073cb4de6699a416b37dfead227d7ca3922647c940f"; @@ -326,19 +324,37 @@ GENESIS_VALIDATORS: } #[test] - fn verify_state_accepts_state_from_same_genesis() { + fn verify_state_genesis_accepts_a_state_of_that_genesis() { + use crate::beacon::containers::BeaconState; + let config = test_config(); - assert_eq!(config.verify_state(&state_of(&config)), Ok(())); + let state = BeaconState::Lean(state_of(&config)); + + assert_eq!( + verify_state_genesis( + &state, + config.genesis_time, + config.genesis_validators_root() + ), + Ok(()) + ); } #[test] - fn verify_state_rejects_different_genesis_time() { + fn verify_state_genesis_rejects_a_different_genesis_time() { + use crate::beacon::containers::BeaconState; + let config = test_config(); let mut other = test_config(); other.genesis_time = config.genesis_time + 1; + let state = BeaconState::Lean(state_of(&other)); assert_eq!( - config.verify_state(&state_of(&other)), + verify_state_genesis( + &state, + config.genesis_time, + config.genesis_validators_root() + ), Err(GenesisMismatch::GenesisTime { expected: config.genesis_time, got: config.genesis_time + 1, @@ -346,49 +362,69 @@ GENESIS_VALIDATORS: ); } + /// Same count and same genesis time, different keys. #[test] - fn verify_state_rejects_different_validator_count() { + fn verify_state_genesis_rejects_a_different_registry() { + use crate::beacon::containers::BeaconState; + let config = test_config(); let mut other = test_config(); - other.genesis_validators.pop(); - - assert_eq!( - config.verify_state(&state_of(&other)), - Err(GenesisMismatch::ValidatorCount { - expected: 3, - got: 2, - }) - ); + other.genesis_validators.swap(0, 1); + let state = BeaconState::Lean(state_of(&other)); + + assert!(matches!( + verify_state_genesis( + &state, + config.genesis_time, + config.genesis_validators_root() + ), + Err(GenesisMismatch::GenesisValidatorsRoot { .. }) + )); } - /// Same validator count and same genesis time, different keys: the case a - /// genesis-time-only check cannot see. + /// Stands in for the old field-by-field `ValidatorCount` check: a + /// registry one validator shorter than expected roots differently, even + /// though every validator it does carry matches. #[test] - fn verify_state_rejects_different_validator_keys() { + fn verify_state_genesis_rejects_a_shorter_registry() { + use crate::beacon::containers::BeaconState; + let config = test_config(); let mut other = test_config(); - other.genesis_validators.swap(0, 1); - - assert_eq!( - config.verify_state(&state_of(&other)), - Err(GenesisMismatch::ValidatorPubkey { index: 0 }) - ); + other.genesis_validators.pop(); + let state = BeaconState::Lean(state_of(&other)); + + assert!(matches!( + verify_state_genesis( + &state, + config.genesis_time, + config.genesis_validators_root() + ), + Err(GenesisMismatch::GenesisValidatorsRoot { .. }) + )); } + /// Stands in for the old field-by-field `NonSequentialIndex` check. + /// `GenesisConfig::validators()` always renumbers sequentially from + /// position, so this registry cannot be reached through a config; it is + /// built directly, the way the check it stands in for once did. #[test] - fn verify_state_rejects_non_sequential_validator_indices() { + fn verify_state_genesis_rejects_non_sequential_indices() { + use crate::beacon::containers::BeaconState; + let config = test_config(); let mut validators = config.validators(); validators[1].index = 7; - let state = State::from_genesis(config.genesis_time, validators); - - assert_eq!( - config.verify_state(&state), - Err(GenesisMismatch::NonSequentialIndex { - position: 1, - got: 7, - }) - ); + let state = BeaconState::Lean(State::from_genesis(config.genesis_time, validators)); + + assert!(matches!( + verify_state_genesis( + &state, + config.genesis_time, + config.genesis_validators_root() + ), + Err(GenesisMismatch::GenesisValidatorsRoot { .. }) + )); } #[test] @@ -481,4 +517,35 @@ GENESIS_VALIDATORS: }) ); } + + #[test] + fn genesis_validators_root_matches_what_a_state_of_that_genesis_reports() { + use crate::beacon::containers::BeaconState; + + let config = test_config(); + let state = BeaconState::Lean(State::from_genesis( + config.genesis_time, + config.validators(), + )); + + assert_eq!( + config.genesis_validators_root(), + state.genesis_validators_root() + ); + } + + /// Same count and same genesis time, different keys: the case a + /// genesis-time-only check cannot see, and the reason the registry is + /// committed to at all. + #[test] + fn genesis_validators_root_changes_with_the_registry() { + let config = test_config(); + let mut other = test_config(); + other.genesis_validators.swap(0, 1); + + assert_ne!( + config.genesis_validators_root(), + other.genesis_validators_root() + ); + } } diff --git a/crates/common/types/src/lib.rs b/crates/common/types/src/lib.rs index cfb8957b2..ecf92456e 100644 --- a/crates/common/types/src/lib.rs +++ b/crates/common/types/src/lib.rs @@ -1,12 +1,15 @@ pub mod aggregator; pub mod attestation; +pub mod beacon; pub mod block; pub mod chain_config; pub mod checkpoint; pub mod constants; +pub mod enr; pub mod genesis; pub mod primitives; pub mod state; +pub mod time; /// Display helper for truncated root hashes (8 hex chars) pub struct ShortRoot<'a>(pub &'a [u8; 32]); diff --git a/crates/common/types/src/primitives.rs b/crates/common/types/src/primitives.rs index 3eda690e7..0e8cf99fe 100644 --- a/crates/common/types/src/primitives.rs +++ b/crates/common/types/src/primitives.rs @@ -21,7 +21,6 @@ pub type ByteList = libssz_types::SszList; /// Encoded as a fixed 32-byte array (transparent SSZ wrapper). /// Serialized as a `"0x..."` hex string. #[derive( - Debug, Clone, Copy, Default, @@ -65,6 +64,15 @@ impl<'de> serde::Deserialize<'de> for H256 { impl H256 { pub const ZERO: Self = Self([0u8; 32]); + /// Every byte set to `byte`. + /// + /// Test fixtures want a hash that is obviously not the zero hash and is + /// obviously not any other fixture's hash, which one repeated byte gives + /// while staying short enough to read at a call site. + pub const fn repeat_byte(byte: u8) -> Self { + Self([byte; 32]) + } + pub fn is_zero(&self) -> bool { self.0 == [0u8; 32] } @@ -108,6 +116,19 @@ impl std::fmt::Display for H256 { } } +/// Same as [`Display`](std::fmt::Display), rather than derived. +/// +/// A derived `Debug` prints the inner array, so a hash comes out as 32 decimal +/// numbers: unreadable on its own, and unreadable in bulk inside the `Debug` of +/// a container holding thousands of roots. Assertion failures in the spec suites +/// print roots this way, so the hex is the whole point. Truncate at a call site +/// that wants it short with [`crate::ShortRoot`]. +impl std::fmt::Debug for H256 { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{self}") + } +} + #[cfg(test)] mod tests { use super::*; @@ -134,6 +155,16 @@ mod tests { assert_eq!(h, H256([0xcd; 32])); } + /// Not the derived `Debug`, which would print the inner array as 32 decimal + /// numbers. Spec-suite assertion failures print roots through `Debug`, so + /// this is the difference between a readable diff and an unreadable one. + #[test] + fn h256_debug_is_the_same_hex_as_display() { + let h = H256::repeat_byte(0xab); + assert_eq!(format!("{h:?}"), format!("{h}")); + assert_eq!(format!("{h:?}"), format!("0x{}", "ab".repeat(32))); + } + #[test] fn h256_from_slice_exact_32_bytes() { let bytes = [0x42u8; 32]; diff --git a/crates/common/types/src/state.rs b/crates/common/types/src/state.rs index 5ef6100c1..baf10fe82 100644 --- a/crates/common/types/src/state.rs +++ b/crates/common/types/src/state.rs @@ -12,9 +12,9 @@ use crate::{ use primitives::HashTreeRoot as _; /// The main consensus state object -#[derive(Debug, Clone, SszEncode, SszDecode, HashTreeRoot)] +#[derive(Debug, Clone, PartialEq, SszEncode, SszDecode, HashTreeRoot)] pub struct State { - /// The chain's configuration parameters + /// The chain's genesis parameters pub config: StateConfig, /// The current slot number pub slot: u64, @@ -66,7 +66,7 @@ pub type JustificationValidators = /// Each validator has two independent XMSS keys: one for signing attestations /// and one for signing block proposals. This allows signing both in the same /// slot without violating OTS (one-time signature) constraints. -#[derive(Debug, Clone, Serialize, SszEncode, SszDecode, HashTreeRoot)] +#[derive(Debug, Clone, PartialEq, Serialize, SszEncode, SszDecode, HashTreeRoot)] pub struct Validator { /// XMSS public key used for attestation signing. #[serde(serialize_with = "serialize_pubkey_hex")] @@ -124,12 +124,15 @@ impl State { } } -/// The chain config carried inside [`State`], the spec's `Config` container. +/// The genesis parameters baked into the state and hashed into its root, the +/// spec's `Config` container (leanSpec's `GenesisConfig`, +/// `forks/lstar/containers/state.py`). /// -/// Merkleized into the state root, so its layout is fixed by the spec and no -/// field may be added here. [`crate::chain_config::ChainConfig`] is the node's -/// own view: this plus the slot duration. -#[derive(Debug, Clone, Serialize, Deserialize, SszEncode, SszDecode, HashTreeRoot)] +/// Spec-fixed and cross-client: adding a field here changes every lean state +/// root and forks this client off every other one. +/// [`crate::chain_config::ChainConfig`] is the node's own runtime view, a +/// different type for a different job: this plus the slot duration. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, SszEncode, SszDecode, HashTreeRoot)] pub struct StateConfig { pub genesis_time: u64, } @@ -173,3 +176,28 @@ pub fn anchor_pair_is_consistent(state: &mut State, block: &Block) -> bool { block.state_root == computed } + +#[cfg(test)] +mod tests { + use super::State; + use crate::primitives::HashTreeRoot as _; + + /// Captured from the build that introduced this test. + const EXPECTED_GENESIS_ROOT: &str = + "97720dcece1942bac508933252fb58550e4ec357842b4465956459a42920d053"; + + /// The container's layout is cross-client, so this pins the genesis + /// state's root: a field reorder, an added field, or a type change + /// disguised as a rename fails here rather than in interop. + #[test] + fn the_genesis_state_root_is_fixed() { + let state = State::from_genesis(1234, vec![]); + let root = state.hash_tree_root(); + + assert_eq!( + hex::encode(root.0), + EXPECTED_GENESIS_ROOT, + "the genesis state root changed; every other lean client disagrees now" + ); + } +} diff --git a/crates/common/types/src/time.rs b/crates/common/types/src/time.rs new file mode 100644 index 000000000..34ec39d76 --- /dev/null +++ b/crates/common/types/src/time.rs @@ -0,0 +1,19 @@ +//! Wall-clock reading shared by crates that have no dependency relationship +//! with each other, but both depend on this one. +//! +//! Not a `constants` module: [`crate::constants`] and +//! [`crate::beacon::constants`] hold values the specification (or the shared +//! protocol) fixes outright, and a clock reading is neither. It lives here for +//! the same underlying reason those modules exist at all: the blockchain and +//! p2p crates each need it, neither depends on the other, and both already +//! depend on this crate. + +use std::time::SystemTime; + +/// Current UNIX timestamp in milliseconds. +pub fn unix_now_ms() -> u64 { + SystemTime::UNIX_EPOCH + .elapsed() + .expect("already past the unix epoch") + .as_millis() as u64 +} diff --git a/crates/common/types/tests/beacon_json.rs b/crates/common/types/tests/beacon_json.rs new file mode 100644 index 000000000..7809cf5ae --- /dev/null +++ b/crates/common/types/tests/beacon_json.rs @@ -0,0 +1,1341 @@ +//! Every integer in a Beacon API response is a quoted string. +//! +//! A field whose `#[serde(with = …)]` attribute was forgotten serializes as a +//! bare JSON number, which is valid JSON, decodes fine in a permissive client, +//! and is wrong. Nothing in the type system catches it, so this walks the +//! serialized tree and fails on any number it finds. +//! +//! To actually reach a field, the walker has to be handed a value that has +//! one: an empty list never touches its element type, and a state never gets +//! built at all unless something here builds one. So every guard test below +//! is paired with a fixture that populates every collection on the container +//! it checks, at least one level into anything a list or vector holds, with +//! distinct nonzero scalars so a missing attribute cannot hide behind a +//! zero. `fixtures` holds the builders shared across forks (nothing in +//! `shared.rs`, plus the phase0 attestation family altair also uses +//! unchanged); each fork then gets its own `_block_body`/ +//! `_block`/`_state` builders for the parts that do change shape, +//! plus a `carries_no_bare_numbers` test per block and per state. Adding a +//! fork means adding one such block of builders and two tests in the same +//! shape, reusing `fixtures` for everything unchanged from phase0. + +use ethlambda_types::beacon::containers::{ + altair, bellatrix, capella, deneb, electra, fulu, phase0, shared, +}; +use ethlambda_types::beacon::primitives::{ + BlsPubkey, BlsSignature, H160, H256, KzgCommitment, U256, +}; +use libssz_types::{SszList, SszVector}; + +/// Every path in `value` whose leaf is a JSON number. +fn bare_numbers(value: &serde_json::Value, path: &str, found: &mut Vec) { + match value { + serde_json::Value::Number(n) => found.push(format!("{path} = {n}")), + serde_json::Value::Array(items) => { + for (i, item) in items.iter().enumerate() { + bare_numbers(item, &format!("{path}[{i}]"), found); + } + } + serde_json::Value::Object(fields) => { + for (key, field) in fields { + bare_numbers(field, &format!("{path}.{key}"), found); + } + } + _ => {} + } +} + +/// Proof that [`bare_numbers`] actually catches what it exists to catch. +/// +/// Every other test in this file asserts an *absence* of findings, which +/// passes just as well if the walker is broken and never finds anything. This +/// is the one test that has to see a finding: a `u64` field with no +/// `#[serde(with = …)]` attribute serializes to a bare JSON number, and the +/// walker must name it. Without this, a `bare_numbers` that silently matched +/// nothing would leave every "carries no bare numbers" test in this file +/// passing for the wrong reason. +#[test] +fn the_walker_catches_an_unannotated_integer() { + #[derive(serde::Serialize)] + struct Unannotated { + count: u64, + } + + let json = serde_json::to_value(Unannotated { count: 7 }).expect("serializes"); + let mut found = Vec::new(); + bare_numbers(&json, "Unannotated", &mut found); + assert_eq!(found, vec!["Unannotated.count = 7".to_string()]); +} + +/// Assert a serialized container carries no bare numbers, naming every one it +/// does carry so a failure points straight at the missing attribute. +pub fn assert_no_bare_numbers(label: &str, value: &T) { + let json = serde_json::to_value(value).expect("serializes"); + let mut found = Vec::new(); + bare_numbers(&json, label, &mut found); + assert!( + found.is_empty(), + "{label}: {} field(s) serialized as bare JSON numbers, each missing a \ + #[serde(with = \"…\")] attribute:\n {}", + found.len(), + found.join("\n ") + ); +} + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +/// Element builders for the fields the guard tests below need populated: +/// everything in `shared.rs`, plus the phase0 attestation family (unchanged +/// through altair). +/// +/// Every builder takes a `seed: u8` so two elements of the same field (a +/// `ProposerSlashing`'s two headers, an `AttesterSlashing`'s two +/// attestations) read as distinct in a failure message, and every scalar it +/// sets is derived from the seed so it is never zero. +mod fixtures { + use super::*; + + /// An `SszVector` filled with `N` clones of `value`, for the + /// fixed-length fields (`block_roots`, `randao_mixes`, a sync + /// committee's pubkeys, a deposit's merkle proof, …) that need exactly + /// their declared length rather than "at least one" element. + pub fn vector(value: T) -> SszVector { + SszVector::try_from(vec![value; N]).expect("exactly N elements by construction") + } + + pub fn checkpoint(seed: u8) -> shared::Checkpoint { + shared::Checkpoint { + epoch: u64::from(seed) + 1, + root: H256::repeat_byte(seed), + } + } + + pub fn attestation_data(seed: u8) -> shared::AttestationData { + shared::AttestationData { + slot: u64::from(seed) + 200, + index: u64::from(seed) + 1, + beacon_block_root: H256::repeat_byte(seed.wrapping_add(1)), + source: checkpoint(seed.wrapping_add(2)), + target: checkpoint(seed.wrapping_add(3)), + } + } + + pub fn eth1_data(seed: u8) -> shared::Eth1Data { + shared::Eth1Data { + deposit_root: H256::repeat_byte(seed), + deposit_count: u64::from(seed) + 1, + block_hash: H256::repeat_byte(seed.wrapping_add(1)), + } + } + + pub fn validator(seed: u8) -> shared::Validator { + shared::Validator { + pubkey: BlsPubkey([seed; 48]), + withdrawal_credentials: H256::repeat_byte(seed.wrapping_add(1)), + effective_balance: 32_000_000_000 + u64::from(seed), + slashed: seed % 2 == 1, + activation_eligibility_epoch: u64::from(seed) + 1, + activation_epoch: u64::from(seed) + 2, + exit_epoch: u64::from(seed) + 3, + withdrawable_epoch: u64::from(seed) + 4, + } + } + + pub fn beacon_block_header(seed: u8) -> shared::BeaconBlockHeader { + shared::BeaconBlockHeader { + slot: u64::from(seed) + 1, + proposer_index: u64::from(seed) + 2, + parent_root: H256::repeat_byte(seed), + state_root: H256::repeat_byte(seed.wrapping_add(1)), + body_root: H256::repeat_byte(seed.wrapping_add(2)), + } + } + + pub fn signed_beacon_block_header(seed: u8) -> shared::SignedBeaconBlockHeader { + shared::SignedBeaconBlockHeader { + message: beacon_block_header(seed), + signature: BlsSignature([seed; 96]), + } + } + + pub fn proposer_slashing(seed: u8) -> shared::ProposerSlashing { + shared::ProposerSlashing { + signed_header_1: signed_beacon_block_header(seed), + signed_header_2: signed_beacon_block_header(seed.wrapping_add(50)), + } + } + + pub fn signed_voluntary_exit(seed: u8) -> shared::SignedVoluntaryExit { + shared::SignedVoluntaryExit { + message: shared::VoluntaryExit { + epoch: u64::from(seed) + 1, + validator_index: u64::from(seed) + 2, + }, + signature: BlsSignature([seed; 96]), + } + } + + /// A deposit with every proof slot filled, not left at its default + /// length-33 zero vector: `DepositProof` is fixed-size, so it is always + /// "fully populated" regardless, but a zero proof would still hide a + /// forgotten annotation on `DepositData`'s scalars behind an + /// otherwise-unremarkable all-zero neighbour in a failure listing. + pub fn deposit(seed: u8) -> shared::Deposit { + shared::Deposit { + proof: vector(H256::repeat_byte(seed)), + data: shared::DepositData { + pubkey: BlsPubkey([seed; 48]), + withdrawal_credentials: H256::repeat_byte(seed.wrapping_add(1)), + amount: 32_000_000_000 + u64::from(seed), + signature: BlsSignature([seed.wrapping_add(2); 96]), + }, + } + } + + /// An aggregate attestation with a nonempty `aggregation_bits`, the + /// phase0 shape altair, bellatrix, capella, and deneb all reuse + /// unchanged. + pub fn attestation(seed: u8) -> phase0::Attestation { + let mut bits = phase0::AggregationBits::with_length(8).expect("within capacity"); + bits.set(usize::from(seed % 8), true).expect("in bounds"); + phase0::Attestation { + aggregation_bits: bits, + data: attestation_data(seed), + signature: BlsSignature([seed; 96]), + } + } + + /// An indexed attestation with a nonempty `attesting_indices`, which is + /// the list one level below `AttesterSlashing` that an empty-body test + /// would never reach. + pub fn indexed_attestation(seed: u8) -> phase0::IndexedAttestation { + phase0::IndexedAttestation { + attesting_indices: SszList::try_from(vec![u64::from(seed) + 1, u64::from(seed) + 2]) + .expect("within capacity"), + data: attestation_data(seed), + signature: BlsSignature([seed; 96]), + } + } + + pub fn attester_slashing(seed: u8) -> phase0::AttesterSlashing { + phase0::AttesterSlashing { + attestation_1: indexed_attestation(seed), + attestation_2: indexed_attestation(seed.wrapping_add(50)), + } + } + + /// Capella-onward: one validator's withdrawal payout. Shared here rather + /// than defined per fork since deneb and electra carry `Withdrawal` + /// unchanged. + pub fn withdrawal(seed: u8) -> capella::Withdrawal { + capella::Withdrawal { + index: u64::from(seed) + 1, + validator_index: u64::from(seed) + 2, + address: H160::repeat_byte(seed.wrapping_add(3)), + amount: 32_000_000_000 + u64::from(seed), + } + } + + /// Capella-onward: a validator's signed withdrawal-credential switch. + /// Shared here for the same reason as [`withdrawal`]. + pub fn signed_bls_to_execution_change(seed: u8) -> capella::SignedBLSToExecutionChange { + capella::SignedBLSToExecutionChange { + message: capella::BLSToExecutionChange { + validator_index: u64::from(seed) + 1, + from_bls_pubkey: BlsPubkey([seed; 48]), + to_execution_address: H160::repeat_byte(seed.wrapping_add(1)), + }, + signature: BlsSignature([seed; 96]), + } + } + + /// Electra-onward: a deposit queued in the state, not yet credited to the + /// validator registry. Shared here since fulu's `PendingDeposits` is + /// electra's type, unchanged. + pub fn pending_deposit(seed: u8) -> electra::PendingDeposit { + electra::PendingDeposit { + pubkey: BlsPubkey([seed; 48]), + withdrawal_credentials: H256::repeat_byte(seed.wrapping_add(1)), + amount: 32_000_000_000 + u64::from(seed), + signature: BlsSignature([seed.wrapping_add(2); 96]), + slot: u64::from(seed) + 3, + } + } + + /// Electra-onward: a partial withdrawal queued in the state, not yet paid + /// out. Shared here for the same reason as [`pending_deposit`]. + pub fn pending_partial_withdrawal(seed: u8) -> electra::PendingPartialWithdrawal { + electra::PendingPartialWithdrawal { + validator_index: u64::from(seed) + 1, + amount: 32_000_000_000 + u64::from(seed), + withdrawable_epoch: u64::from(seed) + 2, + } + } + + /// Electra-onward: a validator consolidation queued in the state, not yet + /// applied. Shared here for the same reason as [`pending_deposit`]. + pub fn pending_consolidation(seed: u8) -> electra::PendingConsolidation { + electra::PendingConsolidation { + source_index: u64::from(seed) + 1, + target_index: u64::from(seed) + 2, + } + } +} + +// --------------------------------------------------------------------------- +// Phase0 +// --------------------------------------------------------------------------- + +/// A body with one element in every `SszList` field, so each element type +/// (`Attestation`, `AttesterSlashing` and the `IndexedAttestation`s inside +/// it, `Deposit` and the proof vector inside it, `ProposerSlashing`, +/// `SignedVoluntaryExit`) is actually walked instead of serializing as `[]`. +fn phase0_block_body() -> phase0::BeaconBlockBody { + phase0::BeaconBlockBody { + randao_reveal: BlsSignature([0x44; 96]), + eth1_data: fixtures::eth1_data(1), + graffiti: H256::repeat_byte(0x55), + proposer_slashings: SszList::try_from(vec![fixtures::proposer_slashing(2)]) + .expect("within capacity"), + attester_slashings: SszList::try_from(vec![fixtures::attester_slashing(3)]) + .expect("within capacity"), + attestations: SszList::try_from(vec![fixtures::attestation(4)]).expect("within capacity"), + deposits: SszList::try_from(vec![fixtures::deposit(5)]).expect("within capacity"), + voluntary_exits: SszList::try_from(vec![fixtures::signed_voluntary_exit(6)]) + .expect("within capacity"), + } +} + +fn phase0_block() -> phase0::SignedBeaconBlock { + phase0::SignedBeaconBlock { + message: phase0::BeaconBlock { + slot: 12_345, + proposer_index: 7, + parent_root: H256([0x11; 32]), + state_root: H256([0x22; 32]), + body: phase0_block_body(), + }, + signature: BlsSignature([0x33; 96]), + } +} + +/// Phase0-only: dropped from altair onward in favour of participation flags, +/// so it has no place in `fixtures`. +fn pending_attestation(seed: u8) -> phase0::PendingAttestation { + let mut bits = phase0::AggregationBits::with_length(4).expect("within capacity"); + bits.set(0, true).expect("in bounds"); + phase0::PendingAttestation { + aggregation_bits: bits, + data: fixtures::attestation_data(seed), + inclusion_delay: u64::from(seed) + 1, + proposer_index: u64::from(seed) + 2, + } +} + +/// A state with every one of its 21 fields set: fixed-size vectors filled to +/// their declared length, lists given at least one element, and every scalar +/// nonzero, so nothing serializes as an empty collection or a suspiciously +/// absent field. +fn phase0_state() -> phase0::BeaconState { + phase0::BeaconState { + genesis_time: 1_700_000_000, + genesis_validators_root: H256::repeat_byte(0x66), + slot: 42, + fork: shared::Fork { + previous_version: [0x01, 0x02, 0x03, 0x04], + current_version: [0x05, 0x06, 0x07, 0x08], + epoch: 1, + }, + latest_block_header: fixtures::beacon_block_header(7), + block_roots: fixtures::vector(H256::repeat_byte(0x77)), + state_roots: fixtures::vector(H256::repeat_byte(0x88)), + historical_roots: SszList::try_from(vec![H256::repeat_byte(0x99)]) + .expect("within capacity"), + eth1_data: fixtures::eth1_data(8), + eth1_data_votes: SszList::try_from(vec![fixtures::eth1_data(9)]).expect("within capacity"), + eth1_deposit_index: 3, + validators: shared::Validators::try_from(vec![fixtures::validator(10)]) + .expect("within capacity"), + balances: shared::Balances::try_from(vec![32_000_000_001u64]).expect("within capacity"), + randao_mixes: fixtures::vector(H256::repeat_byte(0xaa)), + slashings: fixtures::vector(1_000_000_000u64), + previous_epoch_attestations: SszList::try_from(vec![pending_attestation(11)]) + .expect("within capacity"), + current_epoch_attestations: SszList::try_from(vec![pending_attestation(12)]) + .expect("within capacity"), + justification_bits: { + let mut bits = shared::JustificationBits::new(); + bits.set(0, true).expect("in bounds"); + bits + }, + previous_justified_checkpoint: fixtures::checkpoint(13), + current_justified_checkpoint: fixtures::checkpoint(14), + finalized_checkpoint: fixtures::checkpoint(15), + } +} + +#[test] +fn a_phase0_block_carries_no_bare_numbers() { + assert_no_bare_numbers("phase0::SignedBeaconBlock", &phase0_block()); +} + +#[test] +fn a_phase0_block_quotes_its_slot_and_hexes_its_roots() { + let json = serde_json::to_value(phase0_block()).unwrap(); + assert_eq!(json["message"]["slot"], "12345"); + assert_eq!(json["message"]["proposer_index"], "7"); + assert_eq!( + json["message"]["parent_root"], + "0x1111111111111111111111111111111111111111111111111111111111111111" + ); + assert!( + json["signature"].as_str().unwrap().starts_with("0x3333"), + "got {}", + json["signature"] + ); +} + +#[test] +fn a_phase0_state_carries_no_bare_numbers() { + assert_no_bare_numbers("phase0::BeaconState", &phase0_state()); +} + +// --------------------------------------------------------------------------- +// Altair +// --------------------------------------------------------------------------- + +/// Phase0's body plus a populated `sync_aggregate`, altair's one addition to +/// the block body. +fn altair_block_body() -> altair::BeaconBlockBody { + altair::BeaconBlockBody { + randao_reveal: BlsSignature([0x44; 96]), + eth1_data: fixtures::eth1_data(1), + graffiti: H256::repeat_byte(0x55), + proposer_slashings: SszList::try_from(vec![fixtures::proposer_slashing(2)]) + .expect("within capacity"), + attester_slashings: SszList::try_from(vec![fixtures::attester_slashing(3)]) + .expect("within capacity"), + attestations: SszList::try_from(vec![fixtures::attestation(4)]).expect("within capacity"), + deposits: SszList::try_from(vec![fixtures::deposit(5)]).expect("within capacity"), + voluntary_exits: SszList::try_from(vec![fixtures::signed_voluntary_exit(6)]) + .expect("within capacity"), + sync_aggregate: altair::SyncAggregate { + sync_committee_bits: { + let mut bits = altair::SyncCommitteeBits::new(); + bits.set(0, true).expect("in bounds"); + bits + }, + sync_committee_signature: BlsSignature([0x99; 96]), + }, + } +} + +fn altair_block() -> altair::SignedBeaconBlock { + altair::SignedBeaconBlock { + message: altair::BeaconBlock { + slot: 12_345, + proposer_index: 7, + parent_root: H256([0x11; 32]), + state_root: H256([0x22; 32]), + body: altair_block_body(), + }, + signature: BlsSignature([0x33; 96]), + } +} + +/// Altair-only: `SyncCommittee`'s `pubkeys` vector needs exactly +/// `SYNC_COMMITTEE_SIZE` entries, which no list-population helper covers. +fn sync_committee(seed: u8) -> altair::SyncCommittee { + altair::SyncCommittee { + pubkeys: fixtures::vector(BlsPubkey([seed; 48])), + aggregate_pubkey: BlsPubkey([seed.wrapping_add(1); 48]), + } +} + +/// Phase0's 21 fields through `slashings`, altair's participation flags and +/// inactivity scores in place of the dropped pending-attestation lists, and +/// the two sync committees altair appends: 24 fields in all. +fn altair_state() -> altair::BeaconState { + altair::BeaconState { + genesis_time: 1_700_000_000, + genesis_validators_root: H256::repeat_byte(0x66), + slot: 42, + fork: shared::Fork { + previous_version: [0x01, 0x02, 0x03, 0x04], + current_version: [0x05, 0x06, 0x07, 0x08], + epoch: 1, + }, + latest_block_header: fixtures::beacon_block_header(7), + block_roots: fixtures::vector(H256::repeat_byte(0x77)), + state_roots: fixtures::vector(H256::repeat_byte(0x88)), + historical_roots: SszList::try_from(vec![H256::repeat_byte(0x99)]) + .expect("within capacity"), + eth1_data: fixtures::eth1_data(8), + eth1_data_votes: SszList::try_from(vec![fixtures::eth1_data(9)]).expect("within capacity"), + eth1_deposit_index: 3, + validators: shared::Validators::try_from(vec![fixtures::validator(10)]) + .expect("within capacity"), + balances: shared::Balances::try_from(vec![32_000_000_001u64]).expect("within capacity"), + randao_mixes: fixtures::vector(H256::repeat_byte(0xaa)), + slashings: fixtures::vector(1_000_000_000u64), + previous_epoch_participation: SszList::try_from(vec![7u8]).expect("within capacity"), + current_epoch_participation: SszList::try_from(vec![9u8]).expect("within capacity"), + justification_bits: { + let mut bits = shared::JustificationBits::new(); + bits.set(0, true).expect("in bounds"); + bits + }, + previous_justified_checkpoint: fixtures::checkpoint(13), + current_justified_checkpoint: fixtures::checkpoint(14), + finalized_checkpoint: fixtures::checkpoint(15), + inactivity_scores: SszList::try_from(vec![4u64]).expect("within capacity"), + current_sync_committee: sync_committee(16), + next_sync_committee: sync_committee(17), + } +} + +#[test] +fn an_altair_block_carries_no_bare_numbers() { + assert_no_bare_numbers("altair::SignedBeaconBlock", &altair_block()); +} + +#[test] +fn an_altair_state_carries_no_bare_numbers() { + assert_no_bare_numbers("altair::BeaconState", &altair_state()); +} + +// --------------------------------------------------------------------------- +// Bellatrix +// --------------------------------------------------------------------------- + +/// An execution payload with `logs_bloom` filled to its exact length and +/// `extra_data`/`transactions` each holding a nonempty, nonzero element, so +/// `Transaction` — a byte list one level below `transactions` an empty-body +/// test would never reach — is actually walked. +fn execution_payload(seed: u8) -> bellatrix::ExecutionPayload { + bellatrix::ExecutionPayload { + parent_hash: H256::repeat_byte(seed), + fee_recipient: H160::repeat_byte(seed.wrapping_add(1)), + state_root: H256::repeat_byte(seed.wrapping_add(2)), + receipts_root: H256::repeat_byte(seed.wrapping_add(3)), + logs_bloom: fixtures::vector(seed.wrapping_add(4)), + prev_randao: H256::repeat_byte(seed.wrapping_add(5)), + block_number: u64::from(seed) + 1, + gas_limit: u64::from(seed) + 2, + gas_used: u64::from(seed) + 3, + timestamp: u64::from(seed) + 4, + extra_data: SszList::try_from(vec![seed.wrapping_add(6), seed.wrapping_add(7)]) + .expect("within capacity"), + base_fee_per_gas: U256::from(u64::from(seed) + 5), + block_hash: H256::repeat_byte(seed.wrapping_add(8)), + transactions: SszList::try_from(vec![ + bellatrix::Transaction::try_from(vec![seed.wrapping_add(9), seed.wrapping_add(10)]) + .expect("within capacity"), + ]) + .expect("within capacity"), + } +} + +/// [`execution_payload`] with `transactions` replaced by `transactions_root`, +/// the same substitution [`bellatrix::ExecutionPayloadHeader`] makes. +fn execution_payload_header(seed: u8) -> bellatrix::ExecutionPayloadHeader { + bellatrix::ExecutionPayloadHeader { + parent_hash: H256::repeat_byte(seed), + fee_recipient: H160::repeat_byte(seed.wrapping_add(1)), + state_root: H256::repeat_byte(seed.wrapping_add(2)), + receipts_root: H256::repeat_byte(seed.wrapping_add(3)), + logs_bloom: fixtures::vector(seed.wrapping_add(4)), + prev_randao: H256::repeat_byte(seed.wrapping_add(5)), + block_number: u64::from(seed) + 1, + gas_limit: u64::from(seed) + 2, + gas_used: u64::from(seed) + 3, + timestamp: u64::from(seed) + 4, + extra_data: SszList::try_from(vec![seed.wrapping_add(6), seed.wrapping_add(7)]) + .expect("within capacity"), + base_fee_per_gas: U256::from(u64::from(seed) + 5), + block_hash: H256::repeat_byte(seed.wrapping_add(8)), + transactions_root: H256::repeat_byte(seed.wrapping_add(9)), + } +} + +/// Altair's body plus a populated `execution_payload`, bellatrix's one +/// addition to the block body. +fn bellatrix_block_body() -> bellatrix::BeaconBlockBody { + bellatrix::BeaconBlockBody { + randao_reveal: BlsSignature([0x44; 96]), + eth1_data: fixtures::eth1_data(1), + graffiti: H256::repeat_byte(0x55), + proposer_slashings: SszList::try_from(vec![fixtures::proposer_slashing(2)]) + .expect("within capacity"), + attester_slashings: SszList::try_from(vec![fixtures::attester_slashing(3)]) + .expect("within capacity"), + attestations: SszList::try_from(vec![fixtures::attestation(4)]).expect("within capacity"), + deposits: SszList::try_from(vec![fixtures::deposit(5)]).expect("within capacity"), + voluntary_exits: SszList::try_from(vec![fixtures::signed_voluntary_exit(6)]) + .expect("within capacity"), + sync_aggregate: altair::SyncAggregate { + sync_committee_bits: { + let mut bits = altair::SyncCommitteeBits::new(); + bits.set(0, true).expect("in bounds"); + bits + }, + sync_committee_signature: BlsSignature([0x99; 96]), + }, + execution_payload: execution_payload(20), + } +} + +fn bellatrix_block() -> bellatrix::SignedBeaconBlock { + bellatrix::SignedBeaconBlock { + message: bellatrix::BeaconBlock { + slot: 12_345, + proposer_index: 7, + parent_root: H256([0x11; 32]), + state_root: H256([0x22; 32]), + body: bellatrix_block_body(), + }, + signature: BlsSignature([0x33; 96]), + } +} + +/// Altair's 24 fields through `next_sync_committee` unchanged, plus +/// `latest_execution_payload_header`, bellatrix's one addition: 25 fields in +/// all. +fn bellatrix_state() -> bellatrix::BeaconState { + bellatrix::BeaconState { + genesis_time: 1_700_000_000, + genesis_validators_root: H256::repeat_byte(0x66), + slot: 42, + fork: shared::Fork { + previous_version: [0x01, 0x02, 0x03, 0x04], + current_version: [0x05, 0x06, 0x07, 0x08], + epoch: 1, + }, + latest_block_header: fixtures::beacon_block_header(7), + block_roots: fixtures::vector(H256::repeat_byte(0x77)), + state_roots: fixtures::vector(H256::repeat_byte(0x88)), + historical_roots: SszList::try_from(vec![H256::repeat_byte(0x99)]) + .expect("within capacity"), + eth1_data: fixtures::eth1_data(8), + eth1_data_votes: SszList::try_from(vec![fixtures::eth1_data(9)]).expect("within capacity"), + eth1_deposit_index: 3, + validators: shared::Validators::try_from(vec![fixtures::validator(10)]) + .expect("within capacity"), + balances: shared::Balances::try_from(vec![32_000_000_001u64]).expect("within capacity"), + randao_mixes: fixtures::vector(H256::repeat_byte(0xaa)), + slashings: fixtures::vector(1_000_000_000u64), + previous_epoch_participation: SszList::try_from(vec![7u8]).expect("within capacity"), + current_epoch_participation: SszList::try_from(vec![9u8]).expect("within capacity"), + justification_bits: { + let mut bits = shared::JustificationBits::new(); + bits.set(0, true).expect("in bounds"); + bits + }, + previous_justified_checkpoint: fixtures::checkpoint(13), + current_justified_checkpoint: fixtures::checkpoint(14), + finalized_checkpoint: fixtures::checkpoint(15), + inactivity_scores: SszList::try_from(vec![4u64]).expect("within capacity"), + current_sync_committee: sync_committee(16), + next_sync_committee: sync_committee(17), + latest_execution_payload_header: execution_payload_header(30), + } +} + +#[test] +fn a_bellatrix_block_carries_no_bare_numbers() { + assert_no_bare_numbers("bellatrix::SignedBeaconBlock", &bellatrix_block()); +} + +#[test] +fn a_bellatrix_state_carries_no_bare_numbers() { + assert_no_bare_numbers("bellatrix::BeaconState", &bellatrix_state()); +} + +// --------------------------------------------------------------------------- +// Capella +// --------------------------------------------------------------------------- + +/// Bellatrix's execution payload fields, with a nonempty `withdrawals` +/// appended — capella's one addition to the payload shape. `transactions` +/// stays nonempty too, so [`crate::beacon::serde_helpers::ssz_hex_seq`] (via +/// `serde_helpers::ssz_hex_seq::serialize`, shared with bellatrix's own +/// `ExecutionPayload`) is actually walked rather than serializing as `[]`. +fn capella_execution_payload(seed: u8) -> capella::ExecutionPayload { + capella::ExecutionPayload { + parent_hash: H256::repeat_byte(seed), + fee_recipient: H160::repeat_byte(seed.wrapping_add(1)), + state_root: H256::repeat_byte(seed.wrapping_add(2)), + receipts_root: H256::repeat_byte(seed.wrapping_add(3)), + logs_bloom: fixtures::vector(seed.wrapping_add(4)), + prev_randao: H256::repeat_byte(seed.wrapping_add(5)), + block_number: u64::from(seed) + 1, + gas_limit: u64::from(seed) + 2, + gas_used: u64::from(seed) + 3, + timestamp: u64::from(seed) + 4, + extra_data: SszList::try_from(vec![seed.wrapping_add(6), seed.wrapping_add(7)]) + .expect("within capacity"), + base_fee_per_gas: U256::from(u64::from(seed) + 5), + block_hash: H256::repeat_byte(seed.wrapping_add(8)), + transactions: SszList::try_from(vec![ + bellatrix::Transaction::try_from(vec![seed.wrapping_add(9), seed.wrapping_add(10)]) + .expect("within capacity"), + ]) + .expect("within capacity"), + withdrawals: SszList::try_from(vec![fixtures::withdrawal(seed.wrapping_add(11))]) + .expect("within capacity"), + } +} + +/// Bellatrix's header fields plus `withdrawals_root`, capella's one addition +/// to the execution payload header shape. +fn capella_execution_payload_header(seed: u8) -> capella::ExecutionPayloadHeader { + capella::ExecutionPayloadHeader { + parent_hash: H256::repeat_byte(seed), + fee_recipient: H160::repeat_byte(seed.wrapping_add(1)), + state_root: H256::repeat_byte(seed.wrapping_add(2)), + receipts_root: H256::repeat_byte(seed.wrapping_add(3)), + logs_bloom: fixtures::vector(seed.wrapping_add(4)), + prev_randao: H256::repeat_byte(seed.wrapping_add(5)), + block_number: u64::from(seed) + 1, + gas_limit: u64::from(seed) + 2, + gas_used: u64::from(seed) + 3, + timestamp: u64::from(seed) + 4, + extra_data: SszList::try_from(vec![seed.wrapping_add(6), seed.wrapping_add(7)]) + .expect("within capacity"), + base_fee_per_gas: U256::from(u64::from(seed) + 5), + block_hash: H256::repeat_byte(seed.wrapping_add(8)), + transactions_root: H256::repeat_byte(seed.wrapping_add(9)), + withdrawals_root: H256::repeat_byte(seed.wrapping_add(10)), + } +} + +/// Bellatrix's body plus a populated `bls_to_execution_changes`, capella's +/// one addition to the block body. +fn capella_block_body() -> capella::BeaconBlockBody { + capella::BeaconBlockBody { + randao_reveal: BlsSignature([0x44; 96]), + eth1_data: fixtures::eth1_data(1), + graffiti: H256::repeat_byte(0x55), + proposer_slashings: SszList::try_from(vec![fixtures::proposer_slashing(2)]) + .expect("within capacity"), + attester_slashings: SszList::try_from(vec![fixtures::attester_slashing(3)]) + .expect("within capacity"), + attestations: SszList::try_from(vec![fixtures::attestation(4)]).expect("within capacity"), + deposits: SszList::try_from(vec![fixtures::deposit(5)]).expect("within capacity"), + voluntary_exits: SszList::try_from(vec![fixtures::signed_voluntary_exit(6)]) + .expect("within capacity"), + sync_aggregate: altair::SyncAggregate { + sync_committee_bits: { + let mut bits = altair::SyncCommitteeBits::new(); + bits.set(0, true).expect("in bounds"); + bits + }, + sync_committee_signature: BlsSignature([0x99; 96]), + }, + execution_payload: capella_execution_payload(20), + bls_to_execution_changes: SszList::try_from(vec![ + fixtures::signed_bls_to_execution_change(40), + ]) + .expect("within capacity"), + } +} + +fn capella_block() -> capella::SignedBeaconBlock { + capella::SignedBeaconBlock { + message: capella::BeaconBlock { + slot: 12_345, + proposer_index: 7, + parent_root: H256([0x11; 32]), + state_root: H256([0x22; 32]), + body: capella_block_body(), + }, + signature: BlsSignature([0x33; 96]), + } +} + +/// Bellatrix's 25 fields through `latest_execution_payload_header` (capella's +/// own header shape, with `withdrawals_root` appended), plus +/// `next_withdrawal_index`, `next_withdrawal_validator_index`, and +/// `historical_summaries`: 28 fields in all. +fn capella_state() -> capella::BeaconState { + capella::BeaconState { + genesis_time: 1_700_000_000, + genesis_validators_root: H256::repeat_byte(0x66), + slot: 42, + fork: shared::Fork { + previous_version: [0x01, 0x02, 0x03, 0x04], + current_version: [0x05, 0x06, 0x07, 0x08], + epoch: 1, + }, + latest_block_header: fixtures::beacon_block_header(7), + block_roots: fixtures::vector(H256::repeat_byte(0x77)), + state_roots: fixtures::vector(H256::repeat_byte(0x88)), + historical_roots: SszList::try_from(vec![H256::repeat_byte(0x99)]) + .expect("within capacity"), + eth1_data: fixtures::eth1_data(8), + eth1_data_votes: SszList::try_from(vec![fixtures::eth1_data(9)]).expect("within capacity"), + eth1_deposit_index: 3, + validators: shared::Validators::try_from(vec![fixtures::validator(10)]) + .expect("within capacity"), + balances: shared::Balances::try_from(vec![32_000_000_001u64]).expect("within capacity"), + randao_mixes: fixtures::vector(H256::repeat_byte(0xaa)), + slashings: fixtures::vector(1_000_000_000u64), + previous_epoch_participation: SszList::try_from(vec![7u8]).expect("within capacity"), + current_epoch_participation: SszList::try_from(vec![9u8]).expect("within capacity"), + justification_bits: { + let mut bits = shared::JustificationBits::new(); + bits.set(0, true).expect("in bounds"); + bits + }, + previous_justified_checkpoint: fixtures::checkpoint(13), + current_justified_checkpoint: fixtures::checkpoint(14), + finalized_checkpoint: fixtures::checkpoint(15), + inactivity_scores: SszList::try_from(vec![4u64]).expect("within capacity"), + current_sync_committee: sync_committee(16), + next_sync_committee: sync_committee(17), + latest_execution_payload_header: capella_execution_payload_header(30), + next_withdrawal_index: 40, + next_withdrawal_validator_index: 41, + historical_summaries: SszList::try_from(vec![shared::HistoricalSummary { + block_summary_root: H256::repeat_byte(0xbb), + state_summary_root: H256::repeat_byte(0xcc), + }]) + .expect("within capacity"), + } +} + +#[test] +fn a_capella_block_carries_no_bare_numbers() { + assert_no_bare_numbers("capella::SignedBeaconBlock", &capella_block()); +} + +#[test] +fn a_capella_state_carries_no_bare_numbers() { + assert_no_bare_numbers("capella::BeaconState", &capella_state()); +} + +// --------------------------------------------------------------------------- +// Deneb +// --------------------------------------------------------------------------- + +/// Capella's execution payload fields, with `blob_gas_used` and +/// `excess_blob_gas` appended — deneb's one addition to the payload shape. +/// `transactions` and `withdrawals` stay nonempty, same as capella's own +/// fixture, so both remain walked rather than serializing as `[]`. +fn deneb_execution_payload(seed: u8) -> deneb::ExecutionPayload { + deneb::ExecutionPayload { + parent_hash: H256::repeat_byte(seed), + fee_recipient: H160::repeat_byte(seed.wrapping_add(1)), + state_root: H256::repeat_byte(seed.wrapping_add(2)), + receipts_root: H256::repeat_byte(seed.wrapping_add(3)), + logs_bloom: fixtures::vector(seed.wrapping_add(4)), + prev_randao: H256::repeat_byte(seed.wrapping_add(5)), + block_number: u64::from(seed) + 1, + gas_limit: u64::from(seed) + 2, + gas_used: u64::from(seed) + 3, + timestamp: u64::from(seed) + 4, + extra_data: SszList::try_from(vec![seed.wrapping_add(6), seed.wrapping_add(7)]) + .expect("within capacity"), + base_fee_per_gas: U256::from(u64::from(seed) + 5), + block_hash: H256::repeat_byte(seed.wrapping_add(8)), + transactions: SszList::try_from(vec![ + bellatrix::Transaction::try_from(vec![seed.wrapping_add(9), seed.wrapping_add(10)]) + .expect("within capacity"), + ]) + .expect("within capacity"), + withdrawals: SszList::try_from(vec![fixtures::withdrawal(seed.wrapping_add(11))]) + .expect("within capacity"), + blob_gas_used: u64::from(seed) + 12, + excess_blob_gas: u64::from(seed) + 13, + } +} + +/// [`deneb_execution_payload`] with `transactions`/`withdrawals` replaced by +/// their roots, the same substitution [`deneb::ExecutionPayloadHeader`] +/// makes. +fn deneb_execution_payload_header(seed: u8) -> deneb::ExecutionPayloadHeader { + deneb::ExecutionPayloadHeader { + parent_hash: H256::repeat_byte(seed), + fee_recipient: H160::repeat_byte(seed.wrapping_add(1)), + state_root: H256::repeat_byte(seed.wrapping_add(2)), + receipts_root: H256::repeat_byte(seed.wrapping_add(3)), + logs_bloom: fixtures::vector(seed.wrapping_add(4)), + prev_randao: H256::repeat_byte(seed.wrapping_add(5)), + block_number: u64::from(seed) + 1, + gas_limit: u64::from(seed) + 2, + gas_used: u64::from(seed) + 3, + timestamp: u64::from(seed) + 4, + extra_data: SszList::try_from(vec![seed.wrapping_add(6), seed.wrapping_add(7)]) + .expect("within capacity"), + base_fee_per_gas: U256::from(u64::from(seed) + 5), + block_hash: H256::repeat_byte(seed.wrapping_add(8)), + transactions_root: H256::repeat_byte(seed.wrapping_add(9)), + withdrawals_root: H256::repeat_byte(seed.wrapping_add(10)), + blob_gas_used: u64::from(seed) + 11, + excess_blob_gas: u64::from(seed) + 12, + } +} + +/// Capella's body plus a populated `blob_kzg_commitments`, deneb's one +/// addition to the block body. +fn deneb_block_body() -> deneb::BeaconBlockBody { + deneb::BeaconBlockBody { + randao_reveal: BlsSignature([0x44; 96]), + eth1_data: fixtures::eth1_data(1), + graffiti: H256::repeat_byte(0x55), + proposer_slashings: SszList::try_from(vec![fixtures::proposer_slashing(2)]) + .expect("within capacity"), + attester_slashings: SszList::try_from(vec![fixtures::attester_slashing(3)]) + .expect("within capacity"), + attestations: SszList::try_from(vec![fixtures::attestation(4)]).expect("within capacity"), + deposits: SszList::try_from(vec![fixtures::deposit(5)]).expect("within capacity"), + voluntary_exits: SszList::try_from(vec![fixtures::signed_voluntary_exit(6)]) + .expect("within capacity"), + sync_aggregate: altair::SyncAggregate { + sync_committee_bits: { + let mut bits = altair::SyncCommitteeBits::new(); + bits.set(0, true).expect("in bounds"); + bits + }, + sync_committee_signature: BlsSignature([0x99; 96]), + }, + execution_payload: deneb_execution_payload(20), + bls_to_execution_changes: SszList::try_from(vec![ + fixtures::signed_bls_to_execution_change(40), + ]) + .expect("within capacity"), + blob_kzg_commitments: SszList::try_from(vec![KzgCommitment([0x77; 48])]) + .expect("within capacity"), + } +} + +fn deneb_block() -> deneb::SignedBeaconBlock { + deneb::SignedBeaconBlock { + message: deneb::BeaconBlock { + slot: 12_345, + proposer_index: 7, + parent_root: H256([0x11; 32]), + state_root: H256([0x22; 32]), + body: deneb_block_body(), + }, + signature: BlsSignature([0x33; 96]), + } +} + +/// Capella's 28 fields, field for field: only the type held in +/// `latest_execution_payload_header` changes, to deneb's own +/// [`deneb::ExecutionPayloadHeader`]. +fn deneb_state() -> deneb::BeaconState { + deneb::BeaconState { + genesis_time: 1_700_000_000, + genesis_validators_root: H256::repeat_byte(0x66), + slot: 42, + fork: shared::Fork { + previous_version: [0x01, 0x02, 0x03, 0x04], + current_version: [0x05, 0x06, 0x07, 0x08], + epoch: 1, + }, + latest_block_header: fixtures::beacon_block_header(7), + block_roots: fixtures::vector(H256::repeat_byte(0x77)), + state_roots: fixtures::vector(H256::repeat_byte(0x88)), + historical_roots: SszList::try_from(vec![H256::repeat_byte(0x99)]) + .expect("within capacity"), + eth1_data: fixtures::eth1_data(8), + eth1_data_votes: SszList::try_from(vec![fixtures::eth1_data(9)]).expect("within capacity"), + eth1_deposit_index: 3, + validators: shared::Validators::try_from(vec![fixtures::validator(10)]) + .expect("within capacity"), + balances: shared::Balances::try_from(vec![32_000_000_001u64]).expect("within capacity"), + randao_mixes: fixtures::vector(H256::repeat_byte(0xaa)), + slashings: fixtures::vector(1_000_000_000u64), + previous_epoch_participation: SszList::try_from(vec![7u8]).expect("within capacity"), + current_epoch_participation: SszList::try_from(vec![9u8]).expect("within capacity"), + justification_bits: { + let mut bits = shared::JustificationBits::new(); + bits.set(0, true).expect("in bounds"); + bits + }, + previous_justified_checkpoint: fixtures::checkpoint(13), + current_justified_checkpoint: fixtures::checkpoint(14), + finalized_checkpoint: fixtures::checkpoint(15), + inactivity_scores: SszList::try_from(vec![4u64]).expect("within capacity"), + current_sync_committee: sync_committee(16), + next_sync_committee: sync_committee(17), + latest_execution_payload_header: deneb_execution_payload_header(30), + next_withdrawal_index: 40, + next_withdrawal_validator_index: 41, + historical_summaries: SszList::try_from(vec![shared::HistoricalSummary { + block_summary_root: H256::repeat_byte(0xbb), + state_summary_root: H256::repeat_byte(0xcc), + }]) + .expect("within capacity"), + } +} + +#[test] +fn a_deneb_block_carries_no_bare_numbers() { + assert_no_bare_numbers("deneb::SignedBeaconBlock", &deneb_block()); +} + +#[test] +fn a_deneb_state_carries_no_bare_numbers() { + assert_no_bare_numbers("deneb::BeaconState", &deneb_state()); +} + +// --------------------------------------------------------------------------- +// Electra +// --------------------------------------------------------------------------- + +/// Electra's aggregate attestation (EIP-7549): `aggregation_bits` now spans +/// every committee named in `committee_bits`, rather than one committee's +/// worth as in every fork before electra, so this needs its own fixture +/// distinct from [`fixtures::attestation`]. +fn electra_attestation(seed: u8) -> electra::Attestation { + let mut committee_bits = electra::CommitteeBits::default(); + committee_bits.set(0, true).expect("in bounds"); + let mut aggregation_bits = electra::AggregationBits::with_length(8).expect("within capacity"); + aggregation_bits + .set(usize::from(seed % 8), true) + .expect("in bounds"); + electra::Attestation { + aggregation_bits, + data: fixtures::attestation_data(seed), + signature: BlsSignature([seed; 96]), + committee_bits, + } +} + +/// Electra's indexed attestation: `attesting_indices` widens the same way +/// `aggregation_bits` does, following [`electra::AttestingIndices`]. +fn electra_indexed_attestation(seed: u8) -> electra::IndexedAttestation { + electra::IndexedAttestation { + attesting_indices: SszList::try_from(vec![u64::from(seed) + 1, u64::from(seed) + 2]) + .expect("within capacity"), + data: fixtures::attestation_data(seed), + signature: BlsSignature([seed; 96]), + } +} + +fn electra_attester_slashing(seed: u8) -> electra::AttesterSlashing { + electra::AttesterSlashing { + attestation_1: electra_indexed_attestation(seed), + attestation_2: electra_indexed_attestation(seed.wrapping_add(50)), + } +} + +fn deposit_request(seed: u8) -> electra::DepositRequest { + electra::DepositRequest { + pubkey: BlsPubkey([seed; 48]), + withdrawal_credentials: H256::repeat_byte(seed.wrapping_add(1)), + amount: 32_000_000_000 + u64::from(seed), + signature: BlsSignature([seed.wrapping_add(2); 96]), + index: u64::from(seed) + 3, + } +} + +fn withdrawal_request(seed: u8) -> electra::WithdrawalRequest { + electra::WithdrawalRequest { + source_address: H160::repeat_byte(seed), + validator_pubkey: BlsPubkey([seed.wrapping_add(1); 48]), + amount: 32_000_000_000 + u64::from(seed), + } +} + +fn consolidation_request(seed: u8) -> electra::ConsolidationRequest { + electra::ConsolidationRequest { + source_address: H160::repeat_byte(seed), + source_pubkey: BlsPubkey([seed.wrapping_add(1); 48]), + target_pubkey: BlsPubkey([seed.wrapping_add(2); 48]), + } +} + +/// A nonempty [`electra::ExecutionRequests`], so each of `DepositRequest`, +/// `WithdrawalRequest`, and `ConsolidationRequest` — one level below a field +/// an empty-body test would never reach — is actually walked. +fn execution_requests(seed: u8) -> electra::ExecutionRequests { + electra::ExecutionRequests { + deposits: SszList::try_from(vec![deposit_request(seed)]).expect("within capacity"), + withdrawals: SszList::try_from(vec![withdrawal_request(seed.wrapping_add(10))]) + .expect("within capacity"), + consolidations: SszList::try_from(vec![consolidation_request(seed.wrapping_add(20))]) + .expect("within capacity"), + } +} + +/// Deneb's body shape, with electra's widened attestation family and +/// `execution_requests` appended, electra's one addition to the block body. +/// `execution_payload` reuses [`deneb_execution_payload`] directly: +/// `electra::BeaconBlockBody::execution_payload` is `deneb::ExecutionPayload` +/// itself, imported unchanged rather than redefined, so there is no separate +/// `electra::ExecutionPayload` type to build a fixture for. +fn electra_block_body() -> electra::BeaconBlockBody { + electra::BeaconBlockBody { + randao_reveal: BlsSignature([0x44; 96]), + eth1_data: fixtures::eth1_data(1), + graffiti: H256::repeat_byte(0x55), + proposer_slashings: SszList::try_from(vec![fixtures::proposer_slashing(2)]) + .expect("within capacity"), + attester_slashings: SszList::try_from(vec![electra_attester_slashing(3)]) + .expect("within capacity"), + attestations: SszList::try_from(vec![electra_attestation(4)]).expect("within capacity"), + deposits: SszList::try_from(vec![fixtures::deposit(5)]).expect("within capacity"), + voluntary_exits: SszList::try_from(vec![fixtures::signed_voluntary_exit(6)]) + .expect("within capacity"), + sync_aggregate: altair::SyncAggregate { + sync_committee_bits: { + let mut bits = altair::SyncCommitteeBits::new(); + bits.set(0, true).expect("in bounds"); + bits + }, + sync_committee_signature: BlsSignature([0x99; 96]), + }, + execution_payload: deneb_execution_payload(20), + bls_to_execution_changes: SszList::try_from(vec![ + fixtures::signed_bls_to_execution_change(40), + ]) + .expect("within capacity"), + blob_kzg_commitments: SszList::try_from(vec![KzgCommitment([0x77; 48])]) + .expect("within capacity"), + execution_requests: execution_requests(50), + } +} + +fn electra_block() -> electra::SignedBeaconBlock { + electra::SignedBeaconBlock { + message: electra::BeaconBlock { + slot: 12_345, + proposer_index: 7, + parent_root: H256([0x11; 32]), + state_root: H256([0x22; 32]), + body: electra_block_body(), + }, + signature: BlsSignature([0x33; 96]), + } +} + +/// Deneb's 28 fields, field for field, plus the nine electra appends: +/// `deposit_requests_start_index` (EIP-6110) and the balance-churn accounting +/// plus three pending queues (EIP-7251): 37 fields in all. +/// `latest_execution_payload_header` reuses [`deneb_execution_payload_header`] +/// directly, the header-side counterpart of [`electra_block_body`]'s reuse of +/// [`deneb_execution_payload`]. +fn electra_state() -> electra::BeaconState { + electra::BeaconState { + genesis_time: 1_700_000_000, + genesis_validators_root: H256::repeat_byte(0x66), + slot: 42, + fork: shared::Fork { + previous_version: [0x01, 0x02, 0x03, 0x04], + current_version: [0x05, 0x06, 0x07, 0x08], + epoch: 1, + }, + latest_block_header: fixtures::beacon_block_header(7), + block_roots: fixtures::vector(H256::repeat_byte(0x77)), + state_roots: fixtures::vector(H256::repeat_byte(0x88)), + historical_roots: SszList::try_from(vec![H256::repeat_byte(0x99)]) + .expect("within capacity"), + eth1_data: fixtures::eth1_data(8), + eth1_data_votes: SszList::try_from(vec![fixtures::eth1_data(9)]).expect("within capacity"), + eth1_deposit_index: 3, + validators: shared::Validators::try_from(vec![fixtures::validator(10)]) + .expect("within capacity"), + balances: shared::Balances::try_from(vec![32_000_000_001u64]).expect("within capacity"), + randao_mixes: fixtures::vector(H256::repeat_byte(0xaa)), + slashings: fixtures::vector(1_000_000_000u64), + previous_epoch_participation: SszList::try_from(vec![7u8]).expect("within capacity"), + current_epoch_participation: SszList::try_from(vec![9u8]).expect("within capacity"), + justification_bits: { + let mut bits = shared::JustificationBits::new(); + bits.set(0, true).expect("in bounds"); + bits + }, + previous_justified_checkpoint: fixtures::checkpoint(13), + current_justified_checkpoint: fixtures::checkpoint(14), + finalized_checkpoint: fixtures::checkpoint(15), + inactivity_scores: SszList::try_from(vec![4u64]).expect("within capacity"), + current_sync_committee: sync_committee(16), + next_sync_committee: sync_committee(17), + latest_execution_payload_header: deneb_execution_payload_header(30), + next_withdrawal_index: 40, + next_withdrawal_validator_index: 41, + historical_summaries: SszList::try_from(vec![shared::HistoricalSummary { + block_summary_root: H256::repeat_byte(0xbb), + state_summary_root: H256::repeat_byte(0xcc), + }]) + .expect("within capacity"), + deposit_requests_start_index: 50, + deposit_balance_to_consume: 51, + exit_balance_to_consume: 52, + earliest_exit_epoch: 53, + consolidation_balance_to_consume: 54, + earliest_consolidation_epoch: 55, + pending_deposits: SszList::try_from(vec![fixtures::pending_deposit(60)]) + .expect("within capacity"), + pending_partial_withdrawals: SszList::try_from(vec![fixtures::pending_partial_withdrawal( + 70, + )]) + .expect("within capacity"), + pending_consolidations: SszList::try_from(vec![fixtures::pending_consolidation(80)]) + .expect("within capacity"), + } +} + +#[test] +fn an_electra_block_carries_no_bare_numbers() { + assert_no_bare_numbers("electra::SignedBeaconBlock", &electra_block()); +} + +#[test] +fn an_electra_state_carries_no_bare_numbers() { + assert_no_bare_numbers("electra::BeaconState", &electra_state()); +} + +// --------------------------------------------------------------------------- +// Fulu +// --------------------------------------------------------------------------- + +/// Fulu defines no block types of its own: `BeaconBlockBody`, `BeaconBlock`, +/// and `SignedBeaconBlock` are unchanged from electra +/// (`SignedBeaconBlock::Fulu` in `containers/mod.rs` wraps +/// `electra::SignedBeaconBlock` directly), so this reuses [`electra_block`] +/// rather than redefining an identical builder for a type that does not +/// exist under `fulu::`. +fn fulu_block() -> electra::SignedBeaconBlock { + electra_block() +} + +/// Electra's 37 fields, field for field, plus `proposer_lookahead`, fulu's +/// one addition to the state: 38 fields in all. Reuses +/// [`deneb_execution_payload_header`] and the electra-onward pending-queue +/// fixtures the same way [`electra_state`] does, since none of those types +/// change again in fulu. +fn fulu_state() -> fulu::BeaconState { + fulu::BeaconState { + genesis_time: 1_700_000_000, + genesis_validators_root: H256::repeat_byte(0x66), + slot: 42, + fork: shared::Fork { + previous_version: [0x01, 0x02, 0x03, 0x04], + current_version: [0x05, 0x06, 0x07, 0x08], + epoch: 1, + }, + latest_block_header: fixtures::beacon_block_header(7), + block_roots: fixtures::vector(H256::repeat_byte(0x77)), + state_roots: fixtures::vector(H256::repeat_byte(0x88)), + historical_roots: SszList::try_from(vec![H256::repeat_byte(0x99)]) + .expect("within capacity"), + eth1_data: fixtures::eth1_data(8), + eth1_data_votes: SszList::try_from(vec![fixtures::eth1_data(9)]).expect("within capacity"), + eth1_deposit_index: 3, + validators: shared::Validators::try_from(vec![fixtures::validator(10)]) + .expect("within capacity"), + balances: shared::Balances::try_from(vec![32_000_000_001u64]).expect("within capacity"), + randao_mixes: fixtures::vector(H256::repeat_byte(0xaa)), + slashings: fixtures::vector(1_000_000_000u64), + previous_epoch_participation: SszList::try_from(vec![7u8]).expect("within capacity"), + current_epoch_participation: SszList::try_from(vec![9u8]).expect("within capacity"), + justification_bits: { + let mut bits = shared::JustificationBits::new(); + bits.set(0, true).expect("in bounds"); + bits + }, + previous_justified_checkpoint: fixtures::checkpoint(13), + current_justified_checkpoint: fixtures::checkpoint(14), + finalized_checkpoint: fixtures::checkpoint(15), + inactivity_scores: SszList::try_from(vec![4u64]).expect("within capacity"), + current_sync_committee: sync_committee(16), + next_sync_committee: sync_committee(17), + latest_execution_payload_header: deneb_execution_payload_header(30), + next_withdrawal_index: 40, + next_withdrawal_validator_index: 41, + historical_summaries: SszList::try_from(vec![shared::HistoricalSummary { + block_summary_root: H256::repeat_byte(0xbb), + state_summary_root: H256::repeat_byte(0xcc), + }]) + .expect("within capacity"), + deposit_requests_start_index: 50, + deposit_balance_to_consume: 51, + exit_balance_to_consume: 52, + earliest_exit_epoch: 53, + consolidation_balance_to_consume: 54, + earliest_consolidation_epoch: 55, + pending_deposits: SszList::try_from(vec![fixtures::pending_deposit(60)]) + .expect("within capacity"), + pending_partial_withdrawals: SszList::try_from(vec![fixtures::pending_partial_withdrawal( + 70, + )]) + .expect("within capacity"), + pending_consolidations: SszList::try_from(vec![fixtures::pending_consolidation(80)]) + .expect("within capacity"), + proposer_lookahead: fixtures::vector(90u64), + } +} + +#[test] +fn a_fulu_block_carries_no_bare_numbers() { + assert_no_bare_numbers("fulu::SignedBeaconBlock", &fulu_block()); +} + +#[test] +fn a_fulu_state_carries_no_bare_numbers() { + assert_no_bare_numbers("fulu::BeaconState", &fulu_state()); +} + +// --------------------------------------------------------------------------- +// The wrapping enums add no tag +// --------------------------------------------------------------------------- +// +// `SignedBeaconBlock` and `BeaconState` (in `containers/mod.rs`) wrap every +// fork's per-fork struct in one enum. The Beacon API's response envelope +// carries the fork name itself, as a `version` field and an +// `Eth-Consensus-Version` header, so the enum must serialize as exactly its +// inner value: no variant tag, no wrapper object. + +#[test] +fn the_block_enum_serializes_as_its_inner_block_with_no_tag() { + use ethlambda_types::beacon::containers::SignedBeaconBlock as Enum; + + let inner = phase0_block(); + let wrapped = Enum::Phase0(inner.clone()); + + assert_eq!( + serde_json::to_value(&wrapped).unwrap(), + serde_json::to_value(&inner).unwrap(), + "the enum must add no tag: the fork travels in the envelope's version field" + ); +} + +#[test] +fn the_state_enum_serializes_as_its_inner_state_with_no_tag() { + use ethlambda_types::beacon::containers::BeaconState as Enum; + + let inner = phase0_state(); + let wrapped = Enum::Phase0(inner.clone()); + + assert_eq!( + serde_json::to_value(&wrapped).unwrap(), + serde_json::to_value(&inner).unwrap(), + "the enum must add no tag: the fork travels in the envelope's version field" + ); +} + +/// [`BeaconState::Lean`] wraps `crate::state::State`, which stays SSZ-only by +/// design: `/lean/v0/states/finalized` serves SSZ, never JSON, so this +/// variant deliberately has no encoding to produce. The enum's hand-written +/// `Serialize` impl (see its doc in `containers/mod.rs`) answers this with a +/// serde error rather than a panic or a silently-wrong encoding, and this +/// test pins that: an `Err`, not an `Ok` and not an abort. +#[test] +fn the_lean_state_variant_errors_rather_than_serializing() { + use ethlambda_types::beacon::containers::BeaconState as Enum; + + let lean = ethlambda_types::state::State::from_genesis(0, Vec::new()); + let wrapped = Enum::Lean(lean); + + assert!( + serde_json::to_value(&wrapped).is_err(), + "a lean state has no JSON encoding and must not silently produce one" + ); +} diff --git a/crates/common/types/tests/tree_bench.rs b/crates/common/types/tests/tree_bench.rs new file mode 100644 index 000000000..e560b3d39 --- /dev/null +++ b/crates/common/types/tests/tree_bench.rs @@ -0,0 +1,315 @@ +//! Mainnet-scale comparison of the tree-backed `Validators` / `Balances` +//! against the `Vec`-backed `SszList`s they replaced. +//! +//! `#[ignore]`d: it allocates several gigabytes and runs for seconds. +//! +//! ```text +//! cargo test -p ethlambda-types --profile release-fast --test tree_bench \ +//! -- --ignored --nocapture --test-threads=1 +//! ``` +//! +//! Every line it prints starts with `tree_bench`, so the numbers can be +//! grepped out of the test harness's output. + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::hint::black_box; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Instant; + +use ethlambda_types::beacon::containers::{Balances, Validator, Validators}; +use ethlambda_types::beacon::preset; +use ethlambda_types::beacon::primitives::BlsPubkey; +use libssz::{SszDecode as _, SszEncode as _}; +use libssz_merkle::{HashTreeRoot, Sha2Hasher}; +use libssz_types::SszList; + +/// Counts live heap bytes, so memory is measured without an external tool. +struct Counting; + +static LIVE_BYTES: AtomicUsize = AtomicUsize::new(0); + +// SAFETY: every method forwards to `System` unchanged and only adds +// bookkeeping on an atomic counter. +unsafe impl GlobalAlloc for Counting { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + let pointer = unsafe { System.alloc(layout) }; + if !pointer.is_null() { + LIVE_BYTES.fetch_add(layout.size(), Ordering::Relaxed); + } + pointer + } + + unsafe fn dealloc(&self, pointer: *mut u8, layout: Layout) { + unsafe { System.dealloc(pointer, layout) }; + LIVE_BYTES.fetch_sub(layout.size(), Ordering::Relaxed); + } + + unsafe fn realloc(&self, pointer: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { + let new_pointer = unsafe { System.realloc(pointer, layout, new_size) }; + if !new_pointer.is_null() { + LIVE_BYTES.fetch_add(new_size, Ordering::Relaxed); + LIVE_BYTES.fetch_sub(layout.size(), Ordering::Relaxed); + } + new_pointer + } +} + +#[global_allocator] +static ALLOCATOR: Counting = Counting; + +const VALIDATOR_COUNT: usize = 2_400_000; + +/// Derived states held at once, as the storage LRU holds up to 32. The +/// `Vec`-backed side holds fewer, since each is a full copy. +const TREE_STATES: usize = 32; +const VEC_STATES: usize = 4; + +type VecValidators = SszList; +type VecBalances = SszList; + +fn validator(index: usize) -> Validator { + let mut pubkey = [0u8; 48]; + pubkey[..8].copy_from_slice(&(index as u64).to_le_bytes()); + Validator { + pubkey: BlsPubkey(pubkey), + effective_balance: preset::MAX_EFFECTIVE_BALANCE, + exit_epoch: u64::MAX, + withdrawable_epoch: u64::MAX, + ..Default::default() + } +} + +fn root(list: &L) -> [u8; 32] { + HashTreeRoot::hash_tree_root(list, &Sha2Hasher) +} + +fn live_mib() -> f64 { + LIVE_BYTES.load(Ordering::Relaxed) as f64 / f64::from(1 << 20) +} + +fn time(label: &str, run: impl FnOnce() -> R) -> R { + let start = Instant::now(); + let result = black_box(run()); + println!( + "tree_bench {label} {:.1} ms", + start.elapsed().as_secs_f64() * 1e3 + ); + result +} + +/// The registry indices one block writes: a sync committee's worth of balance +/// changes plus the proposer (513), spread over the registry the way sync +/// committee members are. +fn block_balance_indices(block: usize) -> impl Iterator { + (0..513).map(move |i| (i * 4679 + block * 131) % VALIDATOR_COUNT) +} + +/// The validator records one block writes: an exit and a slashing. +fn block_validator_indices(block: usize) -> [usize; 2] { + [ + (block * 7919) % VALIDATOR_COUNT, + (block * 104_729 + 1) % VALIDATOR_COUNT, + ] +} + +fn write_block_vec(validators: &mut VecValidators, balances: &mut VecBalances, block: usize) { + for index in block_balance_indices(block) { + balances[index] += 1; + } + for index in block_validator_indices(block) { + validators[index].exit_epoch = block as u64; + } +} + +fn write_block_tree(validators: &mut Validators, balances: &mut Balances, block: usize) { + for index in block_balance_indices(block) { + balances[index] += 1; + } + for index in block_validator_indices(block) { + validators[index].exit_epoch = block as u64; + } + validators.apply_updates(); + balances.apply_updates(); +} + +#[test] +#[ignore = "mainnet-scale benchmark: several GB and seconds to run"] +// Index loops on both sides keep the Vec and tree measurements the same shape. +#[allow(clippy::needless_range_loop)] +fn tree_bench() { + let validators: Vec = (0..VALIDATOR_COUNT).map(validator).collect(); + let balances: Vec = (0..VALIDATOR_COUNT) + .map(|i| preset::MAX_EFFECTIVE_BALANCE + i as u64) + .collect(); + + // One unshared copy of each. Each side is built from clones made inside + // the measured window, so the source Vecs, allocated before it, do not + // skew the delta. + let before = live_mib(); + let vec_validators: VecValidators = validators.clone().try_into().unwrap(); + let vec_balances: VecBalances = balances.clone().try_into().unwrap(); + println!( + "tree_bench memory_one_vec_state {:.0} MiB", + live_mib() - before + ); + + let before = live_mib(); + let tree_validators: Validators = time("build_tree", || validators.clone().try_into().unwrap()); + let tree_balances: Balances = balances.clone().try_into().unwrap(); + println!( + "tree_bench memory_one_tree_state {:.0} MiB", + live_mib() - before + ); + drop(validators); + drop(balances); + + // Hashing from scratch. + let vec_roots = time("cold_hash_vec", || { + (root(&vec_validators), root(&vec_balances)) + }); + let tree_roots = time("cold_hash_tree", || { + (root(&tree_validators), root(&tree_balances)) + }); + assert_eq!(vec_roots, tree_roots, "the tree must hash like SszList"); + + // What an import does: clone the parent, write one block, hash. + time("clone_write_block_rehash_vec", || { + let mut validators = vec_validators.clone(); + let mut balances = vec_balances.clone(); + write_block_vec(&mut validators, &mut balances, 1); + (root(&validators), root(&balances)) + }); + time("clone_write_block_rehash_tree", || { + let mut validators = tree_validators.clone(); + let mut balances = tree_balances.clone(); + write_block_tree(&mut validators, &mut balances, 1); + (root(&validators), root(&balances)) + }); + + // An epoch boundary: every balance written, then hashed. + time("epoch_sweep_vec", || { + let mut balances = vec_balances.clone(); + for index in 0..VALIDATOR_COUNT { + balances[index] += 1; + } + root(&balances) + }); + time("epoch_sweep_tree", || { + let mut balances = tree_balances.clone(); + for index in 0..VALIDATOR_COUNT { + balances[index] += 1; + } + balances.apply_updates(); + root(&balances) + }); + + // Memory held by a chain of derived states, per state. + let before = live_mib(); + let mut tree_states = Vec::with_capacity(TREE_STATES); + let (mut validators, mut balances) = (tree_validators.clone(), tree_balances.clone()); + for block in 0..TREE_STATES { + write_block_tree(&mut validators, &mut balances, block); + root(&validators); + root(&balances); + tree_states.push((validators.clone(), balances.clone())); + } + println!( + "tree_bench memory_per_derived_tree_state {:.1} MiB", + (live_mib() - before) / TREE_STATES as f64 + ); + drop(tree_states); + + let before = live_mib(); + let mut vec_states = Vec::with_capacity(VEC_STATES); + let (mut validators, mut balances) = (vec_validators.clone(), vec_balances.clone()); + for block in 0..VEC_STATES { + write_block_vec(&mut validators, &mut balances, block); + vec_states.push((validators.clone(), balances.clone())); + } + println!( + "tree_bench memory_per_derived_vec_state {:.1} MiB", + (live_mib() - before) / VEC_STATES as f64 + ); + drop(vec_states); + + // Storage round trip. + let bytes = time("encode_vec", || vec_validators.to_ssz()); + let tree_bytes = time("encode_tree", || tree_validators.to_ssz()); + assert_eq!(bytes, tree_bytes, "the tree must encode like SszList"); + time("decode_vec", || { + VecValidators::from_ssz_bytes(&bytes).unwrap() + }); + let mut decoded = time("decode_tree", || { + Validators::from_ssz_bytes(&bytes).unwrap() + }); + time("rebase_decoded_onto_resident", || { + decoded.rebase_on(&tree_validators) + }); + assert!(decoded.ptr_eq(&tree_validators)); + + // A pubkey -> index lookup scans the whole registry. + let needle = validator(VALIDATOR_COUNT - 1).pubkey; + time("scan_vec", || { + vec_validators.iter().position(|v| v.pubkey == needle) + }); + time("scan_tree", || { + tree_validators.iter().position(|v| v.pubkey == needle) + }); + + // Scattered single-element reads, as `state.validator(i)` does. + time("reads_1m_vec", || { + (0..1_000_000) + .map(|i| vec_validators[(i * 7919) % VALIDATOR_COUNT].effective_balance) + .sum::() + }); + time("reads_1m_tree", || { + (0..1_000_000) + .map(|i| tree_validators[(i * 7919) % VALIDATOR_COUNT].effective_balance) + .sum::() + }); + + // About the number of `state.validator(i)` calls `get_base_reward` makes + // for one mainnet block, with a different stride than `reads_1m` so the + // access pattern isn't identical. + const ATTESTER_READS: usize = 32 * 1024; + time("block_attester_reads_vec", || { + (0..ATTESTER_READS) + .map(|i| vec_validators[(i * 104_729 + 17) % VALIDATOR_COUNT].effective_balance) + .sum::() + }); + time("block_attester_reads_tree", || { + (0..ATTESTER_READS) + .map(|i| tree_validators[(i * 104_729 + 17) % VALIDATOR_COUNT].effective_balance) + .sum::() + }); + + // The shape of `process_effective_balance_updates`, which loops over + // every validator and reads `balances[index]`. + time("effective_balance_sweep_indexed_vec", || { + let mut sum = 0u64; + for index in 0..VALIDATOR_COUNT { + sum = sum + .wrapping_add(vec_balances[index]) + .wrapping_add(vec_validators[index].effective_balance); + } + sum + }); + time("effective_balance_sweep_indexed_tree", || { + let mut sum = 0u64; + for index in 0..VALIDATOR_COUNT { + sum = sum + .wrapping_add(tree_balances[index]) + .wrapping_add(tree_validators[index].effective_balance); + } + sum + }); + time("effective_balance_sweep_zipped_tree", || { + tree_validators + .iter() + .zip(tree_balances.iter()) + .fold(0u64, |sum, (validator, balance)| { + sum.wrapping_add(*balance) + .wrapping_add(validator.effective_balance) + }) + }); +} diff --git a/crates/net/api/src/lib.rs b/crates/net/api/src/lib.rs index d6ec647d1..ae39a8a02 100644 --- a/crates/net/api/src/lib.rs +++ b/crates/net/api/src/lib.rs @@ -1,5 +1,12 @@ +use std::time::Instant; + use ethlambda_types::{ attestation::{SignedAggregatedAttestation, SignedAttestation}, + beacon::containers::{ + SignedAggregateAndProof, SignedBeaconBlock, electra::SingleAttestation, + fulu::DataColumnSidecar, + }, + beacon::primitives::ValidatorIndex, block::SignedBlock, primitives::H256, }; @@ -17,7 +24,49 @@ pub trait BlockChainToP2P: Send + Sync { &self, attestation: SignedAggregatedAttestation, ) -> Result<(), ActorError>; - fn fetch_block(&self, root: H256) -> Result<(), ActorError>; + /// Ask peers for whatever of one block this node is missing. + fn fetch_block(&self, request: FetchRequest) -> Result<(), ActorError>; + /// Run the chain checks on sidecars the chain actor had parked, now that + /// their parent has a post-state. + /// + /// The chain actor keeps a sidecar without checking it, so it hands these + /// back rather than judging them itself: the p2p layer runs every column + /// check, off both actors, and sends the ones that pass back through + /// [`P2PToBlockChain::new_data_column_sidecars`]. + fn check_data_column_sidecars( + &self, + sidecars: Vec, + ) -> Result<(), ActorError>; +} + +/// What one block is missing, from the chain actor's point of view. +/// +/// One message rather than a block fetch and a column fetch, because the +/// answer to "what does this node still need for this block" is one answer: +/// the block itself, some of its columns, or both. Splitting it left the +/// caller deciding which protocol to reach for, which is the p2p layer's +/// decision and not the chain's. +/// +/// Chain-agnostic. A lean node never custodies a column, so it names an empty +/// `columns` and the request degenerates to the by-root block lookup it was +/// before. +#[derive(Clone, Debug)] +pub struct FetchRequest { + /// The block all of this is about, and the key the p2p layer deduplicates + /// an in-flight lookup on. + pub block_root: H256, + /// Whether the block itself is missing. + /// + /// Explicit rather than inferred from an empty `columns`: the two are + /// disjoint today only by coincidence of the callers, and a p2p layer + /// reading its own store to find out would pay a DB read per request to + /// re-derive what the caller already knew. + pub needs_block: bool, + /// Columns of this block that this node custodies and does not have. + /// + /// Empty when nothing is missing, or when the block itself is, since a + /// node that has never seen a block does not know what it committed to. + pub columns: Vec, } /// How a block reached this node. @@ -31,18 +80,237 @@ pub enum BlockSource { Gossip, /// Fetched via req/resp (`BlocksByRoot` / `BlocksByRange`). Sync, + /// Re-delivered by the chain actor to itself, for a block it received + /// before that block's own slot had started and held until it did. + /// + /// The p2p layer never sends this one: it is the only variant that says + /// the block is not arriving now but arriving again, which is what keeps + /// a held block out of the timeliness measurements its first arrival + /// already fed. + Deferred, + /// Read from a corpus by the offline import benchmark + /// (`ethlambda benchmark import replay`). + /// + /// The p2p layer never sends this one either. It exists so a replayed + /// block's import sections are published under a label of their own: + /// with no source at all they would not be published, and the harness + /// reads its per-phase numbers from exactly those observations, while + /// under `gossip` or `sync` they would pass for arrivals that crossed a + /// wire. + Replay, +} + +/// When a block's payload reached this node. +/// +/// Carried on the message rather than read by the chain actor when it handles +/// one, because the two differ by however long the block sat in that actor's +/// mailbox, and that wait is invisible from the far side. It is also the one +/// thing about a block's arrival that no store state records, which is why it +/// rides here instead of being derived on receipt. +/// +/// Instants rather than wall-clock milliseconds: these exist to be subtracted +/// from one another, and a monotonic clock is the only one that may be. +#[derive(Clone, Copy, Debug)] +pub struct BlockArrival { + /// The payload came off the wire, before decompression. + /// + /// `None` where this node did not decode the block itself, which is the + /// req/resp path: its codec has already produced a block by the time any + /// handler sees one, so the only instant that path can report is the + /// hand-off. `None` rather than a copy of `handed_off`, because a decode + /// of zero reads as "free" where the truth is "not measured". + pub decode_start: Option, + /// The block is about to be handed to the chain actor. + /// + /// Where `decode_start` is set, this doubles as the end of the decode + /// section: a producer hands a block over as soon as it has one. On the + /// beacon wire that "as soon as" includes gossip validation, since a + /// gossiped block is not handed off until it has a verdict + /// (`crate::beacon::verdict` in `ethlambda-p2p`), so `decode` there also + /// covers the cheap checks, the stateful check itself, and the verdict's + /// trip back through the p2p actor's mailbox. Not a wait for a free + /// validation slot: `try_acquire_owned` never blocks, and a message + /// arriving with none free is reported `Ignore(Overloaded)` immediately. + pub handed_off: Instant, + /// Set when this delivery re-delivers a block held for a slot that had + /// not started. + /// + /// `Some` only alongside [`BlockSource::Deferred`]. It is what lets the + /// held block's end-to-end timing still start where it really started, + /// rather than at the re-delivery. + pub deferred_from: Option, +} + +/// Where a re-delivered block was before it was re-delivered. +/// +/// Carries the original source as well as the instant, because +/// [`BlockSource::Deferred`] on the re-delivery says how the block reached the +/// actor this time, not how it reached the node. Reporting a deferred block +/// under its own source would take it out of the population it belongs to: +/// a gossip block held for 200ms is still a gossip block, and the hold is +/// already visible as its own section of the import. +#[derive(Clone, Copy, Debug)] +pub struct DeferredFrom { + pub at: Instant, + pub source: BlockSource, +} + +impl BlockArrival { + /// An arrival whose earliest knowable moment is now. + /// + /// For producers that did not decode the block themselves, so have no + /// earlier instant to report than the one they hand it over at. + pub fn now() -> Self { + Self { + decode_start: None, + handed_off: Instant::now(), + deferred_from: None, + } + } } // --- Protocol: P2P -> BlockChain --- #[protocol] pub trait P2PToBlockChain: Send + Sync { - fn new_block(&self, block: SignedBlock, source: BlockSource) -> Result<(), ActorError>; + /// A block for whichever chain this node follows. + /// + /// [`SignedBeaconBlock`] rather than a lean [`SignedBlock`] because its + /// `Lean` variant carries one, and its `message:` accessors answer for that + /// variant too. That is what lets the actor's import cascade be written + /// once for both chains rather than twice. + fn new_block( + &self, + block: SignedBeaconBlock, + source: BlockSource, + arrival: BlockArrival, + ) -> Result<(), ActorError>; fn new_attestation(&self, attestation: SignedAttestation) -> Result<(), ActorError>; fn new_aggregated_attestation( &self, attestation: SignedAggregatedAttestation, ) -> Result<(), ActorError>; + /// Data column sidecars that passed every check: gossip's own, for one it + /// accepted, or the chain checks (`beacon::gossip::column::chain_checks` + /// in `ethlambda-state-transition`) for any other. + /// + /// The chain actor stores these without checking them again. That is the + /// point of running the checks in the p2p layer: a KZG batch and a BLS + /// verification per sidecar cost too much on the actor's single thread, + /// which also imports every block. Debug builds do check again, so a path + /// that sends a sidecar it never checked fails a test rather than reaching + /// the store. + /// + /// A batch rather than one sidecar, because every producer but gossip has + /// a batch to hand: a `DataColumnsByRoot` answer carries every column of + /// one block a peer held, and a `DataColumnsByRange` answer carries a span + /// of them. Sending those one message at a time put one mailbox hop per + /// sidecar between the answer and the actor that needed it, on the path + /// that drains a backlog. Gossip sends a batch of one. + /// + /// The subnet is not carried: the p2p actor has already checked that each + /// gossiped sidecar's own index maps to the subnet it arrived on, and + /// nothing on the far side would do anything with it but check that + /// again. + fn new_data_column_sidecars(&self, sidecars: Vec) -> Result<(), ActorError>; + /// Data column sidecars the chain checks could not judge yet, because + /// their parent has no post-state: the chain actor parks them and sends + /// them back through [`BlockChainToP2P::check_data_column_sidecars`] once + /// the parent imports. + fn data_column_sidecars_awaiting_parent( + &self, + sidecars: Vec, + ) -> Result<(), ActorError>; + /// An aggregate gossip validation accepted: `ethlambda-p2p`'s beacon + /// gossip verdict machinery already ran every `beacon_aggregate_and_proof` + /// condition, `attesting_indices` included, so the chain actor only has to + /// apply it to fork choice. + /// + /// Separate from [`Self::new_aggregated_attestation`], which carries + /// lean's unrelated [`SignedAggregatedAttestation`]: the two chains' + /// aggregate containers share no type, so unlike [`Self::new_block`] there + /// is nothing for one message to be generic over. + /// + /// Boxed for the reason `ethlambda-p2p`'s own gossip enum boxes it: two + /// signed block headers' worth of payload would otherwise set the size of + /// every message in this protocol. + /// + /// `attesting_indices` are the validators whose votes the aggregate + /// signature verified, resolved once against the committee p2p's gossip + /// validation already looked up. The chain actor never rebuilds a + /// committee or checks a signature for this topic: its only consumer is + /// `ethlambda_state_transition`'s apply-only + /// `fork_choice::apply_verified_aggregate`. + /// + /// One aggregate per message rather than a batch, unlike + /// [`Self::new_data_column_sidecars`]: gossip is the only producer, and it + /// has exactly one to hand. + fn new_beacon_aggregate( + &self, + aggregate: Box, + attesting_indices: Vec, + arrival: AggregateArrival, + ) -> Result<(), ActorError>; +} + +/// When an aggregate reached this node, for the one metric that cannot be +/// derived on the far side of the mailbox. +/// +/// The mailbox hop is the failure mode applying gossip aggregates introduces: +/// this topic carries up to `MAX_COMMITTEES_PER_SLOT * TARGET_AGGREGATORS_PER_COMMITTEE` +/// messages a slot, and if they start queueing behind block imports the votes +/// arrive too late to move the head while every per-aggregate timing still +/// looks healthy. Measuring it means capturing an instant before the queue and +/// reading it after, which is what this carries. +/// +/// [`BlockArrival`]'s shape without its `deferred_from`: an aggregate held for +/// a slot that has not started is held *inside* the chain actor, so that wait +/// is measured where it happens rather than travelling on the message. And +/// `decode_start` is not optional here, because gossip is the only producer +/// and it always decodes the payload itself. +#[derive(Clone, Copy, Debug)] +pub struct AggregateArrival { + /// The payload came off the wire, before decompression. + pub decode_start: Instant, + /// The aggregate is about to be handed to the chain actor, which is also + /// the end of the decode. + pub handed_off: Instant, +} + +// --- Protocol: RPC -> P2P --- + +/// What the Beacon API asks of the network. +/// +/// A protocol of its own rather than more methods on [`BlockChainToP2P`]: +/// these requests come from a validator client through the HTTP server, not +/// from the chain actor, and a beacon node's chain actor has nothing to publish +/// on its own behalf. +#[protocol] +pub trait RpcToP2P: Send + Sync { + /// Gossip one unaggregated attestation on `beacon_attestation_{subnet_id}`. + /// + /// The caller has already validated it and computed its subnet: both need + /// the committee assignment, which needs a state, and the p2p actor holds + /// none. + fn publish_beacon_attestation( + &self, + subnet_id: u64, + attestation: SingleAttestation, + ) -> Result<(), ActorError>; + /// Gossip one signed aggregate on `beacon_aggregate_and_proof`, already + /// validated by the caller for the same reason as above. + fn publish_beacon_aggregate( + &self, + aggregate: SignedAggregateAndProof, + ) -> Result<(), ActorError>; + /// Join attestation subnets a validator client's aggregators need, each + /// until the end of the paired slot, so their committees' attestations + /// reach this node's pool. `(subnet_id, slot)` pairs. + fn subscribe_attestation_subnets(&self, subnets: Vec<(u64, u64)>) -> Result<(), ActorError>; + /// Gossip a block a validator client signed, and import it: gossip never + /// delivers a node its own messages, so without the second half this node + /// would not follow its own proposal. Checked by the caller as above. + fn publish_beacon_block(&self, block: SignedBeaconBlock) -> Result<(), ActorError>; } // --- Init messages --- diff --git a/crates/net/engine/Cargo.toml b/crates/net/engine/Cargo.toml new file mode 100644 index 000000000..501dbb2d5 --- /dev/null +++ b/crates/net/engine/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "ethlambda-engine" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +ethlambda-types.workspace = true +# `json` on top of the workspace's rustls-only default: this crate's whole +# payload is JSON-RPC, and the feature only pulls in `serde_json`, which is +# already a direct dependency here. +reqwest = { workspace = true, features = ["json"] } +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true +hmac.workspace = true +base64.workspace = true +hex.workspace = true +thiserror.workspace = true +tokio = { workspace = true, features = ["time"] } +tracing.workspace = true + +[dev-dependencies] +tokio = { workspace = true, features = [ + "io-util", + "macros", + "net", + "rt-multi-thread", + "sync", +] } diff --git a/crates/net/engine/src/auth.rs b/crates/net/engine/src/auth.rs new file mode 100644 index 000000000..e35dd6577 --- /dev/null +++ b/crates/net/engine/src/auth.rs @@ -0,0 +1,154 @@ +//! JWT authentication for the Engine API. +//! +//! `authentication.md`: HMAC-SHA256 over a JOSE header and a claim set whose +//! only member is `iat`, the issued-at time in seconds. Execution clients accept +//! a skew of ±60 seconds, so a token is good for about two minutes; this mints a +//! fresh one per request rather than caching, because minting is two hashes and +//! a cache would need its own clock. + +use std::path::Path; + +use base64::Engine as _; +use hmac::{Hmac, Mac}; +use sha2::Sha256; + +use crate::error::EngineError; + +const B64: base64::engine::general_purpose::GeneralPurpose = + base64::engine::general_purpose::URL_SAFE_NO_PAD; + +/// The fixed JOSE header, `{"alg":"HS256","typ":"JWT"}`, pre-encoded. +/// +/// Constant because nothing about it varies: the specification names exactly one +/// algorithm, and a header serialized fresh per request would risk a different +/// key order producing a different signature over the same logical header. +const HEADER_B64: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"; + +/// The 32-byte shared secret an execution client authenticates against. +#[derive(Clone)] +pub struct JwtSecret([u8; 32]); + +impl std::fmt::Debug for JwtSecret { + /// Never prints the secret. A `#[derive(Debug)]` here would put the shared + /// secret into any log line that formats a config struct. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("JwtSecret()") + } +} + +impl JwtSecret { + pub fn new(secret: [u8; 32]) -> Self { + Self(secret) + } + + /// Parses a 64-character hex string, with or without a `0x` prefix. + pub fn from_hex(value: &str) -> Result { + let trimmed = value.trim().trim_start_matches("0x"); + let bytes = hex::decode(trimmed) + .map_err(|err| EngineError::Jwt(format!("secret is not hex: {err}")))?; + let secret: [u8; 32] = bytes + .try_into() + .map_err(|_| EngineError::Jwt("secret is not 32 bytes".to_string()))?; + Ok(Self(secret)) + } + + /// Reads a hex secret from a file, as `--execution-jwt-secret` names one. + pub fn from_file(path: &Path) -> Result { + let contents = std::fs::read_to_string(path) + .map_err(|err| EngineError::Jwt(format!("reading {}: {err}", path.display())))?; + Self::from_hex(&contents) + } + + /// A token whose `iat` is the current wall clock. + pub fn token(&self) -> String { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| elapsed.as_secs()) + .unwrap_or(0); + self.token_at(now) + } + + /// A token for an explicit `iat`. Separated from [`token`](Self::token) so + /// tests can pin the clock; a token is a pure function of the secret and + /// that one number. + pub fn token_at(&self, issued_at: u64) -> String { + let claim = format!(r#"{{"iat":{issued_at}}}"#); + let payload = B64.encode(claim.as_bytes()); + let signing_input = format!("{HEADER_B64}.{payload}"); + + let mut mac = as Mac>::new_from_slice(&self.0) + .expect("HMAC accepts a key of any length"); + mac.update(signing_input.as_bytes()); + let signature = B64.encode(mac.finalize().into_bytes()); + + format!("{signing_input}.{signature}") + } +} + +#[cfg(test)] +mod tests { + use base64::Engine as _; + + use super::*; + + const SECRET: [u8; 32] = [0x0f; 32]; + + #[test] + fn a_token_has_three_base64url_segments() { + let secret = JwtSecret::new(SECRET); + let token = secret.token_at(1_700_000_000); + + let parts: Vec<&str> = token.split('.').collect(); + assert_eq!(parts.len(), 3); + // base64url, unpadded: no '+', '/' or '=' anywhere. + assert!(!token.contains('+')); + assert!(!token.contains('/')); + assert!(!token.contains('=')); + } + + #[test] + fn the_claim_carries_iat_and_nothing_else() { + let secret = JwtSecret::new(SECRET); + let token = secret.token_at(1_700_000_000); + + let payload = token.split('.').nth(1).expect("a three-segment token"); + let decoded = B64.decode(payload).expect("the payload is base64url"); + let claim: serde_json::Value = serde_json::from_slice(&decoded).expect("the claim is JSON"); + + assert_eq!(claim["iat"], 1_700_000_000u64); + assert_eq!(claim.as_object().expect("a JSON object").len(), 1); + } + + #[test] + fn the_same_second_gives_the_same_token_and_a_later_one_differs() { + let secret = JwtSecret::new(SECRET); + assert_eq!(secret.token_at(1_000), secret.token_at(1_000)); + assert_ne!(secret.token_at(1_000), secret.token_at(1_001)); + } + + #[test] + fn a_hex_secret_parses_with_or_without_the_prefix() { + let bare = "0f".repeat(32); + let prefixed = format!("0x{bare}"); + + assert_eq!( + JwtSecret::from_hex(&bare).expect("valid hex").token_at(5), + JwtSecret::from_hex(&prefixed) + .expect("valid hex") + .token_at(5) + ); + } + + #[test] + fn a_secret_that_is_not_32_bytes_is_refused() { + assert!(JwtSecret::from_hex(&"0f".repeat(16)).is_err()); + assert!(JwtSecret::from_hex("nonsense").is_err()); + } + + #[test] + fn the_debug_impl_never_prints_the_secret() { + let rendered = format!("{:?}", JwtSecret::new(SECRET)); + assert_eq!(rendered, "JwtSecret()"); + assert!(!rendered.contains("0f")); + } +} diff --git a/crates/net/engine/src/building.rs b/crates/net/engine/src/building.rs new file mode 100644 index 000000000..e7493d9e9 --- /dev/null +++ b/crates/net/engine/src/building.rs @@ -0,0 +1,378 @@ +//! The Engine API's payload-building half: `PayloadAttributesV3` going out on +//! `engine_forkchoiceUpdatedV3`, and `engine_getPayloadV5`'s answer coming +//! back. +//! +//! A proposer asks its execution client to start building on a head by sending +//! `forkchoiceUpdated` with attributes, gets a `payloadId` back, and later asks +//! for the payload by that id. `getPayloadV5` is Osaka's (fulu's) version: the +//! blobs bundle carries cell proofs (`CELLS_PER_EXT_BLOB` per blob) rather than +//! one proof per blob. +//! +//! The response is decoded straight into `ethlambda-types`' own containers, so +//! the block producer never sees a wire shape. + +use ethlambda_types::beacon::containers::{bellatrix, capella, deneb}; +use ethlambda_types::beacon::primitives::{ + Bytes32, ExecutionAddress, H160, H256, KzgCommitment, KzgProof, Root, U256, Uint256, +}; +use serde::{Deserialize, Serialize, Serializer}; + +use crate::error::EngineError; +use crate::types::{data, quantity}; + +/// `PayloadAttributesV3`: what the execution client needs to build the payload +/// for one slot. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PayloadAttributesV3 { + /// The slot's start time, which becomes the payload's `timestamp`. + pub timestamp: u64, + /// The RANDAO mix the payload's `prevRandao` must equal. + pub prev_randao: Bytes32, + pub suggested_fee_recipient: ExecutionAddress, + /// The withdrawals the consensus layer expects this payload to carry, as + /// `get_expected_withdrawals` computes them for the slot. + pub withdrawals: Vec, + pub parent_beacon_block_root: Root, +} + +impl Serialize for PayloadAttributesV3 { + fn serialize(&self, serializer: S) -> Result { + let withdrawals: Vec = self + .withdrawals + .iter() + .map(|withdrawal| { + serde_json::json!({ + "index": quantity(withdrawal.index), + "validatorIndex": quantity(withdrawal.validator_index), + "address": data(&withdrawal.address.0), + "amount": quantity(withdrawal.amount), + }) + }) + .collect(); + serde_json::json!({ + "timestamp": quantity(self.timestamp), + "prevRandao": data(&self.prev_randao.0), + "suggestedFeeRecipient": data(&self.suggested_fee_recipient.0), + "withdrawals": withdrawals, + "parentBeaconBlockRoot": data(&self.parent_beacon_block_root.0), + }) + .serialize(serializer) + } +} + +/// The execution client's name for one build process, `DATA` of eight bytes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct PayloadId(pub [u8; 8]); + +impl<'de> Deserialize<'de> for PayloadId { + fn deserialize>(deserializer: D) -> Result { + let text = String::deserialize(deserializer)?; + parse_fixed::<8>(&text) + .map(PayloadId) + .map_err(serde::de::Error::custom) + } +} + +impl Serialize for PayloadId { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&data(&self.0)) + } +} + +/// `engine_getPayloadV5`'s answer, decoded. +#[derive(Debug, Clone)] +pub struct BuiltPayload { + pub execution_payload: deneb::ExecutionPayload, + /// What the payload pays its fee recipient, in wei. + pub block_value: Uint256, + pub blobs_bundle: BlobsBundle, + /// The EIP-7685 request list, each entry its type byte then its data. + pub execution_requests: Vec>, +} + +/// `BlobsBundleV2`: the payload's blobs, their commitments, and every blob's +/// `CELLS_PER_EXT_BLOB` cell proofs, flattened blob by blob. +#[derive(Debug, Clone, Default)] +pub struct BlobsBundle { + pub commitments: Vec, + pub proofs: Vec, + pub blobs: Vec>, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct GetPayloadV5Response { + execution_payload: ExecutionPayloadJson, + block_value: String, + blobs_bundle: BlobsBundleJson, + #[serde(default)] + execution_requests: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ExecutionPayloadJson { + parent_hash: String, + fee_recipient: String, + state_root: String, + receipts_root: String, + logs_bloom: String, + prev_randao: String, + block_number: String, + gas_limit: String, + gas_used: String, + timestamp: String, + extra_data: String, + base_fee_per_gas: String, + block_hash: String, + transactions: Vec, + withdrawals: Vec, + blob_gas_used: String, + excess_blob_gas: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct WithdrawalJson { + index: String, + validator_index: String, + address: String, + amount: String, +} + +#[derive(Deserialize)] +struct BlobsBundleJson { + commitments: Vec, + proofs: Vec, + blobs: Vec, +} + +impl TryFrom for BuiltPayload { + type Error = EngineError; + + fn try_from(response: GetPayloadV5Response) -> Result { + let payload = response.execution_payload; + let transactions = payload + .transactions + .iter() + .map(|transaction| { + bellatrix::Transaction::try_from(parse_data(transaction)?) + .map_err(|_| decode("a transaction exceeds MAX_BYTES_PER_TRANSACTION")) + }) + .collect::, _>>()?; + let withdrawals = payload + .withdrawals + .iter() + .map(|withdrawal| { + Ok(capella::Withdrawal { + index: parse_quantity(&withdrawal.index)?, + validator_index: parse_quantity(&withdrawal.validator_index)?, + address: H160(parse_fixed(&withdrawal.address)?), + amount: parse_quantity(&withdrawal.amount)?, + }) + }) + .collect::, EngineError>>()?; + + let execution_payload = deneb::ExecutionPayload { + parent_hash: H256(parse_fixed(&payload.parent_hash)?), + fee_recipient: H160(parse_fixed(&payload.fee_recipient)?), + state_root: H256(parse_fixed(&payload.state_root)?), + receipts_root: H256(parse_fixed(&payload.receipts_root)?), + logs_bloom: bellatrix::LogsBloom::try_from(parse_data(&payload.logs_bloom)?) + .map_err(|_| decode("logsBloom is not BYTES_PER_LOGS_BLOOM long"))?, + prev_randao: H256(parse_fixed(&payload.prev_randao)?), + block_number: parse_quantity(&payload.block_number)?, + gas_limit: parse_quantity(&payload.gas_limit)?, + gas_used: parse_quantity(&payload.gas_used)?, + timestamp: parse_quantity(&payload.timestamp)?, + extra_data: bellatrix::ExtraData::try_from(parse_data(&payload.extra_data)?) + .map_err(|_| decode("extraData exceeds MAX_EXTRA_DATA_BYTES"))?, + base_fee_per_gas: parse_uint256(&payload.base_fee_per_gas)?, + block_hash: H256(parse_fixed(&payload.block_hash)?), + transactions: transactions + .try_into() + .map_err(|_| decode("more transactions than MAX_TRANSACTIONS_PER_PAYLOAD"))?, + withdrawals: withdrawals + .try_into() + .map_err(|_| decode("more withdrawals than MAX_WITHDRAWALS_PER_PAYLOAD"))?, + blob_gas_used: parse_quantity(&payload.blob_gas_used)?, + excess_blob_gas: parse_quantity(&payload.excess_blob_gas)?, + }; + + let bundle = response.blobs_bundle; + let blobs_bundle = BlobsBundle { + commitments: bundle + .commitments + .iter() + .map(|text| parse_fixed(text).map(KzgCommitment)) + .collect::>()?, + proofs: bundle + .proofs + .iter() + .map(|text| parse_fixed(text).map(KzgProof)) + .collect::>()?, + blobs: bundle + .blobs + .iter() + .map(|text| parse_data(text)) + .collect::>()?, + }; + + Ok(BuiltPayload { + execution_payload, + block_value: parse_uint256(&response.block_value)?, + blobs_bundle, + execution_requests: response + .execution_requests + .iter() + .map(|text| parse_data(text)) + .collect::>()?, + }) + } +} + +fn decode(message: &str) -> EngineError { + EngineError::Decode(message.to_string()) +} + +/// A `DATA` of any length. +fn parse_data(text: &str) -> Result, EngineError> { + let digits = text + .strip_prefix("0x") + .ok_or_else(|| decode("DATA must be 0x-prefixed"))?; + hex::decode(digits).map_err(|err| EngineError::Decode(format!("DATA is not hex: {err}"))) +} + +/// A `DATA` of exactly `N` bytes. +fn parse_fixed(text: &str) -> Result<[u8; N], EngineError> { + parse_data(text)?.try_into().map_err(|bytes: Vec| { + EngineError::Decode(format!("expected {N} bytes, got {}", bytes.len())) + }) +} + +/// A `QUANTITY` that fits in 64 bits. +fn parse_quantity(text: &str) -> Result { + let digits = text + .strip_prefix("0x") + .ok_or_else(|| decode("QUANTITY must be 0x-prefixed"))?; + u64::from_str_radix(digits, 16) + .map_err(|err| EngineError::Decode(format!("QUANTITY is not a u64: {err}"))) +} + +/// A 256-bit `QUANTITY`, into `Uint256`'s little-endian bytes. +fn parse_uint256(text: &str) -> Result { + let digits = text + .strip_prefix("0x") + .ok_or_else(|| decode("QUANTITY must be 0x-prefixed"))?; + if digits.is_empty() || digits.len() > 64 { + return Err(decode("a 256-bit QUANTITY must have 1 to 64 hex digits")); + } + let padded = format!("{digits:0>64}"); + let mut bytes: [u8; 32] = hex::decode(padded) + .map_err(|err| EngineError::Decode(format!("QUANTITY is not hex: {err}")))? + .try_into() + .expect("64 hex digits are 32 bytes"); + bytes.reverse(); + Ok(U256(bytes)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::uint256; + + fn response_json() -> serde_json::Value { + serde_json::json!({ + "executionPayload": { + "parentHash": format!("0x{}", "11".repeat(32)), + "feeRecipient": format!("0x{}", "de".repeat(20)), + "stateRoot": format!("0x{}", "22".repeat(32)), + "receiptsRoot": format!("0x{}", "33".repeat(32)), + "logsBloom": format!("0x{}", "00".repeat(256)), + "prevRandao": format!("0x{}", "44".repeat(32)), + "blockNumber": "0x10", + "gasLimit": "0x1c9c380", + "gasUsed": "0x0", + "timestamp": "0x6553f100", + "extraData": "0x", + "baseFeePerGas": "0x7", + "blockHash": format!("0x{}", "55".repeat(32)), + "transactions": ["0x02f8"], + "withdrawals": [{ + "index": "0x1", "validatorIndex": "0x2", + "address": format!("0x{}", "66".repeat(20)), "amount": "0x3" + }], + "blobGasUsed": "0x0", + "excessBlobGas": "0x0" + }, + "blockValue": "0x1bc16d674ec80000", + "blobsBundle": { "commitments": [], "proofs": [], "blobs": [] }, + "shouldOverrideBuilder": false, + "executionRequests": ["0x00aa"] + }) + } + + #[test] + fn a_get_payload_v5_answer_decodes_into_the_consensus_containers() { + let response: GetPayloadV5Response = serde_json::from_value(response_json()).unwrap(); + let built = BuiltPayload::try_from(response).unwrap(); + let payload = &built.execution_payload; + assert_eq!(payload.block_number, 16); + assert_eq!(payload.gas_limit, 30_000_000); + assert_eq!(payload.block_hash, H256([0x55; 32])); + assert_eq!(payload.fee_recipient, H160([0xde; 20])); + assert_eq!(uint256(&payload.base_fee_per_gas), "0x7"); + assert_eq!(payload.transactions.len(), 1); + assert_eq!(payload.withdrawals[0].validator_index, 2); + assert_eq!(uint256(&built.block_value), "0x1bc16d674ec80000"); + assert_eq!(built.execution_requests, vec![vec![0x00, 0xaa]]); + } + + #[test] + fn a_wrong_width_hash_is_a_decode_error() { + let mut json = response_json(); + json["executionPayload"]["blockHash"] = "0x1234".into(); + let response: GetPayloadV5Response = serde_json::from_value(json).unwrap(); + assert!(matches!( + BuiltPayload::try_from(response), + Err(EngineError::Decode(_)) + )); + } + + #[test] + fn payload_attributes_serialize_as_the_spec_names_them() { + let attributes = PayloadAttributesV3 { + timestamp: 12, + prev_randao: H256([1; 32]), + suggested_fee_recipient: H160([2; 20]), + withdrawals: vec![capella::Withdrawal { + index: 0, + validator_index: 5, + address: H160([3; 20]), + amount: 10, + }], + parent_beacon_block_root: Root::repeat_byte(4), + }; + let json = serde_json::to_value(&attributes).unwrap(); + assert_eq!(json["timestamp"], "0xc"); + assert_eq!(json["prevRandao"], format!("0x{}", "01".repeat(32))); + assert_eq!( + json["suggestedFeeRecipient"], + format!("0x{}", "02".repeat(20)) + ); + assert_eq!(json["withdrawals"][0]["validatorIndex"], "0x5"); + assert_eq!(json["withdrawals"][0]["amount"], "0xa"); + assert_eq!( + json["parentBeaconBlockRoot"], + format!("0x{}", "04".repeat(32)) + ); + } + + #[test] + fn a_zero_uint256_and_a_full_one_both_parse() { + assert_eq!(uint256(&parse_uint256("0x0").unwrap()), "0x0"); + let max = format!("0x{}", "f".repeat(64)); + assert_eq!(uint256(&parse_uint256(&max).unwrap()), max); + assert!(parse_uint256(&format!("0x{}", "f".repeat(65))).is_err()); + } +} diff --git a/crates/net/engine/src/client.rs b/crates/net/engine/src/client.rs new file mode 100644 index 000000000..00418181a --- /dev/null +++ b/crates/net/engine/src/client.rs @@ -0,0 +1,299 @@ +//! The JSON-RPC client, its retry ladder, and the four methods. + +use std::time::Duration; + +use ethlambda_types::beacon::containers::deneb; +use ethlambda_types::beacon::primitives::{Bytes32, Root}; +use serde_json::json; +use tracing::{debug, warn}; + +use crate::auth::JwtSecret; +use crate::error::EngineError; +use crate::types::{ + ClientVersionV1, ExecutionPayloadV3, ForkchoiceStateV1, ForkchoiceUpdatedResponse, + PayloadStatusV1, data, +}; + +/// Per-attempt timeout. +/// +/// The specification's own value for `engine_newPayload` and +/// `engine_forkchoiceUpdated`. It is a ceiling, not a target: a healthy +/// `newPayload` on mainnet answers in 50-500 ms. +pub const ENGINE_TIMEOUT: Duration = Duration::from_secs(8); + +/// How many times one call is attempted before it is given up on. +/// +/// Three, not the ten `ethlambda-p2p` uses for a peer fetch. Those are different +/// numbers measuring different things: a peer ladder is bounded by a millisecond +/// backoff and a per-request timeout of its own, while every attempt here can +/// cost [`ENGINE_TIMEOUT`], and the import cascade awaits it inline. Three +/// attempts bound one block's worst case at about twenty-five seconds of a +/// stalled actor; ten would bound it at roughly twenty mainnet slots. +/// +/// An execution client that has not answered three calls at this timeout is not +/// going to answer a fourth. +pub const ENGINE_MAX_ATTEMPTS: u32 = 3; + +/// First backoff between attempts, doubling thereafter. +/// +/// Not the peer ladder's few milliseconds. That value is sized for a LAN round +/// trip to another node; an execution client that just failed a call is busy, +/// and asking again a few milliseconds later only makes it busier. +pub const ENGINE_INITIAL_BACKOFF: Duration = Duration::from_millis(500); + +const BACKOFF_MULTIPLIER: u32 = 2; + +/// A connection to one execution client's Engine API endpoint. +#[derive(Debug, Clone)] +pub struct EngineClient { + http: reqwest::Client, + endpoint: String, + secret: JwtSecret, +} + +impl EngineClient { + pub fn new(endpoint: String, secret: JwtSecret) -> Result { + let http = reqwest::Client::builder() + .timeout(ENGINE_TIMEOUT) + .build() + .map_err(|err| EngineError::Transport(err.to_string()))?; + Ok(Self { + http, + endpoint, + secret, + }) + } + + /// The endpoint this client posts to, for a startup log line. + pub fn endpoint(&self) -> &str { + &self.endpoint + } + + /// One JSON-RPC call, retried up to [`ENGINE_MAX_ATTEMPTS`] times. + /// + /// Only failures to get an answer are retried. An [`EngineError::Rpc`] is + /// the execution client answering: with a refusal, but an answer, so asking + /// again would get the same refusal and burn the ladder for nothing. + /// + /// After the last attempt this returns `Err`, and the caller must not import + /// the block: `optimistic-sync.md` requires exactly that, and the caller may + /// queue the block for later. It is worth being explicit that nothing here + /// re-drives a given-up call, so a persistently unreachable execution client + /// parks the follower's head behind the first block it could not ask about. + /// That is a known and accepted limitation of this phase; the fix is a + /// tick-driven re-drive of the pending set. + async fn call( + &self, + method: &str, + params: serde_json::Value, + ) -> Result { + let mut backoff = ENGINE_INITIAL_BACKOFF; + let mut last = EngineError::Transport("no attempt was made".to_string()); + + for attempt in 1..=ENGINE_MAX_ATTEMPTS { + match self.call_once(method, ¶ms).await { + Ok(value) => return Ok(value), + // An answer, not a failure to get one. Do not retry. + Err(err @ EngineError::Rpc { .. }) => return Err(err), + Err(err) => { + warn!(method, attempt, %err, "Engine call failed"); + last = err; + } + } + if attempt < ENGINE_MAX_ATTEMPTS { + tokio::time::sleep(backoff).await; + backoff *= BACKOFF_MULTIPLIER; + } + } + + Err(last) + } + + async fn call_once( + &self, + method: &str, + params: &serde_json::Value, + ) -> Result { + let body = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + }); + + let response = self + .http + .post(&self.endpoint) + .bearer_auth(self.secret.token()) + .json(&body) + .send() + .await + .map_err(|err| { + if err.is_timeout() { + EngineError::Timeout(ENGINE_TIMEOUT) + } else { + EngineError::Transport(err.to_string()) + } + })?; + + // Check the status before parsing. A rejected JWT answers 401 with a + // body that is not a JSON-RPC envelope, and a reverse proxy in front of + // the execution client can answer 5xx with HTML; parsing either first + // turns a precise, actionable failure into an opaque decode error. + // Transport rather than a terminal error, so an execution client that + // is merely still starting up gets the rest of the ladder. + let status = response.status(); + if !status.is_success() { + return Err(EngineError::Transport(format!( + "the execution client answered HTTP {status}" + ))); + } + + let envelope: serde_json::Value = response + .json() + .await + .map_err(|err| EngineError::Decode(err.to_string()))?; + + if let Some(error) = envelope.get("error") { + return Err(EngineError::Rpc { + code: error + .get("code") + .and_then(|code| code.as_i64()) + .unwrap_or(0), + message: error + .get("message") + .and_then(|message| message.as_str()) + .unwrap_or("(no message)") + .to_string(), + }); + } + + let result = envelope.get("result").ok_or_else(|| { + EngineError::Decode("response carried neither result nor error".to_string()) + })?; + + serde_json::from_value(result.clone()).map_err(|err| EngineError::Decode(err.to_string())) + } + + /// `engine_newPayloadV4`. + /// + /// Osaka's `newPayload`: Osaka adds no version of its own, and V5 is + /// Amsterdam's. The four parameters are the payload, the versioned hashes + /// the block's blob commitments imply, the parent beacon block root, and the + /// EIP-7685 execution requests list. + pub async fn new_payload( + &self, + payload: &deneb::ExecutionPayload, + versioned_hashes: &[Bytes32], + parent_beacon_block_root: Root, + execution_requests: &[Vec], + ) -> Result { + let hashes: Vec = versioned_hashes.iter().map(|hash| data(&hash.0)).collect(); + let requests: Vec = execution_requests + .iter() + .map(|request| data(request)) + .collect(); + let params = json!([ + ExecutionPayloadV3(payload), + hashes, + data(&parent_beacon_block_root.0), + requests, + ]); + self.call("engine_newPayloadV4", params).await + } + + /// `engine_forkchoiceUpdatedV3`, always with a `null` `payloadAttributes`. + /// + /// A follower never proposes, so it never asks an execution client to start + /// building. That also keeps this call outside the fork-scheduling rules + /// attached to `payloadAttributes.timestamp`. + pub async fn forkchoice_updated( + &self, + state: &ForkchoiceStateV1, + ) -> Result { + let params = json!([state, serde_json::Value::Null]); + let response: ForkchoiceUpdatedResponse = + self.call("engine_forkchoiceUpdatedV3", params).await?; + Ok(response.payload_status) + } + + /// `engine_forkchoiceUpdatedV3` with `payloadAttributes`: the same fork + /// choice notification, plus a request to start building a payload on the + /// head for the slot the attributes describe. + /// + /// Returns the head's status and the build process's id; the id is `None` + /// when the execution client declined to build. + pub async fn forkchoice_updated_with_attributes( + &self, + state: &ForkchoiceStateV1, + attributes: &crate::building::PayloadAttributesV3, + ) -> Result<(PayloadStatusV1, Option), EngineError> { + let params = json!([state, attributes]); + let response: ForkchoiceUpdatedResponse = + self.call("engine_forkchoiceUpdatedV3", params).await?; + Ok((response.payload_status, response.payload_id)) + } + + /// `engine_getPayloadV5`: the payload a build process has assembled so + /// far, with its blobs bundle and execution requests. + pub async fn get_payload( + &self, + payload_id: crate::building::PayloadId, + ) -> Result { + let response: crate::building::GetPayloadV5Response = self + .call("engine_getPayloadV5", json!([payload_id])) + .await?; + response.try_into() + } + + /// `engine_exchangeCapabilities`. Returns what the execution client says it + /// supports. + pub async fn exchange_capabilities(&self, ours: &[&str]) -> Result, EngineError> { + self.call("engine_exchangeCapabilities", json!([ours])) + .await + } + + /// `engine_getClientVersionV1`. + pub async fn client_version( + &self, + ours: &ClientVersionV1, + ) -> Result, EngineError> { + self.call("engine_getClientVersionV1", json!([ours])).await + } + + /// Runs the startup handshake, logging what the execution client is and + /// warning about any method this client needs that it does not advertise. + /// + /// Warns rather than refuses: an execution client that under-reports its + /// capabilities still works, and refusing to start over a handshake would + /// turn a cosmetic mismatch into an outage. + pub async fn handshake(&self, ours: &ClientVersionV1) -> Result<(), EngineError> { + let theirs = self + .exchange_capabilities(crate::ETHLAMBDA_ENGINE_CAPABILITIES) + .await?; + for required in ["engine_newPayloadV4", "engine_forkchoiceUpdatedV3"] { + if !theirs.iter().any(|method| method == required) { + warn!( + method = required, + "The execution client does not advertise a method this node needs" + ); + } + } + match self.client_version(ours).await { + Ok(versions) => { + for version in versions { + debug!( + code = %version.code, + name = %version.name, + version = %version.version, + commit = %version.commit, + "Execution client" + ); + } + } + // Optional by the specification's own word ("SHOULD support"). + Err(err) => debug!(%err, "The execution client did not report its version"), + } + Ok(()) + } +} diff --git a/crates/net/engine/src/error.rs b/crates/net/engine/src/error.rs new file mode 100644 index 000000000..4d2c717cd --- /dev/null +++ b/crates/net/engine/src/error.rs @@ -0,0 +1,22 @@ +//! Engine API client errors. +//! +//! The variants are split by what a caller should do about them, not by where +//! they came from. [`Rpc`](EngineError::Rpc) is the execution client refusing a +//! well-formed call and says so with the specification's own error code; +//! everything else is a failure to get an answer at all, which +//! `optimistic-sync.md` treats identically: do not import, do not touch fork +//! choice, retry later. + +#[derive(Debug, thiserror::Error)] +pub enum EngineError { + #[error("jwt: {0}")] + Jwt(String), + #[error("transport: {0}")] + Transport(String), + #[error("timed out after {0:?}")] + Timeout(std::time::Duration), + #[error("rpc error {code}: {message}")] + Rpc { code: i64, message: String }, + #[error("decoding the response: {0}")] + Decode(String), +} diff --git a/crates/net/engine/src/lib.rs b/crates/net/engine/src/lib.rs new file mode 100644 index 000000000..922ec474a --- /dev/null +++ b/crates/net/engine/src/lib.rs @@ -0,0 +1,46 @@ +//! The Ethereum Engine API, as a consensus-layer follower needs it. +//! +//! Wire concerns only: JWT authentication, the JSON-RPC envelope, and the four +//! methods a follower calls. Nothing here knows what a beacon block is beyond +//! the containers it serializes, and nothing here decides what an answer means. +//! Assembling a request from a block, and reading a status as a fork choice +//! verdict, both live in `ethlambda-blockchain`, which already depends on the +//! state transition; see that crate's `beacon_engine` module. +//! +//! # Which methods, and why so few +//! +//! Osaka introduces no new `newPayload`: its own document adds only +//! `engine_getPayloadV5` and `engine_getBlobsV2`/`V3`, and `engine_newPayloadV5` +//! belongs to Amsterdam. So the Osaka-current call for a payload is Prague's +//! `engine_newPayloadV4`, and the Osaka-current fork choice notification is +//! Cancun's `engine_forkchoiceUpdatedV3`. +//! +//! Payload building (`PayloadAttributesV3` on `forkchoiceUpdated`, and +//! Osaka's `engine_getPayloadV5`) is in [`building`], for the Beacon API's +//! block production. Two method families a full client would have are +//! deliberately absent: `engine_getBlobs*`, because there is no blob-pool +//! fetch path and data columns come from peers; and +//! `engine_getPayloadBodies*`, because nothing consumes them. + +pub mod auth; +pub mod building; +pub mod client; +pub mod error; +pub mod types; + +pub use auth::JwtSecret; +pub use client::EngineClient; +pub use error::EngineError; +pub use types::{ForkchoiceStateV1, PayloadStatusV1, PayloadStatusValue}; + +/// The methods this client will call, sent in the `engine_exchangeCapabilities` +/// handshake. +/// +/// The specification requires each name to carry its version suffix, and +/// requires `engine_exchangeCapabilities` itself not to appear. +pub const ETHLAMBDA_ENGINE_CAPABILITIES: &[&str] = &[ + "engine_newPayloadV4", + "engine_forkchoiceUpdatedV3", + "engine_getPayloadV5", + "engine_getClientVersionV1", +]; diff --git a/crates/net/engine/src/types.rs b/crates/net/engine/src/types.rs new file mode 100644 index 000000000..dee89790f --- /dev/null +++ b/crates/net/engine/src/types.rs @@ -0,0 +1,321 @@ +//! The Engine API's JSON wire shapes. +//! +//! Views over the containers `ethlambda-types` already defines, not new +//! definitions: `ExecutionPayloadV3` is deneb's `ExecutionPayload` in JSON, and +//! deneb is the last fork whose payload shape changed, so electra and fulu reuse +//! it unchanged. +//! +//! Every 32-byte value is `DATA`, a `0x`-prefixed even-length hex string. Every +//! number crossing this boundary is `QUANTITY`, `0x`-prefixed with leading zeros +//! stripped: `0x0` for zero, never `0x00` and never `0x`. + +use ethlambda_types::beacon::containers::deneb; +use ethlambda_types::beacon::primitives::{ExecutionBlockHash, Uint256}; +use ethlambda_types::beacon::serde_helpers::HexPrefixed; +use serde::{Deserialize, Serialize, Serializer}; + +/// `PayloadStatusV1.status`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum PayloadStatusValue { + Valid, + Invalid, + Syncing, + Accepted, + InvalidBlockHash, +} + +/// An execution client's answer about a payload, or about a fork choice head. +/// +/// Both `engine_newPayloadV4` and `engine_forkchoiceUpdatedV3` return one of +/// these, which is what makes `forkchoiceUpdated` the channel by which a block +/// imported on `SYNCING` later becomes `VALID`. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PayloadStatusV1 { + pub status: PayloadStatusValue, + /// `#[serde(default)]` for the key being absent entirely; a present `null` + /// needs nothing, since serde's own `Option` impl reads it as `None`. The + /// hash itself goes through [`ExecutionBlockHash`]'s own `Deserialize`, + /// which already takes `0x`-prefixed hex and already checks the length. + #[serde(default)] + pub latest_valid_hash: Option, + #[serde(default)] + pub validation_error: Option, +} + +/// `engine_forkchoiceUpdatedV3`'s first parameter. +/// +/// The three hashes serialize through [`ExecutionBlockHash`]'s own `Serialize`, +/// which already writes the `0x`-prefixed lowercase hex the `DATA` encoding +/// wants. [`data`] is still needed for the payload below, whose `feeRecipient` +/// is an [`ExecutionAddress`](ethlambda_types::beacon::primitives::ExecutionAddress) +/// and has no `Serialize` impl of its own. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ForkchoiceStateV1 { + pub head_block_hash: ExecutionBlockHash, + pub safe_block_hash: ExecutionBlockHash, + pub finalized_block_hash: ExecutionBlockHash, +} + +/// `engine_forkchoiceUpdatedV3`'s result. +/// +/// `payloadId` names the build process a call with `payloadAttributes` +/// started; it is `null` otherwise, and when the execution client declined to +/// build (a head it is still syncing, say). +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ForkchoiceUpdatedResponse { + pub payload_status: PayloadStatusV1, + #[serde(default)] + pub payload_id: Option, +} + +/// `engine_getClientVersionV1`'s element type, in both directions. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClientVersionV1 { + pub code: String, + pub name: String, + pub version: String, + pub commit: String, +} + +/// `ExecutionPayloadV3`, borrowed from a decoded block rather than copied. +/// +/// A newtype around a reference because a mainnet payload carries every +/// transaction in the block: cloning one to serialize it would double the +/// largest allocation on the import path for no gain. +/// +/// Deneb's shape serves electra and fulu unchanged. Neither fork changed the +/// payload container, which is why `engine_newPayloadV4` still takes +/// `ExecutionPayloadV3` and why there is no `V4` of this structure to write. +pub struct ExecutionPayloadV3<'a>(pub &'a deneb::ExecutionPayload); + +impl Serialize for ExecutionPayloadV3<'_> { + fn serialize(&self, serializer: S) -> Result { + use serde::ser::SerializeStruct as _; + + let payload = self.0; + let mut out = serializer.serialize_struct("ExecutionPayloadV3", 17)?; + out.serialize_field("parentHash", &data(&payload.parent_hash.0))?; + out.serialize_field("feeRecipient", &data(&payload.fee_recipient.0))?; + out.serialize_field("stateRoot", &data(&payload.state_root.0))?; + out.serialize_field("receiptsRoot", &data(&payload.receipts_root.0))?; + out.serialize_field("logsBloom", &data(payload.logs_bloom.as_ref()))?; + out.serialize_field("prevRandao", &data(&payload.prev_randao.0))?; + out.serialize_field("blockNumber", &quantity(payload.block_number))?; + out.serialize_field("gasLimit", &quantity(payload.gas_limit))?; + out.serialize_field("gasUsed", &quantity(payload.gas_used))?; + out.serialize_field("timestamp", &quantity(payload.timestamp))?; + out.serialize_field("extraData", &data(payload.extra_data.as_ref()))?; + out.serialize_field("baseFeePerGas", &uint256(&payload.base_fee_per_gas))?; + out.serialize_field("blockHash", &data(&payload.block_hash.0))?; + + let transactions: Vec = payload + .transactions + .iter() + .map(|transaction| data(transaction.as_ref())) + .collect(); + out.serialize_field("transactions", &transactions)?; + + let withdrawals: Vec = payload + .withdrawals + .iter() + .map(|withdrawal| { + serde_json::json!({ + "index": quantity(withdrawal.index), + "validatorIndex": quantity(withdrawal.validator_index), + "address": data(&withdrawal.address.0), + "amount": quantity(withdrawal.amount), + }) + }) + .collect(); + out.serialize_field("withdrawals", &withdrawals)?; + + out.serialize_field("blobGasUsed", &quantity(payload.blob_gas_used))?; + out.serialize_field("excessBlobGas", &quantity(payload.excess_blob_gas))?; + out.end() + } +} + +/// Encodes a `DATA`: `0x`-prefixed, every byte rendered, no stripping. +pub fn data(bytes: &[u8]) -> String { + HexPrefixed(bytes).to_string() +} + +/// Encodes a `QUANTITY`: `0x`-prefixed, leading zeros stripped, `0x0` for zero. +pub fn quantity(value: u64) -> String { + format!("0x{value:x}") +} + +/// Encodes a 256-bit `QUANTITY`. +/// +/// `U256` stores its bytes little-endian and the wire wants big-endian hex with +/// leading zeros stripped, so this reverses before encoding. Zero renders as +/// `0x0`: the `QUANTITY` grammar forbids both the empty `0x` that a naive strip +/// produces and the `0x00` that no stripping produces. +pub fn uint256(value: &Uint256) -> String { + let mut bytes = value.0; + bytes.reverse(); + let encoded = hex::encode(bytes); + let trimmed = encoded.trim_start_matches('0'); + if trimmed.is_empty() { + return "0x0".to_string(); + } + format!("0x{trimmed}") +} + +#[cfg(test)] +mod tests { + use ethlambda_types::beacon::containers::bellatrix; + use ethlambda_types::beacon::preset; + use ethlambda_types::beacon::primitives::{Bytes32, ExecutionAddress, U256}; + + use super::*; + + /// An otherwise-zero deneb execution payload whose `block_hash` is + /// `block_hash`. + /// + /// Hand-built because the consensus containers do not derive `Default`, and + /// `logs_bloom` is a fixed-length vector that has none even among its + /// `SszList` neighbours. Matches the idiom in `stf/fulu.rs` and + /// `containers/bellatrix.rs`. + fn payload_with_block_hash(block_hash: ExecutionBlockHash) -> deneb::ExecutionPayload { + deneb::ExecutionPayload { + parent_hash: ExecutionBlockHash::ZERO, + fee_recipient: ExecutionAddress::ZERO, + state_root: Bytes32::ZERO, + receipts_root: Bytes32::ZERO, + logs_bloom: bellatrix::LogsBloom::try_from(vec![0u8; preset::BYTES_PER_LOGS_BLOOM]) + .expect("built at exactly BYTES_PER_LOGS_BLOOM"), + prev_randao: Bytes32::ZERO, + block_number: 0, + gas_limit: 0, + gas_used: 0, + timestamp: 0, + extra_data: Default::default(), + base_fee_per_gas: Uint256::ZERO, + block_hash, + transactions: Default::default(), + withdrawals: Default::default(), + blob_gas_used: 0, + excess_blob_gas: 0, + } + } + + #[test] + fn a_status_deserializes_from_the_wire_shape() { + let json = r#"{ + "status": "SYNCING", + "latestValidHash": null, + "validationError": null + }"#; + let status: PayloadStatusV1 = serde_json::from_str(json).expect("the wire shape"); + + assert_eq!(status.status, PayloadStatusValue::Syncing); + assert_eq!(status.latest_valid_hash, None); + } + + #[test] + fn an_invalid_status_keeps_its_latest_valid_hash_and_reason() { + let json = r#"{ + "status": "INVALID", + "latestValidHash": "0x0202020202020202020202020202020202020202020202020202020202020202", + "validationError": "invalid" + }"#; + let status: PayloadStatusV1 = serde_json::from_str(json).expect("the wire shape"); + + assert_eq!(status.status, PayloadStatusValue::Invalid); + assert_eq!( + status.latest_valid_hash, + Some(ExecutionBlockHash::repeat_byte(2)) + ); + assert_eq!(status.validation_error.as_deref(), Some("invalid")); + } + + #[test] + fn invalid_block_hash_uses_its_screaming_snake_name() { + let json = r#"{"status": "INVALID_BLOCK_HASH"}"#; + let status: PayloadStatusV1 = serde_json::from_str(json).expect("the wire shape"); + assert_eq!(status.status, PayloadStatusValue::InvalidBlockHash); + } + + #[test] + fn a_forkchoice_state_serializes_camel_case_and_hex() { + let state = ForkchoiceStateV1 { + head_block_hash: ExecutionBlockHash::repeat_byte(1), + safe_block_hash: ExecutionBlockHash::repeat_byte(2), + finalized_block_hash: ExecutionBlockHash::ZERO, + }; + let json = serde_json::to_value(&state).expect("serializes"); + + assert_eq!( + json["headBlockHash"], + "0x0101010101010101010101010101010101010101010101010101010101010101" + ); + assert_eq!( + json["finalizedBlockHash"], + "0x0000000000000000000000000000000000000000000000000000000000000000" + ); + } + + #[test] + fn a_payload_serializes_every_field_in_the_wire_names() { + let payload = payload_with_block_hash(ExecutionBlockHash::repeat_byte(7)); + let json = serde_json::to_value(ExecutionPayloadV3(&payload)).expect("serializes"); + + for field in [ + "parentHash", + "feeRecipient", + "stateRoot", + "receiptsRoot", + "logsBloom", + "prevRandao", + "blockNumber", + "gasLimit", + "gasUsed", + "timestamp", + "extraData", + "baseFeePerGas", + "blockHash", + "transactions", + "withdrawals", + "blobGasUsed", + "excessBlobGas", + ] { + assert!(json.get(field).is_some(), "missing {field}"); + } + assert_eq!(json.as_object().expect("a JSON object").len(), 17); + assert_eq!( + json["blockHash"], + "0x0707070707070707070707070707070707070707070707070707070707070707" + ); + } + + #[test] + fn quantities_strip_leading_zeros_and_zero_is_a_single_digit() { + assert_eq!(quantity(0), "0x0"); + assert_eq!(quantity(1), "0x1"); + assert_eq!(quantity(255), "0xff"); + assert_eq!(quantity(4096), "0x1000"); + } + + #[test] + fn a_uint256_renders_big_endian_with_leading_zeros_stripped() { + assert_eq!(uint256(&U256::ZERO), "0x0"); + assert_eq!(uint256(&U256::from_u128(1)), "0x1"); + assert_eq!(uint256(&U256::from_u128(255)), "0xff"); + assert_eq!(uint256(&U256::from_u128(0x1_0000_0000)), "0x100000000"); + + // The most significant byte set. Little-endian storage puts it last, and + // the rendered form must lead with it. + let mut most_significant = [0u8; 32]; + most_significant[31] = 0x0a; + assert_eq!( + uint256(&U256(most_significant)), + format!("0xa{}", "0".repeat(62)) + ); + } +} diff --git a/crates/net/engine/tests/wire_smoke.rs b/crates/net/engine/tests/wire_smoke.rs new file mode 100644 index 000000000..78ed116bf --- /dev/null +++ b/crates/net/engine/tests/wire_smoke.rs @@ -0,0 +1,136 @@ +//! End-to-end round trips against a hand-rolled mock execution client. +//! +//! A `TcpListener` on an ephemeral port rather than a mocking library: the thing +//! under test is the bytes on the wire, and a library that intercepts before +//! serialization would test the interception instead. +//! +//! These bind a socket. Under a sandbox that forbids it they fail with +//! `PermissionDenied`; they pass in a normal shell. + +use std::sync::Arc; + +use ethlambda_engine::types::{ForkchoiceStateV1, PayloadStatusValue}; +use ethlambda_engine::{EngineClient, EngineError, JwtSecret}; +use ethlambda_types::beacon::primitives::ExecutionBlockHash; +use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; +use tokio::net::TcpListener; +use tokio::sync::Mutex; + +/// Serves exactly one request, answering with `body`, and hands the caller the +/// request it saw. +async fn serve_once(body: &'static str) -> (String, Arc>>) { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("an ephemeral loopback port"); + let addr = listener.local_addr().expect("the bound address"); + let seen = Arc::new(Mutex::new(None)); + let seen_writer = Arc::clone(&seen); + + tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.expect("one connection"); + let mut buffer = vec![0u8; 64 * 1024]; + let read = socket.read(&mut buffer).await.expect("the request bytes"); + *seen_writer.lock().await = Some(String::from_utf8_lossy(&buffer[..read]).to_string()); + + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", + body.len(), + body + ); + socket + .write_all(response.as_bytes()) + .await + .expect("the response bytes"); + socket.flush().await.expect("flush"); + }); + + (format!("http://{addr}"), seen) +} + +fn client(endpoint: String) -> EngineClient { + EngineClient::new(endpoint, JwtSecret::new([0x0f; 32])).expect("a client") +} + +#[tokio::test] +async fn forkchoice_updated_sends_the_wire_shape_and_parses_syncing() { + let (endpoint, seen) = serve_once( + r#"{"jsonrpc":"2.0","id":1,"result":{"payloadStatus":{"status":"SYNCING","latestValidHash":null,"validationError":null},"payloadId":null}}"#, + ) + .await; + + let state = ForkchoiceStateV1 { + head_block_hash: ExecutionBlockHash::repeat_byte(1), + safe_block_hash: ExecutionBlockHash::repeat_byte(2), + finalized_block_hash: ExecutionBlockHash::repeat_byte(3), + }; + let status = client(endpoint) + .forkchoice_updated(&state) + .await + .expect("the mock answers"); + + assert_eq!(status.status, PayloadStatusValue::Syncing); + assert_eq!(status.latest_valid_hash, None); + + let request = seen.lock().await.clone().expect("the mock saw a request"); + assert!(request.contains("engine_forkchoiceUpdatedV3")); + assert!(request.contains("headBlockHash")); + assert!(request.contains("0x0101010101010101010101010101010101010101010101010101010101010101")); + // A follower never asks for a build. + assert!(request.contains("null")); + // The JWT is present as a bearer token. + assert!(request.to_lowercase().contains("authorization: bearer ")); +} + +#[tokio::test] +async fn an_invalid_verdict_carries_its_latest_valid_hash() { + let (endpoint, _seen) = serve_once( + r#"{"jsonrpc":"2.0","id":1,"result":{"payloadStatus":{"status":"INVALID","latestValidHash":"0x0404040404040404040404040404040404040404040404040404040404040404","validationError":"bad"},"payloadId":null}}"#, + ) + .await; + + let state = ForkchoiceStateV1 { + head_block_hash: ExecutionBlockHash::ZERO, + safe_block_hash: ExecutionBlockHash::ZERO, + finalized_block_hash: ExecutionBlockHash::ZERO, + }; + let status = client(endpoint) + .forkchoice_updated(&state) + .await + .expect("the mock answers"); + + assert_eq!(status.status, PayloadStatusValue::Invalid); + assert_eq!( + status.latest_valid_hash, + Some(ExecutionBlockHash::repeat_byte(4)) + ); + assert_eq!(status.validation_error.as_deref(), Some("bad")); +} + +#[tokio::test] +async fn an_rpc_error_envelope_surfaces_typed_and_is_not_retried() { + // The mock answers exactly once. A retried call would hang and time out + // instead of returning promptly, so a fast typed error is itself the + // assertion that `Rpc` short-circuits the ladder. + let (endpoint, _seen) = serve_once( + r#"{"jsonrpc":"2.0","id":1,"error":{"code":-38005,"message":"Unsupported fork"}}"#, + ) + .await; + + let state = ForkchoiceStateV1 { + head_block_hash: ExecutionBlockHash::ZERO, + safe_block_hash: ExecutionBlockHash::ZERO, + finalized_block_hash: ExecutionBlockHash::ZERO, + }; + let err = client(endpoint) + .forkchoice_updated(&state) + .await + .expect_err("the mock answered with an error envelope"); + + match err { + EngineError::Rpc { code, message } => { + assert_eq!(code, -38005); + assert_eq!(message, "Unsupported fork"); + } + other => panic!("expected an Rpc error, got {other:?}"), + } +} diff --git a/crates/net/p2p/Cargo.toml b/crates/net/p2p/Cargo.toml index 712ba60f7..cbadf5e4f 100644 --- a/crates/net/p2p/Cargo.toml +++ b/crates/net/p2p/Cargo.toml @@ -14,6 +14,11 @@ ethlambda-network-api.workspace = true ethlambda-storage.workspace = true ethlambda-metrics.workspace = true ethlambda-types.workspace = true +# For `verify_data_column_sidecar`, the one sidecar check cheap enough to run +# in the gossip loop: pure structural validation, no KZG. `blst` and `c-kzg` +# were already on this binary's dependency path through the chain actor; this +# only widens which crate in it links them. +ethlambda-state-transition.workspace = true spawned-concurrency.workspace = true @@ -25,9 +30,20 @@ libp2p = { git = "https://github.com/lambdaclass/rust-libp2p.git", rev = "2f14d0 "full", ] } +# Mainnet beacon peers refuse a yamux-only muxer proposal, so the TCP transport +# has to offer mplex too. The libp2p facade dropped its `mplex` re-export when +# the muxer was deprecated, so it is taken directly from the same pinned tree. +# See the `muxers` module for the measurement. +libp2p-mplex = { git = "https://github.com/lambdaclass/rust-libp2p.git", rev = "2f14d0ec9665a01cfb6a02326c90628c4bba521c" } + snap = "1.1" -tokio.workspace = true +# Only to name the TCP muxer's error type, `Either`, +# which libp2p-core builds from this crate and does not re-export. Read by +# `disconnect_cause` to say why a TCP peer's connection ended. +either = "1" + +tokio = { workspace = true, features = ["rt", "sync"] } tracing.workspace = true thiserror.workspace = true @@ -43,8 +59,8 @@ ethrex-p2p = { git = "https://github.com/lambdaclass/ethrex", tag = "v25.0.0" } ethrex-rlp = { git = "https://github.com/lambdaclass/ethrex", tag = "v25.0.0" } ethrex-common = { git = "https://github.com/lambdaclass/ethrex", tag = "v25.0.0" } -# Version pinned to ethrex's workspace: `SecretKey` crosses the API boundary. -secp256k1 = { version = "0.30.0", default-features = false, features = ["global-context"] } +# `rand` seeds the throwaway signing keys the ENR tests build records with. +secp256k1.workspace = true # SSZ libssz.workspace = true @@ -53,8 +69,4 @@ libssz-merkle.workspace = true libssz-types.workspace = true sha2 = "0.10" - -[dev-dependencies] hex.workspace = true -# `rand` seeds the throwaway signing keys the ENR tests build records with. -secp256k1 = { version = "0.30.0", default-features = false, features = ["global-context", "rand"] } diff --git a/crates/net/p2p/src/beacon/column_checks.rs b/crates/net/p2p/src/beacon/column_checks.rs new file mode 100644 index 000000000..9d92c0985 --- /dev/null +++ b/crates/net/p2p/src/beacon/column_checks.rs @@ -0,0 +1,199 @@ +//! The chain checks, run on every data column sidecar gossip did not accept, +//! before the chain actor gets it. +//! +//! The chain actor keeps a sidecar without checking it (only debug builds +//! check again), so a sidecar reaches it by one of two routes: gossip accepted +//! it, having run every rule already, or it came through here. What comes +//! through here: +//! +//! - a gossiped sidecar reported `Queue` or `Ignore(Overloaded)`, whose +//! stateful checks stopped early or never ran; +//! - every `DataColumnsByRoot` and `DataColumnsByRange` answer; +//! - sidecars the chain actor had parked, handed back once their parent +//! imported (`BlockChainToP2P::check_data_column_sidecars`). +//! +//! Each sidecar is checked on a blocking thread, bounded by +//! [`P2PServer::column_check_permits`]. Unlike a gossip verdict, nothing here +//! has a deadline, so a sidecar with no free permit waits for one rather than +//! being dropped. + +use ethlambda_state_transition::beacon::gossip::column::{self, ChainVerdict}; +use ethlambda_state_transition::beacon::gossip::{IgnoreReason, Outcome}; +use ethlambda_types::beacon::containers::fulu::DataColumnSidecar; +use ethlambda_types::time::unix_now_ms; +use tracing::{error, warn}; + +use crate::{P2PServer, metrics}; + +/// Run the chain checks on `sidecars`, then send the chain actor the ones that +/// passed as one batch and the ones waiting on a parent as another. +/// +/// Returns at once: the checks run on a task of their own, so the p2p actor +/// goes on handling swarm events while a range batch's KZG proofs are +/// verified. +pub(crate) fn check_and_forward(server: &P2PServer, sidecars: Vec) { + if sidecars.is_empty() { + return; + } + let Some(blockchain) = server.blockchain.clone() else { + return; + }; + let store = server.store.clone(); + let permits = server.column_check_permits.clone(); + tokio::spawn(async move { + let mut checks = Vec::with_capacity(sidecars.len()); + for sidecar in sidecars { + // The semaphore is never closed, so this only returns once a + // permit is free. + let Ok(permit) = permits.clone().acquire_owned().await else { + return; + }; + let store = store.clone(); + checks.push(tokio::task::spawn_blocking(move || { + let _permit = permit; + let verdict = column::chain_checks(&store, &sidecar, unix_now_ms()); + (sidecar, verdict) + })); + } + + let mut keep = Vec::new(); + let mut awaiting_parent = Vec::new(); + for check in checks { + // A panic inside the checks (a `Store` read's `.expect()` on a DB + // error, say) costs that one sidecar, which is what dropping it + // would have done anyway. Tokio has already caught it. + let (sidecar, verdict) = match check.await { + Ok(checked) => checked, + Err(err) => { + error!(%err, "A data column check panicked; dropping the sidecar"); + continue; + } + }; + match verdict { + ChainVerdict::Keep => keep.push(sidecar), + ChainVerdict::AwaitParent => awaiting_parent.push(sidecar), + ChainVerdict::Drop(outcome) => count_drop(outcome), + } + } + + if !keep.is_empty() { + let _ = blockchain + .new_data_column_sidecars(keep) + .inspect_err(|err| warn!(%err, "Failed to forward checked data column sidecars")); + } + if !awaiting_parent.is_empty() { + let _ = blockchain + .data_column_sidecars_awaiting_parent(awaiting_parent) + .inspect_err( + |err| warn!(%err, "Failed to forward data column sidecars awaiting a parent"), + ); + } + }); +} + +/// Count a dropped sidecar under its reason. +/// +/// An already stored one is not counted: consecutive range batches ask for +/// overlapping spans of columns, so re-deliveries are routine, and they are +/// duplicates rather than rejections. +fn count_drop(outcome: Outcome) { + if outcome == Outcome::Ignore(IgnoreReason::AlreadyStored) { + return; + } + let (_, reason) = outcome.labels(); + metrics::inc_data_column_rejected(reason); +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use ethlambda_network_api::{AggregateArrival, BlockArrival, BlockSource, P2PToBlockChain}; + use ethlambda_types::attestation::{SignedAggregatedAttestation, SignedAttestation}; + use ethlambda_types::beacon::config::Config; + use ethlambda_types::beacon::containers::{SignedAggregateAndProof, SignedBeaconBlock}; + use ethlambda_types::beacon::primitives::ValidatorIndex; + use spawned_concurrency::error::ActorError; + use tokio::sync::mpsc; + + use super::*; + use crate::test_support::{unconnected_beacon_server, valid_shaped_sidecar}; + + /// What [`check_and_forward`] sent the chain actor. + #[derive(Debug, PartialEq)] + enum Forwarded { + Checked(Vec), + AwaitingParent(Vec), + } + + /// A chain actor stand-in that reports every sidecar batch it receives. + struct RecordingChain(mpsc::UnboundedSender); + + impl P2PToBlockChain for RecordingChain { + fn new_block( + &self, + _block: SignedBeaconBlock, + _source: BlockSource, + _arrival: BlockArrival, + ) -> Result<(), ActorError> { + Ok(()) + } + fn new_attestation(&self, _attestation: SignedAttestation) -> Result<(), ActorError> { + Ok(()) + } + fn new_aggregated_attestation( + &self, + _attestation: SignedAggregatedAttestation, + ) -> Result<(), ActorError> { + Ok(()) + } + fn new_data_column_sidecars( + &self, + sidecars: Vec, + ) -> Result<(), ActorError> { + let _ = self.0.send(Forwarded::Checked(sidecars)); + Ok(()) + } + fn data_column_sidecars_awaiting_parent( + &self, + sidecars: Vec, + ) -> Result<(), ActorError> { + let _ = self.0.send(Forwarded::AwaitingParent(sidecars)); + Ok(()) + } + fn new_beacon_aggregate( + &self, + _aggregate: Box, + _attesting_indices: Vec, + _arrival: AggregateArrival, + ) -> Result<(), ActorError> { + Ok(()) + } + } + + /// A sidecar the checks cannot judge yet goes back to the chain actor to + /// be parked, and one they refuse goes nowhere: neither reaches the batch + /// the chain actor stores unchecked. + #[tokio::test] + async fn only_what_passes_is_forwarded_as_checked() { + let mut server = unconnected_beacon_server(Config::mainnet(), 0).await; + let (sender, mut received) = mpsc::unbounded_channel(); + server.blockchain = Some(Arc::new(RecordingChain(sender))); + + // Nothing in the store is its parent. + let orphan = valid_shaped_sidecar(5, 0); + let mut malformed = valid_shaped_sidecar(5, 1); + malformed.kzg_commitments = Default::default(); + + check_and_forward(&server, vec![orphan.clone(), malformed]); + + assert_eq!( + received.recv().await, + Some(Forwarded::AwaitingParent(vec![orphan])) + ); + // The task sends at most one message per kind and has now finished, + // so the channel closes with nothing else in it. + drop(server); + assert_eq!(received.recv().await, None); + } +} diff --git a/crates/net/p2p/src/beacon/decode.rs b/crates/net/p2p/src/beacon/decode.rs new file mode 100644 index 000000000..79c0c4655 --- /dev/null +++ b/crates/net/p2p/src/beacon/decode.rs @@ -0,0 +1,534 @@ +//! Fork-aware decode of every subscribed gossip topic. +//! +//! SSZ carries no type tag, so the fork has to come from context. For three +//! topics the context is inside the payload: the slot sits at a position fixed +//! by the container's layout, and slot maps to epoch maps to [`ForkName`]. The +//! other four topics carry containers whose shape has not changed since the +//! fork that introduced them, so they decode with no fork lookup at all. +//! `data_column_sidecar_{subnet_id}`, the one family among the subscribed +//! topics rather than a fixed name, decodes with no lookup either, for a +//! different reason: fulu is the only fork that defines the container, so +//! there is no ladder to begin with (see [`decode_data_column_sidecar`]). +//! `beacon_attestation_{subnet_id}` is the exception to reading the fork off +//! the payload: electra moved its slot, so its fork comes from the topic's +//! digest instead (see [`decode_attestation`]). +//! +//! | Topic | Fork-dependent | +//! |---|---| +//! | `beacon_block` | Yes, every fork | +//! | `beacon_aggregate_and_proof` | Yes, at electra | +//! | `attester_slashing` | Yes, at electra | +//! | `beacon_attestation_{subnet_id}` | Yes, at electra, by topic digest | +//! | `voluntary_exit`, `proposer_slashing` | No | +//! | `bls_to_execution_change` | No, capella onward | +//! | `sync_committee_contribution_and_proof` | No, altair onward | +//! | `data_column_sidecar_{subnet_id}` | No, fulu only | + +use ethlambda_types::beacon::config::Config; +use ethlambda_types::beacon::containers::{ + SignedBeaconBlock, altair, capella, electra, fulu, phase0, shared, +}; +use ethlambda_types::beacon::fork::ForkName; +use ethlambda_types::beacon::preset; +use ethlambda_types::beacon::primitives::Slot; +use libssz::SszDecode as _; + +use super::topics; + +/// An aggregate attestation with its selection proof, in whichever shape the +/// slot's fork gives it. +/// +/// Re-exported rather than declared here, where it used to live. The gossip +/// path no longer ends at this decode: an aggregate now travels over +/// `ethlambda-network-api` to the chain actor and into fork choice, so the type +/// has to sit where every one of those layers can name it. The accessors this +/// module's own logging uses came with it. +pub use ethlambda_types::beacon::containers::SignedAggregateAndProof; + +/// Slashing evidence, in whichever shape the slot's fork gives it. Electra +/// widened `IndexedAttestation`'s committee bound. +#[derive(Debug, Clone, PartialEq)] +pub enum AttesterSlashing { + Phase0(phase0::AttesterSlashing), + Electra(electra::AttesterSlashing), +} + +/// A decoded gossip payload, one variant per subscribed topic. +#[derive(Debug, Clone, PartialEq)] +pub enum BeaconGossip { + Block(Box), + AggregateAndProof(Box), + AttesterSlashing(Box), + VoluntaryExit(shared::SignedVoluntaryExit), + /// Boxed for the same reason the fork-dependent variants are: two signed + /// block headers make this the widest payload of the seven, and an unboxed + /// one sets the size of every `BeaconGossip` the handler moves. + ProposerSlashing(Box), + BlsToExecutionChange(capella::SignedBLSToExecutionChange), + SyncCommitteeContribution(Box), +} + +impl BeaconGossip { + /// The topic kind this payload came from, for logs and metrics. + pub fn topic_kind(&self) -> &'static str { + match self { + BeaconGossip::Block(_) => topics::BEACON_BLOCK, + BeaconGossip::AggregateAndProof(_) => topics::BEACON_AGGREGATE_AND_PROOF, + BeaconGossip::AttesterSlashing(_) => topics::ATTESTER_SLASHING, + BeaconGossip::VoluntaryExit(_) => topics::VOLUNTARY_EXIT, + BeaconGossip::ProposerSlashing(_) => topics::PROPOSER_SLASHING, + BeaconGossip::BlsToExecutionChange(_) => topics::BLS_TO_EXECUTION_CHANGE, + BeaconGossip::SyncCommitteeContribution(_) => { + topics::SYNC_COMMITTEE_CONTRIBUTION_AND_PROOF + } + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DecodeError { + /// A topic this node never subscribed to. + UnknownTopic, + /// The payload is shorter than the offsets it claims to carry. + Truncated, + /// SSZ rejected the payload for the fork the slot selected. + Ssz, +} + +impl std::fmt::Display for DecodeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::UnknownTopic => write!(f, "unsubscribed topic"), + Self::Truncated => write!(f, "payload truncated before the slot"), + Self::Ssz => write!(f, "ssz decode failed"), + } + } +} + +/// The four-byte little-endian SSZ offset at `at`. +fn read_offset(bytes: &[u8], at: usize) -> Result { + let raw: [u8; 4] = bytes + .get(at..at + 4) + .ok_or(DecodeError::Truncated)? + .try_into() + .expect("the slice is exactly four bytes"); + Ok(u32::from_le_bytes(raw) as usize) +} + +/// The eight-byte little-endian `uint64` at `at`. +fn read_u64(bytes: &[u8], at: usize) -> Result { + let raw: [u8; 8] = bytes + .get(at..at + 8) + .ok_or(DecodeError::Truncated)? + .try_into() + .expect("the slice is exactly eight bytes"); + Ok(u64::from_le_bytes(raw)) +} + +/// The `slot` of a `SignedBeaconBlock`. +/// +/// The container's fixed part is the offset to `message` followed by +/// `signature`, so the first variable element starts at the offset the first +/// four bytes carry, and `BeaconBlock`'s own first field is `slot`. Reading the +/// offset rather than assuming its value keeps this correct even if a future +/// fork adds a fixed field ahead of `message`. +pub fn block_slot(bytes: &[u8]) -> Result { + read_u64(bytes, read_offset(bytes, 0)?) +} + +/// The `slot` of a `SignedAggregateAndProof`. +/// +/// `message` is the first variable element of the outer container. +/// `AggregateAndProof`'s fixed part is `aggregator_index`, then the offset to +/// `aggregate`, then `selection_proof`, so the aggregate's offset sits eight +/// bytes into the message. `Attestation`'s fixed part opens with the offset to +/// `aggregation_bits` and is followed immediately by `data`, whose first field +/// is `slot`, at every fork. +pub fn aggregate_slot(bytes: &[u8]) -> Result { + let message = read_offset(bytes, 0)?; + let aggregate = message + .checked_add(read_offset( + bytes, + message.checked_add(8).ok_or(DecodeError::Truncated)?, + )?) + .ok_or(DecodeError::Truncated)?; + read_u64( + bytes, + aggregate.checked_add(4).ok_or(DecodeError::Truncated)?, + ) +} + +/// The `slot` of an `AttesterSlashing`, taken from its first attestation. +/// +/// The container is two offsets. `IndexedAttestation`'s fixed part opens with +/// the offset to `attesting_indices` and is followed immediately by `data`. +pub fn attester_slashing_slot(bytes: &[u8]) -> Result { + let attestation_1 = read_offset(bytes, 0)?; + read_u64( + bytes, + attestation_1.checked_add(4).ok_or(DecodeError::Truncated)?, + ) +} + +/// The fork whose rules apply to `slot`. +pub fn fork_at_slot(config: &Config, slot: Slot) -> ForkName { + config.fork_at_epoch(slot / preset::SLOTS_PER_EPOCH) +} + +/// Decode a `beacon_block` payload, at the fork its own slot names. +/// +/// Separate from [`decode_gossip`] because the two topics worth logging in +/// detail are dispatched by name, and a handler that already knows it is +/// holding a block should not have to unwrap a [`BeaconGossip`] to find one. +pub fn decode_block(config: &Config, bytes: &[u8]) -> Result { + let fork = fork_at_slot(config, block_slot(bytes)?); + SignedBeaconBlock::from_ssz(fork, bytes).map_err(|_| DecodeError::Ssz) +} + +/// Decode a data column sidecar off a subnet topic. +/// +/// Takes no `Config` and no fork, unlike [`decode_block`]: only fulu defines +/// this container, so there is no fork ladder to choose from. A sidecar whose +/// slot predates fulu is rejected later, by the checks that know the schedule. +pub fn decode_data_column_sidecar(bytes: &[u8]) -> Result { + fulu::DataColumnSidecar::from_ssz_bytes(bytes).map_err(|_| DecodeError::Ssz) +} + +/// Decode a `beacon_aggregate_and_proof` payload, at the fork its slot names. +pub fn decode_aggregate_and_proof( + config: &Config, + bytes: &[u8], +) -> Result { + let fork = fork_at_slot(config, aggregate_slot(bytes)?); + if fork >= ForkName::Electra { + electra::SignedAggregateAndProof::from_ssz_bytes(bytes) + .map(SignedAggregateAndProof::Electra) + } else { + phase0::SignedAggregateAndProof::from_ssz_bytes(bytes).map(SignedAggregateAndProof::Phase0) + } + .map_err(|_| DecodeError::Ssz) +} + +/// An unaggregated attestation, in whichever shape the topic's fork gives it. +/// +/// Electra split this topic differently from [`SignedAggregateAndProof`]'s. An +/// aggregate kept its container and widened it, but a subnet vote changed +/// container altogether: once EIP-7549 made `aggregation_bits` span every +/// committee in the slot, a lone attester's bit no longer said which committee +/// it sat in, so [`electra::SingleAttestation`] names `committee_index` and +/// `attester_index` outright. +#[derive(Debug, Clone, PartialEq)] +pub enum Attestation { + Phase0(phase0::Attestation), + Electra(electra::SingleAttestation), +} + +impl Attestation { + /// The fork-invariant half of the attestation. + pub fn data(&self) -> ethlambda_types::beacon::containers::AttestationData { + match self { + Self::Phase0(attestation) => attestation.data, + Self::Electra(attestation) => attestation.data, + } + } +} + +/// Decode a `beacon_attestation_{subnet_id}` payload, in the shape `fork` +/// gives it. +/// +/// The one fork-dependent topic whose fork cannot come from its own slot: the +/// two shapes put `slot` at different offsets, four bytes in for phase0's +/// `Attestation` and sixteen for `SingleAttestation`, so choosing the offset is +/// the question the slot was supposed to answer. The topic answers it instead. +/// `p2p-interface.md` types each topic by the fork its digest names, and +/// lighthouse decodes this one on that digest too. `fork` is the fork the +/// subscribed digest was computed at. +pub fn decode_attestation(fork: ForkName, bytes: &[u8]) -> Result { + if fork >= ForkName::Electra { + electra::SingleAttestation::from_ssz_bytes(bytes).map(Attestation::Electra) + } else { + phase0::Attestation::from_ssz_bytes(bytes).map(Attestation::Phase0) + } + .map_err(|_| DecodeError::Ssz) +} + +/// Decode a decompressed gossip payload according to its topic kind. +/// +/// The caller has already snappy-decompressed and already matched the topic +/// against the subscribed set, so an `UnknownTopic` here means the gossipsub +/// subscription set and this function have drifted apart. +pub fn decode_gossip( + config: &Config, + topic_kind: &str, + bytes: &[u8], +) -> Result { + match topic_kind { + topics::BEACON_BLOCK => { + decode_block(config, bytes).map(|block| BeaconGossip::Block(Box::new(block))) + } + topics::BEACON_AGGREGATE_AND_PROOF => decode_aggregate_and_proof(config, bytes) + .map(|value| BeaconGossip::AggregateAndProof(Box::new(value))), + topics::ATTESTER_SLASHING => { + let fork = fork_at_slot(config, attester_slashing_slot(bytes)?); + let decoded = if fork >= ForkName::Electra { + electra::AttesterSlashing::from_ssz_bytes(bytes).map(AttesterSlashing::Electra) + } else { + phase0::AttesterSlashing::from_ssz_bytes(bytes).map(AttesterSlashing::Phase0) + }; + decoded + .map(|value| BeaconGossip::AttesterSlashing(Box::new(value))) + .map_err(|_| DecodeError::Ssz) + } + topics::VOLUNTARY_EXIT => shared::SignedVoluntaryExit::from_ssz_bytes(bytes) + .map(BeaconGossip::VoluntaryExit) + .map_err(|_| DecodeError::Ssz), + topics::PROPOSER_SLASHING => shared::ProposerSlashing::from_ssz_bytes(bytes) + .map(|value| BeaconGossip::ProposerSlashing(Box::new(value))) + .map_err(|_| DecodeError::Ssz), + topics::BLS_TO_EXECUTION_CHANGE => { + capella::SignedBLSToExecutionChange::from_ssz_bytes(bytes) + .map(BeaconGossip::BlsToExecutionChange) + .map_err(|_| DecodeError::Ssz) + } + topics::SYNC_COMMITTEE_CONTRIBUTION_AND_PROOF => { + altair::SignedContributionAndProof::from_ssz_bytes(bytes) + .map(|value| BeaconGossip::SyncCommitteeContribution(Box::new(value))) + .map_err(|_| DecodeError::Ssz) + } + _ => Err(DecodeError::UnknownTopic), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ethlambda_types::beacon::primitives::{BlsSignature, Bytes32, Root}; + use libssz::SszEncode as _; + + /// The first slot of `epoch`. + fn slot_of(epoch: u64) -> Slot { + epoch * preset::SLOTS_PER_EPOCH + } + + fn phase0_block(slot: Slot) -> phase0::SignedBeaconBlock { + phase0::SignedBeaconBlock { + message: phase0::BeaconBlock { + slot, + proposer_index: 7, + parent_root: Root::repeat_byte(1), + state_root: Root::repeat_byte(2), + body: phase0::BeaconBlockBody { + randao_reveal: BlsSignature::default(), + eth1_data: shared::Eth1Data::default(), + graffiti: Bytes32::ZERO, + proposer_slashings: Default::default(), + attester_slashings: Default::default(), + attestations: Default::default(), + deposits: Default::default(), + voluntary_exits: Default::default(), + }, + }, + signature: BlsSignature::default(), + } + } + + #[test] + fn the_preset_is_mainnet() { + // Every epoch computed here divides by this. If `preset-minimal` ever + // leaks into ethlambda-p2p's feature resolution, the beacon wire would + // silently compute epochs eight slots wide and pick the wrong fork. + assert_eq!(preset::SLOTS_PER_EPOCH, 32); + } + + #[test] + fn block_slot_is_read_from_the_encoding() { + let slot = slot_of(1_000); + let bytes = phase0_block(slot).to_ssz(); + assert_eq!(block_slot(&bytes), Ok(slot)); + } + + #[test] + fn fork_selection_follows_the_mainnet_schedule() { + let config = Config::mainnet(); + let boundaries = [ + (0u64, ForkName::Phase0), + (74_240, ForkName::Altair), + (144_896, ForkName::Bellatrix), + (194_048, ForkName::Capella), + (269_568, ForkName::Deneb), + (364_032, ForkName::Electra), + (411_392, ForkName::Fulu), + ]; + for (epoch, fork) in boundaries { + assert_eq!(fork_at_slot(&config, slot_of(epoch)), fork, "epoch {epoch}"); + if epoch > 0 { + assert_ne!( + fork_at_slot(&config, slot_of(epoch) - 1), + fork, + "the slot before epoch {epoch} must still be the previous fork" + ); + } + } + } + + #[test] + fn a_phase0_block_round_trips_through_decode_gossip() { + let config = Config::mainnet(); + let block = phase0_block(slot_of(10)); + let decoded = + decode_gossip(&config, topics::BEACON_BLOCK, &block.to_ssz()).expect("decodes"); + assert_eq!( + decoded, + BeaconGossip::Block(Box::new(SignedBeaconBlock::Phase0(block))) + ); + assert_eq!(decoded.topic_kind(), topics::BEACON_BLOCK); + } + + #[test] + fn the_slot_actually_drives_which_shape_is_decoded() { + // A phase0-shaped payload whose slot lands in fulu must be refused, not + // decoded as phase0. Without this, `decode_gossip` could ignore the + // slot entirely and every test above would still pass. + let config = Config::mainnet(); + let bytes = phase0_block(slot_of(config.fulu_fork_epoch)).to_ssz(); + assert_eq!( + decode_gossip(&config, topics::BEACON_BLOCK, &bytes), + Err(DecodeError::Ssz) + ); + } + + #[test] + fn a_voluntary_exit_needs_no_fork_lookup() { + let config = Config::mainnet(); + let exit = shared::SignedVoluntaryExit::default(); + let decoded = + decode_gossip(&config, topics::VOLUNTARY_EXIT, &exit.to_ssz()).expect("decodes"); + assert_eq!(decoded, BeaconGossip::VoluntaryExit(exit)); + } + + #[test] + fn a_proposer_slashing_needs_no_fork_lookup() { + let config = Config::mainnet(); + let slashing = shared::ProposerSlashing::default(); + let decoded = + decode_gossip(&config, topics::PROPOSER_SLASHING, &slashing.to_ssz()).expect("decodes"); + assert_eq!(decoded, BeaconGossip::ProposerSlashing(Box::new(slashing))); + } + + #[test] + fn a_sidecar_decodes_and_a_truncated_one_does_not() { + let sidecar = fulu::DataColumnSidecar { + index: 3, + column: Default::default(), + kzg_commitments: Default::default(), + kzg_proofs: Default::default(), + signed_block_header: Default::default(), + // `SszVector` has no blanket `Default`, unlike the `SszList` + // fields above: a vector's whole point is a length fixed at the + // type level, so there is no length-zero default to fall back on. + kzg_commitments_inclusion_proof: vec![ + Root::default(); + preset::KZG_COMMITMENTS_INCLUSION_PROOF_DEPTH + ] + .try_into() + .expect("exactly the required depth"), + }; + let bytes = sidecar.to_ssz(); + assert_eq!(decode_data_column_sidecar(&bytes).unwrap().index, 3); + assert!(decode_data_column_sidecar(&bytes[..bytes.len() - 1]).is_err()); + } + + /// One attester's vote at `slot`, in the shape electra puts on a subnet. + fn single_attestation(slot: Slot) -> electra::SingleAttestation { + electra::SingleAttestation { + committee_index: 5, + attester_index: 123_456, + data: shared::AttestationData { + slot, + beacon_block_root: Root::repeat_byte(1), + ..Default::default() + }, + signature: BlsSignature::default(), + } + } + + /// One attester's vote at `slot`, in the shape phase0 puts on a subnet: a + /// whole `Attestation` with a single bit set. + fn phase0_attestation(slot: Slot) -> phase0::Attestation { + let mut aggregation_bits = phase0::AggregationBits::with_length(8).unwrap(); + aggregation_bits.set(3, true).unwrap(); + phase0::Attestation { + aggregation_bits, + data: shared::AttestationData { + slot, + beacon_block_root: Root::repeat_byte(1), + ..Default::default() + }, + signature: BlsSignature::default(), + } + } + + #[test] + fn a_subnet_attestation_from_electra_on_is_a_single_attestation() { + let config = Config::mainnet(); + let single = single_attestation(slot_of(config.fulu_fork_epoch)); + let decoded = decode_attestation(ForkName::Fulu, &single.to_ssz()).expect("decodes"); + assert_eq!(decoded, Attestation::Electra(single)); + } + + #[test] + fn a_subnet_attestation_before_electra_is_a_whole_attestation() { + let attestation = phase0_attestation(slot_of(10)); + let decoded = decode_attestation(ForkName::Deneb, &attestation.to_ssz()).expect("decodes"); + assert_eq!(decoded, Attestation::Phase0(attestation)); + } + + #[test] + fn the_topic_fork_rather_than_the_payload_picks_the_attestation_shape() { + // Each shape offered under the other's fork is refused rather than + // misread. Without this, `decode_attestation` could ignore `fork` and + // the two tests above would still pass. + let single = single_attestation(slot_of(10)).to_ssz(); + assert_eq!( + decode_attestation(ForkName::Deneb, &single), + Err(DecodeError::Ssz) + ); + let phase0 = phase0_attestation(slot_of(10)).to_ssz(); + assert_eq!( + decode_attestation(ForkName::Electra, &phase0), + Err(DecodeError::Ssz) + ); + } + + #[test] + fn a_truncated_subnet_attestation_is_refused() { + let bytes = single_attestation(slot_of(10)).to_ssz(); + for length in 0..bytes.len() { + assert!(decode_attestation(ForkName::Fulu, &bytes[..length]).is_err()); + } + } + + #[test] + fn an_unsubscribed_topic_is_refused() { + let config = Config::mainnet(); + assert_eq!( + decode_gossip(&config, "beacon_attestation_3", &[0u8; 8]), + Err(DecodeError::UnknownTopic) + ); + } + + #[test] + fn a_truncated_payload_is_refused_rather_than_panicking() { + // Every slot read indexes into attacker-supplied bytes, so this is the + // property that stops a two-byte gossip message from taking the node + // down. + let config = Config::mainnet(); + for length in 0..16 { + let bytes = vec![0xffu8; length]; + for kind in topics::SUBSCRIBED_TOPIC_KINDS { + let result = decode_gossip(&config, kind, &bytes); + assert!(result.is_err(), "{kind} accepted {length} junk bytes"); + } + } + } +} diff --git a/crates/net/p2p/src/beacon/encoding.rs b/crates/net/p2p/src/beacon/encoding.rs new file mode 100644 index 000000000..d5791b445 --- /dev/null +++ b/crates/net/p2p/src/beacon/encoding.rs @@ -0,0 +1,327 @@ +//! How this chain's request/response bodies go on and off the wire. +//! +//! The counterpart of [`crate::lean::encoding`]. Everything above these two +//! modules is shared: one `Request`, one `ResponsePayload`, one dispatch, one +//! set of handlers. Encoding is where the chains genuinely differ, so it is +//! where the split lives. +//! +//! The two block *requests* are not among the differences. What a peer is +//! asking for is the same question on either chain, so +//! [`crate::req_resp::Request`] carries one request for both wires: a shared +//! struct of its own for the range, which belongs to neither wire, and lean's +//! container for the root list, whose contents beacon sends bare. The +//! conversions below are the whole of what this wire adds: a deprecated `step` +//! on the range body, and no container around the root list. See +//! [`crate::req_resp::messages::Request`]. +//! +//! Two things differ here. The first is **version dispatch**: `status` and +//! `metadata` carry a different container per negotiated version, and picking +//! the wrong one puts a short body on the wire. The second is +//! **``**: a block chunk names the fork its payload is shaped +//! for, because a `SignedBeaconBlock` has had seven shapes and SSZ carries no +//! type tag. Lean has neither. +//! +//! The context bytes follow the spec's rule verbatim: the epoch is +//! `compute_epoch_at_slot(signed_beacon_block.message.slot)`, and the digest is +//! `compute_fork_digest(genesis_validators_root, epoch)` at that epoch. It is +//! computed per chunk rather than looked up in a per-fork table, because from +//! fulu on the digest also moves at every blob-schedule boundary, so a fork +//! name alone does not determine it. The two data column sidecar protocols +//! share this exact rule, keyed off `signed_block_header.message.slot` rather +//! than a block's own slot, since a sidecar carries a header rather than a +//! full block. + +use std::io; + +use ethlambda_types::beacon::config::Config; +use ethlambda_types::beacon::containers::SignedBeaconBlock; +use ethlambda_types::beacon::containers::fulu::DataColumnSidecar; +use ethlambda_types::beacon::fork_digest::compute_fork_digest; +use ethlambda_types::beacon::preset; +use ethlambda_types::beacon::primitives::Root; +use libp2p::futures::{AsyncRead, AsyncWrite}; +use libssz::{SszDecode, SszEncode}; +use tracing::warn; + +use super::decode; +use super::messages::{ + BeaconBlocksByRangeRequest, BeaconMetaData, BeaconStatus, MetaDataV1, MetaDataV2, MetaDataV3, + StatusV1, StatusV2, +}; +use super::protocols::{self, MAX_REQUEST_BLOCKS_DENEB}; +use crate::req_resp::codec::write_success_chunk; +use crate::req_resp::encoding::{ChunkLimits, MAX_PAYLOAD_SIZE, invalid, read_chunked_response}; +use crate::req_resp::messages::BlocksByRangeRequest; + +/// This chain's wire body for a slot window. Every field is shared, since this +/// is the wire the shared request's `step` exists for. +impl From<&BlocksByRangeRequest> for BeaconBlocksByRangeRequest { + fn from(request: &BlocksByRangeRequest) -> Self { + Self { + start_slot: request.start_slot, + count: request.count, + step: request.step, + } + } +} + +/// The shared request a wire body describes. +/// +/// `step` is carried through rather than validated here. The spec deprecates it +/// and says a requester MUST set it to 1, but a peer that gets that wrong +/// deserves to be told which rule it broke, and only a handler holding the +/// response channel can say so; refusing at decode drops the stream instead. +/// See `handle_beacon_blocks_by_range_request`. +impl From for BlocksByRangeRequest { + fn from(wire: BeaconBlocksByRangeRequest) -> Self { + Self { + start_slot: wire.start_slot, + count: wire.count, + step: wire.step, + } + } +} + +/// Encode a `Status` for the negotiated protocol version. +/// +/// A version mismatch is an error rather than a conversion: a v1 value written +/// on a v2 stream would be eight bytes short and the peer would read a +/// truncated container, which is worse than a refused write. +pub fn encode_status(protocol: &str, status: &BeaconStatus) -> io::Result> { + match (protocol, status) { + (protocols::STATUS_V1, BeaconStatus::V1(status)) => Ok(status.to_ssz()), + (protocols::STATUS_V2, BeaconStatus::V2(status)) => Ok(status.to_ssz()), + _ => Err(invalid(format!( + "status version does not match protocol {protocol}" + ))), + } +} + +pub fn decode_status(protocol: &str, payload: &[u8]) -> io::Result { + match protocol { + protocols::STATUS_V1 => StatusV1::from_ssz_bytes(payload) + .map(BeaconStatus::V1) + .map_err(|err| invalid(format!("{err:?}"))), + protocols::STATUS_V2 => StatusV2::from_ssz_bytes(payload) + .map(BeaconStatus::V2) + .map_err(|err| invalid(format!("{err:?}"))), + _ => Err(invalid(format!("not a status protocol: {protocol}"))), + } +} + +pub fn encode_metadata(protocol: &str, metadata: &BeaconMetaData) -> io::Result> { + match (protocol, metadata) { + (protocols::METADATA_V1, BeaconMetaData::V1(value)) => Ok(value.to_ssz()), + (protocols::METADATA_V2, BeaconMetaData::V2(value)) => Ok(value.to_ssz()), + (protocols::METADATA_V3, BeaconMetaData::V3(value)) => Ok(value.to_ssz()), + _ => Err(invalid(format!( + "metadata version does not match protocol {protocol}" + ))), + } +} + +pub fn decode_metadata(protocol: &str, payload: &[u8]) -> io::Result { + match protocol { + protocols::METADATA_V1 => MetaDataV1::from_ssz_bytes(payload) + .map(BeaconMetaData::V1) + .map_err(|err| invalid(format!("{err:?}"))), + protocols::METADATA_V2 => MetaDataV2::from_ssz_bytes(payload) + .map(BeaconMetaData::V2) + .map_err(|err| invalid(format!("{err:?}"))), + protocols::METADATA_V3 => MetaDataV3::from_ssz_bytes(payload) + .map(BeaconMetaData::V3) + .map_err(|err| invalid(format!("{err:?}"))), + _ => Err(invalid(format!("not a metadata protocol: {protocol}"))), + } +} + +/// Write a block response: one result code, one `ForkDigest` and one payload per +/// block. +/// +/// The counterpart of [`crate::lean::encoding::write_blocks_response`], and the +/// same shape apart from the four context bytes. Each block is encoded before +/// its code byte goes out, so an oversized block is skipped rather than leaving +/// a SUCCESS byte on the wire with no payload behind it. An empty response is a +/// stream that just ends, which is the honest answer for a range this node does +/// not hold. +pub async fn write_blocks_response( + io: &mut T, + label: &'static str, + config: &Config, + genesis_validators_root: Root, + blocks: &[SignedBeaconBlock], +) -> io::Result<()> +where + T: AsyncWrite + Unpin + Send, +{ + for block in blocks { + let encoded = block.to_ssz(); + if encoded.len() > MAX_PAYLOAD_SIZE - 1024 { + warn!( + slot = block.slot(), + size = encoded.len(), + "Skipping oversized block in beacon block response" + ); + continue; + } + // The block's own epoch, not the one this node runs on, so a backfill + // labels each chunk with its own fork. + let epoch = block.slot() / preset::SLOTS_PER_EPOCH; + let digest = compute_fork_digest(config, genesis_validators_root, epoch); + write_success_chunk(io, label, &digest, encoded).await?; + } + Ok(()) +} + +/// Read a block response: one `SignedBeaconBlock` per chunk, until the peer +/// closes. +/// +/// The fork a chunk decodes under comes from the **slot inside the payload**, +/// through the same [`decode::decode_block`] the gossip path uses, rather than +/// from the context bytes. The context bytes are then checked against the digest +/// that slot implies, which is a stronger test than using them as the decoder +/// key would be: it catches a peer whose `genesis_validators_root` or fork +/// schedule differs from ours, which is exactly what the digest exists to say +/// and is not otherwise visible until a signature fails. +/// +/// A mismatch ends the stream rather than skipping the chunk. A peer past the +/// handshake already agreed with us about the *current* digest, so disagreeing +/// about a historical one means its schedule or its chain is not ours, and none +/// of what it sent is worth keeping. It is logged at `warn` with both digests, +/// because the one way to reach it in good faith is a blob schedule of ours that +/// has fallen behind the network's. +pub async fn decode_blocks_response( + io: &mut T, + protocol_label: &str, + config: &Config, + genesis_validators_root: Root, +) -> io::Result> +where + T: AsyncRead + Unpin + Send, +{ + let limits = ChunkLimits { + has_context: true, + // The deneb ceiling rather than the phase0 one, because it bounds what + // *this* node asks for and nothing else opens one of these streams. + max_chunks: MAX_REQUEST_BLOCKS_DENEB as usize, + }; + read_chunked_response(io, protocol_label, limits, |context, payload| { + let block = decode::decode_block(config, payload) + .map_err(|err| invalid(format!("beacon block chunk: {err}")))?; + let epoch = block.slot() / preset::SLOTS_PER_EPOCH; + let expected = compute_fork_digest(config, genesis_validators_root, epoch); + if context != expected { + warn!( + slot = block.slot(), + fork = %block.fork_name(), + peer_context = %hex::encode(context), + our_context = %hex::encode(expected), + "Beacon block chunk names another fork digest" + ); + return Err(invalid(format!( + "block chunk context {} does not match {} for slot {}", + hex::encode(context), + hex::encode(expected), + block.slot(), + ))); + } + Ok(block) + }) + .await +} + +/// Write a data column sidecar response: one result code, one `ForkDigest` and +/// one payload per sidecar. +/// +/// The counterpart of [`write_blocks_response`] for the two column protocols, +/// and the same shape apart from what is being encoded. Each sidecar is +/// encoded before its code byte goes out, so an oversized one is skipped +/// rather than leaving a SUCCESS byte on the wire with no payload behind it. +/// An empty response is a stream that just ends, which is the honest answer +/// for a request this node holds nothing for. +pub async fn write_data_column_sidecars_response( + io: &mut T, + label: &'static str, + config: &Config, + genesis_validators_root: Root, + sidecars: &[DataColumnSidecar], +) -> io::Result<()> +where + T: AsyncWrite + Unpin + Send, +{ + for sidecar in sidecars { + let encoded = sidecar.to_ssz(); + if encoded.len() > MAX_PAYLOAD_SIZE - 1024 { + warn!( + index = sidecar.index, + size = encoded.len(), + "Skipping oversized data column sidecar in response" + ); + continue; + } + // The sidecar's own epoch, taken from the header it carries rather + // than the one this node runs on, so a backfill labels each chunk + // with its own fork. + let epoch = sidecar.signed_block_header.message.slot / preset::SLOTS_PER_EPOCH; + let digest = compute_fork_digest(config, genesis_validators_root, epoch); + write_success_chunk(io, label, &digest, encoded).await?; + } + Ok(()) +} + +/// Read a data column sidecar response: one `DataColumnSidecar` per chunk, +/// until the peer closes. +/// +/// The counterpart of [`decode_blocks_response`]. Only fulu defines this +/// container, so there is no fork ladder to select a decoder from the way a +/// block chunk's slot selects one; [`super::decode::decode_data_column_sidecar`] +/// decodes unconditionally. The context bytes are still checked against the +/// digest the sidecar's own slot implies, for the same reason a block chunk's +/// are: it catches a peer whose `genesis_validators_root` or fork schedule +/// differs from ours, which a signature failure would otherwise be the only +/// way to notice. +/// +/// A mismatch ends the stream rather than skipping the chunk, matching +/// [`decode_blocks_response`]: a peer that disagrees about a historical digest +/// after having agreed about the current one during the handshake is not +/// running our schedule, and nothing else it sent is worth keeping. +pub async fn decode_data_column_sidecars_response( + io: &mut T, + protocol_label: &str, + config: &Config, + genesis_validators_root: Root, +) -> io::Result> +where + T: AsyncRead + Unpin + Send, +{ + let limits = ChunkLimits { + has_context: true, + // The widest a single request can legitimately ask for, on either + // column protocol; see `protocols::max_request_data_column_sidecars`. + max_chunks: protocols::max_request_data_column_sidecars() as usize, + }; + read_chunked_response(io, protocol_label, limits, |context, payload| { + let sidecar = decode::decode_data_column_sidecar(payload) + .map_err(|err| invalid(format!("data column sidecar chunk: {err}")))?; + let slot = sidecar.signed_block_header.message.slot; + let epoch = slot / preset::SLOTS_PER_EPOCH; + let expected = compute_fork_digest(config, genesis_validators_root, epoch); + if context != expected { + warn!( + slot, + index = sidecar.index, + peer_context = %hex::encode(context), + our_context = %hex::encode(expected), + "Data column sidecar chunk names another fork digest" + ); + return Err(invalid(format!( + "data column sidecar chunk context {} does not match {} for slot {}", + hex::encode(context), + hex::encode(expected), + slot, + ))); + } + Ok(sidecar) + }) + .await +} diff --git a/crates/net/p2p/src/beacon/handler.rs b/crates/net/p2p/src/beacon/handler.rs new file mode 100644 index 000000000..c593815da --- /dev/null +++ b/crates/net/p2p/src/beacon/handler.rs @@ -0,0 +1,431 @@ +//! The beacon-specific halves of what `P2PServer` does. +//! +//! One thing now: it builds this node's own `Status` and `MetaData` bodies and +//! opens the handshake with them. Neither answering a request nor decoding +//! gossip is here; those are `crate::req_resp::handlers` and +//! `crate::gossipsub::handler`, one dispatch each for both chains, which call +//! [`build_status`] and [`build_metadata`] for the bodies they cannot construct +//! themselves. + +use ethlambda_storage::Store; +use ethlambda_types::beacon::primitives::Root; +use libp2p::PeerId; +use tracing::debug; + +use super::messages::{ + AttnetsBits, BeaconMetaData, BeaconStatus, MetaDataV1, MetaDataV2, MetaDataV3, StatusV1, + StatusV2, SyncnetsBits, +}; +use super::{BeaconWire, constants, protocols}; +use crate::P2PServer; +use crate::ReqRespProtocol; +use crate::req_resp::Request; + +/// The `Status` this node advertises, in the version the stream asked for. +/// +/// Derived from `store`: `head_root`/`head_slot` come from +/// [`Store::beacon_head`], `finalized_root`/`finalized_epoch` from +/// [`Store::beacon_finalized_checkpoint`], and (on v2) `earliest_available_slot` +/// from [`Store::latest_finalized`]'s own slot rather than +/// `finalized_epoch * SLOTS_PER_EPOCH`: this node anchors at a checkpoint and +/// keeps only the unfinalized window above it, so the epoch's first slot is +/// not necessarily a slot it holds, while the anchor checkpoint written by +/// [`Store::init_beacon`] and only ever advanced forward by finality is +/// exactly the oldest block this directory can still produce. Naming that +/// slot is what tells a peer not to ask this node for anything older; naming +/// zero would claim genesis is in reach. +/// +/// [`Store::beacon_head`] answers `None` in one window: between +/// [`Store::init_beacon`] seeding `KEY_HEAD` with the anchor root and the +/// beacon fork choice's own `get_forkchoice_store` inserting the block that +/// root names. There is no chain to derive anything from yet in that window, +/// so every field but the fork digest falls back to zero, which stays honest +/// for it: lighthouse's relevance check explicitly exempts a zero +/// `finalized_root` from its finalized-root comparison, reading it as "this +/// peer is syncing" rather than as a conflicting chain, so a zero Status keeps +/// the connection instead of earning an `IrrelevantPeer` disconnect. +/// +/// `version` is the stream's, not ours to choose: a v1 body written on a v2 +/// stream is eight bytes short and the codec refuses it, which killed the +/// connection outright. Answering a request means answering in its own version. +pub fn build_status(store: &Store, wire: &BeaconWire, version: StatusVersion) -> BeaconStatus { + let (finalized_root, finalized_epoch, head_root, head_slot, earliest_available_slot) = + match store.beacon_head() { + Some((head_slot, head_root)) => { + let finalized = store.beacon_finalized_checkpoint(); + // Where this directory's chain begins, not where it has + // finalized to: the two coincide only until the first + // finalization, after which the finalized slot climbs away + // from the anchor and would understate, by a growing margin, + // the range this node can still serve. + let earliest_available_slot = store.anchor_slot(); + ( + finalized.root, + finalized.epoch, + head_root, + head_slot, + earliest_available_slot, + ) + } + None => (Root::ZERO, 0, Root::ZERO, 0, 0), + }; + + let v1 = StatusV1 { + fork_digest: wire.fork_digest, + finalized_root, + finalized_epoch, + head_root, + head_slot, + }; + match version { + StatusVersion::V1 => BeaconStatus::V1(v1), + StatusVersion::V2 => BeaconStatus::V2(StatusV2 { + fork_digest: v1.fork_digest, + finalized_root: v1.finalized_root, + finalized_epoch: v1.finalized_epoch, + head_root: v1.head_root, + head_slot: v1.head_slot, + earliest_available_slot, + }), + } +} + +/// Which `Status` version a stream negotiated. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StatusVersion { + V1, + V2, +} + +impl StatusVersion { + /// The version to answer a request in: the one it arrived in. + pub fn of(status: &BeaconStatus) -> Self { + match status { + BeaconStatus::V1(_) => Self::V1, + BeaconStatus::V2(_) => Self::V2, + } + } +} + +/// The `MetaData` this node advertises, in the version the protocol asked for. +/// +/// `attnets` names the backbone subnets this node actually subscribed to (see +/// [`BeaconWire::attestation_subnets`]), so what it claims to serve and what it +/// listens on are one set. It used to be all-zero, which was honest while this +/// node held no subscription; claiming subnets it does not serve would earn +/// peer-score penalties for silence on them, and claiming none while serving +/// two loses the peers looking for exactly that. +/// +/// `syncnets` stays all-zero: no sync-committee subnet is subscribed. +/// `custody_group_count` is `CUSTODY_REQUIREMENT`, the floor a peer may demand, +/// not this node's actual custody: `sampling_size` raises what it stores and +/// serves (`BeaconWire::custody_columns`) to cover at least `SAMPLES_PER_SLOT` +/// groups, so `cgc` can only understate this node's real coverage, never +/// overstate it. +pub fn build_metadata(wire: &BeaconWire, protocol: &str) -> Option { + let seq_number = wire.metadata_seq_number; + let attnets = attnets(wire); + match protocol { + protocols::METADATA_V1 => Some(BeaconMetaData::V1(MetaDataV1 { + seq_number, + attnets, + })), + protocols::METADATA_V2 => Some(BeaconMetaData::V2(MetaDataV2 { + seq_number, + attnets, + syncnets: SyncnetsBits::default(), + })), + protocols::METADATA_V3 => Some(BeaconMetaData::V3(MetaDataV3 { + seq_number, + attnets, + syncnets: SyncnetsBits::default(), + custody_group_count: constants::CUSTODY_REQUIREMENT, + })), + _ => None, + } +} + +/// The `attnets` bitfield for this node's backbone subscription. +/// +/// A subnet id past the bitfield's width is dropped rather than wrapped: the +/// width is `ATTESTATION_SUBNET_COUNT`, a compile-time constant because +/// `AttnetsBits` is an SSZ bitvector, while the subnet ids come from +/// `Config::attestation_subnet_count`. A configuration that widened the count +/// past the compiled width would otherwise set the wrong bit, which is a worse +/// answer than setting none. +fn attnets(wire: &BeaconWire) -> AttnetsBits { + let mut attnets = AttnetsBits::default(); + for &subnet_id in &wire.attestation_subnets { + let _ = attnets.set(subnet_id as usize, true); + } + attnets +} + +/// Open the handshake on a newly established connection. +/// +/// `status/1` rather than `status/2`: every mainnet client still answers v1, +/// and the throwaway probe that proved this path completed its handshake on v1. +/// A peer that has dropped v1 refuses the stream with "the remote supports none +/// of the requested protocols", which is what [`retry_status_on_other_version`] +/// answers. +pub async fn send_status(server: &P2PServer, peer_id: PeerId, wire_status: BeaconStatus) { + let protocol = match StatusVersion::of(&wire_status) { + StatusVersion::V1 => ReqRespProtocol::BeaconStatusV1, + StatusVersion::V2 => ReqRespProtocol::BeaconStatusV2, + }; + server + .swarm_handle + .send_request(peer_id, Request::Status(wire_status), protocol) + .await; +} + +/// Ask `peer_id` for its `MetaData`, to learn the columns it custodies. +/// +/// Version 3 specifically, because `custody_group_count` is the field this is +/// for and only v3 carries it. A peer that does not speak v3 refuses the +/// stream, which costs one failed request and leaves its custody unknown — +/// handled, and strictly better than the alternative of never asking. +/// +/// Sent once per connection, right behind `Status`: the count changes only +/// when a peer changes its own validator load, and a stale entry aims requests +/// no worse than the random choice it replaced. +pub async fn request_metadata(server: &P2PServer, peer_id: PeerId) { + server + .swarm_handle + .send_request( + peer_id, + Request::MetaData(protocols::METADATA_V3), + ReqRespProtocol::BeaconMetadataV3, + ) + .await; +} + +/// Re-open a refused handshake on the other `Status` version. +/// +/// A peer that supports neither version was never going to talk to us, and one +/// retry cannot loop: the retry is sent in the version the first attempt was +/// not. +pub async fn retry_status_on_other_version(server: &P2PServer, peer_id: PeerId) { + let Some(wire) = server.wire.beacon() else { + return; + }; + let status = build_status(&server.store, wire, StatusVersion::V2); + debug!(%peer_id, "Retrying the beacon handshake on status/2"); + send_status(server, peer_id, status).await; +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + use crate::beacon::topics; + use ethlambda_storage::backend::InMemoryBackend; + use ethlambda_types::beacon::config::Config; + use ethlambda_types::beacon::containers::{SignedBeaconBlock, phase0}; + use ethlambda_types::beacon::fork::ForkName; + use ethlambda_types::beacon::preset::SLOTS_PER_EPOCH; + use ethlambda_types::checkpoint::Checkpoint; + + fn wire() -> BeaconWire { + BeaconWire { + fork_digest: [0x8c, 0x9f, 0x62, 0xfe], + fork: ForkName::Fulu, + topics: topics::BeaconTopics::new([0x8c, 0x9f, 0x62, 0xfe], &[], &[]), + config: Config::mainnet(), + genesis_time: 1_606_824_023, + genesis_validators_root: Root::ZERO, + metadata_seq_number: 0, + custody_columns: Vec::new(), + attestation_subnets: Vec::new(), + } + } + + /// A beacon store past `Store::init_beacon` but before the beacon fork + /// choice's `get_forkchoice_store` has inserted the anchor block: `KEY_HEAD` + /// names a root no block row exists for, so `Store::beacon_head` answers + /// `None`. + fn store_with_no_head() -> Store { + Store::init_beacon( + Arc::new(InMemoryBackend::default()), + 1_606_824_023, + Config::mainnet(), + Root::ZERO, + Checkpoint::default(), + 0, + ) + } + + /// A beacon store anchored at a real, inserted block, so both + /// `Store::beacon_head` and `Store::beacon_finalized_checkpoint` answer + /// from it. Returns the store alongside the anchor's slot and root, which + /// `init_beacon` also seeds as the finalized (and justified) checkpoint. + fn anchored_store() -> (Store, u64, Root) { + let anchor_slot = 2 * SLOTS_PER_EPOCH; + let block = SignedBeaconBlock::Phase0(phase0::SignedBeaconBlock { + message: phase0::BeaconBlock { + slot: anchor_slot, + proposer_index: 0, + parent_root: Root::ZERO, + state_root: Root::ZERO, + body: phase0::BeaconBlockBody { + randao_reveal: Default::default(), + eth1_data: Default::default(), + graffiti: Root::ZERO, + proposer_slashings: Default::default(), + attester_slashings: Default::default(), + attestations: Default::default(), + deposits: Default::default(), + voluntary_exits: Default::default(), + }, + }, + signature: Default::default(), + }); + let anchor_root = block.message_hash_tree_root(); + + let mut store = Store::init_beacon( + Arc::new(InMemoryBackend::default()), + 1_606_824_023, + Config::mainnet(), + anchor_root, + Checkpoint { + root: anchor_root, + slot: anchor_slot, + }, + anchor_slot, + ); + store + .insert_signed_block(anchor_root, block) + .expect("insert anchor block"); + + (store, anchor_slot, anchor_root) + } + + #[test] + fn a_store_with_no_head_advertises_the_digest_and_nothing_else() { + // Zero roots are the honest answer for the window before this store has + // a head row to read, and lighthouse exempts a zero finalized_root from + // its relevance check, so this keeps the connection rather than earning + // a disconnect. + let store = store_with_no_head(); + let BeaconStatus::V1(status) = build_status(&store, &wire(), StatusVersion::V1) else { + panic!("v1 was asked for"); + }; + assert_eq!(status.fork_digest, [0x8c, 0x9f, 0x62, 0xfe]); + assert_eq!(status.finalized_root, Root::ZERO); + assert_eq!(status.head_root, Root::ZERO); + assert_eq!(status.finalized_epoch, 0); + assert_eq!(status.head_slot, 0); + } + + #[test] + fn an_anchored_store_advertises_its_own_head_and_finalized_checkpoint() { + let (store, anchor_slot, anchor_root) = anchored_store(); + + let BeaconStatus::V2(status) = build_status(&store, &wire(), StatusVersion::V2) else { + panic!("v2 was asked for"); + }; + assert_eq!(status.head_root, anchor_root); + assert_eq!(status.head_slot, anchor_slot); + // `init_beacon` seeds the finalized checkpoint with the anchor itself, + // and nothing in this test advances finality past it. + assert_eq!(status.finalized_root, anchor_root); + assert_eq!(status.finalized_epoch, anchor_slot / SLOTS_PER_EPOCH); + // The anchor's own slot: the oldest block this directory can serve, + // not genesis. + assert_eq!(status.earliest_available_slot, anchor_slot); + } + + /// A v1 body on a v2 stream is eight bytes short, and the codec refuses to + /// write it: answering every `Status` in v1 dropped the connection of every + /// peer that opened the handshake on `status/2`. + #[test] + fn the_status_version_answered_is_the_one_that_was_asked_for() { + let wire = wire(); + let store = store_with_no_head(); + + assert!(matches!( + build_status(&store, &wire, StatusVersion::V1), + BeaconStatus::V1(_) + )); + assert!(matches!( + build_status(&store, &wire, StatusVersion::V2), + BeaconStatus::V2(_) + )); + + let peer_asked_in = BeaconStatus::V2(StatusV2 { + fork_digest: wire.fork_digest, + finalized_root: Default::default(), + finalized_epoch: 0, + head_root: Default::default(), + head_slot: 0, + earliest_available_slot: 0, + }); + assert_eq!(StatusVersion::of(&peer_asked_in), StatusVersion::V2); + } + + #[test] + fn metadata_matches_the_protocol_version_asked_for() { + let wire = wire(); + assert!(matches!( + build_metadata(&wire, protocols::METADATA_V1), + Some(BeaconMetaData::V1(_)) + )); + assert!(matches!( + build_metadata(&wire, protocols::METADATA_V2), + Some(BeaconMetaData::V2(_)) + )); + let Some(BeaconMetaData::V3(v3)) = build_metadata(&wire, protocols::METADATA_V3) else { + panic!("v3 requested"); + }; + assert_eq!(v3.custody_group_count, constants::CUSTODY_REQUIREMENT); + assert!(build_metadata(&wire, protocols::PING_V1).is_none()); + } + + #[test] + fn a_node_with_no_backbone_advertises_no_subnet() { + // What a node subscribing to no subnet actually serves. Claiming + // otherwise would earn peer-score penalties for silence on subnets we + // advertised. + let Some(BeaconMetaData::V3(v3)) = build_metadata(&wire(), protocols::METADATA_V3) else { + panic!("v3 requested"); + }; + assert_eq!(v3.attnets, AttnetsBits::default()); + assert_eq!(v3.syncnets, SyncnetsBits::default()); + } + + /// The other half of the same rule: a node that does hold a backbone + /// subscription has to say so, or the peers looking for that subnet never + /// find it. + #[test] + fn the_advertised_subnets_are_the_subscribed_ones() { + let mut wire = wire(); + wire.attestation_subnets = vec![12, 40]; + let Some(BeaconMetaData::V3(v3)) = build_metadata(&wire, protocols::METADATA_V3) else { + panic!("v3 requested"); + }; + for subnet in 0..64usize { + let expected = subnet == 12 || subnet == 40; + assert_eq!( + v3.attnets.get(subnet).unwrap_or(false), + expected, + "subnet {subnet} advertised wrongly" + ); + } + // Still nothing claimed on the sync-committee side. + assert_eq!(v3.syncnets, SyncnetsBits::default()); + } + + /// A subnet id the compiled bitfield has no room for is dropped rather than + /// wrapped onto some other subnet's bit, which would advertise a subnet + /// this node never subscribed to. + #[test] + fn a_subnet_past_the_bitfield_width_sets_no_bit() { + let mut wire = wire(); + wire.attestation_subnets = vec![64, 999]; + let Some(BeaconMetaData::V3(v3)) = build_metadata(&wire, protocols::METADATA_V3) else { + panic!("v3 requested"); + }; + assert_eq!(v3.attnets, AttnetsBits::default()); + } +} diff --git a/crates/net/p2p/src/beacon/messages.rs b/crates/net/p2p/src/beacon/messages.rs new file mode 100644 index 000000000..e32d26256 --- /dev/null +++ b/crates/net/p2p/src/beacon/messages.rs @@ -0,0 +1,332 @@ +//! The beacon request/response payloads this node speaks. +//! +//! Every payload through [`BeaconBlocksByRangeRequest`] is fixed-size, so each +//! has an exact wire length. The tests assert those lengths, which is what +//! catches a reordered or mistyped field: SSZ has no field names on the wire, +//! so a swapped pair of same-width fields is otherwise invisible until a peer +//! disagrees about our chain. The two data column sidecar request bodies below +//! it are the exception: each carries a column list whose length the requester +//! chooses, so there is no one wire length for a test to assert. + +use ethlambda_types::beacon::containers::fulu::{ColumnIndices, DataColumnsByRootIdentifier}; +use ethlambda_types::beacon::primitives::{Epoch, ForkDigest, Root, Slot}; +use libssz_derive::{SszDecode, SszEncode}; +use libssz_types::{SszBitvector, SszList}; + +use super::constants::{ATTESTATION_SUBNET_COUNT, SYNC_COMMITTEE_SUBNET_COUNT}; +use super::protocols::MAX_REQUEST_BLOCKS_DENEB; + +/// `attnets`: which attestation subnets a node serves. +pub type AttnetsBits = SszBitvector<{ ATTESTATION_SUBNET_COUNT as usize }>; +/// `syncnets`: which sync committee subnets a node serves. +pub type SyncnetsBits = SszBitvector; + +/// `Status` v1: the pre-fulu handshake. +#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode)] +pub struct StatusV1 { + pub fork_digest: ForkDigest, + pub finalized_root: Root, + pub finalized_epoch: Epoch, + pub head_root: Root, + pub head_slot: Slot, +} + +/// `Status` v2: v1 plus the oldest slot the peer can serve, which fulu adds so +/// a peer can advertise how far its backfill reaches. +#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode)] +pub struct StatusV2 { + pub fork_digest: ForkDigest, + pub finalized_root: Root, + pub finalized_epoch: Epoch, + pub head_root: Root, + pub head_slot: Slot, + pub earliest_available_slot: Slot, +} + +/// A `Status` in whichever version the negotiated protocol asked for. +/// +/// The version is a property of the stream, not of the value, so it is carried +/// alongside the fields rather than being recovered from them: v1 and v2 differ +/// only by a trailing `uint64`, which SSZ cannot tell apart from a truncated +/// v2. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BeaconStatus { + V1(StatusV1), + V2(StatusV2), +} + +impl BeaconStatus { + pub fn fork_digest(&self) -> ForkDigest { + match self { + BeaconStatus::V1(status) => status.fork_digest, + BeaconStatus::V2(status) => status.fork_digest, + } + } + + pub fn head_slot(&self) -> Slot { + match self { + BeaconStatus::V1(status) => status.head_slot, + BeaconStatus::V2(status) => status.head_slot, + } + } + + pub fn finalized_epoch(&self) -> Epoch { + match self { + BeaconStatus::V1(status) => status.finalized_epoch, + BeaconStatus::V2(status) => status.finalized_epoch, + } + } +} + +/// `Ping`, and its response: a metadata sequence number, so a peer can tell +/// whether the `MetaData` it holds for us is stale. +#[derive(Debug, Clone, Copy, PartialEq, Eq, SszEncode, SszDecode)] +pub struct Ping { + pub seq_number: u64, +} + +/// `Goodbye`: a reason code. This node only ever receives one. +#[derive(Debug, Clone, Copy, PartialEq, Eq, SszEncode, SszDecode)] +pub struct Goodbye { + pub reason: u64, +} + +impl Goodbye { + /// A bounded label for this reason code, for + /// [`crate::metrics::inc_peer_goodbye`]. + /// + /// Bounded because the code is a `u64` read off the wire: a peer may send + /// any of 2^64 values, and labelling with one straight would let a remote + /// decide this node's metric cardinality. + /// + /// Only 1, 2 and 3 are named by the spec, which reserves `[4, 127]` and + /// leaves everything from 128 up to the "alternative, erroneous + /// request-specific responses" a client picks for itself. The four above + /// 127 are therefore a convention rather than a standard, taken from + /// lighthouse's `GoodbyeReason` because that is what mainnet peers send. + /// They are the codes worth telling apart: `too_many_peers` says the peer + /// had no room, while `bad_score`, `banned` and `banned_ip` say it decided + /// against *us*, and those two readings call for opposite responses. + /// + /// `unknown` is code 0, which lighthouse sends when it has no code for the + /// reason; `other` is anything unmapped, including the reserved range. + pub fn reason_label(&self) -> &'static str { + match self.reason { + 0 => "unknown", + 1 => "client_shutdown", + 2 => "irrelevant_network", + 3 => "fault", + 128 => "unable_to_verify_network", + 129 => "too_many_peers", + 250 => "bad_score", + 251 => "banned", + 252 => "banned_ip", + _ => "other", + } + } +} + +/// `MetaData` v1: phase0. +#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode)] +pub struct MetaDataV1 { + pub seq_number: u64, + pub attnets: AttnetsBits, +} + +/// `MetaData` v2: altair adds the sync committee subnets. +#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode)] +pub struct MetaDataV2 { + pub seq_number: u64, + pub attnets: AttnetsBits, + pub syncnets: SyncnetsBits, +} + +/// `MetaData` v3: fulu adds the custody group count. +#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode)] +pub struct MetaDataV3 { + pub seq_number: u64, + pub attnets: AttnetsBits, + pub syncnets: SyncnetsBits, + pub custody_group_count: u64, +} + +/// A `MetaData` in whichever version the negotiated protocol asked for. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BeaconMetaData { + V1(MetaDataV1), + V2(MetaDataV2), + V3(MetaDataV3), +} + +/// `BeaconBlocksByRange`'s body **as it goes on the wire**. +/// +/// Not what the rest of the crate passes around: a request for a slot window is +/// the same question on either chain, so [`crate::req_resp::Request`] carries +/// the shared +/// [`BlocksByRangeRequest`](crate::req_resp::messages::BlocksByRangeRequest), +/// which belongs to neither wire, and `crate::beacon::encoding` converts to and +/// from this at the wire boundary. +/// +/// The third field is why the conversion exists. `step` is deprecated and must +/// be 1, but it is **still on the wire**: altair says the v2 request is +/// unchanged from phase0's, and lighthouse pins this protocol's request length +/// to `min == max == 24 bytes`, so a two-field body is refused before it is +/// ever decoded. Nothing constructs this with a `step` other than 1, and a peer +/// that sends another value is refused. +/// +/// `BeaconBlocksByRoot` has no counterpart here. Its body is the bare +/// `List[Root, MAX_REQUEST_BLOCKS]`, and `Root` *is* `H256` with the same 1024 +/// bound, so lean's `RequestedBlockRoots` is already that type exactly; the +/// encoder unwraps lean's container rather than converting anything. +#[derive(Debug, Clone, Copy, PartialEq, Eq, SszEncode, SszDecode)] +pub struct BeaconBlocksByRangeRequest { + pub start_slot: Slot, + pub count: u64, + /// Deprecated: must be 1. See the container's doc comment for why a field + /// with one legal value is still carried. + pub step: u64, +} + +/// `DataColumnSidecarsByRoot` v1's request body: the identifiers asked for. +/// +/// An SSZ list rather than a bare `Vec` on the wire, so its bound is part of +/// the type rather than something a caller has to remember to check. +/// [`MAX_REQUEST_BLOCKS_DENEB`] is the spec's own limit for this list, the +/// same ceiling `BeaconBlocksByRange` and `BeaconBlocksByRoot` are served +/// against: a request cannot sensibly name more blocks' worth of columns than +/// either of those protocols would ever hand back in one answer. +pub type DataColumnsByRootIdentifiers = + SszList; + +/// `DataColumnSidecarsByRange` v1's request body. +#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode)] +pub struct DataColumnsByRangeRequest { + pub start_slot: Slot, + pub count: u64, + pub columns: ColumnIndices, +} + +#[cfg(test)] +mod tests { + use super::*; + use libssz::{SszDecode as _, SszEncode as _}; + + fn status_v2() -> StatusV2 { + StatusV2 { + fork_digest: [0x8c, 0x9f, 0x62, 0xfe], + finalized_root: Root::repeat_byte(1), + finalized_epoch: 419_072, + head_root: Root::repeat_byte(2), + head_slot: 13_410_304, + earliest_available_slot: 13_400_000, + } + } + + #[test] + fn status_has_the_spec_wire_lengths() { + // v1: 4 + 32 + 8 + 32 + 8. v2 appends one more uint64. + let v1 = StatusV1 { + fork_digest: [0x8c, 0x9f, 0x62, 0xfe], + finalized_root: Root::repeat_byte(1), + finalized_epoch: 419_072, + head_root: Root::repeat_byte(2), + head_slot: 13_410_304, + }; + assert_eq!(v1.to_ssz().len(), 84); + assert_eq!(status_v2().to_ssz().len(), 92); + } + + #[test] + fn status_round_trips() { + let encoded = status_v2().to_ssz(); + assert_eq!(StatusV2::from_ssz_bytes(&encoded).unwrap(), status_v2()); + } + + #[test] + fn a_v2_status_does_not_decode_as_v1() { + // The two differ only by a trailing uint64, so this is the one thing + // that stops a v1 stream from silently accepting a v2 payload. + assert!(StatusV1::from_ssz_bytes(&status_v2().to_ssz()).is_err()); + } + + #[test] + fn metadata_has_the_spec_wire_lengths() { + // v1: 8 + 8 (64 bits of attnets). v2 adds 1 byte of syncnets. v3 adds + // a uint64 custody group count. + let v1 = MetaDataV1 { + seq_number: 0, + attnets: AttnetsBits::default(), + }; + let v2 = MetaDataV2 { + seq_number: 0, + attnets: AttnetsBits::default(), + syncnets: SyncnetsBits::default(), + }; + let v3 = MetaDataV3 { + seq_number: 0, + attnets: AttnetsBits::default(), + syncnets: SyncnetsBits::default(), + custody_group_count: super::super::constants::CUSTODY_REQUIREMENT, + }; + assert_eq!(v1.to_ssz().len(), 16); + assert_eq!(v2.to_ssz().len(), 17); + assert_eq!(v3.to_ssz().len(), 25); + } + + #[test] + fn metadata_round_trips() { + let v3 = MetaDataV3 { + seq_number: 7, + attnets: AttnetsBits::default(), + syncnets: SyncnetsBits::default(), + custody_group_count: 4, + }; + let encoded = v3.to_ssz(); + assert_eq!(MetaDataV3::from_ssz_bytes(&encoded).unwrap(), v3); + } + + #[test] + fn ping_and_goodbye_are_bare_uint64s() { + assert_eq!(Ping { seq_number: 3 }.to_ssz().len(), 8); + assert_eq!(Goodbye { reason: 1 }.to_ssz().len(), 8); + assert_eq!( + Ping::from_ssz_bytes(&Ping { seq_number: 3 }.to_ssz()).unwrap(), + Ping { seq_number: 3 } + ); + } + + /// The three the spec names, and the four above 127 that mainnet clients + /// agree on. Pinned because the whole point of the metric is telling + /// "the peer was full" apart from "the peer rejected us", and those are + /// 129 against 250/251/252. + #[test] + fn a_goodbye_reason_is_labelled_by_its_code() { + for (reason, label) in [ + (0, "unknown"), + (1, "client_shutdown"), + (2, "irrelevant_network"), + (3, "fault"), + (128, "unable_to_verify_network"), + (129, "too_many_peers"), + (250, "bad_score"), + (251, "banned"), + (252, "banned_ip"), + ] { + assert_eq!(Goodbye { reason }.reason_label(), label, "reason {reason}"); + } + } + + /// The code is a `u64` off the wire, so the label set must not follow it. + /// A peer sending an unmapped code, including one from the range the spec + /// reserves, has to land on a value already in the set. + #[test] + fn an_unmapped_goodbye_reason_cannot_add_a_label() { + for reason in [4, 127, 130, 249, 253, u64::MAX] { + assert_eq!( + Goodbye { reason }.reason_label(), + "other", + "reason {reason}" + ); + } + } +} diff --git a/crates/net/p2p/src/beacon/mod.rs b/crates/net/p2p/src/beacon/mod.rs new file mode 100644 index 000000000..3369ccba0 --- /dev/null +++ b/crates/net/p2p/src/beacon/mod.rs @@ -0,0 +1,120 @@ +//! Ethereum mainnet's wire: topic names, req/resp protocol ids, ENR entries, +//! and fork-aware decode. +//! +//! Not the bootnode list: that is operator-facing configuration rather than +//! wire format, and lives with mainnet's genesis in the binary's `beacon` +//! module. +//! +//! Nothing here is shared with lean. What *is* shared is one layer down: the +//! discv5 stack in [`crate::discovery`], the `ssz_snappy` framing and the +//! chunk-per-item response loop in [`crate::req_resp::encoding`], and +//! `compute_message_id` in [`crate`], all of which are the beacon spec's to +//! begin with. Both chains serve blocks as a chunk per block, so that loop is +//! written once and handed the two things the chains disagree about: how wide +//! the `` field is, and how a chunk body becomes a block. + +pub mod column_checks; +pub mod decode; +pub mod encoding; +pub mod handler; +pub mod messages; +pub mod protocols; +pub mod subnets; +pub mod swarm; +pub mod topics; +pub mod verdict; + +use ethlambda_types::beacon::config::Config; +use ethlambda_types::beacon::fork::ForkName; +use ethlambda_types::beacon::primitives::{ForkDigest, Root}; + +/// Everything the beacon wire needs after startup has computed it. +/// +/// `config` and `genesis_time` are carried rather than looked up because the +/// fork a gossip payload decodes under is derived from its slot, and that +/// derivation must use the same schedule the fork digest was computed from. +pub struct BeaconWire { + pub fork_digest: ForkDigest, + /// The fork `fork_digest` was computed at, so the fork of everything + /// that arrives on a subscribed topic. + /// + /// Only `beacon_attestation_{subnet_id}` reads it. Every other + /// fork-dependent topic finds its fork from the slot inside the payload, + /// which this one cannot do; see [`decode::decode_attestation`]. + pub fork: ForkName, + pub topics: topics::BeaconTopics, + pub config: Config, + pub genesis_time: u64, + /// The chain every fork digest is bound to. + /// + /// Carried alongside `fork_digest`, which is only the *current* one: + /// a block response labels each chunk with the digest of that block's own + /// epoch, so serving history means computing digests this node never runs + /// on. See [`encoding`]'s module docs for the rule. + pub genesis_validators_root: Root, + /// Advertised in `Ping` responses and in `MetaData`. Never bumped today: + /// nothing this node advertises changes at runtime. + pub metadata_seq_number: u64, + /// The columns this node custodies, carried alongside `topics` so the + /// gossip handler and the request handlers can check what this node + /// promises to serve without recomputing it from the node id. + pub custody_columns: Vec, + /// The attestation subnets this node backbones, from the same node id. + /// + /// Read by `build_metadata` for the `attnets` bitfield it advertises, so + /// what this node claims to serve is what it actually subscribed to. + pub attestation_subnets: Vec, +} + +impl BeaconWire { + /// The two values the codec needs to put a block chunk on or off the wire. + pub fn codec_context(&self) -> BeaconContext { + BeaconContext { + config: self.config.clone(), + genesis_validators_root: self.genesis_validators_root, + } + } +} + +/// The beacon chain's identity, as the request/response codec needs it. +/// +/// A block chunk's `` are a function of the block's slot, the +/// fork schedule and the chain, so the codec cannot compute them from the +/// payload alone the way it can for every other beacon protocol. `P2PServer` +/// holds the same two values on its [`BeaconWire`], but the codec runs below +/// the actor and never sees it, so it is handed its own copy at +/// [`crate::build_swarm`] time. Lean's half of the codec needs nothing, which is +/// why this is an `Option` there rather than a second codec type. +#[derive(Debug, Clone)] +pub struct BeaconContext { + pub config: Config, + pub genesis_validators_root: Root, +} + +/// Beacon-chain networking constants. +/// +/// `ethlambda_types::beacon::config::Config` carries the networking values a +/// `config.yaml` sets, but this crate runs on compile-time constants instead, +/// because some of them size a type (the `attnets` bitfield). Startup refuses a +/// network whose `Config` disagrees with any of them, so the two never differ +/// on a running node and `/eth/v1/config/spec` can report the `Config`'s. +/// +/// `ATTESTATION_SUBNET_COUNT` lives here, with the code that reads it. The +/// other two are re-exported from the types crate because something outside +/// networking reads them too: `CUSTODY_REQUIREMENT` the availability check, +/// `SYNC_COMMITTEE_SUBNET_COUNT` the sync subcommittee container and +/// `/eth/v1/config/spec`. +pub mod constants { + /// `ATTESTATION_SUBNET_COUNT`. The `attnets` bitfield is this wide even + /// though this node subscribes to none of them. + pub const ATTESTATION_SUBNET_COUNT: u64 = 64; + + /// The width of `MetaData`'s `syncnets`. Re-exported so the bitfield and + /// the sync subcommittee size divide by one value. + pub use ethlambda_types::beacon::constants::SYNC_COMMITTEE_SUBNET_COUNT; + + /// Re-exported rather than redefined: the subnet subscription, the `cgc` + /// ENR entry, the `MetaDataV3` field and the availability check must all + /// name one value, and das-core is where it is defined. + pub use ethlambda_types::beacon::constants::CUSTODY_REQUIREMENT; +} diff --git a/crates/net/p2p/src/beacon/protocols.rs b/crates/net/p2p/src/beacon/protocols.rs new file mode 100644 index 000000000..3c376ba58 --- /dev/null +++ b/crates/net/p2p/src/beacon/protocols.rs @@ -0,0 +1,177 @@ +//! The request/response protocols `ethlambda beacon` registers. +//! +//! Registered by direction, following the same subscribe-only-what-you-consume +//! rule the topics follow. The two data column sidecar protocols are +//! registered because this node custodies the columns its node id selects +//! (`BeaconWire::custody_columns`) and can answer for them out of +//! `Table::DataColumns`. The blob sidecar protocols stay absent: nothing here +//! custodies a whole blob, only the erasure-coded columns fulu derives it +//! into, and an unregistered protocol is still refused at stream negotiation +//! rather than answered with a lie. +//! +//! Only version 2 of the two block protocols is registered. Version 1 is +//! deprecated by the spec, which lets a client answer it with an empty list, +//! and its chunks carry no ``, so serving it would mean a second +//! encoder for a shape no mainnet peer needs. + +use ethlambda_types::beacon::preset; +use libp2p::StreamProtocol; +use libp2p::request_response::ProtocolSupport; + +pub const STATUS_V1: &str = "/eth2/beacon_chain/req/status/1/ssz_snappy"; +pub const STATUS_V2: &str = "/eth2/beacon_chain/req/status/2/ssz_snappy"; +pub const PING_V1: &str = "/eth2/beacon_chain/req/ping/1/ssz_snappy"; +pub const METADATA_V1: &str = "/eth2/beacon_chain/req/metadata/1/ssz_snappy"; +pub const METADATA_V2: &str = "/eth2/beacon_chain/req/metadata/2/ssz_snappy"; +pub const METADATA_V3: &str = "/eth2/beacon_chain/req/metadata/3/ssz_snappy"; +pub const GOODBYE_V1: &str = "/eth2/beacon_chain/req/goodbye/1/ssz_snappy"; +pub const BLOCKS_BY_RANGE_V2: &str = "/eth2/beacon_chain/req/beacon_blocks_by_range/2/ssz_snappy"; +pub const BLOCKS_BY_ROOT_V2: &str = "/eth2/beacon_chain/req/beacon_blocks_by_root/2/ssz_snappy"; +pub const DATA_COLUMN_SIDECARS_BY_RANGE_V1: &str = + "/eth2/beacon_chain/req/data_column_sidecars_by_range/1/ssz_snappy"; +pub const DATA_COLUMN_SIDECARS_BY_ROOT_V1: &str = + "/eth2/beacon_chain/req/data_column_sidecars_by_root/1/ssz_snappy"; + +/// `MAX_REQUEST_BLOCKS`: the ceiling phase0 put on either block request. +/// +/// This is what an inbound request is *judged* against, because it is the +/// widest a peer may ever legitimately have been built to ask for. +pub const MAX_REQUEST_BLOCKS: u64 = 1024; + +/// `MAX_REQUEST_BLOCKS_DENEB`: the ceiling from deneb on, and so the real one +/// on any live network. +/// +/// This is what an outbound request is *built* to, and what an answer is +/// truncated to. Kept separate from [`MAX_REQUEST_BLOCKS`] rather than +/// collapsed into it, because the two ceilings answer different questions: +/// asking for more than this is a protocol violation, while *receiving* a +/// request for more than this is only a peer running pre-deneb logic. The spec +/// allows "Clients MAY limit the number of blocks in the response", so that +/// peer is answered with this many rather than refused. +pub const MAX_REQUEST_BLOCKS_DENEB: u64 = 128; + +// Everything this node sends is built to the deneb ceiling and everything it +// serves is truncated to it, while an inbound request is judged against the +// phase0 one. Reversing the two would put every outbound request over the limit +// the peer enforces, so it is refused at compile time rather than in a test. +const _: () = assert!( + MAX_REQUEST_BLOCKS_DENEB < MAX_REQUEST_BLOCKS, + "the ceiling requests are built to has to fit inside the one they are judged against" +); + +/// `max_request_data_column_sidecars`: the ceiling on one request. +/// +/// Every column of every block a peer may ask for at once. Answers are +/// truncated to it rather than refused, the same way the block protocols treat +/// a peer asking past the deneb ceiling. +pub fn max_request_data_column_sidecars() -> u64 { + MAX_REQUEST_BLOCKS_DENEB * preset::NUMBER_OF_COLUMNS as u64 +} + +/// The protocols this node registers, with the direction it supports each in. +/// +/// `goodbye/1` is inbound only: this node logs the reason code a peer sends and +/// never sends one itself, because it has no opinion worth disconnecting over. +/// Everything else is bidirectional, since the handshake runs in both +/// directions on every connection. +pub fn registrations() -> Vec<(StreamProtocol, ProtocolSupport)> { + vec![ + (StreamProtocol::new(STATUS_V1), ProtocolSupport::Full), + (StreamProtocol::new(STATUS_V2), ProtocolSupport::Full), + (StreamProtocol::new(PING_V1), ProtocolSupport::Full), + (StreamProtocol::new(METADATA_V1), ProtocolSupport::Full), + (StreamProtocol::new(METADATA_V2), ProtocolSupport::Full), + (StreamProtocol::new(METADATA_V3), ProtocolSupport::Full), + (StreamProtocol::new(GOODBYE_V1), ProtocolSupport::Inbound), + ( + StreamProtocol::new(BLOCKS_BY_RANGE_V2), + ProtocolSupport::Full, + ), + ( + StreamProtocol::new(BLOCKS_BY_ROOT_V2), + ProtocolSupport::Full, + ), + ( + StreamProtocol::new(DATA_COLUMN_SIDECARS_BY_RANGE_V1), + ProtocolSupport::Full, + ), + ( + StreamProtocol::new(DATA_COLUMN_SIDECARS_BY_ROOT_V1), + ProtocolSupport::Full, + ), + ] +} + +/// Short label for the `protocol` dimension on req/resp size metrics. +pub fn label(protocol: &str) -> Option<&'static str> { + match protocol { + STATUS_V1 => Some("beacon_status_v1"), + STATUS_V2 => Some("beacon_status_v2"), + PING_V1 => Some("beacon_ping"), + METADATA_V1 => Some("beacon_metadata_v1"), + METADATA_V2 => Some("beacon_metadata_v2"), + METADATA_V3 => Some("beacon_metadata_v3"), + GOODBYE_V1 => Some("beacon_goodbye"), + BLOCKS_BY_RANGE_V2 => Some("beacon_blocks_by_range_v2"), + BLOCKS_BY_ROOT_V2 => Some("beacon_blocks_by_root_v2"), + DATA_COLUMN_SIDECARS_BY_RANGE_V1 => Some("beacon_data_column_sidecars_by_range"), + DATA_COLUMN_SIDECARS_BY_ROOT_V1 => Some("beacon_data_column_sidecars_by_root"), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn protocol_ids_are_the_mainnet_strings() { + // Read verbatim off a throwaway probe binary that completed the + // handshake against live mainnet clients with exactly these strings. + assert_eq!(STATUS_V1, "/eth2/beacon_chain/req/status/1/ssz_snappy"); + assert_eq!(STATUS_V2, "/eth2/beacon_chain/req/status/2/ssz_snappy"); + assert_eq!(PING_V1, "/eth2/beacon_chain/req/ping/1/ssz_snappy"); + assert_eq!(METADATA_V2, "/eth2/beacon_chain/req/metadata/2/ssz_snappy"); + assert_eq!(METADATA_V3, "/eth2/beacon_chain/req/metadata/3/ssz_snappy"); + assert_eq!(GOODBYE_V1, "/eth2/beacon_chain/req/goodbye/1/ssz_snappy"); + } + + #[test] + fn every_registration_has_a_metric_label() { + for (protocol, _) in registrations() { + assert!( + label(protocol.as_ref()).is_some(), + "{protocol} has no metric label" + ); + } + } + + #[test] + fn the_sidecar_protocol_ids_are_the_mainnet_strings() { + assert_eq!( + DATA_COLUMN_SIDECARS_BY_ROOT_V1, + "/eth2/beacon_chain/req/data_column_sidecars_by_root/1/ssz_snappy" + ); + assert_eq!( + DATA_COLUMN_SIDECARS_BY_RANGE_V1, + "/eth2/beacon_chain/req/data_column_sidecars_by_range/1/ssz_snappy" + ); + } + + #[test] + fn a_sidecar_request_is_bounded_by_the_block_ceiling_times_the_columns() { + assert_eq!( + max_request_data_column_sidecars(), + MAX_REQUEST_BLOCKS_DENEB * ethlambda_types::beacon::preset::NUMBER_OF_COLUMNS as u64 + ); + } + + #[test] + fn goodbye_is_inbound_only() { + let goodbye = registrations() + .into_iter() + .find(|(protocol, _)| protocol.as_ref() == GOODBYE_V1) + .expect("goodbye is registered"); + assert!(matches!(goodbye.1, ProtocolSupport::Inbound)); + } +} diff --git a/crates/net/p2p/src/beacon/subnets.rs b/crates/net/p2p/src/beacon/subnets.rs new file mode 100644 index 000000000..169ec87ec --- /dev/null +++ b/crates/net/p2p/src/beacon/subnets.rs @@ -0,0 +1,377 @@ +//! Which attestation subnets this node subscribes to, and which one an +//! attestation belongs on. +//! +//! `p2p-interface.md` makes a node's long-lived subnet subscription a public +//! function of its node id, so any peer can compute what any other peer should +//! be listening to without asking it. This module is that function and the +//! attestation-to-subnet mapping, and nothing else. +//! +//! # Why this is wire code and not state-transition code +//! +//! Every function here is a pure function of a node id, a slot and +//! [`Config`]. None of them touches a `BeaconState`, so none of them belongs +//! with the state transition; what they describe is which topic a message goes +//! on, which is this crate's subject. The two state-transition primitives they +//! do need, the swap-or-not shuffle and SHA-256, are imported. +//! +//! [`ethlambda_state_transition::beacon::das`] is the same shape for columns +//! rather than attestations and stays where it is, because the `networking` +//! consensus-spec fixture suite has handlers for `get_custody_groups` and +//! `compute_columns_for_custody_group` and its runner lives beside it. That +//! suite has no attestation-subnet handler, so nothing anchors this module +//! there. +//! +//! # Why a beacon node with no validators subscribes at all +//! +//! Phase 0 has no shard committees, so nothing gives the attestation subnets a +//! stable membership of their own. `p2p-interface.md`'s "Attestation subnet +//! subscription" answers that by asking *every* beacon node to hold +//! [`Config::subnets_per_node`] subscriptions for their own sake, advertised +//! in the ENR's `attnets`, so that a validator publishing to a subnet finds a +//! mesh already there. A follower therefore subscribes, verifies and relays on +//! its own subnets without ever applying what arrives to its fork choice: the +//! subscription is owed to the network, not to this node's head. +//! +//! # No rotation +//! +//! The specification frames the subscription as lasting +//! `EPOCHS_PER_SUBNET_SUBSCRIPTION` epochs, with `epoch` an argument to +//! [`compute_subscribed_subnets`] precisely so the set rotates. This node +//! computes the set once at startup and keeps it for the lifetime of the +//! process, which is also what lighthouse does: its `compute_attestation_subnets` +//! is documented as subscribing "for the duration of the node's runtime", and +//! nothing there reads `epochs_per_subnet_subscription` at all. The argument is +//! kept rather than dropped so that adding rotation later is a matter of +//! calling this again, not of changing its shape. See `docs/spec_deviations.md`. + +// The state transition crate owns the swap-or-not shuffle and the SHA-256 this +// builds on, and the error type they report through. Nothing here needs a +// state; what it needs is those two primitives, which no other crate has, so +// they are imported rather than the module living beside them. See the module +// documentation. +use ethlambda_state_transition::beacon::error::{Result, verify}; +use ethlambda_state_transition::beacon::hash::hash; +use ethlambda_state_transition::beacon::helpers::shuffling::compute_shuffled_index; +use ethlambda_types::beacon::config::Config; +use ethlambda_types::beacon::preset; +use ethlambda_types::beacon::primitives::{CommitteeIndex, Slot}; + +/// The width of a discv5 node id, in bits. `p2p-interface.md`'s `NODE_ID_BITS`. +pub const NODE_ID_BITS: u32 = 256; + +/// The subnet an attestation for `committee_index` at `slot` belongs on. +/// +/// `validator.md`'s `compute_subnet_for_attestation`. Takes the subnet count +/// off [`Config`] rather than reading a constant, so a devnet that narrows the +/// subnet space maps its attestations the way its own configuration says +/// rather than the way mainnet's does. +/// +/// `committees_per_slot` is the caller's, because it is a function of the +/// state at the attestation's epoch and this module holds no state. +pub fn compute_subnet_for_attestation( + committees_per_slot: u64, + slot: Slot, + committee_index: CommitteeIndex, + config: &Config, +) -> u64 { + let slots_since_epoch_start = slot % preset::SLOTS_PER_EPOCH; + let committees_since_epoch_start = committees_per_slot.saturating_mul(slots_since_epoch_start); + committees_since_epoch_start.saturating_add(committee_index) % config.attestation_subnet_count +} + +/// How many leading bits of a node id select its subnet, as +/// `compute_attestation_subnet_prefix_bits`. +/// +/// Derived from the subnet count and the extra bits rather than read off +/// [`Config::attestation_subnet_prefix_bits`], although that key has a typed +/// home and mainnet's published file carries it. The specification defines +/// this as a derivation, so deriving it cannot disagree with the subnet count +/// it is taken over, whereas reading the field can: a configuration that +/// narrows `ATTESTATION_SUBNET_COUNT` and leaves the prefix key out falls back +/// to mainnet's 6 and would shuffle over a space its own subnet count does not +/// match. `the_shipped_configs_agree_with_the_derivation` pins the two +/// together for the configurations that do carry it. +fn attestation_subnet_prefix_bits(config: &Config) -> u32 { + let count = config.attestation_subnet_count.max(1); + // ceillog2: how many bits it takes to index `count` values. Exact for the + // powers of two every shipped configuration uses, and rounds up for + // anything else, which is what `ceillog2` means. + let ceil_log2 = u64::BITS - (count - 1).leading_zeros(); + ceil_log2 + config.attestation_subnet_extra_bits as u32 +} + +/// One of the subnets `node_id` subscribes to at `epoch`, by `index`. +/// +/// `p2p-interface.md`'s `compute_subscribed_subnet`. `node_id` is the discv5 +/// node id, 32 bytes big endian, the same form +/// [`ethlambda_state_transition::beacon::das::get_custody_groups`] takes and the same form a peer reads off +/// an ENR. +/// +/// Two byte-order traps, either of which produces a plausible-looking set that +/// agrees with no other client: +/// +/// * `node_id >> (NODE_ID_BITS - prefix_bits)` is a shift on the *number*, so +/// the prefix is the leading bits of the big-endian encoding. +/// * `uint_to_bytes` is SSZ's, so the seed is hashed over the **little-endian** +/// eight bytes of the subscription period, not its big-endian ones. This is +/// the mirror image of the trap `get_custody_groups` documents, where the +/// number being hashed is the node id itself. +pub fn compute_subscribed_subnet( + node_id: [u8; 32], + epoch: u64, + index: u64, + config: &Config, +) -> Result { + let prefix_bits = attestation_subnet_prefix_bits(config); + // Strictly less than `u64::BITS`: `1u64 << prefix_bits` below requires a + // shift strictly less than the type's width, and `prefix_bits == + // u64::BITS` would overflow it (a panic in debug, `1` itself in release, + // either way not the value the shift asks for). + verify( + prefix_bits > 0 && prefix_bits < u64::BITS, + "the attestation subnet prefix fits in a u64", + )?; + let period = config.epochs_per_subnet_subscription.max(1); + + let node_id_prefix = leading_bits(node_id, prefix_bits); + let node_offset = modulo(node_id, period); + // Integer division, so every epoch in one subscription period hashes to the + // same seed and therefore selects the same subnet: that is what makes the + // subscription long-lived rather than per-epoch. + let subscription_period = epoch.saturating_add(node_offset) / period; + let permutation_seed = hash(&subscription_period.to_le_bytes()); + + // `node_id_prefix < 2^prefix_bits` holds by construction, which is + // `compute_shuffled_index`'s own precondition. + let permutated_prefix = + compute_shuffled_index(node_id_prefix, 1u64 << prefix_bits, permutation_seed)?; + Ok(permutated_prefix.saturating_add(index) % config.attestation_subnet_count) +} + +/// Every subnet `node_id` subscribes to at `epoch`, sorted ascending and +/// deduplicated. +/// +/// `p2p-interface.md`'s `compute_subscribed_subnets`. Sorted and deduplicated +/// where the specification's list comprehension is neither, for the reason +/// [`ethlambda_state_transition::beacon::das::custody_columns`] sorts: the callers subscribe to a set of +/// topics and set a set of ENR bits, and a repeated entry there would mean a +/// second `subscribe()` call and a topic count that disagrees with the map +/// beside it. A repeat is reachable whenever [`Config::subnets_per_node`] is +/// not smaller than [`Config::attestation_subnet_count`], since the index is +/// added modulo the count. +pub fn compute_subscribed_subnets( + node_id: [u8; 32], + epoch: u64, + config: &Config, +) -> Result> { + let mut subnets = Vec::with_capacity(config.subnets_per_node as usize); + for index in 0..config.subnets_per_node { + let subnet = compute_subscribed_subnet(node_id, epoch, index, config)?; + if !subnets.contains(&subnet) { + subnets.push(subnet); + } + } + subnets.sort_unstable(); + Ok(subnets) +} + +/// The leading `bits` bits of a big-endian 256-bit integer, as a `u64`. +/// +/// `node_id >> (NODE_ID_BITS - bits)`. Walks only the bytes the prefix can +/// touch rather than materializing a 256-bit shift, since `bits` is at most +/// [`u64::BITS`] and the caller has already checked that. +fn leading_bits(node_id: [u8; 32], bits: u32) -> u64 { + // The bytes the prefix spans, rounded up: a prefix of 6 bits lives entirely + // in the first byte, one of 9 bits spans the first two. + let byte_count = bits.div_ceil(8) as usize; + let mut value: u64 = 0; + for &byte in &node_id[..byte_count] { + value = (value << 8) | u64::from(byte); + } + // `value` now holds `byte_count * 8` bits, which is `bits` rounded up to a + // byte boundary, so drop the extra low bits the rounding pulled in. + value >> (byte_count as u32 * 8 - bits) +} + +/// A big-endian 256-bit integer modulo `modulus`. +/// +/// Long division a byte at a time, in `u128` so the intermediate +/// `remainder << 8` cannot overflow: `remainder` is below `modulus`, which is +/// at most [`u64::MAX`], so the shifted value needs 72 bits. +fn modulo(node_id: [u8; 32], modulus: u64) -> u64 { + let modulus = u128::from(modulus); + let mut remainder: u128 = 0; + for &byte in &node_id { + remainder = ((remainder << 8) | u128::from(byte)) % modulus; + } + remainder as u64 +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use super::*; + + /// The specification derives the prefix bits; the published configurations + /// also carry the answer as a key. They must agree, or one of the two + /// readings is shuffling over the wrong space. + #[test] + fn the_shipped_configs_agree_with_the_derivation() { + for config in [Config::mainnet(), Config::minimal()] { + assert_eq!( + u64::from(attestation_subnet_prefix_bits(&config)), + config.attestation_subnet_prefix_bits, + "the derived prefix must match the configured one" + ); + } + } + + /// A prefix of exactly 64 bits must be refused rather than reaching the + /// `1u64 << prefix_bits` shift below, which is exactly as wide as `u64` + /// and would overflow it. + #[test] + fn a_prefix_of_exactly_64_bits_is_refused() { + let config = Config { + attestation_subnet_count: 64, + // `attestation_subnet_prefix_bits` is `ceillog2(64) + extra_bits` + // = `6 + extra_bits`, so 58 extra bits makes the derived prefix + // exactly `u64::BITS`. + attestation_subnet_extra_bits: 58, + ..Config::mainnet() + }; + assert_eq!(attestation_subnet_prefix_bits(&config), u64::BITS); + assert!(compute_subscribed_subnet([0u8; 32], 0, 0, &config).is_err()); + } + + #[test] + fn mainnet_takes_the_top_six_bits_of_the_node_id() { + let config = Config::mainnet(); + assert_eq!(attestation_subnet_prefix_bits(&config), 6); + // 0b1010_1100: the top six bits are 0b101011, which is 43. + let mut node_id = [0u8; 32]; + node_id[0] = 0b1010_1100; + assert_eq!(leading_bits(node_id, 6), 43); + } + + /// A prefix spanning more than one byte must not pick up the low bits of + /// the byte it only partly covers. + #[test] + fn a_prefix_spanning_two_bytes_drops_the_rounding_bits() { + let mut node_id = [0u8; 32]; + node_id[0] = 0b1111_1111; + node_id[1] = 0b1000_0000; + // Nine bits: eight ones, then the one. + assert_eq!(leading_bits(node_id, 9), 0b1_1111_1111); + // Ten bits: the tenth is the zero after it. + assert_eq!(leading_bits(node_id, 10), 0b11_1111_1110); + } + + #[test] + fn the_modulo_reads_the_whole_256_bit_number() { + // 256 as a big-endian 256-bit integer: a one in the second-lowest byte. + let mut node_id = [0u8; 32]; + node_id[30] = 1; + assert_eq!(modulo(node_id, 1000), 256); + // All ones mod 2 is 1, which no truncation to the low or the high bytes + // alone gets right for every modulus. + assert_eq!(modulo([0xff; 32], 2), 1); + } + + #[test] + fn a_node_subscribes_to_subnets_per_node_subnets() { + let config = Config::mainnet(); + let subnets = compute_subscribed_subnets([7u8; 32], 0, &config).unwrap(); + assert_eq!(subnets.len() as u64, config.subnets_per_node); + for subnet in &subnets { + assert!(*subnet < config.attestation_subnet_count); + } + } + + #[test] + fn the_subnets_are_sorted_and_distinct() { + let config = Config::mainnet(); + for seed in 0u8..32 { + let subnets = compute_subscribed_subnets([seed; 32], 0, &config).unwrap(); + let mut sorted = subnets.clone(); + sorted.sort_unstable(); + sorted.dedup(); + assert_eq!( + subnets, sorted, + "node id {seed} produced an unsorted or repeated set" + ); + } + } + + /// The property the whole subscription rests on: the set holds for a + /// subscription period rather than being redrawn every epoch. Without it, + /// "long-lived subscription" would be a per-epoch churn no mesh could form + /// around. + #[test] + fn the_set_is_stable_across_a_subscription_period() { + let config = Config::mainnet(); + let node_id = [3u8; 32]; + // The node offset shifts where a node's period boundaries fall, so step + // an epoch at a time and require the set to turn over at most once, + // rather than assuming epoch 0 begins a period. + let mut changes = 0; + let mut previous = compute_subscribed_subnets(node_id, 0, &config).unwrap(); + for epoch in 1..config.epochs_per_subnet_subscription { + let current = compute_subscribed_subnets(node_id, epoch, &config).unwrap(); + if current != previous { + changes += 1; + previous = current; + } + } + assert!( + changes <= 1, + "a span of one period may turn over at most once, saw {changes} changes" + ); + } + + /// Different node ids must generally select different sets, or the + /// subscription is not spreading nodes over the subnet space at all. + #[test] + fn different_node_ids_spread_across_subnets() { + let config = Config::mainnet(); + let mut seen = HashSet::new(); + for seed in 0u8..64 { + let mut node_id = [0u8; 32]; + node_id[0] = seed.wrapping_mul(4); + seen.extend(compute_subscribed_subnets(node_id, 0, &config).unwrap()); + } + assert!( + seen.len() > 8, + "64 node ids covered only {} subnets, which is not a spread", + seen.len() + ); + } + + #[test] + fn an_attestation_maps_to_its_subnet() { + let config = Config::mainnet(); + // Slot 0 of an epoch, committee 0: the first subnet. + assert_eq!(compute_subnet_for_attestation(4, 0, 0, &config), 0); + // The same slot, third committee. + assert_eq!(compute_subnet_for_attestation(4, 0, 2, &config), 2); + // The epoch's second slot, with four committees per slot, starts at 4. + assert_eq!(compute_subnet_for_attestation(4, 1, 0, &config), 4); + assert_eq!(compute_subnet_for_attestation(4, 1, 3, &config), 7); + } + + /// The mapping wraps at the subnet count rather than running past it, which + /// is what keeps a full slot's worth of committees inside the bitfield. + #[test] + fn the_subnet_mapping_wraps_at_the_subnet_count() { + let config = Config::mainnet(); + let count = config.attestation_subnet_count; + // 64 committees per slot at slot 1 is 64 committees since the epoch + // began, which wraps to 0. + assert_eq!(compute_subnet_for_attestation(count, 1, 0, &config), 0); + for slot in 0..preset::SLOTS_PER_EPOCH { + for index in 0..4 { + assert!(compute_subnet_for_attestation(4, slot, index, &config) < count); + } + } + } +} diff --git a/crates/net/p2p/src/beacon/swarm.rs b/crates/net/p2p/src/beacon/swarm.rs new file mode 100644 index 000000000..0acb9cfd0 --- /dev/null +++ b/crates/net/p2p/src/beacon/swarm.rs @@ -0,0 +1,314 @@ +//! The beacon half of the swarm configuration. +//! +//! Five things differ from lean at the swarm level: the topic set, the protocol +//! set, the `seen_ttl`, the identify protocol version and the connection limits. +//! [`crate::build_swarm`] resolves all five from the variant it is handed, and +//! every one of them that is a beacon *value* rather than a lean one lives here, +//! so tuning the mainnet numbers never means editing the crate root. + +use std::time::Duration; + +use ethlambda_types::beacon::config::Config; +use ethlambda_types::beacon::fork::ForkName; +use ethlambda_types::beacon::preset; +use ethlambda_types::beacon::primitives::{ForkDigest, Root}; + +/// How long gossipsub remembers a message id, so a duplicate arriving late is +/// dropped rather than re-forwarded. +/// +/// The beacon p2p interface states this as +/// `SLOTS_PER_EPOCH * SECONDS_PER_SLOT * 2`, which is what is written here +/// rather than the number it evaluates to, so it stays correct if either factor +/// moves. +pub fn seen_ttl(config: &Config) -> Duration { + Duration::from_secs(preset::SLOTS_PER_EPOCH * config.seconds_per_slot * 2) +} + +/// Lighthouse's identify protocol version. go-libp2p peers gate gossipsub GRAFT +/// on the identify exchange completing, so a peer that does not answer is +/// silently excluded from the mesh. +pub const IDENTIFY_PROTOCOL_VERSION: &str = "eth2/1.0.0"; + +/// The share of the peer target inbound demand is allowed to hold, as a +/// percentage. +/// +/// The remainder is reserved for connections this node opens itself. Without a +/// reservation, inbound demand takes every slot and discovery can never dial a +/// peer of its own choosing: the eclipse-adjacent case libp2p's own +/// documentation warns about for a total-only limit, and the thing that costs +/// us the ability to seek out peers serving the columns we need. +/// +/// 70 rather than the 90 a 20-slot reservation worked out to, measured on the +/// mainnet follower: it sat at 178 inbound peers and **zero** outbound ones for +/// two days, while every block waited minutes on custody columns no connected +/// peer held. A reservation only helps if the dial loop is still trying to fill +/// it, so the other half of that fix is in +/// [`crate::discovery::dial::dial_tick`], which now paces itself on the +/// outbound shortfall rather than the total peer count. +pub const MAX_INBOUND_CONNECTION_PERCENT: u32 = 70; + +// A share at or above 100 leaves no outbound reservation at all. Exactly 100 is +// the case worth refusing by name: it derives a zero reservation, which every +// other line here then treats as "nothing to reserve" rather than as the +// misconfiguration it is. Zero is refused for the mirror-image reason: it would +// admit no inbound peer at all. +const _: () = assert!( + MAX_INBOUND_CONNECTION_PERCENT > 0 && MAX_INBOUND_CONNECTION_PERCENT < 100, + "the inbound share has to leave a non-empty outbound reservation" +); + +/// Ceiling on connections the beacon swarm keeps established at once. +/// +/// `--discovery.target-peers`, which is the whole of it: the number of peers +/// an operator asks for is the number this node keeps, so the dial loop's +/// cutoff and the swarm's own refusal are one number rather than two that can +/// disagree. A flat ceiling of its own is what let the outbound reservation +/// below be a fixed 60 slots no matter what the operator asked for, so a +/// target of 50 kept dialing to 60 outbound peers and a target of 0, meaning +/// "do not dial", still had a 60-peer shortfall to chase. +/// +/// Bounded at all because mainnet dials us far faster than we dial it: a +/// 22-hour run accepted 12,521 inbound connections against 235 successful +/// outbound dials, and settled at 371 held peers. Every one of them feeds the +/// same gossip decode path, which competes with block import for the single +/// core that decides how fast the head advances. Left uncapped the peer count +/// is set by how popular we are, not by what we can afford. +/// +/// Counts *connections*, while the target counts peers, and +/// [`MAX_CONNECTIONS_PER_PEER`] lets one peer hold two. A peer on both +/// transports therefore spends two of these, which is the pre-existing reason +/// this ceiling is a bound on the peer count and not an equality. +pub fn max_connections(target_peers: usize) -> u32 { + u32::try_from(target_peers).unwrap_or(u32::MAX) +} + +/// How much of [`max_connections`] inbound demand may hold. +/// +/// Rounds down, which is the safe direction: inbound gets slightly less than +/// its nominal share rather than more, and the remainder falls to the +/// reservation. See [`MAX_INBOUND_CONNECTION_PERCENT`]. +pub fn max_inbound_connections(target_peers: usize) -> u32 { + // In `u64` so the share is exact rather than saturating: the product + // overflows `u32` from a ceiling of about 61 million upward, and a + // saturated product would silently stop being a percentage. + let ceiling = u64::from(max_connections(target_peers)); + (ceiling * u64::from(MAX_INBOUND_CONNECTION_PERCENT) / 100) as u32 +} + +/// How much of [`max_connections`] stays reserved for connections we open +/// ourselves, which is also the shortfall the dial loop chases. +/// +/// The remainder rather than its own percentage, so the two allowances add up +/// to the ceiling by construction at every target, including the ones where +/// the division above rounds. +pub fn max_outbound_connections(target_peers: usize) -> u32 { + max_connections(target_peers) - max_inbound_connections(target_peers) +} + +/// Connections a single peer may hold. Two rather than one because the swarm +/// listens on both QUIC and TCP, so a remote is free to establish over each. +pub const MAX_CONNECTIONS_PER_PEER: u32 = 2; + +/// Outbound dials allowed in flight at once. +/// +/// [`max_connections`] and its two halves bound only *established* +/// connections, and a dial that never establishes is never counted by them. +/// That gap did not matter while the loop dialed 1.6 times a second; at +/// [`crate::discovery::MAX_DIAL_RATE_PER_SECOND`] it does, because 96% of +/// outbound dials to mainnet never establish and the ones that fail by timing +/// out hold a socket for seconds first. Unbounded, the in-flight set is the +/// dial rate times however long the slowest peer takes to not answer. +/// +/// Four seconds of dialing at full rate, which is far above what a healthy +/// node has outstanding and still a hard ceiling on the file descriptors this +/// can consume. Denials past it cost a candidate, so it is deliberately not +/// tight enough to be reached in normal operation. +pub const MAX_PENDING_OUTBOUND_CONNECTIONS: u32 = + crate::discovery::MAX_DIAL_RATE_PER_SECOND as u32 * 4; + +/// Connection limits for the beacon network, where inbound supply is +/// effectively unbounded. See [`max_connections`]. +/// +/// Derived from the same `target_peers` the dial loop reads, so what this node +/// refuses and what it goes looking for are two readings of one number. A +/// target of 0 therefore holds no peers rather than serving from a ceiling +/// nobody asked for. +pub fn connection_limits(target_peers: usize) -> libp2p::connection_limits::Behaviour { + let limits = libp2p::connection_limits::ConnectionLimits::default() + .with_max_established(Some(max_connections(target_peers))) + .with_max_established_incoming(Some(max_inbound_connections(target_peers))) + .with_max_established_outgoing(Some(max_outbound_connections(target_peers))) + .with_max_established_per_peer(Some(MAX_CONNECTIONS_PER_PEER)) + .with_max_pending_outgoing(Some(MAX_PENDING_OUTBOUND_CONNECTIONS)); + libp2p::connection_limits::Behaviour::new(limits) +} + +/// The beacon wire's swarm parameters: what [`crate::WireConfig::Beacon`] +/// carries and lean has no equivalent of. +/// +/// `config` and `genesis_time` outlive startup on [`crate::beacon::BeaconWire`], +/// because the fork a gossip payload decodes under is derived from its slot and +/// that derivation must use the schedule the fork digest was computed from. +pub struct BeaconWireConfig { + pub fork_digest: ForkDigest, + /// The fork `fork_digest` was computed at. See + /// [`BeaconWire::fork`](crate::beacon::BeaconWire). + pub fork: ForkName, + pub config: Config, + pub genesis_time: u64, + /// The chain the digests are bound to. See + /// [`BeaconWire::genesis_validators_root`](crate::beacon::BeaconWire). + pub genesis_validators_root: Root, + /// The columns this node custodies, computed once at startup from the + /// node id. Both the subnet subscription and the availability check read + /// this, so the node cannot subscribe to one set and require another. + pub custody_columns: Vec, + /// The attestation subnets this node backbones, computed once at startup + /// from the same node id. + /// + /// Carried rather than derived here for the reason `custody_columns` is: + /// the ENR's `attnets` bits and the gossip subscription have to name one + /// set, and a peer computes the same set from this node's id, so deriving + /// it twice is how the two would come to disagree. + pub attestation_subnets: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The numbers the default target has always produced, now derived from it + /// rather than written down beside it. + #[test] + fn inbound_is_capped_at_its_share_and_the_rest_is_reserved() { + assert_eq!(max_connections(200), 200); + assert_eq!(max_inbound_connections(200), 140); + assert_eq!(max_outbound_connections(200), 60); + } + + /// The property the reservation rests on, at every target rather than at + /// the default alone: inbound may never hold more than its configured + /// share, and whatever the division rounds off falls to the reservation + /// rather than going missing. + #[test] + fn the_two_allowances_add_up_and_rounding_favours_the_reservation() { + // 7 and 13 are the interesting ones: neither is a multiple of 100, so + // the share rounds, which is where an allowance could quietly grow. + for target in [0usize, 1, 7, 13, 50, 199, 200, 1_000] { + let inbound = max_inbound_connections(target); + let outbound = max_outbound_connections(target); + assert_eq!( + inbound + outbound, + max_connections(target), + "the two allowances have to add up to the ceiling at target {target}" + ); + assert!( + u64::from(inbound) * 100 + <= u64::from(max_connections(target)) + * u64::from(MAX_INBOUND_CONNECTION_PERCENT), + "inbound may never hold more than its configured share at target {target}" + ); + } + } + + /// `--discovery.target-peers 0` holds no peers at all, which is the whole + /// of what the flag now means: the dial loop has a zero reservation to + /// chase and the swarm refuses inbound demand it was never asked to carry. + #[test] + fn a_zero_target_reserves_nothing_and_admits_nothing() { + assert_eq!(max_connections(0), 0); + assert_eq!(max_inbound_connections(0), 0); + assert_eq!(max_outbound_connections(0), 0); + } + + /// Real mainnet bootnode ENRs, two `tcp`-dialable and two seed-only. + /// + /// A fixture, not the shipped list (that is the binary's + /// `assets/mainnet/bootstrap_nodes.yaml`): what these tests need is the + /// shape of a mixed dial set, not its current membership. + const BOOTNODE_FIXTURE: [&str; 4] = [ + // Teku, 3.147.37.0 | aws-us-east-2-ohio: ip/tcp/udp. + "enr:-Iu4QLm7bZGdAt9NSeJG0cEnJohWcQTQaI9wFLu3Q7eHIDfrI4cwtzvEW3F3VbG9XdFXlrHyFGeXPn9snTCQJ9bnMRABgmlkgnY0gmlwhAOTJQCJc2VjcDI1NmsxoQIZdZD6tDYpkpEfVo5bgiU8MGRjhcOmHGD2nErK0UKRrIN0Y3CCIyiDdWRwgiMo", + // Teku, 3.107.124.68 | aws-ap-southeast-2-sydney: ip/tcp/udp. + "enr:-Iu4QEDJ4Wa_UQNbK8Ay1hFEkXvd8psolVK6OhfTL9irqz3nbXxxWyKwEplPfkju4zduVQj6mMhUCm9R2Lc4YM5jPcIBgmlkgnY0gmlwhANrfESJc2VjcDI1NmsxoQJCYz2-nsqFpeEj6eov9HSi9QssIVIVNr0I89J1vXM9foN0Y3CCIyiDdWRwgiMo", + // Prylab, 18.223.219.100 | aws-us-east-2-ohio: udp only. + "enr:-Ku4QImhMc1z8yCiNJ1TyUxdcfNucje3BGwEHzodEZUan8PherEo4sF7pPHPSIB1NNuSg5fZy7qFsjmUKs2ea1Whi0EBh2F0dG5ldHOIAAAAAAAAAACEZXRoMpD1pf1CAAAAAP__________gmlkgnY0gmlwhBLf22SJc2VjcDI1NmsxoQOVphkDqal4QzPMksc5wnpuC3gvSC8AfbFOnZY_On34wIN1ZHCCIyg", + // Prylab, 18.223.219.100 | aws-us-east-2-ohio: udp only. + "enr:-Ku4QP2xDnEtUXIjzJ_DhlCRN9SN99RYQPJL92TMlSv7U5C1YnYLjwOQHgZIUXw6c-BvRg2Yc2QsZxxoS_pPRVe0yK8Bh2F0dG5ldHOIAAAAAAAAAACEZXRoMpD1pf1CAAAAAP__________gmlkgnY0gmlwhBLf22SJc2VjcDI1NmsxoQMeFF5GrS7UZpAH2Ly84aLK-TyvH-dRo0JM1i8yygH50YN1ZHCCJxA", + ]; + + #[test] + fn the_seen_ttl_is_two_epochs() { + // The design doc's parenthetical says 385s, which does not match its own + // formula: 32 * 12 * 2 is 768. The formula is the one the beacon p2p + // interface states, so it wins, and writing it out keeps it honest if + // either factor ever moves. + assert_eq!(seen_ttl(&Config::mainnet()), Duration::from_secs(768)); + } + + #[tokio::test] + async fn a_beacon_swarm_subscribes_to_its_custody_columns_and_dials_bootnodes_over_tcp() { + // Port 0 asks the OS for a free port, so this cannot collide with a + // running node or a sibling test. + // Four real mainnet bootnode ENRs rather than the whole published + // list, which now lives in the binary's `beacon` module: the subject + // here is `build_swarm`'s dial set, and what that needs is a mix of + // records it can and cannot dial. The first two are Teku's, which + // advertise `tcp`; the last two are Prylab's, which advertise only + // `udp` and so stay discv5-seed-only. None advertises `quic`, which is + // true of every published mainnet bootnode. + let mainnet_bootnodes = + crate::parse_enrs(BOOTNODE_FIXTURE.iter().map(|s| s.to_string()).collect()); + assert_eq!(mainnet_bootnodes.len(), BOOTNODE_FIXTURE.len()); + let tcp_dialable_count = mainnet_bootnodes + .iter() + .filter(|b| b.tcp_port.is_some()) + .count(); + assert_eq!(tcp_dialable_count, 2, "the fixture's premise changed"); + // Two arbitrary columns, standing in for whatever a real node id would + // select: `build_swarm` must subscribe exactly these, not the custody + // count's-worth of *something*. + let custody_columns = vec![3u64, 9]; + let built = crate::build_swarm(crate::SwarmConfig { + node_key: vec![1u8; 32], + listening_socket: "127.0.0.1:0".parse().expect("valid socket"), + bootnodes: mainnet_bootnodes, + target_peers: crate::discovery::DEFAULT_DISCOVERY_TARGET_PEERS, + agent_version: "ethlambda/test", + wire: crate::WireConfig::Beacon(Box::new(BeaconWireConfig { + fork_digest: [0x8c, 0x9f, 0x62, 0xfe], + fork: ForkName::Fulu, + config: Config::mainnet(), + genesis_time: 1_606_824_023, + genesis_validators_root: Root::ZERO, + custody_columns: custody_columns.clone(), + attestation_subnets: Vec::new(), + })), + }) + .expect("swarm builds"); + + let wire = built.wire.beacon().expect("a beacon wire"); + assert_eq!( + wire.topics.topics.len(), + crate::beacon::topics::SUBSCRIBED_TOPIC_KINDS.len() + custody_columns.len() + ); + assert_eq!(wire.topics.column_topics.len(), custody_columns.len()); + for column in &custody_columns { + assert!(wire.topics.column_topics.contains_key(column)); + } + assert_eq!(wire.custody_columns, custody_columns); + assert_eq!(wire.fork_digest, [0x8c, 0x9f, 0x62, 0xfe]); + // No published mainnet bootnode advertises `quic`, but the ones that + // advertise `tcp` are now dialable, which is the point of adding the + // transport; the rest are still seed-only, exactly as before. + assert_eq!(built.bootnode_addrs.len(), tcp_dialable_count); + for addrs in built.bootnode_addrs.values() { + assert_eq!( + addrs.len(), + 1, + "a quic-less bootnode dial list must carry exactly its tcp address" + ); + assert!(addrs[0].to_string().contains("/tcp/")); + } + } +} diff --git a/crates/net/p2p/src/beacon/topics.rs b/crates/net/p2p/src/beacon/topics.rs new file mode 100644 index 000000000..b367a0536 --- /dev/null +++ b/crates/net/p2p/src/beacon/topics.rs @@ -0,0 +1,447 @@ +//! The gossipsub topics `ethlambda beacon` subscribes to. +//! +//! Seven global topics, plus two families this node's own node id selects a +//! narrow slice of: the data column subnets it custodies, and the +//! `SUBNETS_PER_NODE` attestation subnets it backbones. +//! +//! "Only what it consumes" is no longer the whole rule, and +//! `beacon_attestation_{subnet_id}` is where it stops applying. +//! `p2p-interface.md` asks every beacon node to hold a long-lived subscription +//! to `SUBNETS_PER_NODE` of these, chosen from its node id, precisely so that +//! the subnets have a stable membership for validators to publish into; phase 0 +//! has no shard committees to give them one. That subscription is owed to the +//! network rather than to this node's own head, so what arrives on it is +//! relayed but never applied to fork choice, which is what a lighthouse node +//! with no validators does too. +//! +//! `sync_committee_{0..3}` and `blob_sidecar_{subnet_id}` stay absent; the +//! first arrives with the work that reads it and the second is deneb's format +//! for blobs, deprecated at fulu in favour of the column matrix. Both subnet +//! families that *are* subscribed are subscribed narrowly: this node's sampling +//! size worth of columns rather than the whole matrix, and two attestation +//! subnets rather than all sixty-four, since widening either is what turns this +//! node into a supernode. +//! +//! Publishing is wider than subscribing: the Beacon API gossips a validator +//! client's attestations on whichever subnet each belongs to, through gossipsub +//! fanout, which needs only peers subscribed to that subnet, not this node. + +use std::collections::BTreeMap; + +use ethlambda_types::beacon::primitives::ForkDigest; +use libp2p::gossipsub::IdentTopic; + +/// Topic kind for beacon block gossip. +pub const BEACON_BLOCK: &str = "beacon_block"; +/// Topic kind for aggregated attestations with their selection proofs. +pub const BEACON_AGGREGATE_AND_PROOF: &str = "beacon_aggregate_and_proof"; +/// Topic kind for voluntary exits. +pub const VOLUNTARY_EXIT: &str = "voluntary_exit"; +/// Topic kind for proposer slashings. +pub const PROPOSER_SLASHING: &str = "proposer_slashing"; +/// Topic kind for attester slashings. +pub const ATTESTER_SLASHING: &str = "attester_slashing"; +/// Topic kind for BLS-to-execution withdrawal credential changes. +pub const BLS_TO_EXECUTION_CHANGE: &str = "bls_to_execution_change"; +/// Topic kind for aggregated sync committee contributions. +pub const SYNC_COMMITTEE_CONTRIBUTION_AND_PROOF: &str = "sync_committee_contribution_and_proof"; + +/// Every topic kind this node subscribes to, in the order they are subscribed. +pub const SUBSCRIBED_TOPIC_KINDS: [&str; 7] = [ + BEACON_BLOCK, + BEACON_AGGREGATE_AND_PROOF, + VOLUNTARY_EXIT, + PROPOSER_SLASHING, + ATTESTER_SLASHING, + BLS_TO_EXECUTION_CHANGE, + SYNC_COMMITTEE_CONTRIBUTION_AND_PROOF, +]; + +/// The metric label every data column subnet shares, so the column subnets +/// add one label value rather than one per subnet. +pub const DATA_COLUMN_SIDECAR_KIND: &str = "data_column_sidecar"; + +/// The metric label every attestation subnet shares, so the backbone subnets +/// add one label value rather than one per subnet. See +/// [`DATA_COLUMN_SIDECAR_KIND`]. +pub const BEACON_ATTESTATION_KIND: &str = "beacon_attestation"; + +/// The metric label for a topic kind this node subscribes to on the beacon +/// wire: the kind itself for a global topic, [`DATA_COLUMN_SIDECAR_KIND`] for +/// a column subnet, [`BEACON_ATTESTATION_KIND`] for an attestation subnet. +/// `None` for anything else, lean kinds included, which is what tells the +/// gossip handler a message needs no verdict. +pub fn metric_kind(kind: &str) -> Option<&'static str> { + if let Some(&global) = SUBSCRIBED_TOPIC_KINDS.iter().find(|&&known| known == kind) { + return Some(global); + } + if data_column_subnet(kind).is_some() { + return Some(DATA_COLUMN_SIDECAR_KIND); + } + attestation_subnet(kind).map(|_| BEACON_ATTESTATION_KIND) +} + +/// Build one topic name: `/eth2/{fork_digest}/{kind}/ssz_snappy`. +/// +/// `fork_digest` is lowercase hex with no `0x` prefix, which is what every +/// beacon client emits and what the topic hash is therefore taken over. +pub fn topic_name(fork_digest: ForkDigest, kind: &str) -> String { + format!("/eth2/{}/{kind}/ssz_snappy", hex::encode(fork_digest)) +} + +/// The topic kind embedded in a full topic name, or `None` if the name is not +/// shaped like a beacon topic. +/// +/// `/eth2/{digest}/{kind}/ssz_snappy` splits on `/` into +/// `["", "eth2", digest, kind, "ssz_snappy"]`, so the kind is at index 3 — +/// the same index lean's `/leanconsensus/…` names put it at. +pub fn topic_kind(topic: &str) -> Option<&str> { + crate::gossipsub::topic_kind(topic) +} + +/// Topic family for unaggregated attestations, one topic per subnet. +pub const BEACON_ATTESTATION_PREFIX: &str = "beacon_attestation_"; + +/// The topic carrying one attestation subnet. +pub fn attestation_topic_name(fork_digest: ForkDigest, subnet_id: u64) -> String { + topic_name( + fork_digest, + &format!("{BEACON_ATTESTATION_PREFIX}{subnet_id}"), + ) +} + +/// The subnet an attestation topic kind names, or `None` if the kind is not one. +/// +/// The digit check keeps a hypothetical global `beacon_attestation_something` +/// from being read as a subnet, the same guard [`data_column_subnet`] has and +/// for the same reason. +pub fn attestation_subnet(kind: &str) -> Option { + let suffix = kind.strip_prefix(BEACON_ATTESTATION_PREFIX)?; + if suffix.is_empty() || !suffix.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + suffix.parse().ok() +} + +/// Topic family for data column sidecars, one subnet per column index. +pub const DATA_COLUMN_SIDECAR_PREFIX: &str = "data_column_sidecar_"; + +/// The topic carrying one column subnet's sidecars. +/// +/// Takes the subnet id itself, already reduced: a column index becomes a +/// subnet id via `column_index % DATA_COLUMN_SIDECAR_SUBNET_COUNT` one layer +/// up, in `crate::build_swarm`, since more than one column can share a +/// subnet. This function only ever sees the result. +pub fn data_column_topic_name(fork_digest: ForkDigest, subnet_id: u64) -> String { + topic_name( + fork_digest, + &format!("{DATA_COLUMN_SIDECAR_PREFIX}{subnet_id}"), + ) +} + +/// The subnet a column topic kind names, or `None` if the kind is not one. +/// +/// The digit check is load-bearing: it is what keeps a future +/// `data_column_sidecar_something` global topic from being read as a subnet. +pub fn data_column_subnet(kind: &str) -> Option { + let suffix = kind.strip_prefix(DATA_COLUMN_SIDECAR_PREFIX)?; + if suffix.is_empty() || !suffix.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + suffix.parse().ok() +} + +/// The subscribed topics for one fork digest, built once at startup. +#[derive(Debug, Clone)] +pub struct BeaconTopics { + pub fork_digest: ForkDigest, + /// Parallel to [`SUBSCRIBED_TOPIC_KINDS`], then `column_topics`'s values in + /// ascending subnet order. Derived from `column_topics` rather than built + /// alongside it, so the two cannot disagree about how many column topics + /// there are: pushing one entry per `column_subnets` element here, while a + /// map dedupes the same input, is exactly how the two would drift the + /// moment a caller ever passed a repeated subnet id. + pub topics: Vec, + /// The column subnets this node custodies, by subnet id, so a publish or a + /// re-emission can find its topic without rebuilding the name. + /// + /// A `BTreeMap` rather than a `HashMap` so `topics` derives from it in a + /// stable ascending order, matching `custody_columns`'s own + /// sorted-ascending convention. + pub column_topics: BTreeMap, + /// The attestation subnets this node holds a long-lived subscription to, + /// by subnet id. + /// + /// `p2p-interface.md` asks every beacon node to hold `SUBNETS_PER_NODE` of + /// these whether or not it runs validators, so the subnets have a stable + /// backbone for validators to publish into; phase 0 has no shard committees + /// to give them one otherwise. This node subscribes and relays on them + /// without applying what arrives to its own fork choice, which is what a + /// lighthouse node with no validators does too. + /// + /// A `BTreeMap` for the reason `column_topics` is one. + pub attestation_topics: BTreeMap, +} + +impl BeaconTopics { + pub fn new( + fork_digest: ForkDigest, + column_subnets: &[u64], + attestation_subnets: &[u64], + ) -> Self { + // Built first so the map's own key semantics do the deduplication; + // `topics` below only ever sees what survived that. + let mut column_topics = BTreeMap::new(); + for &subnet_id in column_subnets { + column_topics + .entry(subnet_id) + .or_insert_with(|| IdentTopic::new(data_column_topic_name(fork_digest, subnet_id))); + } + + let mut attestation_topics = BTreeMap::new(); + for &subnet_id in attestation_subnets { + attestation_topics + .entry(subnet_id) + .or_insert_with(|| IdentTopic::new(attestation_topic_name(fork_digest, subnet_id))); + } + + let mut topics: Vec = SUBSCRIBED_TOPIC_KINDS + .iter() + .map(|kind| IdentTopic::new(topic_name(fork_digest, kind))) + .collect(); + topics.extend(column_topics.values().cloned()); + topics.extend(attestation_topics.values().cloned()); + + Self { + fork_digest, + topics, + column_topics, + attestation_topics, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Mainnet's current digest, per docs/discovery.md. + const MAINNET: ForkDigest = [0x8c, 0x9f, 0x62, 0xfe]; + + #[test] + fn topic_names_are_the_mainnet_strings() { + assert_eq!( + topic_name(MAINNET, BEACON_BLOCK), + "/eth2/8c9f62fe/beacon_block/ssz_snappy" + ); + assert_eq!( + topic_name(MAINNET, BEACON_AGGREGATE_AND_PROOF), + "/eth2/8c9f62fe/beacon_aggregate_and_proof/ssz_snappy" + ); + assert_eq!( + topic_name(MAINNET, SYNC_COMMITTEE_CONTRIBUTION_AND_PROOF), + "/eth2/8c9f62fe/sync_committee_contribution_and_proof/ssz_snappy" + ); + } + + #[test] + fn the_digest_is_lowercase_hex_without_a_prefix() { + // A leading 0x, uppercase, or a Debug-formatted byte array would all + // produce a topic hash no peer agrees with, and gossipsub would report + // a healthy mesh of zero peers rather than an error. + let name = topic_name([0x0a, 0xbc, 0xde, 0xf0], BEACON_BLOCK); + assert!(name.starts_with("/eth2/0abcdef0/"), "got {name}"); + } + + #[test] + fn subscriptions_are_exactly_the_seven_global_topics() { + let topics = BeaconTopics::new(MAINNET, &[], &[]); + assert_eq!(topics.topics.len(), 7); + } + + #[test] + fn no_subnet_family_is_subscribed() { + // The narrow subscription set is a design decision, not an accident of + // how many topics happened to be listed: widening it is what pulls in + // ~30k BLS verifications per epoch and the whole column bandwidth. + // `data_column_sidecar_` is no longer in this list: that family is now + // legitimately subscribed, narrowly, by `only_the_custodied_subnets_are_subscribed`. + // `beacon_attestation_` has left it for the same reason, and is covered + // by `only_the_backbone_attestation_subnets_are_subscribed`; what this + // still pins is that neither family appears unless a caller asked for + // it, which is what the empty lists here say. + let excluded = ["beacon_attestation_", "sync_committee_", "blob_sidecar_"]; + for topic in BeaconTopics::new(MAINNET, &[], &[]).topics { + let name = topic.to_string(); + let kind = topic_kind(&name).expect("a well-formed topic name"); + for prefix in excluded { + // A subnet topic is the family prefix followed by its index and + // nothing else, as in `sync_committee_3`. The digit check is + // load-bearing: the global `sync_committee_contribution_and_proof` + // shares the family prefix and *is* legitimately subscribed. + let is_subnet = kind.strip_prefix(prefix).is_some_and(|index| { + !index.is_empty() && index.bytes().all(|b| b.is_ascii_digit()) + }); + assert!(!is_subnet, "{name} is a {prefix} subnet topic"); + } + } + } + + #[test] + fn topic_kind_reads_the_name_back() { + for kind in SUBSCRIBED_TOPIC_KINDS { + assert_eq!(topic_kind(&topic_name(MAINNET, kind)), Some(kind)); + } + } + + #[test] + fn a_column_topic_is_the_family_name_and_its_subnet() { + assert_eq!( + data_column_topic_name(MAINNET, 7), + "/eth2/8c9f62fe/data_column_sidecar_7/ssz_snappy" + ); + } + + #[test] + fn a_column_topic_reads_its_subnet_back() { + assert_eq!(data_column_subnet("data_column_sidecar_42"), Some(42)); + assert_eq!(data_column_subnet("data_column_sidecar_"), None); + assert_eq!(data_column_subnet("data_column_sidecar_x"), None); + assert_eq!(data_column_subnet("beacon_block"), None); + } + + #[test] + fn only_the_custodied_subnets_are_subscribed() { + // The narrow set is the point: subscribing to all of them is what + // makes a supernode, at the whole matrix's bandwidth. + let topics = BeaconTopics::new(MAINNET, &[3, 9], &[]); + assert_eq!(topics.topics.len(), SUBSCRIBED_TOPIC_KINDS.len() + 2); + assert_eq!(topics.column_topics.len(), 2); + assert!(topics.column_topics.contains_key(&3)); + assert!(topics.column_topics.contains_key(&9)); + } + + #[test] + fn a_repeated_subnet_id_is_subscribed_once() { + // Two columns can land on the same subnet on a network where + // `NUMBER_OF_CUSTODY_GROUPS` and `DATA_COLUMN_SIDECAR_SUBNET_COUNT` + // differ, so the caller's column-to-subnet reduction can hand this + // constructor the same id twice. `topics` must not gain a duplicate + // entry for it: that would mean two `subscribe()` calls, two log + // lines, and a `topics` count `column_topics.len()` disagrees with. + let topics = BeaconTopics::new(MAINNET, &[3, 3, 9], &[]); + assert_eq!(topics.topics.len(), SUBSCRIBED_TOPIC_KINDS.len() + 2); + assert_eq!(topics.column_topics.len(), 2); + } + + #[test] + fn a_beacon_kind_is_its_own_label_and_columns_share_one() { + assert_eq!(metric_kind(BEACON_BLOCK), Some(BEACON_BLOCK)); + assert_eq!(metric_kind(VOLUNTARY_EXIT), Some(VOLUNTARY_EXIT)); + assert_eq!( + metric_kind("data_column_sidecar_7"), + Some(DATA_COLUMN_SIDECAR_KIND) + ); + // Lean topic kinds get no verdict. + assert_eq!(metric_kind("block"), None); + } + + #[test] + fn attestation_subnets_share_one_label() { + // Every subnet in the family maps to the same label, the same way + // every data column subnet maps to `DATA_COLUMN_SIDECAR_KIND`: one + // metric label value per family, not one per subnet, and every + // subnet message gets a verdict rather than being invisible to + // `handle_beacon_gossip`. + assert_eq!( + metric_kind("beacon_attestation_3"), + Some(BEACON_ATTESTATION_KIND) + ); + assert_eq!( + metric_kind("beacon_attestation_40"), + Some(BEACON_ATTESTATION_KIND) + ); + assert_eq!(metric_kind("beacon_attestation_"), None); + } + + #[test] + fn column_topics_are_appended_in_ascending_subnet_order() { + // `column_topics` is a `BTreeMap` specifically so this holds: it is + // what lets a reader of `topics` predict the tail's order from the + // subnet ids alone, the same way `custody_columns` is sorted + // ascending rather than left in whatever order the walk found them. + let topics = BeaconTopics::new(MAINNET, &[9, 3], &[]); + let tail: Vec = topics.topics[SUBSCRIBED_TOPIC_KINDS.len()..] + .iter() + .map(|topic| topic.to_string()) + .collect(); + assert_eq!( + tail, + vec![ + data_column_topic_name(MAINNET, 3), + data_column_topic_name(MAINNET, 9), + ] + ); + } + + #[test] + fn an_attestation_topic_is_the_family_name_and_its_subnet() { + assert_eq!( + attestation_topic_name(MAINNET, 12), + "/eth2/8c9f62fe/beacon_attestation_12/ssz_snappy" + ); + } + + #[test] + fn an_attestation_topic_reads_its_subnet_back() { + assert_eq!(attestation_subnet("beacon_attestation_7"), Some(7)); + assert_eq!(attestation_subnet("beacon_attestation_"), None); + assert_eq!(attestation_subnet("beacon_attestation_x"), None); + // The global aggregate topic shares no prefix with the family, but the + // block topic is the one a careless `starts_with` would catch. + assert_eq!(attestation_subnet("beacon_block"), None); + assert_eq!(attestation_subnet(BEACON_AGGREGATE_AND_PROOF), None); + } + + /// The backbone is a narrow slice, not the whole family: subscribing to all + /// sixty-four is what makes a supernode, at every attester's bandwidth. + #[test] + fn only_the_backbone_attestation_subnets_are_subscribed() { + let topics = BeaconTopics::new(MAINNET, &[], &[12, 40]); + assert_eq!(topics.topics.len(), SUBSCRIBED_TOPIC_KINDS.len() + 2); + assert_eq!(topics.attestation_topics.len(), 2); + assert!(topics.attestation_topics.contains_key(&12)); + assert!(topics.attestation_topics.contains_key(&40)); + } + + /// Both families can be subscribed at once, and each keeps its own count: + /// the two tails are appended in order, columns then attestations. + #[test] + fn both_subnet_families_are_subscribed_together() { + let topics = BeaconTopics::new(MAINNET, &[3, 9], &[40, 12]); + assert_eq!(topics.topics.len(), SUBSCRIBED_TOPIC_KINDS.len() + 4); + let tail: Vec = topics.topics[SUBSCRIBED_TOPIC_KINDS.len()..] + .iter() + .map(|topic| topic.to_string()) + .collect(); + assert_eq!( + tail, + vec![ + data_column_topic_name(MAINNET, 3), + data_column_topic_name(MAINNET, 9), + attestation_topic_name(MAINNET, 12), + attestation_topic_name(MAINNET, 40), + ] + ); + } + + /// A repeated subnet id is subscribed once, for the reason its column + /// counterpart is: two `subscribe()` calls and a `topics` count that + /// disagrees with the map beside it. + #[test] + fn a_repeated_attestation_subnet_is_subscribed_once() { + let topics = BeaconTopics::new(MAINNET, &[], &[12, 12, 40]); + assert_eq!(topics.topics.len(), SUBSCRIBED_TOPIC_KINDS.len() + 2); + assert_eq!(topics.attestation_topics.len(), 2); + } +} diff --git a/crates/net/p2p/src/beacon/verdict.rs b/crates/net/p2p/src/beacon/verdict.rs new file mode 100644 index 000000000..6d7c31364 --- /dev/null +++ b/crates/net/p2p/src/beacon/verdict.rs @@ -0,0 +1,832 @@ +//! The verdict plumbing between gossipsub and the beacon gossip rules. +//! +//! The rules live in `ethlambda_state_transition::beacon::gossip`; this module +//! decides where each half runs and what happens to the result. Cheap checks +//! run inline in the p2p actor. Stateful checks run on a `spawn_blocking` +//! thread, bounded by one of two pools depending on the kind: a block or a +//! column draws from [`P2PServer::gossip_validation_permits`], an aggregate or +//! a subnet attestation from [`P2PServer::attestation_validation_permits`] (see +//! that field's own documentation for why they must not share one). Either way +//! the blocking task sends a [`GossipVerdict`] back to the actor. Every beacon +//! gossip message ends in exactly one [`report`]: gossipsub holds each one +//! until then. + +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::time::Instant; + +use ethlambda_network_api::{AggregateArrival, BlockArrival, BlockSource}; +use ethlambda_state_transition::beacon::gossip::{self, IgnoreReason, Outcome}; +use ethlambda_state_transition::beacon::helpers::accessors::CommitteeCacheExt as _; +use ethlambda_storage::{CacheKey, Store}; +use ethlambda_types::beacon::containers::electra::SingleAttestation; +use ethlambda_types::beacon::containers::{ + SignedAggregateAndProof, SignedBeaconBlock, fulu::DataColumnSidecar, +}; +use ethlambda_types::beacon::primitives::{Root, ValidatorIndex}; +use libp2p::PeerId; +use libp2p::gossipsub::{MessageAcceptance, MessageId}; +use spawned_concurrency::message::Message; +use spawned_concurrency::tasks::{Context, Handler}; +use tracing::{error, warn}; + +use crate::beacon::column_checks; +use crate::{P2PServer, metrics}; + +/// Which gossip message a verdict is for. +pub(crate) struct GossipId { + pub(crate) message_id: MessageId, + pub(crate) propagation_source: PeerId, + /// The payload came off the wire, before decompression. + pub(crate) received_at: Instant, + /// The topic kind, as a metric label. See [`crate::beacon::topics::metric_kind`]. + pub(crate) kind: &'static str, +} + +/// An object whose stateful checks run on a blocking thread. +pub(crate) enum Validated { + Block { + // Boxed for the reason `BeaconGossip::Block` is (see `beacon::decode`): + // unboxed, a `SignedBeaconBlock` would set the size of every `Validated` + // this module moves through a channel and a `spawn_blocking` closure. + block: Box, + block_root: Root, + }, + // Boxed for the same reason `block` is: `DataColumnSidecar` carries a KZG + // commitment and proof list plus a full cell, wide enough on its own to + // set the enum's size. + Column(Box), + /// A `beacon_aggregate_and_proof`. + Aggregate { + aggregate: Box, + /// The attesting indices its aggregate signature verified. Empty + /// until [`Validated::stateful_checks`] fills it in on `Accept`; + /// [`Validated::forward`] is what reads it, and only ever on that + /// outcome, so an empty value here is never mistaken for a verified + /// one. + attesting_indices: Vec, + }, + /// A `beacon_attestation_{subnet_id}`. Never forwarded to the chain actor + /// (see [`Validated::forward`]'s doc comment), so nothing beyond the + /// verdict and the seen cache is kept once its checks have run. + Attestation { + attestation: Box, + subnet_id: u64, + }, +} + +impl Validated { + /// Run this object's stateful checks. `&mut self` rather than `&self`: + /// [`Self::Aggregate`]'s `attesting_indices` starts empty and is filled in + /// here on `Accept`, the one place its aggregate signature is checked and + /// its attesting indices resolved, so [`Validated::forward`] finds them + /// already in hand rather than having to re-verify the aggregate to learn + /// them. + fn stateful_checks(&mut self, store: &Store) -> Outcome { + match self { + Self::Block { block, block_root } => { + gossip::block::stateful_checks(store, block, *block_root) + } + Self::Column(sidecar) => gossip::column::stateful_checks(store, sidecar), + Self::Aggregate { + aggregate, + attesting_indices, + } => match gossip::aggregate::stateful_checks(store, aggregate) { + Ok(indices) => { + *attesting_indices = indices; + Outcome::Accept + } + Err(outcome) => outcome, + }, + Self::Attestation { + attestation, + subnet_id, + } => gossip::attestation::stateful_checks(store, attestation, *subnet_id), + } + } + + /// Record this object as the first valid one for its key. `false` when + /// another verdict recorded one first. + fn record_seen(&self, server: &mut P2PServer) -> bool { + match self { + Self::Block { block, block_root } => { + server + .seen_blocks + .record(block.slot(), block.proposer_index(), *block_root) + } + Self::Column(sidecar) => { + let header = &sidecar.signed_block_header.message; + server + .seen_columns + .record(header.slot, header.proposer_index, sidecar.index) + } + Self::Aggregate { aggregate, .. } => server.seen_aggregates.record(aggregate), + Self::Attestation { attestation, .. } => server.seen_attestations.record(attestation), + } + } + + /// Hand this object on towards the chain actor, given its gossip + /// `outcome`. + /// + /// A block goes straight to the chain actor whatever the outcome, since + /// its import runs the state transition, which judges it again. A column + /// goes straight there only on `Accept`: the chain actor keeps a column + /// without checking it, so one gossip did not finish judging goes through + /// [`column_checks`] first. An aggregate goes on only on `Accept`, and + /// carries the attesting indices [`Self::stateful_checks`] resolved: the + /// chain actor no longer verifies anything on this topic (see reviewer + /// finding #1 on PR #19), so an aggregate that never got a real `Accept` + /// (`Overloaded`, `Ignore`, `Reject`) must never reach it. A subnet + /// attestation is never forwarded at all, on any outcome: nothing on the + /// chain actor consumes one, matching a lighthouse follower with no + /// validators, which verifies and relays its own backbone subnets but + /// never calls `apply_attestation_to_fork_choice` for them either. + /// + /// What an accepted subnet attestation does feed is the attestation pool, + /// when its subnet is one a validator client's aggregator had this node + /// join: that aggregator will ask for exactly these votes. See + /// [`pool_aggregator_attestation`]. An accepted aggregate goes into the + /// pool too, whatever else happens to it, so block production can pack + /// other nodes' votes; see [`pool_gossip_aggregate`]. + fn forward(self, server: &P2PServer, received_at: Instant, outcome: Outcome) { + if let Self::Aggregate { aggregate, .. } = &self + && outcome == Outcome::Accept + { + pool_gossip_aggregate(server, aggregate); + } + if let Self::Attestation { + attestation, + subnet_id, + } = &self + && outcome == Outcome::Accept + && server.aggregator_subnets.contains_key(subnet_id) + { + pool_aggregator_attestation(server, attestation); + } + let Some(blockchain) = &server.blockchain else { + return; + }; + match self { + Self::Block { block, .. } => { + // `decode_start` is the wire arrival, so the import's decode + // section spans the decode and gossip validation. + let arrival = BlockArrival { + decode_start: Some(received_at), + handed_off: Instant::now(), + deferred_from: None, + }; + let _ = blockchain + .new_block(*block, BlockSource::Gossip, arrival) + .inspect_err(|err| warn!(%err, "Failed to forward a gossip block")); + } + Self::Column(sidecar) if outcome == Outcome::Accept => { + let _ = blockchain + .new_data_column_sidecars(vec![*sidecar]) + .inspect_err(|err| warn!(%err, "Failed to forward a data column sidecar")); + } + Self::Column(sidecar) => column_checks::check_and_forward(server, vec![*sidecar]), + Self::Aggregate { + aggregate, + attesting_indices, + } if outcome == Outcome::Accept => { + let arrival = AggregateArrival { + decode_start: received_at, + handed_off: Instant::now(), + }; + let _ = blockchain + .new_beacon_aggregate(aggregate, attesting_indices, arrival) + .inspect_err(|err| warn!(%err, "Failed to forward a gossip aggregate")); + } + Self::Aggregate { .. } | Self::Attestation { .. } => {} + } + } +} + +/// Pool an accepted gossip aggregate for block production to pack. +/// +/// Pooled here, on `Accept`, because this is where all three of its +/// signatures have just been verified, and one unverified attestation in a +/// block fails the whole block. Pooling on arrival also means a slot's +/// aggregates, published two thirds of the way through it, are in the pool +/// when the next slot's block is asked for at its start, rather than +/// waiting for the chain actor's next tick. Only electra's shape is pooled, +/// since the pool holds electra attestations and electra is the earliest fork +/// this node produces blocks for. +fn pool_gossip_aggregate(server: &P2PServer, aggregate: &SignedAggregateAndProof) { + let SignedAggregateAndProof::Electra(signed) = aggregate else { + return; + }; + server + .attestation_pool + .lock() + .expect("attestation pool lock poisoned") + .insert_aggregate(signed.message.aggregate.clone()); +} + +/// Pool an accepted subnet attestation for a validator client's aggregator. +/// +/// The pool keys a vote by its position in its committee, which the gossip +/// checks resolved but do not return; it is read back from the same place +/// they read it, the voted block's cached post-state and the shared committee +/// cache, so nothing is derived twice. +fn pool_aggregator_attestation(server: &P2PServer, attestation: &SingleAttestation) { + let data = &attestation.data; + let Some(state) = server + .store + .cached_state(CacheKey::BlockState(data.beacon_block_root)) + else { + return; + }; + let committees = server + .store + .committee_cache() + .committees(&state, data.target.epoch); + let Ok(committee) = committees.committee(data.slot, attestation.committee_index) else { + return; + }; + let Some(position) = committee + .iter() + .position(|&member| member == attestation.attester_index) + else { + return; + }; + server + .attestation_pool + .lock() + .expect("attestation pool lock poisoned") + .insert(attestation, position, committee.len()); +} + +/// What to do with a beacon gossip message once the checks that read only the +/// message, the clock and the store's metadata (the `triage_*` functions in +/// [`crate::gossipsub::handler`]) have run. +/// +/// Splits the decision from the action: a `triage_*` function decides and +/// returns one of these, and [`crate::gossipsub::handler::handle_beacon_gossip`] +/// is the single place that acts on it, reporting or spawning. That split is +/// what makes `triage_*` unit-testable with no actor in sight: a +/// `Context` only exists once the actor has started, and deciding a +/// verdict needs no context at all. +pub(crate) enum Dispatch { + /// A verdict is already known; nothing further to check. + /// + /// No cheap check ever answers `Queue`: a `Queue` verdict means "hold the + /// object until its dependency arrives", which is a stateful check's call + /// to make on the decoded object, not a cheap one's. `handle_beacon_gossip`'s + /// `debug_assert!` enforces this invariant in debug builds rather than + /// widening this variant to carry an object for a case that cannot happen; + /// in release, a `Queue` reaching here would still be reported as IGNORE, + /// with nothing forwarded, since this variant carries no object to + /// forward it with. + Report(Outcome), + /// The object passed the cheap checks; its stateful checks decide. + Validate(Validated), +} + +/// A blocking task's verdict, sent back to the p2p actor. +pub(crate) struct GossipVerdict { + id: GossipId, + outcome: Outcome, + object: Validated, +} + +impl Message for GossipVerdict { + type Result = (); +} + +/// Re-check the seen cache at verdict time: two copies of one key can be in +/// validation on separate blocking threads at once, and only the first +/// `Accept` to reach the actor stands, which is the specification's "first +/// valid". Any other outcome passes through unrecorded, since only an Accept +/// is a candidate for the seen cache in the first place. +fn settle(server: &mut P2PServer, outcome: Outcome, object: &Validated) -> Outcome { + if outcome == Outcome::Accept && !object.record_seen(server) { + Outcome::Ignore(IgnoreReason::AlreadySeen) + } else { + outcome + } +} + +impl Handler for P2PServer { + async fn handle(&mut self, msg: GossipVerdict, _ctx: &Context) { + let GossipVerdict { + id, + outcome, + object, + } = msg; + let outcome = settle(self, outcome, &object); + let received_at = id.received_at; + if report(self, id, outcome) { + object.forward(self, received_at, outcome); + } + } +} + +/// How an outcome maps onto gossipsub, and whether the object still goes on +/// towards the chain actor (see [`Validated::forward`] for the route). +pub(crate) fn disposition(outcome: Outcome) -> (MessageAcceptance, bool) { + match outcome { + Outcome::Accept => (MessageAcceptance::Accept, true), + Outcome::Queue(_) => (MessageAcceptance::Ignore, true), + Outcome::Ignore(_) => (MessageAcceptance::Ignore, false), + Outcome::Reject(_) => (MessageAcceptance::Reject, false), + } +} + +/// Report `outcome` for `id` to gossipsub and the metrics. Returns whether +/// the object goes on towards the chain actor. +pub(crate) fn report(server: &P2PServer, id: GossipId, outcome: Outcome) -> bool { + let (acceptance, forward) = disposition(outcome); + let (outcome_label, reason) = outcome.labels(); + metrics::observe_beacon_gossip_verdict( + id.kind, + outcome_label, + reason, + id.received_at.elapsed(), + ); + server.swarm_handle.report_validation( + id.message_id, + id.propagation_source, + acceptance, + id.kind, + ); + forward +} + +/// Run `object`'s stateful checks on a blocking thread. The verdict comes back +/// to the actor as a [`GossipVerdict`]. +/// +/// A block or a column draws its permit from +/// [`P2PServer::gossip_validation_permits`]; an aggregate or a subnet +/// attestation from [`P2PServer::attestation_validation_permits`], a pool of +/// its own so neither topic's per-slot burst can starve the other (see that +/// field's documentation). +/// +/// With every permit taken, the object is reported `Ignore(Overloaded)` +/// instead of queued: queueing it would only make its verdict later than +/// gossipsub's cache can wait for, so it never propagates unvalidated. A block +/// still goes on towards the chain actor regardless: its import runs the +/// state transition, which judges it again. A column goes through +/// [`column_checks`] instead (see [`Validated::forward`]); dropping either +/// here would leave the actor to learn of it only through a child's by-root +/// fetch or range sync, both far slower than gossip. An aggregate or a subnet +/// attestation is not forwarded on this outcome at all: see +/// [`Validated::forward`]'s own documentation for why. +pub(crate) fn spawn_stateful_checks( + server: &P2PServer, + ctx: &Context, + id: GossipId, + object: Validated, +) { + let permits = match &object { + Validated::Block { .. } | Validated::Column(_) => &server.gossip_validation_permits, + Validated::Aggregate { .. } | Validated::Attestation { .. } => { + &server.attestation_validation_permits + } + }; + let Ok(permit) = permits.clone().try_acquire_owned() else { + let received_at = id.received_at; + let outcome = Outcome::Ignore(IgnoreReason::Overloaded); + report(server, id, outcome); + object.forward(server, received_at, outcome); + return; + }; + let store = server.store.clone(); + let actor = ctx.actor_ref(); + tokio::task::spawn_blocking(move || { + let _permit = permit; + let mut object = object; + let outcome = guarded(|| object.stateful_checks(&store)); + let _ = actor + .send(GossipVerdict { + id, + outcome, + object, + }) + .inspect_err(|_| warn!("P2P actor stopped before a gossip verdict arrived")); + }); +} + +/// `checks()`, with a panic turned into `Ignore(Internal)`. +/// +/// A panic inside validation (a `Store` read's `.expect()` on a DB error, say) +/// must still produce a verdict, or gossipsub would hold the message until its +/// cache evicts it. The blocking thread's permit is unaffected either way: it +/// drops on unwind exactly as it would on a normal return. +pub(crate) fn guarded(checks: impl FnOnce() -> Outcome) -> Outcome { + catch_unwind(AssertUnwindSafe(checks)).unwrap_or_else(|payload| { + let panic_message = payload + .downcast_ref::<&str>() + .copied() + .or_else(|| payload.downcast_ref::().map(String::as_str)) + .unwrap_or(""); + error!( + panic_message, + "Beacon gossip validation panicked; reporting Ignore(Internal)" + ); + Outcome::Ignore(IgnoreReason::Internal) + }) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; + + use ethlambda_network_api::P2PToBlockChain; + use ethlambda_state_transition::beacon::gossip::{QueueReason, RejectReason}; + use ethlambda_types::attestation::{SignedAggregatedAttestation, SignedAttestation}; + use ethlambda_types::beacon::config::Config; + use ethlambda_types::beacon::containers::{AttestationData, Checkpoint, electra, phase0}; + use spawned_concurrency::error::ActorError; + + use super::*; + use crate::test_support::{unconnected_beacon_server, valid_shaped_sidecar}; + + /// A minimal fulu block for a given `(slot, proposer)`: `settle` and + /// `record_seen` only ever read those two fields plus the root passed + /// alongside, so nothing else about the block's shape matters here. + fn fulu_block(slot: u64, proposer: u64) -> SignedBeaconBlock { + SignedBeaconBlock::Fulu(electra::SignedBeaconBlock { + message: electra::BeaconBlock { + slot, + proposer_index: proposer, + parent_root: Root::ZERO, + state_root: Root::ZERO, + body: electra::BeaconBlockBody::empty(), + }, + signature: Default::default(), + }) + } + + /// A minimal phase0 aggregate at `(slot, aggregator)`: only what + /// `settle`/`record_seen` and `forward` read is meaningful, nothing here + /// is signature-valid. Mirrors `beacon_aggregates`'s own test helper in + /// `ethlambda-blockchain`. + fn phase0_aggregate(slot: u64, aggregator: u64) -> SignedAggregateAndProof { + SignedAggregateAndProof::Phase0(phase0::SignedAggregateAndProof { + message: phase0::AggregateAndProof { + aggregator_index: aggregator, + aggregate: phase0::Attestation { + aggregation_bits: phase0::AggregationBits::with_length(1).unwrap(), + data: AttestationData { + slot, + index: 0, + beacon_block_root: Root::ZERO, + source: Checkpoint::default(), + target: Checkpoint { + epoch: slot / 32, + root: Root::ZERO, + }, + }, + signature: Default::default(), + }, + selection_proof: Default::default(), + }, + signature: Default::default(), + }) + } + + /// A one-member electra aggregate at `(slot, aggregator)`, from committee + /// 0. Same reasoning as [`phase0_aggregate`]: `forward` runs after the + /// stateful checks, so nothing here needs to be signature-valid. + fn electra_aggregate(slot: u64, aggregator: u64) -> SignedAggregateAndProof { + let mut aggregation_bits = electra::AggregationBits::with_length(1).unwrap(); + aggregation_bits.set(0, true).unwrap(); + let mut committee_bits = electra::CommitteeBits::default(); + committee_bits.set(0, true).unwrap(); + SignedAggregateAndProof::Electra(electra::SignedAggregateAndProof { + message: electra::AggregateAndProof { + aggregator_index: aggregator, + aggregate: electra::Attestation { + aggregation_bits, + data: AttestationData { + slot, + index: 0, + beacon_block_root: Root::ZERO, + source: Checkpoint::default(), + target: Checkpoint { + epoch: slot / 32, + root: Root::ZERO, + }, + }, + signature: Default::default(), + committee_bits, + }, + selection_proof: Default::default(), + }, + signature: Default::default(), + }) + } + + /// A minimal electra `SingleAttestation` at `(slot, attester)`. Same + /// reasoning as [`phase0_aggregate`]. + fn electra_attestation(slot: u64, attester: u64) -> SingleAttestation { + SingleAttestation { + committee_index: 0, + attester_index: attester, + data: AttestationData { + slot, + index: 0, + beacon_block_root: Root::ZERO, + source: Checkpoint::default(), + target: Checkpoint { + epoch: slot / 32, + root: Root::ZERO, + }, + }, + signature: Default::default(), + } + } + + /// A [`P2PToBlockChain`] stand-in that only records whether + /// `new_beacon_aggregate` was called, for the tests that check `forward` + /// keeps an aggregate off the chain actor on every outcome but `Accept`. + struct RecordingChain(AtomicBool); + + impl P2PToBlockChain for RecordingChain { + fn new_block( + &self, + _block: SignedBeaconBlock, + _source: BlockSource, + _arrival: BlockArrival, + ) -> Result<(), ActorError> { + Ok(()) + } + fn new_attestation(&self, _attestation: SignedAttestation) -> Result<(), ActorError> { + Ok(()) + } + fn new_aggregated_attestation( + &self, + _attestation: SignedAggregatedAttestation, + ) -> Result<(), ActorError> { + Ok(()) + } + fn new_data_column_sidecars( + &self, + _sidecars: Vec, + ) -> Result<(), ActorError> { + Ok(()) + } + fn data_column_sidecars_awaiting_parent( + &self, + _sidecars: Vec, + ) -> Result<(), ActorError> { + Ok(()) + } + fn new_beacon_aggregate( + &self, + _aggregate: Box, + _attesting_indices: Vec, + _arrival: AggregateArrival, + ) -> Result<(), ActorError> { + self.0.store(true, Ordering::SeqCst); + Ok(()) + } + } + + #[tokio::test] + async fn the_first_accept_for_a_block_key_stands_and_the_second_is_marked_seen() { + let mut server = unconnected_beacon_server(Config::mainnet(), 0).await; + let object = Validated::Block { + block: Box::new(fulu_block(5, 1)), + block_root: Root::repeat_byte(1), + }; + + assert_eq!( + settle(&mut server, Outcome::Accept, &object), + Outcome::Accept + ); + assert_eq!( + settle(&mut server, Outcome::Accept, &object), + Outcome::Ignore(IgnoreReason::AlreadySeen) + ); + } + + #[tokio::test] + async fn a_queued_or_rejected_block_records_nothing() { + let mut server = unconnected_beacon_server(Config::mainnet(), 0).await; + let object = Validated::Block { + block: Box::new(fulu_block(5, 1)), + block_root: Root::repeat_byte(1), + }; + + assert_eq!( + settle( + &mut server, + Outcome::Queue(QueueReason::ParentUnknown), + &object + ), + Outcome::Queue(QueueReason::ParentUnknown) + ); + assert_eq!( + settle( + &mut server, + Outcome::Reject(RejectReason::BadSignature), + &object + ), + Outcome::Reject(RejectReason::BadSignature) + ); + // Neither the queue nor the reject recorded the key, so a later + // Accept for it still stands. + assert_eq!( + settle(&mut server, Outcome::Accept, &object), + Outcome::Accept + ); + } + + #[tokio::test] + async fn the_first_accept_for_a_column_key_stands_and_the_second_is_marked_seen() { + let mut server = unconnected_beacon_server(Config::mainnet(), 0).await; + let object = Validated::Column(Box::new(valid_shaped_sidecar(5, 0))); + + assert_eq!( + settle(&mut server, Outcome::Accept, &object), + Outcome::Accept + ); + assert_eq!( + settle(&mut server, Outcome::Accept, &object), + Outcome::Ignore(IgnoreReason::AlreadySeen) + ); + } + + #[tokio::test] + async fn the_first_accept_for_an_aggregate_key_stands_and_the_second_is_marked_seen() { + let mut server = unconnected_beacon_server(Config::mainnet(), 0).await; + let object = Validated::Aggregate { + aggregate: Box::new(phase0_aggregate(5, 1)), + attesting_indices: Vec::new(), + }; + + assert_eq!( + settle(&mut server, Outcome::Accept, &object), + Outcome::Accept + ); + assert_eq!( + settle(&mut server, Outcome::Accept, &object), + Outcome::Ignore(IgnoreReason::AlreadySeen) + ); + } + + #[tokio::test] + async fn the_first_accept_for_an_attestation_key_stands_and_the_second_is_marked_seen() { + let mut server = unconnected_beacon_server(Config::mainnet(), 0).await; + let object = Validated::Attestation { + attestation: Box::new(electra_attestation(5, 1)), + subnet_id: 0, + }; + + assert_eq!( + settle(&mut server, Outcome::Accept, &object), + Outcome::Accept + ); + assert_eq!( + settle(&mut server, Outcome::Accept, &object), + Outcome::Ignore(IgnoreReason::AlreadySeen) + ); + } + + /// The condition reviewer finding #1 on PR #19 was about: an aggregate + /// that never got a real `Accept` (here, `Overloaded`, standing in for + /// `Ignore`/`Reject` too, since `forward`'s guard is the same `if let ... + /// if outcome == Outcome::Accept` for all three) must never reach the + /// chain actor. + #[tokio::test] + async fn an_overloaded_aggregate_is_not_forwarded() { + let mut server = unconnected_beacon_server(Config::mainnet(), 0).await; + let chain = Arc::new(RecordingChain(AtomicBool::new(false))); + server.blockchain = Some(chain.clone()); + let object = Validated::Aggregate { + aggregate: Box::new(phase0_aggregate(5, 1)), + attesting_indices: Vec::new(), + }; + + object.forward( + &server, + Instant::now(), + Outcome::Ignore(IgnoreReason::Overloaded), + ); + + assert!(!chain.0.load(Ordering::SeqCst)); + } + + /// An accepted electra aggregate is what block production packs other + /// nodes' votes from, so `forward` has to put it in the pool; a phase0 one + /// has no place there, since the pool holds electra attestations. + #[tokio::test] + async fn an_accepted_electra_aggregate_is_pooled_and_a_phase0_one_is_not() { + let server = unconnected_beacon_server(Config::mainnet(), 0).await; + let forward_accepted = |aggregate| { + Validated::Aggregate { + aggregate: Box::new(aggregate), + attesting_indices: Vec::new(), + } + .forward(&server, Instant::now(), Outcome::Accept) + }; + + forward_accepted(phase0_aggregate(5, 1)); + let pool = server.attestation_pool.clone(); + assert!(pool.lock().unwrap().block_candidates().is_empty()); + + forward_accepted(electra_aggregate(5, 1)); + let SignedAggregateAndProof::Electra(expected) = electra_aggregate(5, 1) else { + unreachable!("built as electra") + }; + assert_eq!( + pool.lock().unwrap().block_candidates(), + vec![expected.message.aggregate] + ); + } + + /// Only `Accept` means the signatures were verified; anything else must + /// stay out of the pool, since one unverified attestation fails the + /// whole block it is packed into. + #[tokio::test] + async fn an_aggregate_that_was_not_accepted_is_not_pooled() { + let server = unconnected_beacon_server(Config::mainnet(), 0).await; + Validated::Aggregate { + aggregate: Box::new(electra_aggregate(5, 1)), + attesting_indices: Vec::new(), + } + .forward( + &server, + Instant::now(), + Outcome::Ignore(IgnoreReason::Overloaded), + ); + assert!( + server + .attestation_pool + .lock() + .unwrap() + .block_candidates() + .is_empty() + ); + } + + /// A subnet attestation is never forwarded, on any outcome, `Accept` + /// included: see `Validated::forward`'s own documentation for why. + #[tokio::test] + async fn a_subnet_attestation_is_never_forwarded_even_on_accept() { + let mut server = unconnected_beacon_server(Config::mainnet(), 0).await; + let chain = Arc::new(RecordingChain(AtomicBool::new(false))); + server.blockchain = Some(chain.clone()); + let object = Validated::Attestation { + attestation: Box::new(electra_attestation(5, 1)), + subnet_id: 0, + }; + + object.forward(&server, Instant::now(), Outcome::Accept); + + assert!(!chain.0.load(Ordering::SeqCst)); + } + + /// The pool an aggregate or a subnet attestation draws its stateful-check + /// permit from is not the pool a block or a column draws from: exhausting + /// one must leave the other untouched, or a burst on this topic could + /// make a block or a column answer `Ignore(Overloaded)` too. + #[tokio::test] + async fn the_block_column_and_attestation_permit_pools_are_independent() { + let server = unconnected_beacon_server(Config::mainnet(), 0).await; + let attestation_permits_before = server.attestation_validation_permits.available_permits(); + + let mut held = Vec::new(); + while let Ok(permit) = server.gossip_validation_permits.clone().try_acquire_owned() { + held.push(permit); + } + assert_eq!(server.gossip_validation_permits.available_permits(), 0); + + assert_eq!( + server.attestation_validation_permits.available_permits(), + attestation_permits_before + ); + assert!(server.attestation_validation_permits.try_acquire().is_ok()); + } + + #[test] + fn only_accept_propagates_and_only_accept_or_queue_reaches_the_chain() { + assert!(matches!( + disposition(Outcome::Accept), + (MessageAcceptance::Accept, true) + )); + assert!(matches!( + disposition(Outcome::Queue(QueueReason::ParentUnknown)), + (MessageAcceptance::Ignore, true) + )); + assert!(matches!( + disposition(Outcome::Ignore(IgnoreReason::FutureSlot)), + (MessageAcceptance::Ignore, false) + )); + assert!(matches!( + disposition(Outcome::Reject(RejectReason::BadSignature)), + (MessageAcceptance::Reject, false) + )); + } + + #[test] + fn a_panicking_check_is_ignored_rather_than_propagated() { + assert_eq!( + guarded(|| panic!("a DB read failed")), + Outcome::Ignore(IgnoreReason::Internal) + ); + assert_eq!(guarded(|| Outcome::Accept), Outcome::Accept); + } +} diff --git a/crates/net/p2p/src/discovery/admission.rs b/crates/net/p2p/src/discovery/admission.rs index 757e2ebde..c4068ba60 100644 --- a/crates/net/p2p/src/discovery/admission.rs +++ b/crates/net/p2p/src/discovery/admission.rs @@ -15,7 +15,7 @@ //! So the dial loop filters nothing: every contact it draws has already passed, //! and all it does is turn the record into something dialable //! ([`LeanFilter::dial_target`]) and rank what it got -//! ([`rank_by_uncovered_subnets`]). +//! ([`rank_candidates`]). use std::collections::HashSet; @@ -25,9 +25,12 @@ use libp2p::{Multiaddr, PeerId}; use libssz::SszDecode; use tracing::debug; +use ethlambda_state_transition::beacon::das; +use ethlambda_types::beacon::constants; + use super::enr::{ - ATTNETS_ENR_KEY, ETH2_ENR_KEY, EnrForkId, read_ip, read_public_key, read_quic_port, - read_tcp_port, subnets_from_attnets, + ATTNETS_ENR_KEY, CGC_ENR_KEY, ETH2_ENR_KEY, EnrForkId, node_id_from_peer_id, read_ip, + read_public_key, read_quic_port, read_tcp_port, subnets_from_attnets, }; use crate::dial_addrs; @@ -41,6 +44,14 @@ pub(crate) struct DiscoveredPeer { pub(crate) addrs: Vec, /// Attestation subnets the peer advertises in `attnets`. pub(crate) subnets: Vec, + /// The `cgc` entry the peer advertises, if any and if in range. + /// + /// A discovery-time hint, not the authority: the record may predate the + /// peer's current count, and a peer reached inbound never produces one at + /// all. `metadata/3` is what settles it (see + /// `P2PServer::record_peer_custody`), and this only fills the gap until + /// that answer arrives. Lighthouse splits the two the same way. + pub(crate) custody_group_count: Option, } /// Why a discovered peer was turned away. @@ -168,27 +179,92 @@ fn admit( .map(|bits| subnets_from_attnets(&bits, attestation_committee_count)) .unwrap_or_default(); + // Out-of-range counts are discarded rather than clamped: a count outside + // `CUSTODY_REQUIREMENT..=NUMBER_OF_CUSTODY_GROUPS` describes no custody + // set the specification defines, and guessing one would send requests to a + // peer that never agreed to hold those columns. Unknown is the honest + // answer, and the caller already handles it. Same range check lighthouse's + // `Enr::custody_group_count` applies. + let custody_group_count = pairs.extra_int::(CGC_ENR_KEY).filter(|count| { + (constants::CUSTODY_REQUIREMENT..=constants::NUMBER_OF_CUSTODY_GROUPS).contains(count) + }); + Ok(DiscoveredPeer { peer_id, addrs: dial_addrs(ip, quic_port, tcp_port, peer_id), subnets, + custody_group_count, }) } -/// Order candidates so those covering the most currently-uncovered attestation -/// subnets are dialed first. +impl DiscoveredPeer { + /// How many of `wanted` this record's advertised `custody_group_count` + /// puts it in custody of. + /// + /// Zero for a record carrying no `cgc`, and zero for one whose peer id the + /// node id cannot be recovered from: an unknown custody set covers + /// nothing, which is the same way an unknown `attnets` is treated. + fn custody_coverage(&self, wanted: &HashSet) -> usize { + let Some(count) = self.custody_group_count else { + return 0; + }; + let Some(node_id) = node_id_from_peer_id(&self.peer_id) else { + return 0; + }; + let Ok(columns) = das::custody_columns(node_id, count) else { + return 0; + }; + columns + .iter() + .filter(|column| wanted.contains(column)) + .count() + } +} + +/// Order candidates so the ones filling this node's gaps are dialed first: +/// custody columns it samples that are undersupplied (fewer connected +/// custodians than `dial::CUSTODY_REDUNDANCY_TARGET`), then attestation +/// subnets no connected peer covers. /// -/// A candidate advertising no subnets scores zero and sorts last, but is never -/// dropped: with few peers, any peer is better than none. -pub(crate) fn rank_by_uncovered_subnets(candidates: &mut [DiscoveredPeer], covered: &HashSet) { +/// Custody outranks subnets because the two shortfalls do not cost the same. +/// At the extreme, a column no connected peer custodies cannot be fetched by +/// root at all, since every peer answers `DataColumnsByRoot` for a column it +/// does not hold with an empty list, and the availability gate then stops the +/// chain on the first block that needs it. But thin, nonzero supply is not +/// safe either: a by-root lookup retries against a custodian it has not +/// already asked, so a column with fewer custodians than the retry ladder has +/// rounds exhausts its ladder re-asking peers that already failed it, with no +/// fresh one left to try. That is the failure a mainnet follower actually hit +/// with 5-8 custodians per sampled column: 78% of its by-root requests went +/// unanswered even though every column had *some* custodian. An uncovered +/// attestation subnet only narrows what this node sees of the mesh, by +/// comparison. The ordering also puts a supernode, which custodies every +/// column, ahead of everything else while any column is undersupplied, which +/// is the fastest way out of that state. +/// +/// A candidate advertising neither scores zero on both and sorts last, but is +/// never dropped: with few peers, any peer is better than none. +pub(crate) fn rank_candidates( + candidates: &mut [DiscoveredPeer], + covered_subnets: &HashSet, + wanted_columns: &HashSet, +) { candidates.sort_by_key(|candidate| { - std::cmp::Reverse( - candidate - .subnets - .iter() - .filter(|subnet| !covered.contains(subnet)) - .count(), - ) + // Skipped rather than computed and discarded when nothing is wanted, + // which is every lean node and every beacon node whose peers already + // supply every sampled column at the target: `custody_coverage` runs + // the custody shuffle per candidate. + let columns = if wanted_columns.is_empty() { + 0 + } else { + candidate.custody_coverage(wanted_columns) + }; + let subnets = candidate + .subnets + .iter() + .filter(|subnet| !covered_subnets.contains(subnet)) + .count(); + (std::cmp::Reverse(columns), std::cmp::Reverse(subnets)) }); } @@ -278,6 +354,7 @@ mod tests { peer_id: PeerId::random(), addrs: vec![Multiaddr::empty()], subnets, + custody_group_count: None, } } } @@ -542,7 +619,7 @@ mod tests { "no admitted peer may advertise a subnet outside the local committee" ); - rank_by_uncovered_subnets(&mut admitted, &HashSet::new()); + rank_candidates(&mut admitted, &HashSet::new(), &HashSet::new()); assert_eq!( admitted[0].subnets, vec![3], @@ -559,18 +636,68 @@ mod tests { DiscoveredPeer::for_test(vec![2]), DiscoveredPeer::for_test(vec![2, 3]), ]; - rank_by_uncovered_subnets(&mut candidates, &HashSet::from([0u64, 1])); + rank_candidates(&mut candidates, &HashSet::from([0u64, 1]), &HashSet::new()); let order: Vec<_> = candidates.iter().map(|c| c.subnets.clone()).collect(); assert_eq!(order, vec![vec![2, 3], vec![2], vec![0]]); } + /// A candidate whose peer id is a real secp256k1 key, so the node id + /// behind it can be recovered and its custody set computed. `PeerId::random` + /// is not that: it is an arbitrary multihash, which is exactly the case + /// `custody_coverage` scores as covering nothing. + fn custodian(custody_group_count: u64) -> DiscoveredPeer { + let secret = secp256k1::SecretKey::new(&mut rand::rngs::OsRng); + let keypair = libp2p::identity::secp256k1::SecretKey::try_from_bytes( + &mut secret.secret_bytes().clone(), + ) + .map(libp2p::identity::secp256k1::Keypair::from) + .expect("a valid key"); + DiscoveredPeer { + peer_id: libp2p::identity::Keypair::from(keypair) + .public() + .to_peer_id(), + addrs: vec![Multiaddr::empty()], + subnets: vec![], + custody_group_count: Some(custody_group_count), + } + } + + #[test] + fn a_peer_custodying_a_wanted_column_outranks_a_better_connected_one() { + // A supernode custodies every column, so it covers whatever is wanted. + let supernode = custodian(constants::NUMBER_OF_CUSTODY_GROUPS); + let well_subnetted = DiscoveredPeer::for_test(vec![0, 1, 2, 3]); + + let mut candidates = vec![well_subnetted.clone(), supernode.clone()]; + rank_candidates(&mut candidates, &HashSet::new(), &HashSet::from([97u64])); + assert_eq!( + candidates[0].peer_id, supernode.peer_id, + "a column no peer holds stops the chain; an uncovered subnet only narrows the view" + ); + + // With every column covered, subnet coverage decides again. + let mut candidates = vec![supernode.clone(), well_subnetted.clone()]; + rank_candidates(&mut candidates, &HashSet::new(), &HashSet::new()); + assert_eq!(candidates[0].peer_id, well_subnetted.peer_id); + } + + #[test] + fn a_peer_that_named_no_custody_count_is_never_credited_with_a_column() { + // The ENR carried no `cgc`, so nothing is known about what this peer + // keeps. Guessing would aim lookups at a peer that answers empty. + let mut unknown = custodian(constants::NUMBER_OF_CUSTODY_GROUPS); + unknown.custody_group_count = None; + + assert_eq!(unknown.custody_coverage(&HashSet::from([97u64])), 0); + } + #[test] fn ranking_keeps_subnet_less_candidates_last_but_present() { let mut candidates = vec![ DiscoveredPeer::for_test(vec![]), DiscoveredPeer::for_test(vec![7]), ]; - rank_by_uncovered_subnets(&mut candidates, &HashSet::new()); + rank_candidates(&mut candidates, &HashSet::new(), &HashSet::new()); let order: Vec<_> = candidates.iter().map(|c| c.subnets.clone()).collect(); assert_eq!( order, diff --git a/crates/net/p2p/src/discovery/dial.rs b/crates/net/p2p/src/discovery/dial.rs index 6e45d7b30..bd1ef0c4c 100644 --- a/crates/net/p2p/src/discovery/dial.rs +++ b/crates/net/p2p/src/discovery/dial.rs @@ -1,29 +1,38 @@ //! The dial loop: turn what discv5 found into libp2p connections. //! -//! Runs as a `P2PServer` tick every [`DISCOVERY_DIAL_INTERVAL`], drawing -//! candidates from the ethrex peer table, ranking them by subnet coverage, and -//! dialing one per tick until [`DiscoveryState::target_peers`] are connected. +//! Runs as a `P2PServer` tick paced by [`dial_interval`], drawing candidates +//! from the ethrex peer table, ranking them by subnet coverage, and dialing one +//! per tick until [`DiscoveryState::target_peers`] are connected. use std::collections::{HashMap, HashSet, VecDeque}; +use std::time::Duration; +use ethrex_p2p::discovery::lookup_interval_function; use ethrex_p2p::peer_table::{PeerTable, PeerTableServerProtocol as _}; use libp2p::PeerId; use libp2p::swarm::dial_opts::DialOpts; -use tracing::info; +use tokio::sync::mpsc; +use tracing::trace; -use super::admission::{DiscoveredPeer, LeanFilter, rank_by_uncovered_subnets}; -use super::{DISCOVERY_CANDIDATE_BATCH, DiscoveryHandle}; +use super::admission::{DiscoveredPeer, LeanFilter, rank_candidates}; +use super::{ + DIAL_INTERVAL_AT_TARGET, DIAL_INTERVAL_AT_ZERO_PEERS, DISCOVERY_CANDIDATE_BATCH, + DiscoveryHandle, +}; use crate::swarm_adapter::DialOutcome; -use crate::{P2PServer, metrics}; +use crate::{ConnectionDirection, P2PServer, metrics}; /// Everything the dial loop needs from a running discovery server. pub(crate) struct DiscoveryState { - peer_table: PeerTable, - /// The same policy the peer table judges records with, asked here for the - /// dial target behind an already-admitted record. - filter: LeanFilter, - /// Admitted candidates, best first, drained one per tick. Refilled from the - /// peer table when empty. + /// Contacts [`spawn_contact_poll`]'s task has drawn from the peer table and + /// passed through admission, waiting to be ranked into `candidates`. + /// + /// A receiver rather than the `PeerTable` itself, because drawing a contact + /// is an actor round trip and this side of it must never await one: the dial + /// loop holds `&mut P2PServer` for the length of a tick. + contacts: mpsc::Receiver, + /// Admitted candidates, best first, drained one per tick. Refilled from + /// `contacts` when empty. candidates: VecDeque, /// Subnets advertised by peers we dialed from discovery. peer_attnets: HashMap>, @@ -34,136 +43,438 @@ pub(crate) struct DiscoveryState { } impl DiscoveryState { + /// Starts the contact poll task, so it must be called from inside a tokio + /// runtime. Every call site builds a `P2PServer` from an async context. pub(crate) fn new(handle: DiscoveryHandle, local_peer_id: PeerId) -> Self { Self { - peer_table: handle.peer_table, - filter: handle.filter, + contacts: spawn_contact_poll(handle.peer_table, handle.filter), candidates: VecDeque::new(), peer_attnets: HashMap::new(), local_peer_id, target_peers: handle.target_peers, } } + + /// Connected-peer count above which the loop stops dialing. + pub(crate) fn target_peers(&self) -> usize { + self.target_peers + } } +/// How long the poll task waits after the peer table offers nothing. +/// +/// Not an end: ethrex clears its tried set once a full scan finds nothing +/// eligible, and discv5 keeps crawling underneath, so contacts reappear on +/// their own. This is only the rate of asking whether they have. +/// +/// Under ethrex's own lookup interval, which is bounded by +/// `INITIAL_LOOKUP_INTERVAL_MS` and `LOOKUP_INTERVAL_MS`, so the crawl stays +/// the thing that decides when a new peer is available and this poll is never +/// what delays one. It matters most on an empty table at startup, where the +/// dial loop previously re-asked on every 20ms tick and this task is the only +/// thing asking at all. +const CONTACT_POLL_IDLE: Duration = Duration::from_millis(250); + /// Drop a peer's discovery bookkeeping. /// /// Called from both teardown paths — a connection that closed and a dial that /// never established — so the map cannot outlive the peers in it and -/// [`covered_subnets`] cannot credit a subnet to someone who left. A no-op when -/// discovery is off. +/// [`covered_subnets`] cannot credit a subnet to someone who left. Without +/// discovery there are no attnets to drop, but custody still goes. pub(crate) fn forget_discovered_peer(server: &mut P2PServer, peer_id: &PeerId) { if let Some(discovery) = server.discovery.as_mut() { discovery.peer_attnets.remove(peer_id); } + // Custody is keyed by peer id and a peer id is a public key, so a returning + // peer recomputes to the same set. Dropped anyway: the map is only ever + // read for a connected peer, and keeping entries for departed ones would + // grow it for the life of the process. + server.peer_custody.remove(peer_id); } -/// One tick of the dial loop. A no-op when discovery is disabled. -pub(crate) async fn dial_tick(server: &mut P2PServer) { - // Snapshot what the refill needs before any `.await`, so no borrow of - // `server.discovery` has to live across the async boundary. Both are handle - // clones: an actor ref and two `Copy` fields, taken only when a refill is - // actually due rather than on every tick that just drains the queue. - let Some(discovery) = server.discovery.as_ref() else { - return; +/// Dials opened per tick. +/// +/// One, because [`dial_interval`] is what sets the rate now. The loop used to +/// dial a whole batch per fixed 5s tick, which was a workaround for a tick too +/// slow to find a peer with room: 8 per 5s is 1.6 dials a second. Pacing the +/// tick instead reaches [`super::MAX_DIAL_RATE_PER_SECOND`] while short and +/// backs off smoothly as the table fills, which the batch could not do. +const DIALS_PER_TICK: usize = 1; + +/// How full the peer table is, 0 when empty and 1 at target, as the pacing +/// curve reads it. +/// +/// The *minimum* of two ratios, so the loop runs fast while either is short. +/// That mirrors [`dial_budget`] taking the maximum of the same two shortfalls, +/// and it is what keeps a table full of inbound peers from reading as "done": +/// at 140 inbound and no outbound the total ratio alone would say 0.7 and pace +/// the loop down to a crawl, which is the exact state that stalled the mainnet +/// follower for two days. +pub(crate) fn dial_progress(server: &P2PServer, target_peers: usize) -> f64 { + let total = if target_peers == 0 { + 1.0 + } else { + server.connected_peers.len() as f64 / target_peers as f64 }; - if server.connected_peers.len() >= discovery.target_peers { - return; - } - let refill = discovery - .candidates - .is_empty() - .then(|| (discovery.peer_table.clone(), discovery.filter.clone())); - let mut admitted = match refill { - Some((peer_table, filter)) => draw_candidates(&peer_table, &filter).await, - None => Vec::new(), + + // A ratio of 1 reads as "nothing to be short on here", which is the answer + // in both of the cases that have no ratio to give: lean, which reserves no + // outbound slots at all, and a zero reservation, which is a zero target and + // so a node asking for no peers. Written out rather than left to the + // division, which would hand back a NaN that only survives this because + // `f64::min` happens to ignore one. + let (outbound, reservation) = outbound_standing(server, target_peers); + let outbound_ratio = match reservation { + Some(0) | None => 1.0, + Some(reserved) => outbound as f64 / reserved as f64, }; + total.min(outbound_ratio).clamp(0.0, 1.0) +} + +/// Outbound peers held, and the reservation they count against (`None` on +/// lean, which reserves nothing). +/// +/// One accessor because [`dial_progress`] and [`dial_budget`] have to read the +/// same two numbers to stay in step: the rate a tick is paced at and the budget +/// that tick spends are the same policy asked twice, and the pair drifting +/// apart is how the mainnet follower stalled in the first place. +fn outbound_standing(server: &P2PServer, target_peers: usize) -> (usize, Option) { + let outbound = server + .connected_peers + .values() + .filter(|direction| **direction == ConnectionDirection::Outbound) + .count(); + // The share of the *target* the swarm reserves, not a fixed slot count: + // `build_swarm` derived the limits libp2p enforces from the same number, so + // a shortfall read here is one the swarm has somewhere to put. A flat + // reservation is what made a target of 50 keep dialing to 60 outbound + // peers, and a target of 0, meaning "do not dial", still have 60 to chase. + let reservation = server + .wire + .beacon() + .map(|_| crate::beacon::swarm::max_outbound_connections(target_peers) as usize); + (outbound, reservation) +} + +/// How long to wait before the next dial, given how full the peer table is. +/// +/// ethrex's `lookup_interval_function`, the easeInOutCubic curve +/// () it paces discv4 and discv5 lookups +/// with, applied to this node's dial loop so both layers ramp the same way. The +/// shape is what matters: it stays near the floor while the table is genuinely +/// short, then climbs steeply through the middle rather than trading rate away +/// linearly for every peer gained. +/// +/// Called rather than transcribed, because "both layers ramp the same way" is +/// the whole reason for this curve and a copy stops being true the moment +/// ethrex retunes it. Upstream takes its bounds in milliseconds and does not +/// clamp, so both of those happen here. +/// +/// [`DIAL_INTERVAL_AT_ZERO_PEERS`] at `progress` 0, which is +/// [`super::MAX_DIAL_RATE_PER_SECOND`], easing to [`DIAL_INTERVAL_AT_TARGET`] +/// at 1. The rate reaches literal zero rather than merely slowing, because +/// [`dial_budget`] returns 0 at target and this loop never dials at all. +pub(crate) fn dial_interval(progress: f64) -> Duration { + lookup_interval_function( + progress.clamp(0.0, 1.0), + DIAL_INTERVAL_AT_ZERO_PEERS.as_micros() as f64 / 1_000.0, + DIAL_INTERVAL_AT_TARGET.as_micros() as f64 / 1_000.0, + ) +} + +/// How many peers this tick may dial, capped at [`DIALS_PER_TICK`]. +/// +/// Two shortfalls, whichever is larger. The first is the plain one: dial until +/// `target_peers` are connected. The second exists because the first is not +/// enough on a network that dials us harder than we dial it. +/// +/// Counting only the total is what stalled the mainnet follower. Inbound demand +/// filled the table to its cap, the total shortfall went to zero, and the dial +/// loop went quiet with **zero** of its reserved outbound slots used, leaving +/// nothing that could reach a peer serving a column it needed. A reservation +/// the dial loop stops trying to fill reserves nothing, so the outbound +/// shortfall is asked separately and is not suppressed by inbound peers, which +/// are not substitutes for it. +/// +/// Beacon only, because the reservation is: the lean swarm runs with +/// [`crate::unlimited_connections`], where a devnet's peer count is bounded by +/// the devnet and there is nothing to reserve against. +fn dial_budget(server: &P2PServer, target_peers: usize) -> usize { + let (outbound, outbound_reservation) = outbound_standing(server, target_peers); + dial_budget_from( + target_peers, + server.connected_peers.len(), + outbound, + outbound_reservation, + ) +} + +/// The arithmetic behind [`dial_budget`], separated from the server it reads so +/// the rule can be stated on numbers alone. +fn dial_budget_from( + target_peers: usize, + connected: usize, + outbound: usize, + outbound_reservation: Option, +) -> usize { + let total_shortfall = target_peers.saturating_sub(connected); + let outbound_shortfall = outbound_reservation + .map(|reserved| reserved.saturating_sub(outbound)) + .unwrap_or(0); + + total_shortfall.max(outbound_shortfall).min(DIALS_PER_TICK) +} + +/// One tick of the dial loop. Returns whether it actually opened a dial, which +/// is what [`crate::P2PServer`] paces the next tick on. +/// +/// A tick that dials nothing is not a tick that should come back in 20ms. The +/// budget alone cannot say so: it is a shortfall against `target_peers`, and a +/// network that has fewer peers than that to offer — every lean devnet, against +/// a default target of [`super::DEFAULT_DISCOVERY_TARGET_PEERS`] — leaves the +/// shortfall permanently open. Pacing on the shortfall alone would then hold +/// the loop at the floor forever, emptying the contact buffer tens of times a +/// second for peers it is already connected to and keeping +/// [`spawn_contact_poll`] drawing the peer table down to refill it. +/// +/// `target_peers` is the running loop's own [`DiscoveryState::target_peers`], +/// read by the caller that already had to check discovery is on. +pub(crate) async fn dial_tick(server: &mut P2PServer, target_peers: usize) -> bool { + // Read once, and spent below. Nothing in between can move it: the one + // `.await` left in this tick is the dial itself, and the `&mut P2PServer` + // borrow held across it keeps any swarm event from touching + // `connected_peers` for the length of the tick. + let budget = dial_budget(server, target_peers); + if budget == 0 { + return false; + } + refill_candidates(server); let Some(discovery) = server.discovery.as_mut() else { - return; + return false; }; - if !admitted.is_empty() { - let covered = covered_subnets(&discovery.peer_attnets, &server.connected_peers); - rank_by_uncovered_subnets(&mut admitted, &covered); - discovery.candidates.extend(admitted); - } - let mut next = None; - while let Some(candidate) = discovery.candidates.pop_front() { - if candidate.peer_id == discovery.local_peer_id - || server.connected_peers.contains(&candidate.peer_id) + // One dial per tick, paced by `dial_interval`. Finding a peer with room is + // a numbers game — a well-connected beacon node completes the handshake and + // answers `Goodbye(129)`, "too many peers", within the same millisecond — + // and the rate is what wins it. That rate used to be smuggled into the + // batch size because the tick itself was a flat 5s; it is in the tick now. + let local_peer_id = discovery.local_peer_id; + let mut dialed = false; + + let mut to_dial = Vec::with_capacity(budget); + while to_dial.len() < budget { + let Some(candidate) = discovery.candidates.pop_front() else { + break; + }; + if candidate.peer_id == local_peer_id + || server.connected_peers.contains_key(&candidate.peer_id) { continue; } - next = Some(candidate); - break; + to_dial.push(candidate); + } + + for candidate in to_dial { + trace!( + peer_id = %candidate.peer_id, + subnets = ?candidate.subnets, + "Dialing discovered peer" + ); + // One `DialOpts` carrying every address, not one dial per address: + // libp2p races them within the attempt, which is what lets a live TCP + // address rescue a peer whose advertised QUIC port does not answer. + let opts = DialOpts::peer_id(candidate.peer_id) + .addresses(candidate.addrs) + .build(); + // The candidate has already been popped and marked tried in the peer + // table, so this is the only chance to record its subnets: whatever + // happens here, it will not be offered again. Which makes the refusal + // the swarm gives back decide whether recording them is right. + match server.swarm_handle.dial_outcome(opts).await { + // Nothing in flight and nothing coming, so `forget_discovered_peer` + // would never run: recording the subnets here would leave + // `covered_subnets` counting a peer we never reach. + DialOutcome::Unreachable => continue, + // A dial to this peer is already in flight, from an earlier tick or + // from the static bootnode path in `build_swarm`. Recording is + // still right: that attempt has a terminal event coming, which + // tears the entry down. Skipping it is what would drift, and + // permanently — the peer connects, covers subnets, and + // `covered_subnets` never counts them, so the dial loop keeps + // hunting for coverage it already has. + DialOutcome::AlreadyInProgress => {} + DialOutcome::Queued => { + metrics::inc_discovered_peers_dialed(); + dialed = true; + } + } + // Seed custody from the record while we have it. `metadata/3` overwrites + // this with the peer's own current answer once it replies; until then a + // stale hint still aims a request far better than a random peer does. + if let Some(count) = candidate.custody_group_count { + crate::req_resp::handlers::record_peer_custody(server, candidate.peer_id, count); + } + if let Some(discovery) = server.discovery.as_mut() { + discovery + .peer_attnets + .insert(candidate.peer_id, candidate.subnets); + } } - let Some(candidate) = next else { + dialed +} + +/// Rank whatever [`spawn_contact_poll`] has admitted since the last refill +/// into the candidate queue, once that queue has run out. +/// +/// Refilled only when empty, which is what leaves `spawn_contact_poll`'s +/// bounded channel able to do its job: draining on every tick would move +/// contacts into this unbounded queue as fast as the table could serve them, +/// and the backpressure that stops it being drawn down for nobody would be +/// gone. Ranking is per refill either way, since it scores a batch against +/// coverage this node has right now. +fn refill_candidates(server: &mut P2PServer) { + let Some(discovery) = server.discovery.as_mut() else { return; }; - - info!( - peer_id = %candidate.peer_id, - subnets = ?candidate.subnets, - "Dialing discovered peer" - ); - // One `DialOpts` carrying every address, not one dial per address: libp2p - // races them within the attempt, which is what lets a live TCP address - // rescue a peer whose advertised QUIC port does not answer. - let opts = DialOpts::peer_id(candidate.peer_id) - .addresses(candidate.addrs) - .build(); - // The candidate has already been popped and marked tried in the peer table, - // so this is the only chance to record its subnets: whatever happens here, - // it will not be offered again. Which makes the refusal the swarm gives back - // decide whether recording them is right. - match server.swarm_handle.dial_outcome(opts).await { - // Nothing in flight and nothing coming, so `forget_discovered_peer` - // would never run: recording the subnets here would leave - // `covered_subnets` counting a peer we never reach. - DialOutcome::Unreachable => return, - // A dial to this peer is already in flight, from an earlier tick or from - // the static bootnode path in `build_swarm`. Recording is still right: - // that attempt has a terminal event coming, which tears the entry down. - // Skipping it is what would drift, and permanently — the peer connects, - // covers subnets, and `covered_subnets` never counts them, so the dial - // loop keeps hunting for coverage it already has. - DialOutcome::AlreadyInProgress => {} - DialOutcome::Queued => metrics::inc_discovered_peers_dialed(), + if !discovery.candidates.is_empty() { + return; } + let mut admitted = Vec::with_capacity(DISCOVERY_CANDIDATE_BATCH); + while let Ok(peer) = discovery.contacts.try_recv() { + admitted.push(peer); + } + if admitted.is_empty() { + return; + } + let covered = covered_subnets(&discovery.peer_attnets, &server.connected_peers); + let wanted = undersupplied_custody_columns(server); + rank_candidates(&mut admitted, &covered, &wanted); if let Some(discovery) = server.discovery.as_mut() { - discovery - .peer_attnets - .insert(candidate.peer_id, candidate.subnets); + discovery.candidates.extend(admitted); } } -/// Draw up to [`DISCOVERY_CANDIDATE_BATCH`] dialable peers from the peer table. +/// Draw dialable peers from the peer table, off the p2p actor's thread. /// -/// ethrex serves one contact per call, skipping anything its `PeerFilter` (ours: -/// [`LeanFilter`]) already rejected, and records each as tried before returning -/// it. So successive calls never repeat, an early `None` means the pool is -/// exhausted, and everything that arrives here has already passed admission. -async fn draw_candidates(peer_table: &PeerTable, filter: &LeanFilter) -> Vec { - let mut admitted = Vec::with_capacity(DISCOVERY_CANDIDATE_BATCH); - for _ in 0..DISCOVERY_CANDIDATE_BATCH { - let Ok(Some(contact)) = peer_table.get_contact_to_initiate().await else { - break; - }; - // A contact whose ENR has not arrived is unjudged, so the peer table - // still offers it, but it carries no address or peer id to dial. - // Skipping it costs nothing: it was marked tried on the way out either - // way, and that set is cleared once a full scan finds nothing eligible. - let Some(peer) = contact - .record - .as_ref() - .and_then(|record| filter.dial_target(record)) - else { - continue; - }; - admitted.push(peer); +/// `get_contact_to_initiate` is an actor round trip, and the dial loop used to +/// await up to [`DISCOVERY_CANDIDATE_BATCH`] of them inline, holding +/// `&mut P2PServer` across every one. That parked the entire actor — gossip +/// forwarding, req/resp, swarm events, every tick — behind the peer table for +/// the length of a refill. The round trips happen in this task now, and what +/// reaches the loop is a channel of already-admitted [`DiscoveredPeer`]s it can +/// drain without awaiting anything. +/// +/// ethrex serves one contact per call, skipping anything its `PeerFilter` +/// (ours: [`LeanFilter`]) already rejected, and records each as *tried* before +/// returning it. So successive calls never repeat, and everything that arrives +/// here has already passed admission. +/// +/// That "marked tried on the way out" is why the channel is bounded and why the +/// task reserves its slot before asking. A contact drawn with nowhere to put it +/// is not offered a second time, so it would be lost rather than queued, and a +/// task free to run ahead of the dial loop would draw the table's eligible set +/// down for candidates nobody ever dialed. A full buffer stops it asking +/// instead. +/// +/// The task ends when the receiver does, which is when the `P2PServer` holding +/// [`DiscoveryState`] is dropped. +fn spawn_contact_poll(peer_table: PeerTable, filter: LeanFilter) -> mpsc::Receiver { + let (contacts, receiver) = mpsc::channel(DISCOVERY_CANDIDATE_BATCH); + tokio::spawn(async move { + while let Ok(permit) = contacts.reserve().await { + let Ok(Some(contact)) = peer_table.get_contact_to_initiate().await else { + drop(permit); + tokio::time::sleep(CONTACT_POLL_IDLE).await; + continue; + }; + // A contact whose ENR has not arrived is unjudged, so the peer table + // still offers it, but it carries no address or peer id to dial. + // Skipping it costs nothing: it was marked tried on the way out + // either way. The permit is released with it, so the slot goes to + // the next contact rather than to this one's absence. + if let Some(peer) = contact + .record + .as_ref() + .and_then(|record| filter.dial_target(record)) + { + permit.send(peer); + } + } + }); + receiver +} + +/// How many distinct custodians a sampled column needs before dialing stops +/// treating it as a gap. +/// +/// Tied to [`crate::MAX_FETCH_RETRIES`]: a by-root column lookup gets that many +/// attempts, `handle_column_fetch_failure` in `req_resp/handlers.rs` tracks +/// `failed_peers` so each retry asks a custodian it has not already asked, and +/// a column with fewer custodians than the ladder has rounds runs out of fresh +/// peers before it runs out of retries. Below this count, a lookup can still +/// exhaust its ladder on a handful of peers that are slow, unreachable, or +/// simply don't have the column cached, with no untried custodian left to +/// fall back to. +/// +/// Presence — at least one custodian — used to be the bar, and it was too low +/// to catch this: a mainnet follower whose `lean_custody_column_peers` showed +/// only 5-8 custodians per sampled column (of 129 connected peers) still read +/// every one of those columns as "covered", so custody stopped contributing to +/// [`rank_candidates`] and dialing optimized purely for attestation-subnet +/// coverage. 78% of that node's by-root column requests went unanswered +/// (81,073 requests against 17,785 response chunks) while it was off the tip +/// and depended on by-root fetches alone. +const CUSTODY_REDUNDANCY_TARGET: usize = crate::MAX_FETCH_RETRIES as usize; + +/// The columns this node samples whose connected-peer custodian count is below +/// [`CUSTODY_REDUNDANCY_TARGET`]. +/// +/// Empty on lean, which samples nothing, and empty on a beacon node whose +/// peers already supply every sampled column at the target, which is what +/// lets the ranking skip the per-candidate custody shuffle entirely in the +/// common case. +/// +/// `peer_custody` holds a peer only once its `metadata/3` answer, or the `cgc` +/// its ENR carried at dial time, has been recorded, so a peer that has told us +/// neither contributes to any column's count. That makes the ranking more +/// eager than strictly necessary, never wrong: the cost of over-counting a gap +/// is one dial aimed at a peer that would have been worth dialing anyway. +fn undersupplied_custody_columns(server: &P2PServer) -> HashSet { + let Some(wire) = server.wire.beacon() else { + return HashSet::new(); + }; + let custodian_counts = + custodian_counts_by_column(&server.peer_custody, &server.connected_peers); + wire.custody_columns + .iter() + .copied() + .filter(|column| { + custodian_counts.get(column).copied().unwrap_or(0) < CUSTODY_REDUNDANCY_TARGET + }) + .collect() +} + +/// Distinct connected-peer custodian counts per column, the custody +/// counterpart of [`covered_subnets`] and read the same way. +/// +/// A peer's own column list is deduplicated before it contributes, so a +/// column listed twice for the same peer (which should not happen, but +/// `metadata/3` is peer-supplied) still counts that peer once. +fn custodian_counts_by_column( + peer_custody: &HashMap>, + connected_peers: &HashMap, +) -> HashMap { + let mut counts = HashMap::new(); + for (_, columns) in peer_custody + .iter() + .filter(|(peer, _)| connected_peers.contains_key(peer)) + { + for column in columns.iter().copied().collect::>() { + *counts.entry(column).or_insert(0) += 1; + } } - admitted + counts } /// Attestation subnets covered by peers we are currently connected to. @@ -173,11 +484,11 @@ async fn draw_candidates(peer_table: &PeerTable, filter: &LeanFilter) -> Vec>, - connected_peers: &HashSet, + connected_peers: &HashMap, ) -> HashSet { peer_attnets .iter() - .filter(|(peer, _)| connected_peers.contains(peer)) + .filter(|(peer, _)| connected_peers.contains_key(peer)) .flat_map(|(_, subnets)| subnets.iter().copied()) .collect() } @@ -197,8 +508,235 @@ mod tests { let gone = random_peer(); let peer_attnets = HashMap::from([(connected, vec![1u64, 2]), (gone, vec![7u64])]); - let covered = covered_subnets(&peer_attnets, &HashSet::from([connected])); + let covered = covered_subnets( + &peer_attnets, + &HashMap::from([(connected, ConnectionDirection::Inbound)]), + ); assert_eq!(covered, HashSet::from([1, 2])); } + + #[test] + fn a_departed_peers_columns_stop_counting_as_covered() { + // The case that stops the chain: what a peer custodied is only + // reachable while that peer is connected, so a lookup aimed at a + // column only a departed peer held gets an empty answer from + // everyone. Counting it as supplied would keep the dial loop from + // looking for a replacement. + let connected = random_peer(); + let gone = random_peer(); + let peer_custody = HashMap::from([(connected, vec![47u64, 63]), (gone, vec![97u64])]); + + let counts = custodian_counts_by_column( + &peer_custody, + &HashMap::from([(connected, ConnectionDirection::Inbound)]), + ); + + assert_eq!(counts, HashMap::from([(47, 1), (63, 1)])); + } + + #[test] + fn two_peers_custodying_the_same_column_count_as_two() { + let first = random_peer(); + let second = random_peer(); + let peer_custody = HashMap::from([(first, vec![47u64]), (second, vec![47u64])]); + let connected = HashMap::from([ + (first, ConnectionDirection::Inbound), + (second, ConnectionDirection::Outbound), + ]); + + let counts = custodian_counts_by_column(&peer_custody, &connected); + + assert_eq!(counts, HashMap::from([(47, 2)])); + } + + #[test] + fn the_same_peer_listed_twice_for_a_column_counts_once() { + // `metadata/3` is peer-supplied, so a duplicate in its own answer must + // not inflate that one peer into two custodians. + let peer = random_peer(); + let peer_custody = HashMap::from([(peer, vec![47u64, 47u64])]); + let connected = HashMap::from([(peer, ConnectionDirection::Inbound)]); + + let counts = custodian_counts_by_column(&peer_custody, &connected); + + assert_eq!(counts, HashMap::from([(47, 1)])); + } + + /// A column already at the redundancy target is not a gap: dialing should + /// not keep chasing coverage it already has. + #[test] + fn a_column_at_the_target_is_not_undersupplied() { + let peers: Vec = (0..CUSTODY_REDUNDANCY_TARGET) + .map(|_| random_peer()) + .collect(); + let peer_custody = peers.iter().map(|peer| (*peer, vec![9u64])).collect(); + let connected = peers + .iter() + .map(|peer| (*peer, ConnectionDirection::Inbound)) + .collect(); + + let counts = custodian_counts_by_column(&peer_custody, &connected); + + assert_eq!( + counts.get(&9).copied().unwrap_or(0), + CUSTODY_REDUNDANCY_TARGET + ); + } + + /// One custodian short of the target must still read as a gap, so dialing + /// keeps looking for one more. + #[test] + fn a_column_below_the_target_is_undersupplied() { + let peers: Vec = (0..CUSTODY_REDUNDANCY_TARGET - 1) + .map(|_| random_peer()) + .collect(); + let peer_custody = peers.iter().map(|peer| (*peer, vec![9u64])).collect(); + let connected = peers + .iter() + .map(|peer| (*peer, ConnectionDirection::Inbound)) + .collect(); + + let counts = custodian_counts_by_column(&peer_custody, &connected); + + assert!(counts.get(&9).copied().unwrap_or(0) < CUSTODY_REDUNDANCY_TARGET); + } + + /// The mainnet-follower regression this budget exists for: the peer table + /// was full, every slot held by an inbound peer, and the dial loop went + /// quiet with its whole outbound reservation unused. Nothing was then left + /// that could reach a peer serving a column the node needed. + #[test] + fn a_full_inbound_table_does_not_stop_the_loop_from_filling_the_reservation() { + let budget = dial_budget_from(200, 200, 0, Some(60)); + + assert_eq!( + budget, DIALS_PER_TICK, + "an idle outbound reservation has to keep the loop dialing" + ); + } + + /// The reservation scales with the target, so the loop never chases slots + /// the swarm would refuse. A flat 60 is what made a target of 50 keep + /// dialing past it, and a target of 0 dial at all. + #[test] + fn the_reservation_follows_the_target() { + use crate::beacon::swarm::max_outbound_connections; + + // At target, on the reservation that target derives: nothing left. + assert_eq!( + dial_budget_from(50, 50, 15, Some(max_outbound_connections(50) as usize)), + 0 + ); + // A flat 60-slot reservation would still read 45 short here and keep + // dialing to 60 outbound peers, well past the 50 asked for. + assert_eq!(max_outbound_connections(50), 15); + } + + /// `--discovery.target-peers 0` is an explicit "hold no peers", so neither + /// shortfall may ask for a dial. The reservation used to be a flat 60 and + /// this case dialed against the operator. + #[test] + fn a_zero_target_never_dials() { + use crate::beacon::swarm::max_outbound_connections; + + let reservation = max_outbound_connections(0) as usize; + assert_eq!(reservation, 0); + assert_eq!(dial_budget_from(0, 0, 0, Some(reservation)), 0); + } + + #[test] + fn a_filled_reservation_on_a_full_table_stops_the_loop() { + // Both shortfalls closed, so there is nothing left to dial for. This is + // the case the outbound clause must not defeat: it widens *when* to + // dial, it does not make the loop dial forever. + assert_eq!(dial_budget_from(200, 200, 60, Some(60)), 0); + } + + #[test] + fn the_outbound_shortfall_is_measured_against_outbound_peers_alone() { + // The table is *at* target, so the total shortfall is zero and only the + // outbound one can still ask for a dial. 200 inbound peers are not a + // substitute for the 5 missing outbound ones, so the loop keeps going. + // How fast it goes is `dial_interval`'s job, not the budget's. + assert_eq!(dial_budget_from(200, 200, 55, Some(60)), DIALS_PER_TICK); + // Fill that last shortfall and it stops, which is what makes the line + // above about outbound rather than about always returning one. + assert_eq!(dial_budget_from(200, 200, 60, Some(60)), 0); + } + + #[test] + fn lean_reserves_nothing_and_paces_on_the_total_alone() { + // The lean swarm runs unlimited, so there is no reservation to chase + // and a table at target stops the loop exactly as it always did. + assert_eq!(dial_budget_from(50, 50, 0, None), 0); + assert_eq!(dial_budget_from(50, 47, 0, None), DIALS_PER_TICK); + } + + /// The floor is the rate the loop is asked for when it has nothing. + #[test] + fn a_node_with_no_peers_dials_at_the_configured_rate() { + let interval = dial_interval(0.0); + + assert_eq!(interval, DIAL_INTERVAL_AT_ZERO_PEERS); + // One dial per tick, so the tick rate *is* the dial rate. + let per_second = 1_000_000 / interval.as_micros(); + assert_eq!(per_second as u64, super::super::MAX_DIAL_RATE_PER_SECOND); + } + + #[test] + fn a_full_table_paces_at_the_slow_end() { + assert_eq!(dial_interval(1.0), DIAL_INTERVAL_AT_TARGET); + } + + /// easeInOutCubic is symmetric about its midpoint, which is the property + /// that makes it hold near the floor while the table is genuinely short + /// instead of trading rate away linearly for every peer gained. + #[test] + fn the_curve_is_ethrex_ease_in_out_cubic() { + let lower = DIAL_INTERVAL_AT_ZERO_PEERS.as_micros() as f64; + let upper = DIAL_INTERVAL_AT_TARGET.as_micros() as f64; + + // Halfway along, easeInOutCubic is exactly 0.5. + let midpoint = dial_interval(0.5).as_micros() as f64; + assert!((midpoint - (lower + upper) / 2.0).abs() < 1.0); + + // A quarter in, it has spent only 1/16 of the range: 4 * 0.25^3. + let quarter = dial_interval(0.25).as_micros() as f64; + let expected = 0.0625 * (upper - lower) + lower; + assert!((quarter - expected).abs() < 1.0, "{quarter} vs {expected}"); + + // Still under a tenth of the way to the slow end at a quarter full, + // where a linear ramp would already be a quarter of the way there. + assert!(quarter < lower + (upper - lower) / 10.0); + } + + #[test] + fn the_curve_never_goes_backwards_and_stays_inside_its_bounds() { + let mut previous = dial_interval(0.0); + for step in 0..=100 { + let interval = dial_interval(step as f64 / 100.0); + assert!(interval >= previous, "interval fell at {step}"); + assert!(interval >= DIAL_INTERVAL_AT_ZERO_PEERS); + assert!(interval <= DIAL_INTERVAL_AT_TARGET); + previous = interval; + } + } + + /// Out-of-range input is clamped rather than extrapolated: a peer count + /// above target would otherwise run the cubic past 1 and produce an + /// interval longer than the slow end. + #[test] + fn progress_outside_the_unit_range_is_clamped() { + assert_eq!(dial_interval(-1.0), DIAL_INTERVAL_AT_ZERO_PEERS); + assert_eq!(dial_interval(4.2), DIAL_INTERVAL_AT_TARGET); + } + + #[test] + fn the_budget_never_exceeds_one_tick() { + // A node at zero peers is short its whole target, and still opens one + // dial per tick: the rate it recovers at is set by `dial_interval`, so + // a large shortfall must not turn into a burst here. + assert_eq!(dial_budget_from(200, 0, 0, Some(60)), DIALS_PER_TICK); + } } diff --git a/crates/net/p2p/src/discovery/enr.rs b/crates/net/p2p/src/discovery/enr.rs index 6ff17e5a5..3c894d97b 100644 --- a/crates/net/p2p/src/discovery/enr.rs +++ b/crates/net/p2p/src/discovery/enr.rs @@ -22,11 +22,9 @@ use std::collections::HashSet; use std::net::IpAddr; -use ethlambda_types::constants::FORK_DIGEST; use ethrex_p2p::types::{INITIAL_ENR_SEQ, Node, NodeRecord, NodeRecordPairs}; -use ethrex_p2p::utils::public_key_from_signing_key; +use ethrex_p2p::utils::{node_id, public_key_from_signing_key}; use libssz::SszEncode; -use libssz_derive::{SszDecode, SszEncode}; use secp256k1::SecretKey; use super::DiscoveryError; @@ -34,41 +32,14 @@ use super::DiscoveryError; pub(crate) const QUIC_ENR_KEY: &[u8] = b"quic"; pub(crate) const ETH2_ENR_KEY: &[u8] = b"eth2"; pub(crate) const ATTNETS_ENR_KEY: &[u8] = b"attnets"; +pub(crate) const CGC_ENR_KEY: &[u8] = b"cgc"; -/// Fork version of the next planned hard fork. The spec says to set this to the -/// current fork version when no fork is planned; lean has neither. -pub(crate) const NEXT_FORK_VERSION: [u8; 4] = [0; 4]; - -/// Sentinel for "no fork is scheduled", per the beacon spec. -pub(crate) const FAR_FUTURE_EPOCH: u64 = u64::MAX; - -/// The `eth2` ENR entry: SSZ, 16 bytes, byte-identical to the beacon-chain -/// `ENRForkID` container. -#[derive(Debug, Clone, Copy, PartialEq, Eq, SszEncode, SszDecode)] -pub(crate) struct EnrForkId { - pub(crate) fork_digest: [u8; 4], - pub(crate) next_fork_version: [u8; 4], - pub(crate) next_fork_epoch: u64, -} - -impl EnrForkId { - /// This node's fork id. Constant for the lifetime of the process. - pub(crate) fn local() -> Self { - Self { - fork_digest: fork_digest(), - next_fork_version: NEXT_FORK_VERSION, - next_fork_epoch: FAR_FUTURE_EPOCH, - } - } -} - -/// [`FORK_DIGEST`] as raw bytes. The constant is the same hex string embedded in -/// every gossipsub topic name, so the ENR and the topics cannot disagree. -pub(crate) fn fork_digest() -> [u8; 4] { - u32::from_str_radix(FORK_DIGEST, 16) - .expect("FORK_DIGEST must be 8 hex digits") - .to_be_bytes() -} +// The `eth2` entry's container, its two "no fork is planned" constants, and the +// lean fork digest. They live in `ethlambda-types` rather than here because the +// binary has to name the type to hand one in, and because the beacon wire +// computes its digest at startup from the fork schedule; re-exported at this +// module's old paths so every use site inside the crate is unchanged. +pub use ethlambda_types::enr::{EnrForkId, FAR_FUTURE_EPOCH, NEXT_FORK_VERSION, fork_digest}; /// Encode subscribed attestation subnets as the `attnets` bitfield: bit `i` set /// means subnet `i` is subscribed. @@ -95,7 +66,7 @@ pub(crate) fn encode_attnets(subnets: &HashSet, committee_count: u64) -> Ve /// as unsubscribed. A longer one cannot be believed either, because `attnets` is /// self-reported and unauthenticated, so a hostile ENR could otherwise pack an /// oversized field that decodes to thousands of subnets and dominate -/// [`rank_by_uncovered_subnets`](super::admission::rank_by_uncovered_subnets) +/// [`rank_candidates`](super::admission::rank_candidates) /// forever. A subnet we have no committee for cannot be useful to us regardless. pub(crate) fn subnets_from_attnets(bits: &[u8], committee_count: u64) -> Vec { (0..committee_count) @@ -106,6 +77,53 @@ pub(crate) fn subnets_from_attnets(bits: &[u8], committee_count: u64) -> Vec Result<[u8; 32], DiscoveryError> { + let signer = SecretKey::from_slice(node_key).map_err(DiscoveryError::NodeKey)?; + Ok(node_id(&public_key_from_signing_key(&signer)).0) +} + +/// The discv5 node id behind a libp2p [`PeerId`], or `None` when it cannot be +/// recovered from the id alone. +/// +/// A peer's custody set is a function of its node id and its advertised +/// custody group count, and both sides have to compute the same one. The node +/// id is available without asking anyone: libp2p stores a public key of 42 +/// bytes or fewer directly in the `PeerId`'s multihash rather than hashing it, +/// and a secp256k1 key is well inside that, so the key can be read back out +/// and put through the same `keccak256(uncompressed)` that +/// [`node_id_from_secret_key`] applies to our own. +/// +/// `None` covers the two cases where that does not hold: a `PeerId` carrying a +/// real (hashed) multihash rather than an identity one, and a peer whose key +/// is not secp256k1. Neither can appear on a mainnet beacon peer, whose +/// identity the ENR's `secp256k1` entry defines, so a `None` here is a peer +/// whose custody simply stays unknown rather than an error worth failing on. +pub(crate) fn node_id_from_peer_id(peer_id: &libp2p::PeerId) -> Option<[u8; 32]> { + const IDENTITY_MULTIHASH_CODE: u64 = 0x00; + + let multihash = peer_id.as_ref(); + if multihash.code() != IDENTITY_MULTIHASH_CODE { + return None; + } + let public_key = libp2p::identity::PublicKey::try_decode_protobuf(multihash.digest()).ok()?; + let compressed = public_key.try_into_secp256k1().ok()?.to_bytes(); + let uncompressed = secp256k1::PublicKey::from_slice(&compressed) + .ok()? + .serialize_uncompressed(); + // `serialize_uncompressed` leads with SEC1's 0x04 tag; the node id is over + // the 64 coordinate bytes alone. + Some(node_id(ðrex_common::H512::from_slice(&uncompressed[1..])).0) +} + /// Everything needed to build this node's ENR. pub(crate) struct LocalEnrParams { pub(crate) signer: SecretKey, @@ -123,6 +141,20 @@ pub(crate) struct LocalEnrParams { pub(crate) p2p_port: u16, pub(crate) subscription_subnets: HashSet, pub(crate) attestation_committee_count: u64, + /// The `eth2` entry to publish. + /// + /// Lean's is a compile-time constant, but the beacon wire computes its + /// digest from the fork schedule and the anchor's genesis validators root + /// at startup, so this cannot be reached for internally. + pub(crate) fork_id: EnrForkId, + /// The `cgc` entry to publish, or `None` to omit it. + /// + /// `Some(CUSTODY_REQUIREMENT)` on the beacon wire: it is the floor a peer + /// may demand, and this node's actual custody only ever meets or exceeds + /// it (`sampling_size` never samples fewer groups than that), so + /// advertising it never overstates what this node stores and serves. + /// `None` on lean, which has no data-availability domain. + pub(crate) custody_group_count: Option, } impl LocalEnrParams { @@ -164,10 +196,13 @@ impl LocalEnrParams { // in the built record, so the answers are not checked here. let attnets = encode_attnets(&self.subscription_subnets, self.attestation_committee_count); pairs.set_extra(ATTNETS_ENR_KEY, attnets); - pairs.set_extra(ETH2_ENR_KEY, EnrForkId::local().to_ssz()); + pairs.set_extra(ETH2_ENR_KEY, self.fork_id.to_ssz()); if let Some(quic_port) = dialable_port(self.p2p_port) { pairs.set_extra_int(QUIC_ENR_KEY, quic_port.into()); } + if let Some(count) = self.custody_group_count { + pairs.set_extra_int(CGC_ENR_KEY, count); + } pairs } } @@ -258,29 +293,144 @@ mod tests { p2p_port: 9001, subscription_subnets: HashSet::from([1u64, 4]), attestation_committee_count: 8, + fork_id: EnrForkId::local(), + custody_group_count: None, }) .expect("ENR builds") } #[test] - fn fork_digest_parses_the_constant() { - assert_eq!(fork_digest(), [0x12, 0x34, 0x56, 0x78]); + fn node_id_from_secret_key_matches_the_discovery_servers_own_computation() { + // If this function ever computed a different id than `local_node`'s + // `Node::node_id()`, a node would custody one set of columns while + // every peer, reading the ENR `local_node` feeds the discovery + // server, computed a different set for it. Nothing else would catch + // that: the columns would simply go unserved. + let signer = secp256k1::SecretKey::new(&mut rand::rngs::OsRng); + let params = LocalEnrParams { + signer, + ip: IpAddr::from(Ipv4Addr::LOCALHOST), + discovery_port: 9010, + p2p_port: 9001, + subscription_subnets: HashSet::new(), + attestation_committee_count: 64, + fork_id: EnrForkId::local(), + custody_group_count: None, + }; + let expected = params.local_node().node_id().0; + assert_eq!( + node_id_from_secret_key(&signer.secret_bytes()).expect("a valid key"), + expected + ); } #[test] - fn enr_fork_id_is_sixteen_bytes_and_round_trips() { - let id = EnrForkId::local(); - let bytes = id.to_ssz(); - assert_eq!(bytes.len(), 16, "ENRForkID is 4 + 4 + 8 bytes"); - assert_eq!(EnrForkId::from_ssz_bytes(&bytes).unwrap(), id); + fn node_id_from_peer_id_agrees_with_the_key_it_was_built_from() { + // The two derivations have to land on the same id: this node computes + // its own custody from the secret key, and computes a *peer's* from + // the PeerId that key produces. A divergence would mean asking peers + // for the columns they are not the ones custodying, silently, with + // every request simply coming back empty. + let signer = secp256k1::SecretKey::new(&mut rand::rngs::OsRng); + let keypair = libp2p::identity::secp256k1::SecretKey::try_from_bytes( + &mut signer.secret_bytes().clone(), + ) + .map(libp2p::identity::secp256k1::Keypair::from) + .expect("a valid key"); + let peer_id = libp2p::identity::Keypair::from(keypair) + .public() + .to_peer_id(); + + assert_eq!( + node_id_from_peer_id(&peer_id), + Some(node_id_from_secret_key(&signer.secret_bytes()).expect("a valid key")) + ); } #[test] - fn local_fork_id_has_no_planned_fork() { - let id = EnrForkId::local(); - assert_eq!(id.fork_digest, fork_digest()); - assert_eq!(id.next_fork_version, NEXT_FORK_VERSION); - assert_eq!(id.next_fork_epoch, FAR_FUTURE_EPOCH); + fn node_id_from_peer_id_declines_a_key_it_cannot_read() { + // An ed25519 identity is not a secp256k1 one, so no node id can be + // computed for it. This must stay a `None` rather than a wrong answer: + // the caller treats unknown custody as "ask someone else", which is + // safe, where a fabricated id would send requests nobody can answer. + let peer_id = libp2p::identity::Keypair::generate_ed25519() + .public() + .to_peer_id(); + + assert_eq!(node_id_from_peer_id(&peer_id), None); + } + + #[test] + fn the_published_fork_id_is_the_one_supplied() { + // Lean's is a compile-time constant, but the beacon wire computes its + // digest from the fork schedule at startup, so the ENR builder must not + // reach for EnrForkId::local() behind the caller's back. + let supplied = EnrForkId { + fork_digest: [0x8c, 0x9f, 0x62, 0xfe], + next_fork_version: [0x06, 0x00, 0x00, 0x00], + next_fork_epoch: FAR_FUTURE_EPOCH, + }; + let record = build_local_enr(&LocalEnrParams { + signer: secp256k1::SecretKey::new(&mut rand::rngs::OsRng), + ip: IpAddr::from(Ipv4Addr::LOCALHOST), + discovery_port: 9010, + p2p_port: 9001, + subscription_subnets: HashSet::new(), + attestation_committee_count: 64, + fork_id: supplied, + custody_group_count: None, + }) + .expect("ENR builds"); + + let raw = record + .pairs() + .extra(ETH2_ENR_KEY) + .expect("eth2 entry present"); + assert_eq!(EnrForkId::from_ssz_bytes(&raw).unwrap(), supplied); + } + + #[test] + fn the_custody_group_count_is_published_only_when_asked_for() { + // Lean has no data-availability domain, so publishing a cgc there would + // advertise a claim with no meaning behind it. + let record = build(); + assert_eq!(record.pairs().extra(CGC_ENR_KEY), None); + + let with_cgc = build_local_enr(&LocalEnrParams { + signer: secp256k1::SecretKey::new(&mut rand::rngs::OsRng), + ip: IpAddr::from(Ipv4Addr::LOCALHOST), + discovery_port: 9010, + p2p_port: 9001, + subscription_subnets: HashSet::new(), + attestation_committee_count: 64, + fork_id: EnrForkId::local(), + custody_group_count: Some(4), + }) + .expect("ENR builds"); + assert_eq!(with_cgc.pairs().extra_int::(CGC_ENR_KEY), Some(4)); + } + + #[test] + fn a_sixty_four_wide_attnets_is_eight_bytes_of_zeroes() { + // What a node subscribing to no attestation subnet actually serves. + // Publishing a shorter bitfield would be a different claim: readers + // treat bits past the end as unset, but the beacon spec's attnets is a + // fixed-width Bitvector and a short one is malformed to a strict reader. + let record = build_local_enr(&LocalEnrParams { + signer: secp256k1::SecretKey::new(&mut rand::rngs::OsRng), + ip: IpAddr::from(Ipv4Addr::LOCALHOST), + discovery_port: 9010, + p2p_port: 9001, + subscription_subnets: HashSet::new(), + attestation_committee_count: 64, + fork_id: EnrForkId::local(), + custody_group_count: Some(4), + }) + .expect("ENR builds"); + assert_eq!( + record.pairs().extra(ATTNETS_ENR_KEY).as_deref(), + Some(&[0u8; 8][..]) + ); } #[test] @@ -353,6 +503,8 @@ mod tests { p2p_port: 0, subscription_subnets: HashSet::from([1u64]), attestation_committee_count: 8, + fork_id: EnrForkId::local(), + custody_group_count: None, }) .expect("ENR builds"); diff --git a/crates/net/p2p/src/discovery/mod.rs b/crates/net/p2p/src/discovery/mod.rs index c1e14da9d..6a6e3a09b 100644 --- a/crates/net/p2p/src/discovery/mod.rs +++ b/crates/net/p2p/src/discovery/mod.rs @@ -28,8 +28,36 @@ use crate::Bootnode; use admission::LeanFilter; use enr::{EnrForkId, LocalEnrParams, build_local_enr}; -/// How often the dial loop looks for a new peer. -pub const DISCOVERY_DIAL_INTERVAL: Duration = Duration::from_secs(5); +/// Dials per second the loop opens when this node has no peers at all. +/// +/// A node short of peers is not idling, it is failing. Mainnet beacon nodes sit +/// at their inbound cap and answer `Goodbye(129)`, "too many peers", within a +/// millisecond of the handshake, so landing one with room is a numbers game: +/// measured on the eth-4 follower, 96% of outbound dials never establish. The +/// rate is set high enough that the 4% still arrives quickly, and it is a +/// *starting* rate, not a standing one — see [`DIAL_INTERVAL_AT_TARGET`]. +pub const MAX_DIAL_RATE_PER_SECOND: u64 = 50; + +/// The gap between dials at [`MAX_DIAL_RATE_PER_SECOND`], which is the floor +/// the pacing curve starts from. +pub const DIAL_INTERVAL_AT_ZERO_PEERS: Duration = + Duration::from_micros(1_000_000 / MAX_DIAL_RATE_PER_SECOND); + +/// The gap between dials as the peer count reaches its target. +/// +/// ethrex's `LOOKUP_INTERVAL_MS` itself, which it uses as the ceiling for both +/// discv5 lookups and RLPx dialing, so the two layers slow down together. Its +/// floor is 100ms against this node's 20ms, but the ceiling is worth matching +/// exactly: it is reached while the table is nearly full and peers still churn, +/// and a slower one there means replacing losses at the rate they happen +/// instead of ahead of it. Read from upstream rather than copied, so "exactly" +/// survives a retune there. +/// +/// This is not where dialing stops. [`dial::dial_budget`] returns 0 at target +/// and the loop opens nothing at all, so a fast ceiling costs nothing once the +/// table is genuinely full. +pub const DIAL_INTERVAL_AT_TARGET: Duration = + Duration::from_micros((ethrex_p2p::discovery::LOOKUP_INTERVAL_MS * 1_000.0) as u64); /// Default connected-peer count above which the dial loop stops dialing. /// Overridable per node via [`DiscoverySpawnConfig::target_peers`]. @@ -48,7 +76,12 @@ pub const DEFAULT_DISCOVERY_TARGET_PEERS: usize = 200; /// reason attached rather than a number pretending to be a peer budget. const PEER_TABLE_TARGET_PEERS: usize = 1; -/// Candidates drawn from the peer table per refill. +/// Admitted contacts [`dial::spawn_contact_poll`] keeps buffered ahead of the +/// dial loop, and so the most one refill can draw. +/// +/// A bound rather than a batch size, because the peer table marks every contact +/// tried on the way out: this is how far ahead of the dialing the table may be +/// drawn down. See [`dial::spawn_contact_poll`]. pub const DISCOVERY_CANDIDATE_BATCH: usize = 8; /// Why discovery could not be started. Every variant is fatal at startup. @@ -95,6 +128,10 @@ pub struct DiscoverySpawnConfig { /// instead, because it counts only peers registered over RLPx and so can /// never see ours; see `PEER_TABLE_TARGET_PEERS`. pub target_peers: usize, + /// The `eth2` entry to publish and to compare discovered peers against. + pub fork_id: EnrForkId, + /// The `cgc` entry to publish, or `None` to omit it. + pub custody_group_count: Option, } /// What the P2P actor needs from a running discovery server. @@ -149,6 +186,8 @@ pub async fn spawn_discovery( p2p_port: config.p2p_port, subscription_subnets: config.subscription_subnets, attestation_committee_count: config.attestation_committee_count, + fork_id: config.fork_id, + custody_group_count: config.custody_group_count, }; let local_node = params.local_node(); let local_record = build_local_enr(¶ms)?; @@ -162,7 +201,7 @@ pub async fn spawn_discovery( // The peer table owns the filter it runs, so the dial loop keeps a clone // rather than sharing one: the two carry the same fork id and committee // count, which is what makes their judgments agree. - let filter = LeanFilter::new(EnrForkId::local(), config.attestation_committee_count); + let filter = LeanFilter::new(config.fork_id, config.attestation_committee_count); let peer_table = PeerTableServer::spawn_with_filter( local_node.node_id(), PEER_TABLE_TARGET_PEERS, @@ -241,6 +280,8 @@ mod tests { bootnodes: Vec::new(), advertise_ip, target_peers: DEFAULT_DISCOVERY_TARGET_PEERS, + fork_id: EnrForkId::local(), + custody_group_count: None, } } diff --git a/crates/net/p2p/src/gossipsub/handler.rs b/crates/net/p2p/src/gossipsub/handler.rs index 1ba5cea36..a1797a813 100644 --- a/crates/net/p2p/src/gossipsub/handler.rs +++ b/crates/net/p2p/src/gossipsub/handler.rs @@ -1,144 +1,469 @@ -use ethlambda_network_api::BlockSource; +//! What `P2PServer` does with gossip, on either chain. +//! +//! One entry point and one dispatch, the shape `crate::req_resp::handlers` has: +//! the topic says which chain a message belongs to, so nothing above this module +//! branches on which chain the node follows. Handler names follow the same +//! convention as there, lean prefixed and beacon bare. + +use std::time::Instant; + +use ethlambda_network_api::{BlockArrival, BlockSource}; +use ethlambda_state_transition::beacon::gossip::{self, IgnoreReason, Outcome, RejectReason}; use ethlambda_types::{ ShortRoot, attestation::{SignedAggregatedAttestation, SignedAttestation}, + beacon::containers::{SignedBeaconBlock, electra::SingleAttestation}, block::SignedBlock, primitives::HashTreeRoot as _, + time::unix_now_ms, }; -use libp2p::gossipsub::Event; +use libp2p::PeerId; +use libp2p::gossipsub::{IdentTopic, Message, MessageId}; use libssz::{SszDecode, SszEncode}; -use tracing::{error, info, trace}; +use spawned_concurrency::tasks::Context; +use tracing::{debug, error, info, trace, warn}; use super::{ encoding::{compress_message, decompress_message}, messages::{ AGGREGATION_TOPIC_KIND, ATTESTATION_SUBNET_TOPIC_PREFIX, BLOCK_TOPIC_KIND, - attestation_subnet_topic, + attestation_subnet_topic, topic_kind, }, }; +use crate::beacon::constants::ATTESTATION_SUBNET_COUNT; +use crate::beacon::verdict::{self, Dispatch, GossipId, Validated}; +use crate::beacon::{BeaconWire, decode as beacon_decode, topics as beacon_topics}; use crate::{P2PServer, metrics}; -pub async fn handle_gossipsub_message(server: &mut P2PServer, event: Event) { - let Event::Message { - propagation_source: _, - message_id: _, - message, - } = event +/// What `P2PServer` does with a gossip message, whichever chain it came from. +/// +/// Read the topic's kind, undo the snappy framing, dispatch. None of those three +/// is chain-specific: the two wires name their topics disjointly +/// (`/leanconsensus/…/block` against `/eth2/…/beacon_block`) and frame with the +/// same raw snappy, so the topic alone says where a message goes and nothing +/// here asks `server.wire`. That mirrors req/resp, where the protocol id plays +/// the same part. +/// +/// One match, with both chains' topics at the same level. Only the SSZ container +/// behind the decompressed bytes differs, and that is a handler's business. +/// Beacon topics get a verdict through [`crate::beacon::verdict`]: gossipsub +/// holds the message until `verdict::report` answers for it. Lean topics are +/// forwarded by gossipsub without one, since lean gossip validation is out of +/// scope here. +pub async fn handle_gossip_message( + server: &mut P2PServer, + ctx: &Context, + propagation_source: PeerId, + message_id: MessageId, + message: Message, +) { + // Taken before anything is done with the payload, so the decode a block + // pays for is inside the span rather than before it. The lean block + // handler uses it for its own import-arrival timing; every beacon topic + // uses it too, via `GossipId::received_at`, since every beacon verdict is + // timed from wire arrival. Lean's aggregation and attestation handlers + // are the only paths that ignore it. + let wire_at = Instant::now(); + let Some(kind) = topic_kind(message.topic.as_str()) else { + trace!(topic = %message.topic, "Gossip on an unparseable topic"); + return; + }; + // A beacon topic needs a verdict: gossipsub holds the message until + // `verdict::report` answers for it. `None` for a lean topic, which the + // lean wire forwards without asking. + let beacon_id = beacon_topics::metric_kind(kind).map(|metric_kind| GossipId { + message_id, + propagation_source, + received_at: wire_at, + kind: metric_kind, + }); + trace!( + kind, + peer_count = server.connected_peers.len(), + "P2P message received" + ); + + let compressed_len = message.data.len(); + let Some(payload) = decompress(&message.data, kind) else { + if let Some(id) = beacon_id { + verdict::report(server, id, Outcome::Reject(RejectReason::Decompress)); + } + return; + }; + + match kind { + BLOCK_TOPIC_KIND => handle_lean_block(server, &payload, compressed_len, wire_at).await, + AGGREGATION_TOPIC_KIND => handle_lean_aggregation(server, &payload, compressed_len).await, + kind if kind.starts_with(ATTESTATION_SUBNET_TOPIC_PREFIX) => { + handle_lean_attestation(server, &payload, compressed_len).await + } + _ => match beacon_id { + Some(id) => handle_beacon_gossip(server, ctx, id, kind, &payload), + None => trace!(topic = %message.topic, "Gossip on an unhandled topic"), + }, + } +} + +/// Undo the snappy framing both wires use. +/// +/// The failure bookkeeping is the one asymmetric part, and stays that way: +/// beacon counts what it drops per topic kind, lean has no counterpart metric. +/// Both log. +/// +/// Gated on [`beacon_topics::metric_kind`] rather than +/// [`beacon_topics::SUBSCRIBED_TOPIC_KINDS`] directly, and labelled with its +/// answer rather than the raw `kind`: a data column or attestation subnet's +/// `kind` is a per-subnet string (`data_column_sidecar_7`), and counting that +/// verbatim would give the metric one label value per subnet rather than one +/// per family, the same collapse every other beacon gossip counter already +/// does for those two families. +fn decompress(data: &[u8], kind: &str) -> Option> { + decompress_message(data) + .inspect_err(|err| { + error!(%err, kind, "Failed to decompress gossipped message"); + if let Some(metric_kind) = beacon_topics::metric_kind(kind) { + metrics::inc_beacon_gossip(metric_kind, "decompress_failed"); + } + }) + .ok() +} + +/// SSZ-decode a lean gossip payload, or `None` after logging why. +fn decode_lean(payload: &[u8], what: &'static str) -> Option { + T::from_ssz_bytes(payload) + .inspect_err(|err| error!(?err, what, "Failed to decode gossipped message")) + .ok() +} + +async fn handle_lean_block( + server: &mut P2PServer, + payload: &[u8], + compressed_len: usize, + wire_at: Instant, +) { + metrics::observe_gossip_block_size(payload.len(), compressed_len); + let Some(signed_block) = decode_lean::(payload, "block") else { + return; + }; + let block_root = signed_block.message.hash_tree_root(); + info!( + slot = %signed_block.message.slot, + proposer = signed_block.message.proposer_index, + block_root = %ShortRoot(&block_root.0), + parent_root = %ShortRoot(&signed_block.message.parent_root.0), + attestation_count = signed_block.message.body.attestations.len(), + "Received block from gossip" + ); + if let Some(ref blockchain) = server.blockchain { + let arrival = BlockArrival { + decode_start: Some(wire_at), + handed_off: Instant::now(), + deferred_from: None, + }; + let _ = blockchain + .new_block( + SignedBeaconBlock::Lean(signed_block), + BlockSource::Gossip, + arrival, + ) + .inspect_err(|err| error!(%err, "Failed to forward block to blockchain")); + } +} + +async fn handle_lean_aggregation(server: &mut P2PServer, payload: &[u8], compressed_len: usize) { + metrics::observe_gossip_aggregation_size(payload.len(), compressed_len); + let Some(aggregation) = decode_lean::(payload, "aggregation") else { - unreachable!("we already matched on Message variant in handle_swarm_event"); - }; - let peer_count = server.connected_peers.len(); - let topic_kind = message.topic.as_str().split("/").nth(3); - match topic_kind { - Some(BLOCK_TOPIC_KIND) => { - trace!(kind = "block", peer_count, "P2P message received"); - let compressed_len = message.data.len(); - let Ok(uncompressed_data) = decompress_message(&message.data) - .inspect_err(|err| error!(%err, "Failed to decompress gossipped block")) - else { - return; - }; - metrics::observe_gossip_block_size(uncompressed_data.len(), compressed_len); - - let Ok(signed_block) = SignedBlock::from_ssz_bytes(&uncompressed_data) - .inspect_err(|err| error!(?err, "Failed to decode gossipped block")) - else { - return; - }; - let slot = signed_block.message.slot; - let block_root = signed_block.message.hash_tree_root(); - let proposer = signed_block.message.proposer_index; - let parent_root = signed_block.message.parent_root; - let attestation_count = signed_block.message.body.attestations.len(); - info!( - %slot, - proposer, - block_root = %ShortRoot(&block_root.0), - parent_root = %ShortRoot(&parent_root.0), - attestation_count, - "Received block from gossip" + return; + }; + info!( + slot = %aggregation.data.slot, + target_slot = aggregation.data.target.slot, + target_root = %ShortRoot(&aggregation.data.target.root.0), + source_slot = aggregation.data.source.slot, + source_root = %ShortRoot(&aggregation.data.source.root.0), + "Received aggregated attestation from gossip" + ); + if let Some(ref blockchain) = server.blockchain { + let _ = blockchain + .new_aggregated_attestation(aggregation) + .inspect_err( + |err| error!(%err, "Failed to forward aggregated attestation to blockchain"), ); - if let Some(ref blockchain) = server.blockchain { - let _ = blockchain - .new_block(signed_block, BlockSource::Gossip) - .inspect_err(|err| error!(%err, "Failed to forward block to blockchain")); - } + } +} + +async fn handle_lean_attestation(server: &mut P2PServer, payload: &[u8], compressed_len: usize) { + metrics::observe_gossip_attestation_size(payload.len(), compressed_len); + let Some(signed_attestation) = decode_lean::(payload, "attestation") else { + return; + }; + trace!( + slot = %signed_attestation.data.slot, + validator = signed_attestation.validator_id, + head_root = %ShortRoot(&signed_attestation.data.head.root.0), + target_slot = signed_attestation.data.target.slot, + target_root = %ShortRoot(&signed_attestation.data.target.root.0), + source_slot = signed_attestation.data.source.slot, + source_root = %ShortRoot(&signed_attestation.data.source.root.0), + "Received attestation from gossip" + ); + if let Some(ref blockchain) = server.blockchain { + let _ = blockchain + .new_attestation(signed_attestation) + .inspect_err(|err| error!(%err, "Failed to forward attestation to blockchain")); + } +} + +/// Decide what to do with one beacon gossip message, then do it. Every path +/// ends in exactly one verdict for `id`, reported here or by the blocking +/// task a [`Dispatch::Validate`] spawns. +fn handle_beacon_gossip( + server: &P2PServer, + ctx: &Context, + id: GossipId, + kind: &str, + payload: &[u8], +) { + let Some(wire) = server.wire.beacon() else { + // Beacon topics are never subscribed on a lean node, whose gossipsub + // holds nothing for a verdict. + debug!(kind, "Beacon gossip arrived on a lean node"); + return; + }; + let dispatch = if kind == beacon_topics::BEACON_BLOCK { + triage_block(server, wire, payload) + } else if let Some(subnet_id) = beacon_topics::data_column_subnet(kind) { + triage_data_column(server, payload, subnet_id) + } else if kind == beacon_topics::BEACON_AGGREGATE_AND_PROOF { + triage_aggregate(server, wire, payload, id.received_at) + } else if let Some(subnet_id) = beacon_topics::attestation_subnet(kind) { + triage_attestation(server, wire, payload, subnet_id) + } else { + triage_other(wire, kind, payload) + }; + match dispatch { + Dispatch::Report(outcome) => { + // A cheap check never answers `Queue`: with no decoded object in + // hand yet, there would be nothing here to forward. See + // `Dispatch::Report`'s doc comment. + debug_assert!(!matches!(outcome, Outcome::Queue(_))); + verdict::report(server, id, outcome); } - Some(AGGREGATION_TOPIC_KIND) => { - trace!(kind = "aggregation", peer_count, "P2P message received"); - let compressed_len = message.data.len(); - let Ok(uncompressed_data) = decompress_message(&message.data) - .inspect_err(|err| error!(%err, "Failed to decompress gossipped aggregation")) - else { - return; - }; - metrics::observe_gossip_aggregation_size(uncompressed_data.len(), compressed_len); - - let Ok(aggregation) = SignedAggregatedAttestation::from_ssz_bytes(&uncompressed_data) - .inspect_err(|err| error!(?err, "Failed to decode gossipped aggregation")) - else { - return; - }; - let slot = aggregation.data.slot; - info!( - %slot, - target_slot = aggregation.data.target.slot, - target_root = %ShortRoot(&aggregation.data.target.root.0), - source_slot = aggregation.data.source.slot, - source_root = %ShortRoot(&aggregation.data.source.root.0), - "Received aggregated attestation from gossip" - ); - if let Some(ref blockchain) = server.blockchain { - let _ = blockchain - .new_aggregated_attestation(aggregation) - .inspect_err( - |err| error!(%err, "Failed to forward aggregated attestation to blockchain"), - ); - } + Dispatch::Validate(object) => verdict::spawn_stateful_checks(server, ctx, id, object), + } +} + +/// Decode a beacon block and run its cheap gossip checks: a verdict already, +/// or the object for [`verdict::spawn_stateful_checks`] to take further. +fn triage_block(server: &P2PServer, wire: &BeaconWire, payload: &[u8]) -> Dispatch { + const KIND: &str = beacon_topics::BEACON_BLOCK; + let block = match beacon_decode::decode_block(&wire.config, payload) { + Ok(block) => block, + Err(err) => { + metrics::inc_beacon_gossip(KIND, "decode_failed"); + debug!(kind = KIND, %err, bytes = payload.len(), "Beacon gossip decode failed"); + return Dispatch::Report(Outcome::Reject(RejectReason::Decode)); } - Some(kind) if kind.starts_with(ATTESTATION_SUBNET_TOPIC_PREFIX) => { - trace!(kind = "attestation", peer_count, "P2P message received"); - let compressed_len = message.data.len(); - let Ok(uncompressed_data) = decompress_message(&message.data) - .inspect_err(|err| error!(%err, "Failed to decompress gossipped attestation")) - else { - return; - }; - metrics::observe_gossip_attestation_size(uncompressed_data.len(), compressed_len); - - let Ok(signed_attestation) = SignedAttestation::from_ssz_bytes(&uncompressed_data) - .inspect_err(|err| error!(?err, "Failed to decode gossipped attestation")) - else { - return; - }; - let slot = signed_attestation.data.slot; - let validator = signed_attestation.validator_id; - trace!( - %slot, - validator, - head_root = %ShortRoot(&signed_attestation.data.head.root.0), - target_slot = signed_attestation.data.target.slot, - target_root = %ShortRoot(&signed_attestation.data.target.root.0), - source_slot = signed_attestation.data.source.slot, - source_root = %ShortRoot(&signed_attestation.data.source.root.0), - "Received attestation from gossip" - ); - if let Some(ref blockchain) = server.blockchain { - let _ = blockchain - .new_attestation(signed_attestation) - .inspect_err(|err| error!(%err, "Failed to forward attestation to blockchain")); - } + }; + metrics::inc_beacon_gossip(KIND, "decoded"); + let block_root = block.message_hash_tree_root(); + info!( + slot = block.slot(), + proposer = block.proposer_index(), + fork = block.fork_name().as_str(), + block_root = %ShortRoot(&block_root.0), + bytes = payload.len(), + "Beacon block decoded" + ); + let now_ms = unix_now_ms(); + if let Err(outcome) = + gossip::block::cheap_checks(&server.seen_blocks, &server.store, &block, now_ms) + { + return Dispatch::Report(outcome); + } + Dispatch::Validate(Validated::Block { + block: Box::new(block), + block_root, + }) +} + +/// Decode a data column sidecar and run its cheap gossip checks. Same shape as +/// [`triage_block`]. +fn triage_data_column(server: &P2PServer, payload: &[u8], subnet_id: u64) -> Dispatch { + const KIND: &str = beacon_topics::DATA_COLUMN_SIDECAR_KIND; + let sidecar = match beacon_decode::decode_data_column_sidecar(payload) { + Ok(sidecar) => sidecar, + Err(err) => { + metrics::inc_beacon_gossip(KIND, "decode_failed"); + debug!(?err, "Dropping an undecodable data column sidecar"); + return Dispatch::Report(Outcome::Reject(RejectReason::Decode)); } - _ => { - trace!("Received message on unknown topic: {}", message.topic); + }; + metrics::inc_beacon_gossip(KIND, "decoded"); + let now_ms = unix_now_ms(); + if let Err(outcome) = gossip::column::cheap_checks( + &server.seen_columns, + &server.store, + &sidecar, + subnet_id, + now_ms, + ) { + return Dispatch::Report(outcome); + } + Dispatch::Validate(Validated::Column(Box::new(sidecar))) +} + +/// Decode a beacon aggregate and run its cheap gossip checks: a verdict +/// already, or the object for [`verdict::spawn_stateful_checks`] to take +/// further. Same shape as [`triage_block`]/[`triage_data_column`]. +fn triage_aggregate( + server: &P2PServer, + wire: &BeaconWire, + payload: &[u8], + received_at: Instant, +) -> Dispatch { + const KIND: &str = beacon_topics::BEACON_AGGREGATE_AND_PROOF; + let aggregate = match beacon_decode::decode_aggregate_and_proof(&wire.config, payload) { + Ok(aggregate) => aggregate, + Err(err) => { + metrics::inc_beacon_gossip(KIND, "decode_failed"); + debug!(kind = KIND, %err, bytes = payload.len(), "Beacon gossip decode failed"); + return Dispatch::Report(Outcome::Reject(RejectReason::Decode)); } + }; + metrics::inc_beacon_gossip(KIND, "decoded"); + metrics::observe_beacon_aggregate_decode(received_at.elapsed()); + + let data = aggregate.data(); + let (target_epoch, target_root) = aggregate.target(); + // `debug` rather than `info`: a mainnet slot carries up to + // `MAX_COMMITTEES_PER_SLOT * TARGET_AGGREGATORS_PER_COMMITTEE` of these, + // and a line each at `info` buries every other line the node emits. What + // an operator wants from this topic is the counters and the histograms, + // not a per-message log. + debug!( + slot = data.slot, + aggregator = aggregate.aggregator_index(), + attesters = aggregate.attester_count(), + target_epoch, + target_root = %ShortRoot(&target_root.0), + bytes = payload.len(), + "Beacon aggregate attestation decoded" + ); + + if let Err(outcome) = gossip::aggregate::cheap_checks( + &server.seen_aggregates, + &server.store, + &aggregate, + unix_now_ms(), + ) { + return Dispatch::Report(outcome); } + Dispatch::Validate(Validated::Aggregate { + aggregate: Box::new(aggregate), + attesting_indices: Vec::new(), + }) +} + +/// Decode an unaggregated attestation off one of this node's backbone +/// subnets and run its cheap gossip checks. Same shape as [`triage_block`]. +/// +/// A phase0-shaped payload (see [`beacon_decode::Attestation`]) answers +/// `Ignore(NoConsumer)` without reaching [`gossip::attestation`] at all: that +/// module's rules are electra's `SingleAttestation` only, matching what every +/// subnet actually carries from electra onward, and a pre-electra shape has +/// never had a consumer on this node either way (see the module's earlier +/// transitional history). +/// +/// Forwarding stays absent even once this topic gets a real verdict. See +/// [`crate::beacon::verdict::Validated::forward`] for why: `p2p-interface.md` +/// asks every beacon node to hold `SUBNETS_PER_NODE` of these subscriptions so +/// the subnets have a stable mesh for validators to publish into, and being in +/// that mesh, verifying and relaying what arrives, is the whole of what this +/// node owes it. A lighthouse node with no validators does exactly this too: +/// it verifies and re-propagates a subnet attestation but skips +/// `apply_attestation_to_fork_choice` unless a local aggregator duty or +/// `--import-all-attestations` says otherwise. Two subnets out of sixty-four +/// would in any case be a small slice of the votes the aggregate topic +/// already carries in full. +fn triage_attestation( + server: &P2PServer, + wire: &BeaconWire, + payload: &[u8], + subnet_id: u64, +) -> Dispatch { + const KIND: &str = beacon_topics::BEACON_ATTESTATION_KIND; + let attestation = match beacon_decode::decode_attestation(wire.fork, payload) { + Ok(attestation) => attestation, + Err(err) => { + metrics::inc_beacon_gossip(KIND, "decode_failed"); + debug!(kind = KIND, %err, bytes = payload.len(), "Beacon gossip decode failed"); + return Dispatch::Report(Outcome::Reject(RejectReason::Decode)); + } + }; + metrics::inc_beacon_gossip(KIND, "decoded"); + let single = match attestation { + beacon_decode::Attestation::Electra(single) => single, + beacon_decode::Attestation::Phase0(_) => { + return Dispatch::Report(Outcome::Ignore(IgnoreReason::NoConsumer)); + } + }; + let data = single.data; + trace!( + slot = data.slot, + subnet_id, + target_epoch = data.target.epoch, + target_root = %ShortRoot(&data.target.root.0), + bytes = payload.len(), + "Beacon attestation decoded" + ); + + if let Err(outcome) = gossip::attestation::cheap_checks( + &server.seen_attestations, + &server.store, + &single, + unix_now_ms(), + ) { + return Dispatch::Report(outcome); + } + Dispatch::Validate(Validated::Attestation { + attestation: Box::new(single), + subnet_id, + }) +} + +/// Decode one of the five beacon topics with nothing particular to report, +/// and count it. Ignored rather than validated: none of the five has a +/// consumer, so this always answers `Dispatch::Report`. +fn triage_other(wire: &BeaconWire, kind: &str, payload: &[u8]) -> Dispatch { + let outcome = match beacon_decode::decode_gossip(&wire.config, kind, payload) { + Ok(decoded) => { + metrics::inc_beacon_gossip(kind, "decoded"); + debug!( + kind = decoded.topic_kind(), + bytes = payload.len(), + "Beacon gossip decoded" + ); + Outcome::Ignore(IgnoreReason::NoConsumer) + } + Err(err) => { + metrics::inc_beacon_gossip(kind, "decode_failed"); + debug!(kind, %err, bytes = payload.len(), "Beacon gossip decode failed"); + Outcome::Reject(RejectReason::Decode) + } + }; + Dispatch::Report(outcome) } pub async fn publish_attestation(server: &mut P2PServer, attestation: SignedAttestation) { let slot = attestation.data.slot; let validator = attestation.validator_id; - let subnet_id = validator % server.attestation_committee_count; + let Some(lean) = server.wire.lean() else { + warn!("Publishing is suppressed on the beacon wire; dropping attestation"); + return; + }; + let subnet_id = validator % lean.attestation_committee_count; // Encode to SSZ let ssz_bytes = attestation.to_ssz(); @@ -149,7 +474,7 @@ pub async fn publish_attestation(server: &mut P2PServer, attestation: SignedAtte metrics::observe_gossip_attestation_size(ssz_bytes.len(), compressed.len()); // Look up subscribed topic or construct on-the-fly for gossipsub fanout - let topic = server + let topic = lean .attestation_topics .get(&subnet_id) .cloned() @@ -184,9 +509,11 @@ pub async fn publish_block(server: &mut P2PServer, signed_block: SignedBlock) { metrics::observe_gossip_block_size(ssz_bytes.len(), compressed.len()); // Publish to gossipsub - server - .swarm_handle - .publish(server.block_topic.clone(), compressed); + let Some(topic) = server.wire.lean().map(|lean| lean.block_topic.clone()) else { + warn!("Publishing is suppressed on the beacon wire; dropping block"); + return; + }; + server.swarm_handle.publish(topic, compressed); info!( %slot, proposer, @@ -212,9 +539,15 @@ pub async fn publish_aggregated_attestation( metrics::observe_gossip_aggregation_size(ssz_bytes.len(), compressed.len()); // Publish to the aggregation topic - server - .swarm_handle - .publish(server.aggregation_topic.clone(), compressed); + let Some(topic) = server + .wire + .lean() + .map(|lean| lean.aggregation_topic.clone()) + else { + warn!("Publishing is suppressed on the beacon wire; dropping aggregate"); + return; + }; + server.swarm_handle.publish(topic, compressed); info!( %slot, target_slot = attestation.data.target.slot, @@ -224,3 +557,458 @@ pub async fn publish_aggregated_attestation( "Published aggregated attestation to gossipsub" ); } + +/// Gossip one of a validator client's attestations, handed over by the Beacon +/// API, on its `beacon_attestation_{subnet_id}` topic. +/// +/// The API has already validated the attestation and computed `subnet_id`; this +/// only refuses what would be a programming error on its side (a lean node, or +/// a subnet id past the last subnet) rather than publish to a topic no peer +/// listens on. +pub async fn publish_beacon_attestation( + server: &mut P2PServer, + subnet_id: u64, + attestation: SingleAttestation, +) { + let slot = attestation.data.slot; + let validator = attestation.attester_index; + let Some(beacon) = server.wire.beacon() else { + error!(%slot, validator, "A beacon attestation reached a lean node; dropping it"); + return; + }; + if subnet_id >= ATTESTATION_SUBNET_COUNT { + error!(%slot, validator, subnet_id, "Attestation subnet out of range; dropping it"); + return; + } + let topic = IdentTopic::new(beacon_topics::attestation_topic_name( + beacon.fork_digest, + subnet_id, + )); + let compressed = compress_message(&attestation.to_ssz()); + server.swarm_handle.publish(topic, compressed); + debug!( + %slot, + validator, + subnet_id, + target_epoch = attestation.data.target.epoch, + target_root = %ShortRoot(&attestation.data.target.root.0), + "Published attestation to gossipsub" + ); +} + +/// Gossip one of a validator client's signed aggregates, handed over by the +/// Beacon API after validation, on `beacon_aggregate_and_proof`. This node is +/// subscribed to that topic, so it reaches the mesh rather than relying on +/// fanout. +pub async fn publish_beacon_aggregate( + server: &mut P2PServer, + aggregate: ethlambda_types::beacon::containers::SignedAggregateAndProof, +) { + let slot = aggregate.slot(); + let aggregator = aggregate.aggregator_index(); + let Some(beacon) = server.wire.beacon() else { + error!(%slot, aggregator, "A beacon aggregate reached a lean node; dropping it"); + return; + }; + let topic = IdentTopic::new(beacon_topics::topic_name( + beacon.fork_digest, + beacon_topics::BEACON_AGGREGATE_AND_PROOF, + )); + // Each fork's container encodes as itself on the wire; the enum is only + // this node's way of holding either. + let ssz = match &aggregate { + ethlambda_types::beacon::containers::SignedAggregateAndProof::Phase0(signed) => { + signed.to_ssz() + } + ethlambda_types::beacon::containers::SignedAggregateAndProof::Electra(signed) => { + signed.to_ssz() + } + }; + server.swarm_handle.publish(topic, compress_message(&ssz)); + debug!(%slot, aggregator, "Published aggregate to gossipsub"); +} + +/// Gossip a block a validator client signed, handed over by the Beacon API, +/// on `beacon_block`, and pass it to the chain actor as a gossiped block would +/// be: gossipsub never delivers a node its own messages, so this is the only +/// way this node imports its own proposal. +pub async fn publish_beacon_block(server: &mut P2PServer, block: SignedBeaconBlock) { + let slot = block.slot(); + let Some(beacon) = server.wire.beacon() else { + error!(slot, "A beacon block reached a lean node; dropping it"); + return; + }; + let topic = IdentTopic::new(beacon_topics::topic_name( + beacon.fork_digest, + beacon_topics::BEACON_BLOCK, + )); + server + .swarm_handle + .publish(topic, compress_message(&block.to_ssz())); + info!( + slot, + proposer = block.proposer_index(), + block_root = %ShortRoot(&block.message_hash_tree_root().0), + "Published block to gossipsub" + ); + if let Some(ref blockchain) = server.blockchain { + let _ = blockchain + .new_block(block, BlockSource::Gossip, BlockArrival::now()) + .inspect_err(|err| error!(%err, "Failed to hand the published block to the chain")); + } +} + +/// The beacon wall-clock slot, from the wire's genesis and slot duration. +fn beacon_wall_slot(wire: &BeaconWire) -> u64 { + let genesis_ms = wire.genesis_time.saturating_mul(1000); + unix_now_ms().saturating_sub(genesis_ms) / wire.config.slot_duration_ms.max(1) +} + +/// Join the attestation subnets a validator client's aggregators need, per +/// phase0's `validator.md` ("Attestation subnet subscription": an aggregator +/// joins its committee's subnet for the slot), and remember until when. +/// +/// A backbone subnet is already joined for good and is left alone. A subnet +/// named twice keeps the later slot. +pub fn join_aggregator_subnets(server: &mut P2PServer, subnets: Vec<(u64, u64)>) { + let Some(wire) = server.wire.beacon() else { + return; + }; + let mut joined = Vec::new(); + for (subnet_id, slot) in subnets { + if subnet_id >= ATTESTATION_SUBNET_COUNT + || wire.topics.attestation_topics.contains_key(&subnet_id) + { + continue; + } + let until = server + .aggregator_subnets + .entry(subnet_id) + .or_insert_with(|| { + joined.push(subnet_id); + slot + }); + *until = (*until).max(slot); + } + for &subnet_id in &joined { + let topic = beacon_topics::attestation_topic_name(wire.fork_digest, subnet_id); + server.swarm_handle.subscribe(IdentTopic::new(topic)); + } + if !joined.is_empty() { + info!(?joined, "Joined attestation subnets for aggregation"); + } +} + +/// Drop attestation pool entries more than an epoch old. +/// +/// Inserts prune as they go; this also runs on the aggregator-subnet sweep, so +/// a pool nothing is inserted into does not keep stale entries. +pub fn prune_attestation_pool(server: &P2PServer) { + let Some(wire) = server.wire.beacon() else { + return; + }; + let now = beacon_wall_slot(wire); + server + .attestation_pool + .lock() + .expect("attestation pool lock poisoned") + .prune_before(now); +} + +/// Leave every aggregator subnet whose last slot has passed. +pub fn leave_expired_aggregator_subnets(server: &mut P2PServer) { + let Some(wire) = server.wire.beacon() else { + return; + }; + let now = beacon_wall_slot(wire); + let expired: Vec = server + .aggregator_subnets + .iter() + .filter(|&(_, &until)| until < now) + .map(|(&subnet_id, _)| subnet_id) + .collect(); + for subnet_id in &expired { + server.aggregator_subnets.remove(subnet_id); + let topic = beacon_topics::attestation_topic_name(wire.fork_digest, *subnet_id); + server.swarm_handle.unsubscribe(IdentTopic::new(topic)); + } + if !expired.is_empty() { + debug!( + ?expired, + "Left attestation subnets whose aggregation slot has passed" + ); + } +} + +#[cfg(test)] +mod tests { + use ethlambda_types::beacon::config::Config; + use ethlambda_types::beacon::containers::{AttestationData, electra, phase0, shared}; + use ethlambda_types::beacon::fork::ForkName; + use ethlambda_types::beacon::primitives::Slot; + + use super::*; + use crate::test_support::{unconnected_beacon_server, valid_shaped_sidecar}; + + /// An electra-shaped aggregate at `slot` with `data.index` set to + /// `data_index`. Only what [`gossip::aggregate::cheap_checks`]'s very + /// first condition reads is meaningful; nothing here is signature-valid + /// or has a real committee. + fn electra_aggregate(slot: Slot, data_index: u64) -> electra::SignedAggregateAndProof { + electra::SignedAggregateAndProof { + message: electra::AggregateAndProof { + aggregator_index: 0, + aggregate: electra::Attestation { + aggregation_bits: Default::default(), + data: AttestationData { + slot, + index: data_index, + ..Default::default() + }, + signature: Default::default(), + committee_bits: Default::default(), + }, + selection_proof: Default::default(), + }, + signature: Default::default(), + } + } + + /// An electra-shaped `SingleAttestation` at `slot` with `data.index` set + /// to `data_index`. Same reasoning as [`electra_aggregate`]. + fn electra_single_attestation(slot: Slot, data_index: u64) -> electra::SingleAttestation { + electra::SingleAttestation { + committee_index: 0, + attester_index: 0, + data: AttestationData { + slot, + index: data_index, + ..Default::default() + }, + signature: Default::default(), + } + } + + /// A phase0-shaped attestation at `slot`: a whole `Attestation` with one + /// bit set, the pre-electra subnet shape. + fn phase0_attestation(slot: Slot) -> phase0::Attestation { + let mut aggregation_bits = phase0::AggregationBits::with_length(1).unwrap(); + aggregation_bits.set(0, true).unwrap(); + phase0::Attestation { + aggregation_bits, + data: AttestationData { + slot, + ..Default::default() + }, + signature: Default::default(), + } + } + + /// A config whose schedule already has electra active at epoch 0, so a + /// server built from it reports `wire.fork >= ForkName::Electra` + /// (`unconnected_beacon_server` fixes `wire.fork` at + /// `config.fork_at_epoch(0)`) and an electra-shaped aggregate at any slot + /// decodes as such too (its own fork comes from its slot, under this same + /// config). + fn electra_at_epoch_zero() -> Config { + Config::mainnet().with_fork_epoch(ForkName::Electra, 0) + } + + #[tokio::test] + async fn garbage_bytes_on_the_block_topic_are_rejected_as_undecodable() { + let server = unconnected_beacon_server(Config::mainnet(), 0).await; + let wire = server + .wire + .beacon() + .expect("a beacon server has a beacon wire"); + + assert!(matches!( + triage_block(&server, wire, &[0xff; 3]), + Dispatch::Report(Outcome::Reject(RejectReason::Decode)) + )); + } + + #[tokio::test] + async fn garbage_bytes_on_a_data_column_subnet_are_rejected_as_undecodable() { + let server = unconnected_beacon_server(Config::mainnet(), 0).await; + + assert!(matches!( + triage_data_column(&server, &[0xff; 3], 0), + Dispatch::Report(Outcome::Reject(RejectReason::Decode)) + )); + } + + #[tokio::test] + async fn a_far_future_sidecar_is_ignored() { + let server = unconnected_beacon_server(Config::mainnet(), 0).await; + // `saturating_mul` in `is_future_slot` turns this into `u64::MAX` + // regardless of `slot_duration_ms`, putting the slot's start + // unreachably far beyond any real clock. + let sidecar = valid_shaped_sidecar(u64::MAX, 0); + let payload = sidecar.to_ssz(); + + assert!(matches!( + triage_data_column(&server, &payload, 0), + Dispatch::Report(Outcome::Ignore(IgnoreReason::FutureSlot)) + )); + } + + #[tokio::test] + async fn a_valid_shaped_sidecar_on_its_subnet_at_a_current_slot_goes_to_stateful_checks() { + // Five real seconds before "now": comfortably not future under any + // clock disparity, and past slot 0 so it clears the finalized check + // against a store anchored there. + let now_ms = unix_now_ms(); + let config = Config { + genesis_time: now_ms / 1_000 - 5, + slot_duration_ms: 1_000, + ..Config::mainnet() + }; + let server = unconnected_beacon_server(config, 0).await; + let sidecar = valid_shaped_sidecar(4, 0); + let payload = sidecar.to_ssz(); + + assert!(matches!( + triage_data_column(&server, &payload, 0), + Dispatch::Validate(Validated::Column(_)) + )); + } + + #[tokio::test] + async fn the_same_sidecar_asked_for_on_the_wrong_subnet_is_rejected() { + let now_ms = unix_now_ms(); + let config = Config { + genesis_time: now_ms / 1_000 - 5, + slot_duration_ms: 1_000, + ..Config::mainnet() + }; + let server = unconnected_beacon_server(config, 0).await; + let sidecar = valid_shaped_sidecar(4, 0); + let payload = sidecar.to_ssz(); + + assert!(matches!( + triage_data_column(&server, &payload, 1), + Dispatch::Report(Outcome::Reject(RejectReason::WrongSubnet)) + )); + } + + #[tokio::test] + async fn garbage_bytes_on_the_aggregate_topic_are_rejected_as_undecodable() { + let server = unconnected_beacon_server(Config::mainnet(), 0).await; + let wire = server + .wire + .beacon() + .expect("a beacon server has a beacon wire"); + + assert!(matches!( + triage_aggregate(&server, wire, &[0xff; 3], Instant::now()), + Dispatch::Report(Outcome::Reject(RejectReason::Decode)) + )); + } + + /// A cheap-check rejection that needs no clock and no state: electra + /// requires `data.index == 0`, and `gossip::aggregate::cheap_checks` + /// checks that before it even looks at the seen cache. + #[tokio::test] + async fn an_electra_aggregate_with_a_nonzero_data_index_is_rejected() { + let server = unconnected_beacon_server(electra_at_epoch_zero(), 0).await; + let wire = server + .wire + .beacon() + .expect("a beacon server has a beacon wire"); + let payload = electra_aggregate(4, 1).to_ssz(); + + assert!(matches!( + triage_aggregate(&server, wire, &payload, Instant::now()), + Dispatch::Report(Outcome::Reject(RejectReason::NonZeroDataIndex)) + )); + } + + #[tokio::test] + async fn garbage_bytes_on_an_attestation_subnet_are_rejected_as_undecodable() { + let server = unconnected_beacon_server(Config::mainnet(), 0).await; + let wire = server + .wire + .beacon() + .expect("a beacon server has a beacon wire"); + + assert!(matches!( + triage_attestation(&server, wire, &[0xff; 3], 0), + Dispatch::Report(Outcome::Reject(RejectReason::Decode)) + )); + } + + /// The other pre-electra shape a subnet can carry: unlike the aggregate + /// topic, whose two shapes both have a validator + /// (`gossip::aggregate` handles both `SignedAggregateAndProof` variants), + /// `gossip::attestation`'s rules only understand electra's + /// `SingleAttestation`. A phase0-shaped payload therefore never reaches + /// them at all. + #[tokio::test] + async fn a_phase0_shaped_attestation_has_no_consumer() { + let server = unconnected_beacon_server(Config::mainnet(), 0).await; + let wire = server + .wire + .beacon() + .expect("a beacon server has a beacon wire"); + let payload = phase0_attestation(4).to_ssz(); + + assert!(matches!( + triage_attestation(&server, wire, &payload, 0), + Dispatch::Report(Outcome::Ignore(IgnoreReason::NoConsumer)) + )); + } + + /// The same cheap, stateless rejection as the aggregate topic's, on its + /// `SingleAttestation` sibling. + #[tokio::test] + async fn an_electra_attestation_with_a_nonzero_data_index_is_rejected() { + let server = unconnected_beacon_server(electra_at_epoch_zero(), 0).await; + let wire = server + .wire + .beacon() + .expect("a beacon server has a beacon wire"); + let payload = electra_single_attestation(4, 1).to_ssz(); + + assert!(matches!( + triage_attestation(&server, wire, &payload, 0), + Dispatch::Report(Outcome::Reject(RejectReason::NonZeroDataIndex)) + )); + } + + #[tokio::test] + async fn garbage_bytes_on_a_consumerless_topic_are_rejected_as_undecodable() { + let server = unconnected_beacon_server(Config::mainnet(), 0).await; + let wire = server + .wire + .beacon() + .expect("a beacon server has a beacon wire"); + + assert!(matches!( + triage_other(wire, beacon_topics::VOLUNTARY_EXIT, &[0xff; 3]), + Dispatch::Report(Outcome::Reject(RejectReason::Decode)) + )); + } + + #[tokio::test] + async fn a_valid_voluntary_exit_is_ignored_for_lack_of_a_consumer() { + let server = unconnected_beacon_server(Config::mainnet(), 0).await; + let wire = server + .wire + .beacon() + .expect("a beacon server has a beacon wire"); + let exit = shared::SignedVoluntaryExit { + message: shared::VoluntaryExit { + epoch: 0, + validator_index: 0, + }, + signature: Default::default(), + }; + let payload = exit.to_ssz(); + + assert!(matches!( + triage_other(wire, beacon_topics::VOLUNTARY_EXIT, &payload), + Dispatch::Report(Outcome::Ignore(IgnoreReason::NoConsumer)) + )); + } +} diff --git a/crates/net/p2p/src/gossipsub/messages.rs b/crates/net/p2p/src/gossipsub/messages.rs index 11664750f..52ce799da 100644 --- a/crates/net/p2p/src/gossipsub/messages.rs +++ b/crates/net/p2p/src/gossipsub/messages.rs @@ -1,5 +1,14 @@ pub use ethlambda_types::constants::FORK_DIGEST; +/// The kind segment of a gossip topic name. +/// +/// Both wires name a topic `/{chain}/{fork_digest}/{kind}/{encoding}`, so this +/// reads either. It is what lets one dispatch classify a message without first +/// asking which chain this node follows. +pub fn topic_kind(topic: &str) -> Option<&str> { + topic.split('/').nth(3) +} + /// Topic kind for block gossip pub const BLOCK_TOPIC_KIND: &str = "block"; /// Topic kind prefix for per-committee attestation subnets. diff --git a/crates/net/p2p/src/gossipsub/mod.rs b/crates/net/p2p/src/gossipsub/mod.rs index b50ea4fd4..608584a2d 100644 --- a/crates/net/p2p/src/gossipsub/mod.rs +++ b/crates/net/p2p/src/gossipsub/mod.rs @@ -4,6 +4,8 @@ mod messages; pub use encoding::decompress_message; pub use handler::{ - handle_gossipsub_message, publish_aggregated_attestation, publish_attestation, publish_block, + handle_gossip_message, join_aggregator_subnets, leave_expired_aggregator_subnets, + prune_attestation_pool, publish_aggregated_attestation, publish_attestation, + publish_beacon_aggregate, publish_beacon_attestation, publish_beacon_block, publish_block, }; -pub use messages::{aggregation_topic, attestation_subnet_topic, block_topic}; +pub use messages::{aggregation_topic, attestation_subnet_topic, block_topic, topic_kind}; diff --git a/crates/net/p2p/src/lean/encoding.rs b/crates/net/p2p/src/lean/encoding.rs new file mode 100644 index 000000000..abceb8eab --- /dev/null +++ b/crates/net/p2p/src/lean/encoding.rs @@ -0,0 +1,163 @@ +//! How this chain's request/response bodies go on and off the wire. +//! +//! The counterpart of [`crate::beacon::encoding`]. Everything above these two +//! modules is shared: one `Request`, one `ResponsePayload`, one dispatch, one +//! set of handlers. Encoding is where the chains genuinely differ, so it is +//! where the split lives. +//! +//! Beacon's half is all version dispatch, because three of its protocols carry +//! a different container per negotiated version. This half has no versions at +//! all, and no `` on any chunk: every container here has had one +//! shape for the chain's whole life, so there is nothing for a chunk to say +//! about which one it is. + +use std::io; + +use ethlambda_types::beacon::containers::SignedBeaconBlock; +use ethlambda_types::beacon::fork::ForkName; +use ethlambda_types::block::SignedBlock; +use libp2p::futures::{AsyncRead, AsyncWrite}; +use libssz::{SszDecode, SszEncode}; +use tracing::{error, warn}; + +use super::messages::{BlocksByRootRequest, LeanBlocksByRangeRequest, Status}; +use super::protocols; +use crate::req_resp::codec::write_success_chunk; +use crate::req_resp::encoding::{ChunkLimits, MAX_PAYLOAD_SIZE, invalid, read_chunked_response}; +use crate::req_resp::messages::BlocksByRangeRequest; +use crate::req_resp::messages::{Request, Response, ResponsePayload}; + +/// This chain's chunks carry no context bytes. Named rather than written as a +/// bare `0`/`&[]` at each call site, so the reason travels with the value. +const NO_CONTEXT: &[u8] = &[]; + +/// Decode a request body on one of this chain's protocols. +/// +/// `None` when `protocol` is not one of them, which is how the codec asks both +/// chains in turn without either knowing about the other. +pub fn decode_request(protocol: &str, payload: &[u8]) -> Option> { + let request = match protocol { + protocols::STATUS_V1 => Status::from_ssz_bytes(payload).map(Request::LeanStatus), + protocols::BLOCKS_BY_ROOT_V1 => { + BlocksByRootRequest::from_ssz_bytes(payload).map(Request::BlocksByRoot) + } + protocols::BLOCKS_BY_RANGE_V1 => LeanBlocksByRangeRequest::from_ssz_bytes(payload) + .map(|wire| Request::BlocksByRange(wire.into())), + _ => return None, + }; + Some(request.map_err(|err| invalid(format!("{err:?}")))) +} + +/// Encode a `status/1` body. +pub fn encode_status(status: &Status) -> Vec { + status.to_ssz() +} + +/// Encode a `blocks_by_root/1` body. +pub fn encode_blocks_by_root(request: &BlocksByRootRequest) -> Vec { + request.to_ssz() +} + +/// Encode a `blocks_by_range/1` body, which is the shared request minus the +/// `step` this chain's wire has no field for. +pub fn encode_blocks_by_range(request: &BlocksByRangeRequest) -> Vec { + LeanBlocksByRangeRequest::from(request).to_ssz() +} + +/// This chain's wire body for a slot window: the shared request without `step`. +impl From<&BlocksByRangeRequest> for LeanBlocksByRangeRequest { + fn from(request: &BlocksByRangeRequest) -> Self { + Self { + start_slot: request.start_slot, + count: request.count, + } + } +} + +/// The shared request a wire body describes. +/// +/// `step` becomes 1, which is what this chain not having the field means. It is +/// never anything else, so the lean handler has nothing to check. +impl From for BlocksByRangeRequest { + fn from(wire: LeanBlocksByRangeRequest) -> Self { + Self::new(wire.start_slot, wire.count) + } +} + +/// Decode the body of a single-chunk `status/1` response. +pub fn decode_status_response(payload: &[u8]) -> io::Result { + Status::from_ssz_bytes(payload) + .map(ResponsePayload::LeanStatus) + .map_err(|err| invalid(format!("{err:?}"))) +} + +/// Read a block response, which is one chunk per block rather than one chunk. +/// +/// The loop, the per-chunk metrics and the skip-on-error-code rule are +/// [`read_chunked_response`]'s, shared with beacon's block response; what is +/// this chain's own is that a chunk has no context bytes to read and decodes as +/// exactly one container. +/// +/// Always `Ok(Response::Success)`, possibly with an empty vector: either no +/// chunk arrived, or none of them carried SUCCESS. It is `Err` only on an I/O +/// error other than `UnexpectedEof`, or on a chunk that is not a `SignedBlock`. +pub async fn decode_blocks_response(io: &mut T, protocol_label: &str) -> io::Result +where + T: AsyncRead + Unpin + Send, +{ + let limits = ChunkLimits { + has_context: false, + // The widest answer either of this chain's block protocols can be asked + // for: `blocks_by_range` is refused above it, and `blocks_by_root` + // cannot name more roots than the request list holds. + max_chunks: protocols::MAX_REQUEST_BLOCKS as usize, + }; + let blocks = read_chunked_response(io, protocol_label, limits, |_, payload| { + SignedBlock::from_ssz_bytes(payload) + .map(SignedBeaconBlock::Lean) + .map_err(|err| invalid(format!("{err:?}"))) + }) + .await?; + + Ok(Response::success(ResponsePayload::Blocks(blocks))) +} + +/// Write a block response: one result code and one payload per block. +/// +/// Each block is encoded before its code byte goes out, so an oversized block +/// is skipped rather than leaving a SUCCESS byte on the wire with no payload +/// behind it. An empty response is a stream that just ends. +pub async fn write_blocks_response( + io: &mut T, + label: &'static str, + blocks: &[SignedBeaconBlock], +) -> io::Result<()> +where + T: AsyncWrite + Unpin + Send, +{ + for block in blocks { + // `SignedBeaconBlock` spans both chains, so this is the one place a + // beacon-shaped block could be written onto a lean stream. It would + // encode without complaint and decode as garbage at the peer, so it is + // refused here rather than trusted to be impossible: a lean directory + // holding one would mean the store's chain tag lied. + if block.fork_name() != ForkName::Lean { + error!( + slot = block.slot(), + fork = %block.fork_name(), + "Refusing to write a non-lean block to a lean block response" + ); + continue; + } + let encoded = block.to_ssz(); + if encoded.len() > MAX_PAYLOAD_SIZE - 1024 { + warn!( + size = encoded.len(), + "Skipping oversized block in block response" + ); + continue; + } + write_success_chunk(io, label, NO_CONTEXT, encoded).await?; + } + Ok(()) +} diff --git a/crates/net/p2p/src/lean/messages.rs b/crates/net/p2p/src/lean/messages.rs new file mode 100644 index 000000000..7faf88e76 --- /dev/null +++ b/crates/net/p2p/src/lean/messages.rs @@ -0,0 +1,45 @@ +//! The containers this chain's request/response protocols carry. +//! +//! The counterpart of [`crate::beacon::messages`]. Only the bodies live here; +//! the [`crate::req_resp::Request`] and [`crate::req_resp::ResponsePayload`] +//! variants that carry them are shared with beacon, because a message is +//! dispatched the same way whichever chain it came from. + +use ethlambda_types::{checkpoint::Checkpoint, primitives::H256}; +use libssz_derive::{SszDecode, SszEncode}; +use libssz_types::SszList; + +/// What each side of a connection tells the other about its chain. +/// +/// Two checkpoints, where beacon's `Status` carries a fork digest and a +/// head/finalized pair of its own: the two protocols share a name and nothing +/// else, which is why [`crate::req_resp::Request`] prefixes this one. +#[derive(Debug, Clone, SszEncode, SszDecode)] +pub struct Status { + pub finalized: Checkpoint, + pub head: Checkpoint, +} + +pub type RequestedBlockRoots = SszList; + +#[derive(Debug, Clone, SszEncode, SszDecode)] +pub struct BlocksByRootRequest { + pub roots: RequestedBlockRoots, +} + +/// `blocks_by_range/1`'s body **as it goes on this chain's wire**. +/// +/// Two fields where beacon's body has three: this chain has no deprecated +/// `step`. The shared +/// [`BlocksByRangeRequest`](crate::req_resp::messages::BlocksByRangeRequest) +/// that [`crate::req_resp::Request`] carries has one, so `crate::lean::encoding` +/// converts, filling it with 1 on the way in and dropping it on the way out. +/// +/// `BlocksByRootRequest` needs no such counterpart: its body is the same list +/// either chain asks for, and only beacon's lack of a container around it +/// differs, which beacon's encoder handles. +#[derive(Debug, Clone, SszEncode, SszDecode)] +pub struct LeanBlocksByRangeRequest { + pub start_slot: u64, + pub count: u64, +} diff --git a/crates/net/p2p/src/lean/mod.rs b/crates/net/p2p/src/lean/mod.rs new file mode 100644 index 000000000..b082fa52c --- /dev/null +++ b/crates/net/p2p/src/lean/mod.rs @@ -0,0 +1,12 @@ +//! The lean consensus chain's wire: protocol ids, containers, and encoding. +//! +//! The counterpart of [`crate::beacon`], and deliberately the same shape. What +//! is *not* here is as telling as what is: there is no dispatch and no handler, +//! because a message is dispatched and handled the same way whichever chain it +//! arrived from. `crate::req_resp` owns that path, and reaches into this module +//! and `crate::beacon` only where the two wires genuinely differ, which is +//! encoding. + +pub mod encoding; +pub mod messages; +pub mod protocols; diff --git a/crates/net/p2p/src/lean/protocols.rs b/crates/net/p2p/src/lean/protocols.rs new file mode 100644 index 000000000..b05b676eb --- /dev/null +++ b/crates/net/p2p/src/lean/protocols.rs @@ -0,0 +1,27 @@ +//! The request/response protocols `ethlambda node` registers. +//! +//! Three, against beacon's seven: this chain has no `ping`, `metadata` or +//! `goodbye`, and keeps a connection alive without them. What it does have that +//! beacon does not is block serving, which is the whole point of the other two. + +pub const STATUS_V1: &str = "/leanconsensus/req/status/1/ssz_snappy"; +pub const BLOCKS_BY_ROOT_V1: &str = "/leanconsensus/req/blocks_by_root/1/ssz_snappy"; +pub const BLOCKS_BY_RANGE_V1: &str = "/leanconsensus/req/blocks_by_range/1/ssz_snappy"; + +/// Maximum number of blocks in a single `blocks_by_range` request. +pub const MAX_REQUEST_BLOCKS: u64 = 1024; + +/// The metrics label for one of this chain's protocols, or `None` if it is not +/// one of them. +/// +/// The counterpart of [`crate::beacon::protocols::label`]; `protocol_label` in +/// the codec asks both, which is the only place the two chains' protocol sets +/// meet. +pub fn label(protocol: &str) -> Option<&'static str> { + match protocol { + STATUS_V1 => Some("status"), + BLOCKS_BY_ROOT_V1 => Some("blocks_by_root"), + BLOCKS_BY_RANGE_V1 => Some("blocks_by_range"), + _ => None, + } +} diff --git a/crates/net/p2p/src/lib.rs b/crates/net/p2p/src/lib.rs index c2b043592..c04091b68 100644 --- a/crates/net/p2p/src/lib.rs +++ b/crates/net/p2p/src/lib.rs @@ -1,28 +1,71 @@ +pub mod muxers { + //! Why the TCP transport offers mplex as well as yamux. + //! + //! Measured against live mainnet peers on 2026-08-13, dialing with a + //! throwaway probe binary carrying nothing but `identify`, so that none of + //! this crate's own protocols can be the cause: + //! + //! ```text + //! yamux only Proposed /yamux/1.0.0 -> NotAvailable + //! connection dies ~250ms in, no Goodbye, yamux frame + //! decode error: multistream-select negotiates the muxer + //! optimistically, so we are already writing yamux frames + //! when the refusal arrives and we parse their reply as one + //! + //! yamux + mplex Proposed /yamux/1.0.0, then /mplex/6.7.0 + //! Negotiated /mplex/6.7.0, identify completes both ways + //! ``` + //! + //! The same probe against a non-Ethereum libp2p node (an IPFS bootstrapper) + //! completes identify with yamux alone, which is what rules out this crate's + //! transport setup and points at the beacon network's own convention. + //! + //! mplex is deprecated in libp2p and the facade crate has already dropped + //! its re-export, so `libp2p-mplex` is depended on directly. When mainnet + //! peers accept yamux, this goes away; until then a yamux-only beacon node + //! peers with nothing over TCP. +} + use std::{ collections::{HashMap, HashSet, hash_map::Entry}, + fmt, io, net::{IpAddr, SocketAddr}, + num::{NonZeroU8, NonZeroUsize}, ops::Range, - time::Duration, + sync::Arc, + time::{Duration, Instant}, }; +use either::Either; use ethlambda_network_api::{ - InitBlockChain, P2PToBlockChainRef, + FetchRequest, InitBlockChain, P2PToBlockChainRef, block_chain_to_p2p::{ - FetchBlock, PublishAggregatedAttestation, PublishAttestation, PublishBlock, + CheckDataColumnSidecars, FetchBlock, PublishAggregatedAttestation, PublishAttestation, + PublishBlock, }, + rpc_to_p2p::{ + PublishBeaconAggregate, PublishBeaconAttestation, PublishBeaconBlock, + SubscribeAttestationSubnets, + }, +}; +use ethlambda_state_transition::beacon::aggregate::MAX_AGGREGATES_PER_SLOT; +use ethlambda_state_transition::beacon::attestation_pool::SharedAttestationPool; +use ethlambda_state_transition::beacon::gossip::{ + SeenBlocks, SeenColumns, aggregate::SeenAggregates, attestation::SeenAttestations, }; -use ethlambda_storage::Store; +use ethlambda_storage::{Chain, Store}; +use ethlambda_types::beacon::preset::{MAX_VALIDATORS_PER_COMMITTEE, SLOTS_PER_EPOCH}; use ethlambda_types::primitives::H256; use ethrex_p2p::types::NodeRecord; use ethrex_rlp::decode::RLPDecode; -use futures::{StreamExt, future::OptionFuture}; +use futures::StreamExt; use libp2p::{ - Multiaddr, StreamProtocol, + Multiaddr, gossipsub::{MessageAuthenticity, ValidationMode}, identity::{Keypair, PublicKey, secp256k1}, multiaddr::Protocol, - request_response::{self, OutboundRequestId}, - swarm::{NetworkBehaviour, SwarmEvent, dial_opts::DialOpts}, + request_response::OutboundRequestId, + swarm::{ConnectionError, NetworkBehaviour, SwarmEvent, dial_opts::DialOpts}, }; use sha2::Digest; use spawned_concurrency::actor; @@ -36,46 +79,327 @@ use tracing::{debug, info, trace, warn}; use crate::{ discovery::{ - DISCOVERY_DIAL_INTERVAL, DiscoveryError, DiscoverySpawnConfig, - dial::{DiscoveryState, dial_tick, forget_discovered_peer}, + DIAL_INTERVAL_AT_TARGET, DIAL_INTERVAL_AT_ZERO_PEERS, DiscoveryError, DiscoverySpawnConfig, + dial::{DiscoveryState, dial_interval, dial_progress, dial_tick, forget_discovered_peer}, enr::{dialable_port, read_ip, read_public_key, read_quic_port, read_tcp_port}, spawn_discovery, }, gossipsub::{ aggregation_topic, attestation_subnet_topic, block_topic, publish_aggregated_attestation, - publish_attestation, publish_block, + publish_attestation, publish_beacon_aggregate, publish_beacon_attestation, + publish_beacon_block, publish_block, }, + lean::protocols::MAX_REQUEST_BLOCKS, req_resp::{ - BLOCKS_BY_RANGE_PROTOCOL_V1, BLOCKS_BY_ROOT_PROTOCOL_V1, Codec, - MAX_COMPRESSED_PAYLOAD_SIZE, MAX_REQUEST_BLOCKS, Request, STATUS_PROTOCOL_V1, build_status, - fetch_block_from_peer, + Codec, MAX_COMPRESSED_PAYLOAD_SIZE, ReqResp, ReqRespEvent, Request, build_status, + fetch_block_from_peer, fetch_data_columns_from_peer, + handlers::{columns_custodied_by, resume_range_batch_held_for_custody}, }, swarm_adapter::SwarmHandle, }; +pub mod beacon; pub mod discovery; mod gossipsub; +pub mod lean; pub mod metrics; mod req_resp; pub(crate) mod swarm_adapter; pub use libp2p::PeerId; +/// Asking a peer for beacon blocks, by range and by root. +/// +/// Both are driven from inside this crate: `fetch_block_from_peer` sends the +/// by-root one, and `request_next_beacon_range_batch` the by-range one, off a +/// peer's `Status`. They stay public because the answer stops at this crate: +/// this node has no beacon `BlockChain` actor to import into, so a fetched +/// block is checked and dropped, and the importer that changes that is expected +/// to drive its own fetches from outside rather than through the range session. +pub use req_resp::{request_beacon_block_by_root, request_beacon_blocks_by_range}; + +/// `MAX_PAYLOAD_SIZE`, the ceiling on an uncompressed gossip or req/resp +/// payload. Public because a beacon `config.yaml` carries it too, and startup +/// refuses a network whose value differs from this one. +pub use req_resp::MAX_PAYLOAD_SIZE; + // 5ms, 10ms, 20ms, 40ms, 80ms, 160ms, 320ms, 640ms, 1280ms, 2560ms +// +// This ladder, not a separate wall-clock budget, is what actually bounds how +// long a lookup persists: `MAX_FETCH_RETRIES` attempts, each capped by the +// request-response layer's own per-request timeout, with a doubling backoff +// between them. A held block is evicted by finality on its own schedule +// regardless, so the ladder only needs to stay well inside that window, which +// it comfortably does. A `COLUMN_LOOKUP_MAX_DURATION` used to sit alongside +// this, copied from Lighthouse without checking it against these timings: it +// was long enough that the attempts ladder above always ran out first, so it +// could never fire, while its own doc claimed it was what stopped the asking. const MAX_FETCH_RETRIES: u32 = 10; const INITIAL_BACKOFF_MS: u64 = 5; const BACKOFF_MULTIPLIER: u64 = 2; + +/// How long an entry in `pending_column_requests` may go without a new request +/// before a fresh [`FetchRequest`] for that root starts its own lookup +/// instead of folding into it. +/// +/// The entry exists to deduplicate: while a lookup is running, a second ask +/// for the same root merges its columns rather than opening a parallel ladder. +/// That is only correct while the lookup it defers to is actually alive. An +/// entry whose round never reported back leaves the root deduplicated against +/// a lookup that will never ask anything again, and the chain actor's +/// per-slot re-drive of a held block would then be swallowed silently, which +/// is precisely the case the re-drive exists for. +/// +/// Comfortably longer than a full ladder (`MAX_FETCH_RETRIES` attempts whose +/// backoffs sum to a few seconds, plus each attempt's round trips) and shorter +/// than a mainnet slot, so a re-drive arriving on the next tick finds either a +/// live lookup or no entry at all. +const STALE_COLUMN_LOOKUP: Duration = Duration::from_secs(8); + +/// How many peers of unknown custody one `DataColumnsByRange` prefetch may +/// speculatively ask for the columns no known custodian covers. +/// +/// A peer's custody is only known once its `metadata/3` answer or its ENR +/// `cgc` has been recorded, and there is always a handful of connected peers +/// that have supplied neither. Those are worth asking; peers that *have* told +/// us what they keep, and do not keep this column, are not. Two rather than +/// all of them, because a range answer is megabytes when it lands. +const UNKNOWN_CUSTODY_RANGE_PEERS: usize = 2; + +/// How long a beacon range batch may be held back waiting for every one of +/// this node's custody columns to have a known custodian among the connected +/// peers. +/// +/// A batch sends its blocks and its `DataColumnsByRange` together, and the +/// column request can only be aimed at peers whose custody is already known. +/// Right after startup that is almost nobody: a peer's custody arrives with its +/// `metadata/3` answer, after it connects. A batch sent then aims its column +/// request at peers that may not keep the columns, a short or empty answer is +/// not retried, and every block it leaves uncovered is held and chased by root +/// one at a time. The wait is what gives the one range request a custodian to +/// go to. +/// +/// Bounded rather than open-ended, unlike lighthouse's range sync, which waits +/// for custody peers as long as it takes. Nothing here goes looking for a +/// custodian of a specific column, so a column no connected peer keeps may stay +/// uncovered for a long time; past this deadline the batch goes anyway, and the +/// uncovered columns get the unknown-custody fallback and the by-root path, as +/// they did before the wait existed. +const RANGE_BATCH_CUSTODY_WAIT: Duration = Duration::from_secs(30); + const PEER_REDIAL_INTERVAL_SECS: u64 = 12; + +/// How many of one peer's addresses a dial attempt starts at once. +/// +/// One, so that libp2p walks [`dial_addrs`] in order rather than racing it, and +/// the QUIC address that list puts first is genuinely tried first. Under +/// libp2p's default factor both transports start together and TCP wins most of +/// the races, which is not a free choice between two equal wires: a TCP +/// connection to a mainnet beacon peer negotiates mplex (see [`muxers`]), and +/// `libp2p_mplex`'s waker bookkeeping was the single largest symbol in a +/// profile of the mainnet follower, ahead of the whole state transition. QUIC +/// carries its own multiplexing and reaches none of that code. +/// +/// The cost is the one the race existed to avoid, now bounded rather than +/// removed: a peer advertising a `quic` port nothing answers waits out +/// `libp2p_quic`'s handshake timeout before its `tcp` address is tried. A peer +/// whose ENR carries no `quic` entry pays nothing, because TCP is then the only +/// address in the list, and neither does lean, whose records advertise `quic` +/// alone. +const DIAL_ADDRESS_CONCURRENCY: NonZeroU8 = NonZeroU8::new(1).expect("1 > 0"); + const MAX_SYNC_RANGE: u64 = MAX_REQUEST_BLOCKS * 64; // 65,536 slots (~3 days) +/// How many beacon gossip messages may be in stateful validation at once. A +/// message arriving with none free is ignored rather than queued. Revisit once +/// `lean_beacon_gossip_validation_seconds` has data from a follower. +const GOSSIP_VALIDATION_PERMITS: usize = 128; + +/// How many data column sidecars may be in the chain checks at once (see +/// [`beacon::column_checks`]). A separate pool from +/// [`GOSSIP_VALIDATION_PERMITS`], so a range batch's hundreds of sidecars +/// cannot take every permit and leave gossip reporting `Overloaded`. A sidecar +/// arriving with none free waits for one. Revisit with data, as for gossip. +const COLUMN_CHECK_PERMITS: usize = 16; + +/// How many `beacon_aggregate_and_proof` and `beacon_attestation_{subnet_id}` +/// stateful checks may run at once. +/// +/// A pool of its own, not [`GOSSIP_VALIDATION_PERMITS`]'s: a mainnet slot +/// carries up to `MAX_COMMITTEES_PER_SLOT * TARGET_AGGREGATORS_PER_COMMITTEE` +/// aggregates plus whatever this node's backbone subnets add, a burst that +/// arrives every slot rather than only during a range sync. Sharing the block +/// and column pool with that burst would let it take every permit and answer +/// `Ignore(Overloaded)` for a block or a column instead, which must never +/// happen: neither topic gates anything the way this one gates fork choice's +/// votes for the current head. Sized the same as [`GOSSIP_VALIDATION_PERMITS`] +/// for now, since both do comparable per-item work (one state read, one to +/// three BLS verifications); revisit once +/// `lean_beacon_gossip_validation_seconds{kind="beacon_aggregate_and_proof"}` +/// has data from a follower. +const ATTESTATION_VALIDATION_PERMITS: usize = 128; + +/// Capacity of the first-valid-block cache, keyed by `(slot, proposer)`. +/// How often to leave aggregator subnets whose slot has passed. One slot's +/// worth: a subnet outlives its need by at most this, which costs a little +/// relayed traffic and nothing else. +const AGGREGATOR_SUBNET_SWEEP_INTERVAL: Duration = Duration::from_secs(12); + +const SEEN_BLOCKS_CAPACITY: NonZeroUsize = NonZeroUsize::new(1024).expect("non-zero"); + +/// Capacity of the first-valid-sidecar cache, keyed by `(slot, proposer, index)`. +const SEEN_COLUMNS_CAPACITY: NonZeroUsize = NonZeroUsize::new(4096).expect("non-zero"); + +/// How many `(target_epoch, aggregator_index)` pairs the accepted-aggregate +/// cache remembers, and how many `(hash_tree_root(data), committee_index)` +/// bitfields alongside it (`SeenAggregates::new`'s two capacities, both sized +/// the same here). +/// +/// [`MAX_AGGREGATES_PER_SLOT`] bounds how many aggregators one slot can select +/// at all, mainnet's largest number this topic ever has to hold coordinates +/// for. `is_current_or_previous_epoch` is the only gossip condition either +/// half of this cache backs, so a key from further back than two epochs is +/// never asked about again; sizing for two epochs of that per-slot bound is +/// generous headroom rather than a tight derivation, in the same spirit +/// [`SEEN_BLOCKS_CAPACITY`] and [`SEEN_COLUMNS_CAPACITY`] are sized in. +const SEEN_AGGREGATES_CAPACITY: NonZeroUsize = + NonZeroUsize::new((MAX_AGGREGATES_PER_SLOT * SLOTS_PER_EPOCH * 2) as usize).expect("non-zero"); + +/// Capacity of the accepted-attestation cache, keyed by `(target_epoch, +/// attester_index)`, for a node relaying `backbone_subnets` attestation +/// subnets. +/// +/// Unlike [`SEEN_AGGREGATES_CAPACITY`], there is no per-slot cap on how many +/// distinct attesters this topic can name: every validator attests once per +/// epoch, and on mainnet a single backbone subnet can carry on the order of a +/// thousand of them per slot. The real bound instead comes from +/// `compute_subnet_for_attestation` (see +/// `beacon::gossip::attestation::compute_subnet_for_attestation`), which is +/// `(committees_per_slot * slot_in_epoch + committee_index) % +/// attestation_subnet_count`. `committees_per_slot` never exceeds +/// `MAX_COMMITTEES_PER_SLOT`, which itself never exceeds +/// `attestation_subnet_count` (64 of 64 on both mainnet and minimal), so the +/// `committees_per_slot` committee indices of one slot are consecutive +/// integers spanning no more residues than there are subnets: they land on +/// distinct subnets without wrapping into a collision. One subnet therefore +/// carries at most one committee per slot, i.e. at most +/// [`MAX_VALIDATORS_PER_COMMITTEE`] attesters. +/// +/// The capacity is two epochs (the only window `is_current_or_previous_epoch` +/// accepts) of `SLOTS_PER_EPOCH` slots, each contributing at most that many +/// attesters per subnet this node backbones. `backbone_subnets` is runtime +/// (`BeaconWire::attestation_subnets`), so this is computed at `P2PServer` +/// construction rather than as a const, and floored to one subnet so a lean +/// node, which backbones none, still gets a valid non-zero capacity. As with +/// [`SEEN_AGGREGATES_CAPACITY`], the LRU only grows to what actually arrives, +/// so this bound costs memory only under that load. +fn seen_attestations_capacity(backbone_subnets: usize) -> NonZeroUsize { + let subnets = backbone_subnets.max(1); + let capacity = 2 * SLOTS_PER_EPOCH as usize * MAX_VALIDATORS_PER_COMMITTEE * subnets; + NonZeroUsize::new(capacity).expect("positive factors give a positive capacity") +} + pub(crate) struct PendingRequest { pub(crate) attempts: u32, pub(crate) failed_peers: HashSet, } +/// A block-root fetch's counterpart for a column lookup: the same +/// attempt/failed-peer bookkeeping [`PendingRequest`] carries, plus what +/// [`PendingRequest`] never needed to. `columns` is the request body a retry +/// resends, since (unlike a block-root fetch, whose body is the root already +/// keying this map) a column request also names which columns. +pub(crate) struct PendingColumnRequest { + pub(crate) columns: Vec, + pub(crate) attempts: u32, + pub(crate) failed_peers: HashSet, + /// Requests sent for this root and not yet answered or failed. + /// + /// One attempt now fans out across the peers that custody the columns, so + /// a single lookup can have several requests open at once. Without this + /// count each of their failures would schedule its own retry, and each + /// retry would fan out again: one unanswered lookup against eight + /// custodians becomes eight retries, then sixty-four. A retry is scheduled + /// only when the last outstanding request of the round reports back, so an + /// attempt still costs exactly one retry however wide it was. + pub(crate) in_flight: usize, + /// When this lookup last put requests on the wire, for + /// [`STALE_COLUMN_LOOKUP`] to measure against. + pub(crate) last_asked: Instant, +} + pub(crate) enum PendingRequestKind { Root(H256), - Range { start_slot: u64, end_slot: u64 }, + Range { + start_slot: u64, + end_slot: u64, + }, + /// A `DataColumnsByRoot` lookup for this block's missing columns. Carries + /// only the root: the columns, attempts and failed-peer set live in + /// `pending_column_requests`, keyed the same way, so this is enough to + /// route the response and nothing this map needs to duplicate. + Columns(H256), + /// A `DataColumnsByRange` sweep for a range sync batch, covering the same + /// slots the matching `BlocksByRange` asked for. + /// + /// Carries no per-root bookkeeping, unlike [`Self::Columns`]: this is not + /// a lookup for one block's missing columns but a bulk prefetch, so a + /// short or empty answer is not a failure to retry. The blocks it is + /// paired with arrive on their own request, and any column still missing + /// when a block is held still gets the by-root path. + ColumnRange { + start_slot: u64, + end_slot: u64, + }, +} + +/// Which single-protocol field of [`ReqResp`] an outbound request travels +/// through. +/// +/// Splitting one shared `request_response::Behaviour` into one per protocol +/// (see [`ReqResp`]'s doc comment) gives each field its own +/// `OutboundRequestId` sequence, starting at 1: two different protocols can +/// now legitimately mint the same numeric id for two unrelated requests. A +/// bare `OutboundRequestId` is therefore no longer a safe map key on its own, +/// and every place that names one names this alongside it instead, forming +/// [`ReqRespRequestId`]. +/// +/// One variant per field, sharing that field's name so the two stay easy to +/// line up by eye; [`crate::swarm_adapter::execute_command`] matches +/// exhaustively on this to choose which field actually sends, and +/// [`handle_behaviour_event`] matches exhaustively on the derived +/// `ReqRespEvent` to tag every inbound event with the variant that produced +/// it. Public because [`request_beacon_block_by_root`] and +/// [`request_beacon_blocks_by_range`] carry a [`ReqRespRequestId`] in their +/// return type and are themselves public, for the reason their own doc +/// comments give. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ReqRespProtocol { + LeanStatus, + LeanBlocksByRoot, + LeanBlocksByRange, + BeaconStatusV1, + BeaconStatusV2, + BeaconPing, + BeaconMetadataV1, + BeaconMetadataV2, + BeaconMetadataV3, + BeaconGoodbye, + BeaconBlocksByRange, + BeaconBlocksByRoot, + DataColumnSidecarsByRange, + DataColumnSidecarsByRoot, +} + +/// An outbound request id, namespaced by the protocol it was sent on. +/// +/// See [`ReqRespProtocol`] for why the protocol has to be carried alongside +/// the id rather than trusted to be unique on its own now that each protocol +/// has its own `Behaviour` field and so its own id sequence. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ReqRespRequestId { + pub protocol: ReqRespProtocol, + pub id: OutboundRequestId, } pub(crate) struct RangeSyncState { @@ -84,6 +408,22 @@ pub(crate) struct RangeSyncState { /// Latest advertised head slot for each peer. pub(crate) peer_set: HashMap, pub(crate) in_flight: bool, + /// When the next batch was first held back for custody, while it still is. + /// Beacon-only; see [`RANGE_BATCH_CUSTODY_WAIT`]. + pub(crate) custody_wait_since: Option, +} + +/// Where a beacon range batch held back for custody stands, as +/// [`RangeSyncState::wait_for_custody`] reports it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CustodyWait { + /// The batch has only now started waiting, so its deadline still needs + /// scheduling. + Started, + /// Still inside [`RANGE_BATCH_CUSTODY_WAIT`]. + Waiting, + /// The deadline has passed: the batch goes with whatever custody is known. + Expired, } impl RangeSyncState { @@ -92,9 +432,43 @@ impl RangeSyncState { current_range, peer_set: HashMap::from([(peer, peer_head)]), in_flight: false, + custody_wait_since: None, + } + } + + /// Hold the next batch back for custody, starting its wait on the first + /// call, and say whether it may keep waiting at `now`. + /// + /// The wait belongs to the batch rather than the session: sending clears + /// it (see [`Self::end_custody_wait`]), so a batch that finds custody + /// uncovered later on, after a custodian disconnected, gets a wait of its + /// own rather than inheriting an expired one. + pub(crate) fn wait_for_custody(&mut self, now: Instant) -> CustodyWait { + match self.custody_wait_since { + None => { + self.custody_wait_since = Some(now); + CustodyWait::Started + } + Some(since) if now.duration_since(since) < RANGE_BATCH_CUSTODY_WAIT => { + CustodyWait::Waiting + } + Some(_) => CustodyWait::Expired, } } + /// End the custody wait of the batch being sent, returning how long it + /// waited, or `None` if it never did. + pub(crate) fn end_custody_wait(&mut self, now: Instant) -> Option { + self.custody_wait_since + .take() + .map(|since| now.duration_since(since)) + } + + /// Whether the next batch is currently held back for custody. + pub(crate) fn is_waiting_for_custody(&self) -> bool { + self.custody_wait_since.is_some() + } + pub(crate) fn merge_peer(&mut self, peer: PeerId, peer_head: u64, end_exclusive: u64) { self.peer_set.insert(peer, peer_head); self.current_range.end = self.current_range.end.max(end_exclusive); @@ -143,23 +517,88 @@ impl RangeSyncState { // --- Swarm construction --- -/// [libp2p Behaviour](libp2p::swarm::NetworkBehaviour) combining identify, Gossipsub -/// and Request-Response Behaviours. +/// [libp2p Behaviour](libp2p::swarm::NetworkBehaviour) combining identify, +/// Gossipsub and the request/response protocols. /// /// `identify` is registered purely for interop: go-libp2p (gean) gates gossipsub /// GRAFT on the identify exchange completing, so a peer that doesn't respond to /// `/ipfs/id/1.0.0` is silently excluded from the mesh. Events from this /// behaviour are intentionally not handled: the registration alone is enough /// to satisfy probing peers. ream and zeam follow the same pattern. +/// +/// Not handling its events is *not* the same as it having no effect, which is +/// why it is built with the address cache off. See [`build_swarm`]. +/// +/// The request/response side is a nested [`ReqResp`]: one +/// `request_response::Behaviour` per protocol id rather than one shared +/// behaviour registering every id. Its doc comment has why, and +/// [`ReqRespProtocol`] names its fields for anything that has to pick one +/// at runtime. #[derive(NetworkBehaviour)] pub(crate) struct Behaviour { + /// Refuses connections past the configured ceiling. A deny from any member + /// behaviour denies the connection, so registering this is the whole + /// mechanism; see [`beacon::swarm::connection_limits`] for the numbers and why the + /// beacon network needs them while lean does not. + /// + /// First, and it has to stay first. The derive asks each field for a + /// handler in declaration order and stops at the first refusal, and a + /// refused connection never produces a `ConnectionClosed`. Every + /// `request_response::Behaviour` in [`ReqResp`] records a connection the + /// moment it is asked, so behind this field a refusal left them holding a + /// connection the swarm never had (libp2p/rust-libp2p#4773, #4870). + connection_limits: libp2p::connection_limits::Behaviour, identify: libp2p::identify::Behaviour, gossipsub: libp2p::gossipsub::Behaviour, - req_resp: request_response::Behaviour, + req_resp: ReqResp, +} + +/// No connection limits, which is what the lean network has always run with: a +/// devnet's peer count is bounded by the size of the devnet itself, so a cap +/// there would only ever cap the operator. +pub(crate) fn unlimited_connections() -> libp2p::connection_limits::Behaviour { + libp2p::connection_limits::Behaviour::new(Default::default()) } /// Configuration for building the libp2p swarm. /// +/// These are the parameters both networks take; [`WireConfig`] carries what only +/// one of them does. One config and one [`build_swarm`] rather than a pair per +/// network, because the transport, the two listeners and the static bootnode +/// dialing are identical and were duplicated line for line while there were two +/// builders. +pub struct SwarmConfig { + pub node_key: Vec, + pub bootnodes: Vec, + pub listening_socket: SocketAddr, + /// How many peers this node is asking for, from + /// `--discovery.target-peers`. The same number + /// [`DiscoverySpawnConfig::target_peers`] carries to the dial loop, because + /// the beacon connection limits are derived from it: what this node refuses + /// and what it goes looking for have to be two readings of one number, or a + /// reservation the swarm does not keep is one the dial loop chases forever. + /// Ignored on lean, which runs [`unlimited_connections`]. + pub target_peers: usize, + /// Which network's wire to build. Decides the gossip topics, the req/resp + /// protocol set, the gossipsub `seen_ttl`, the identify protocol version and + /// the connection limits, and so decides the [`Wire`] the built swarm + /// carries. + pub wire: WireConfig, + /// The identify `agentVersion`, which crawlers read to name a peer's + /// client. Left unset, rust-libp2p reports its own crate version instead. + pub agent_version: &'static str, +} + +/// The half of [`SwarmConfig`] the two networks disagree about. +pub enum WireConfig { + Lean(LeanWireConfig), + /// Boxed for the reason [`Wire::Beacon`] is: it carries a whole `Config`, + /// and every lean node would otherwise pay for it in each config it moves. + Beacon(Box), +} + +/// The lean network's swarm parameters. +/// /// INVARIANT: `subscription_subnets` is the fixed set of attestation subnets /// this node subscribes to. It is computed once by the caller via /// [`attestation_subscription_subnets`] and shared with the blockchain actor, @@ -170,10 +609,7 @@ pub(crate) struct Behaviour { /// resubscribe gossip subnets; this is the leanSpec PR #636 "hot-standby model" /// scope limitation. A node that may aggregate at runtime must include those /// subnets here at startup. -pub struct SwarmConfig { - pub node_key: Vec, - pub bootnodes: Vec, - pub listening_socket: SocketAddr, +pub struct LeanWireConfig { pub validator_ids: Vec, pub attestation_committee_count: u64, /// Attestation subnets to subscribe to, precomputed via @@ -216,16 +652,64 @@ pub fn attestation_subscription_subnets( subnets } +/// Which network's wire this node speaks. +/// +/// One `P2PServer` serves both, dispatching on this once at the top of each +/// handler, exactly as `BlockChainServer` dispatches on the state variant. +/// Nothing is shared below the match: the topic names, the req/resp protocol +/// ids, the handshake and the decode are all different, and the parts that +/// genuinely coincide (the discv5 stack, the `ssz_snappy` framing, +/// `compute_message_id`) sit one layer down and are the beacon spec's anyway. +pub enum Wire { + Lean(LeanWire), + /// Boxed because a `BeaconWire` carries a whole `Config` and so is four + /// times the size of a `LeanWire`; unboxed, every lean node would pay for + /// it in each `Wire` it moves. + Beacon(Box), +} + +/// The lean network's gossip topics. +pub struct LeanWire { + pub(crate) attestation_topics: HashMap, + pub(crate) attestation_committee_count: u64, + pub(crate) block_topic: libp2p::gossipsub::IdentTopic, + pub(crate) aggregation_topic: libp2p::gossipsub::IdentTopic, +} + +impl Wire { + pub(crate) fn lean(&self) -> Option<&LeanWire> { + match self { + Wire::Lean(lean) => Some(lean), + Wire::Beacon(_) => None, + } + } + + pub(crate) fn beacon(&self) -> Option<&beacon::BeaconWire> { + match self { + Wire::Beacon(beacon) => Some(beacon), + Wire::Lean(_) => None, + } + } + + /// Which chain's handler a shared request belongs to. + /// + /// The two block requests are one `Request` variant for both wires, so the + /// dispatch reads this instead of a tag on the message. A node speaks one + /// wire for its whole life, so this is the same answer every time and is + /// already recorded here; carrying it on the message would be a second copy + /// of it. + pub(crate) fn is_beacon(&self) -> bool { + matches!(self, Wire::Beacon(_)) + } +} + /// Result of building the swarm — contains all pieces needed to start the P2P actor. pub struct BuiltSwarm { /// This node's libp2p peer ID, derived from the node key. Exposed so the /// caller can report it (e.g. via the RPC `/lean/v0/node/identity` endpoint). pub local_peer_id: PeerId, pub(crate) swarm: libp2p::Swarm, - pub(crate) attestation_topics: HashMap, - pub(crate) attestation_committee_count: u64, - pub(crate) block_topic: libp2p::gossipsub::IdentTopic, - pub(crate) aggregation_topic: libp2p::gossipsub::IdentTopic, + pub(crate) wire: Wire, /// Every dial target per bootnode; see [`dial_addrs`]. Empty entries are never /// inserted; see [`bootnode_dial_addrs`]. pub(crate) bootnode_addrs: HashMap>, @@ -251,9 +735,23 @@ pub enum SwarmBuildError { Subscription(#[from] libp2p::gossipsub::SubscriptionError), } -/// Build and configure the libp2p swarm, dial bootnodes, subscribe to topics. -pub fn build_swarm(config: SwarmConfig) -> Result { - let gossipsub_config = libp2p::gossipsub::ConfigBuilder::default() +/// The gossipsub parameters both wires share. +/// +/// `mesh_n` 8, low 6, high 12, the 700ms heartbeat, and the 6/3 history already +/// match the beacon spec, so `seen_ttl` is the only value that differs between +/// the two networks: lean's is its slot duration times a 3-slot justification +/// lookback times two, mainnet's epoch is 32 slots of 12s. +/// +/// `validate_messages` holds every received message until the application +/// reports a verdict for it. Beacon only: every beacon topic gets one from +/// `beacon::verdict`, while lean handlers produce none, so turning it on there +/// would stop lean gossip from propagating at all. +pub(crate) fn gossipsub_config( + seen_ttl: Duration, + validate_messages: bool, +) -> libp2p::gossipsub::Config { + let mut builder = libp2p::gossipsub::ConfigBuilder::default(); + builder // d .mesh_n(8) // d_low @@ -266,52 +764,81 @@ pub fn build_swarm(config: SwarmConfig) -> Result { .fanout_ttl(Duration::from_secs(60)) .history_length(6) .history_gossip(3) - .duplicate_cache_time(Duration::from_millis( - config.milliseconds_per_slot * DUPLICATE_CACHE_SLOTS, - )) + .duplicate_cache_time(seen_ttl) .validation_mode(ValidationMode::Anonymous) .message_id_fn(compute_message_id) // Taken from ream .max_transmit_size(MAX_COMPRESSED_PAYLOAD_SIZE) .max_messages_per_rpc(Some(500)) .allow_self_origin(true) - .idontwant_message_size_threshold(1000) - .build() - .expect("invalid gossipsub config"); + .idontwant_message_size_threshold(1000); + if validate_messages { + builder.validate_messages(); + } + builder.build().expect("invalid gossipsub config") +} + +/// Build and configure the libp2p swarm, dial bootnodes, subscribe to topics. +/// +/// One builder for both networks. Four things differ at the behaviour level and +/// are resolved in the first match below; the topic set differs and is resolved +/// in the last one. Everything in between, the transport, the QUIC and TCP +/// listeners and the static bootnode dialing, is the same on either wire. +pub fn build_swarm(config: SwarmConfig) -> Result { + let SwarmConfig { + node_key, + bootnodes, + listening_socket, + target_peers, + wire, + agent_version, + } = config; + + // The codec comes out of this match too, not from `Default`: the two beacon + // block protocols frame their chunks against the fork schedule and the + // chain, so whatever decides that the protocols are registered has to decide + // that the context is there. See [`Codec`]. + let (seen_ttl, identify_version, connection_limits, codec) = match &wire { + WireConfig::Lean(lean) => ( + Duration::from_millis(lean.milliseconds_per_slot * DUPLICATE_CACHE_SLOTS), + // Use the same `protocol_version` string as zeam + "/ipfs/0.1.0", + unlimited_connections(), + Codec::lean(), + ), + WireConfig::Beacon(beacon) => ( + beacon::swarm::seen_ttl(&beacon.config), + beacon::swarm::IDENTIFY_PROTOCOL_VERSION, + beacon::swarm::connection_limits(target_peers), + Codec::beacon(beacon::BeaconContext { + config: beacon.config.clone(), + genesis_validators_root: beacon.genesis_validators_root, + }), + ), + }; - let gossipsub = - libp2p::gossipsub::Behaviour::new(MessageAuthenticity::Anonymous, gossipsub_config) - .expect("failed to initiate behaviour"); + let validate_messages = matches!(wire, WireConfig::Beacon(_)); + let gossipsub = libp2p::gossipsub::Behaviour::new( + MessageAuthenticity::Anonymous, + gossipsub_config(seen_ttl, validate_messages), + ) + .expect("failed to initiate behaviour"); - let req_resp = request_response::Behaviour::new( - vec![ - ( - StreamProtocol::new(STATUS_PROTOCOL_V1), - request_response::ProtocolSupport::Full, - ), - ( - StreamProtocol::new(BLOCKS_BY_ROOT_PROTOCOL_V1), - request_response::ProtocolSupport::Full, - ), - ( - StreamProtocol::new(BLOCKS_BY_RANGE_PROTOCOL_V1), - request_response::ProtocolSupport::Full, - ), - ], - Default::default(), - ); + let req_resp = ReqResp::new(codec, &wire); - let secret_key = - secp256k1::SecretKey::try_from_bytes(config.node_key).expect("invalid node key"); + let secret_key = secp256k1::SecretKey::try_from_bytes(node_key).expect("invalid node key"); let identity = libp2p::identity::Keypair::from(secp256k1::Keypair::from(secret_key)); - // Use the same `protocol_version` string as zeam - let identify = libp2p::identify::Behaviour::new(libp2p::identify::Config::new( - "/ipfs/0.1.0".to_owned(), - identity.public(), - )); + // Cache off, as lighthouse does: with it, identify pushes every `listenAddrs` a + // peer reports into the address book, loopback included, and `req_resp` dials those. + let identify = libp2p::identify::Behaviour::new( + libp2p::identify::Config::new(identify_version.to_owned(), identity.public()) + .with_agent_version(agent_version.to_owned()) + .with_cache_size(0), + ); let behavior = Behaviour { + connection_limits, identify, gossipsub, req_resp, @@ -324,7 +851,15 @@ pub fn build_swarm(config: SwarmConfig) -> Result { .with_tcp( libp2p::tcp::Config::default().nodelay(true), libp2p::noise::Config::new, - libp2p::yamux::Config::default, + // mplex is not decoration: mainnet beacon peers answer `na` to a + // yamux-only proposal. See [`muxers`] for the measurement. Lean + // peers all speak yamux, so offering both costs that wire nothing + // and keeps one transport stack rather than two. + #[allow(deprecated)] + ( + libp2p::yamux::Config::default, + libp2p_mplex::MplexConfig::default, + ), ) .expect("failed to add TCP transport to swarm") .with_quic() @@ -333,11 +868,14 @@ pub fn build_swarm(config: SwarmConfig) -> Result { .with_swarm_config(|c| { // Disable idle connection timeout c.with_idle_connection_timeout(Duration::from_secs(u64::MAX)) + // Address order is a preference, not a race. See + // `DIAL_ADDRESS_CONCURRENCY`. + .with_dial_concurrency_factor(DIAL_ADDRESS_CONCURRENCY) }) .build(); let local_peer_id = *swarm.local_peer_id(); let (bootnode_addrs, undialable_bootnodes) = - merge_bootnode_dial_addrs(config.bootnodes, local_peer_id); + merge_bootnode_dial_addrs(bootnodes, local_peer_id); // The merged map is the dial input, so every address a duplicate entry // contributed is in the one attempt this peer gets. A refused dial is not // fatal: the entry stays in `bootnode_addrs`, so the redial path picks the @@ -360,8 +898,8 @@ pub fn build_swarm(config: SwarmConfig) -> Result { ); } let quic_addr = Multiaddr::empty() - .with(config.listening_socket.ip().into()) - .with(Protocol::Udp(config.listening_socket.port())) + .with(listening_socket.ip().into()) + .with(Protocol::Udp(listening_socket.port())) .with(Protocol::QuicV1); swarm .listen_on(quic_addr.clone()) @@ -373,8 +911,8 @@ pub fn build_swarm(config: SwarmConfig) -> Result { // Same port number as the QUIC listener above: TCP and UDP are separate // namespaces, so this cannot collide with it. let tcp_addr = Multiaddr::empty() - .with(config.listening_socket.ip().into()) - .with(Protocol::Tcp(config.listening_socket.port())); + .with(listening_socket.ip().into()) + .with(Protocol::Tcp(listening_socket.port())); swarm .listen_on(tcp_addr.clone()) .map_err(|source| SwarmBuildError::Listen { @@ -383,49 +921,101 @@ pub fn build_swarm(config: SwarmConfig) -> Result { source, })?; - // Subscribe to block topic (all nodes) - let block_topic = block_topic(); - swarm - .behaviour_mut() - .gossipsub - .subscribe(&block_topic) - .unwrap(); + let wire = match wire { + WireConfig::Lean(lean) => { + // Subscribe to block topic (all nodes) + let block_topic = block_topic(); + swarm + .behaviour_mut() + .gossipsub + .subscribe(&block_topic) + .unwrap(); + + // Subscribe to aggregation topic (all validators) + let aggregation_topic = aggregation_topic(); + swarm + .behaviour_mut() + .gossipsub + .subscribe(&aggregation_topic) + .unwrap(); + + // The committee metric should reflect validator membership only, not + // aggregator-only subscriptions. + let metric_subnet = lean + .validator_ids + .iter() + .map(|vid| vid % lean.attestation_committee_count) + .min() + .unwrap_or(0); + metrics::set_attestation_committee_subnet(metric_subnet); + + let mut attestation_topics: HashMap = + HashMap::new(); + for &subnet_id in &lean.subscription_subnets { + let topic = attestation_subnet_topic(subnet_id); + swarm.behaviour_mut().gossipsub.subscribe(&topic)?; + info!(subnet_id, "Subscribed to attestation subnet"); + attestation_topics.insert(subnet_id, topic); + } - // Subscribe to aggregation topic (all validators) - let aggregation_topic = aggregation_topic(); - swarm - .behaviour_mut() - .gossipsub - .subscribe(&aggregation_topic) - .unwrap(); - - // The committee metric should reflect validator membership only, not - // aggregator-only subscriptions. - let metric_subnet = config - .validator_ids - .iter() - .map(|vid| vid % config.attestation_committee_count) - .min() - .unwrap_or(0); - metrics::set_attestation_committee_subnet(metric_subnet); + info!(socket=%listening_socket, "P2P node started"); - let mut attestation_topics: HashMap = HashMap::new(); - for &subnet_id in &config.subscription_subnets { - let topic = attestation_subnet_topic(subnet_id); - swarm.behaviour_mut().gossipsub.subscribe(&topic)?; - info!(subnet_id, "Subscribed to attestation subnet"); - attestation_topics.insert(subnet_id, topic); - } + Wire::Lean(LeanWire { + attestation_topics, + attestation_committee_count: lean.attestation_committee_count, + block_topic, + aggregation_topic, + }) + } + WireConfig::Beacon(beacon) => { + // The custody set is columns, not subnets: a column's subnet is + // `column % DATA_COLUMN_SIDECAR_SUBNET_COUNT`, computed rather than + // assumed so a network that ever separates the two counts still + // subscribes to the right topic. + let column_subnets: Vec = beacon + .custody_columns + .iter() + .map(|column| { + column % ethlambda_types::beacon::constants::DATA_COLUMN_SIDECAR_SUBNET_COUNT + }) + .collect(); + let topics = beacon::topics::BeaconTopics::new( + beacon.fork_digest, + &column_subnets, + &beacon.attestation_subnets, + ); + for topic in &topics.topics { + swarm.behaviour_mut().gossipsub.subscribe(topic)?; + info!(topic = %topic, "Subscribed to beacon topic"); + } - info!(socket=%config.listening_socket, "P2P node started"); + info!( + socket = %listening_socket, + fork_digest = %hex::encode(beacon.fork_digest), + topics = topics.topics.len(), + columns = beacon.custody_columns.len(), + attestation_subnets = ?beacon.attestation_subnets, + "Beacon P2P node started" + ); + + Wire::Beacon(Box::new(beacon::BeaconWire { + fork_digest: beacon.fork_digest, + fork: beacon.fork, + topics, + config: beacon.config, + genesis_time: beacon.genesis_time, + genesis_validators_root: beacon.genesis_validators_root, + metadata_seq_number: 0, + custody_columns: beacon.custody_columns, + attestation_subnets: beacon.attestation_subnets, + })) + } + }; Ok(BuiltSwarm { local_peer_id, swarm, - attestation_topics, - attestation_committee_count: config.attestation_committee_count, - block_topic, - aggregation_topic, + wire, bootnode_addrs, }) } @@ -441,52 +1031,93 @@ impl P2P { /// Start discovery, start the I/O adapter, spawn the actor, and wire the /// swarm event stream. /// - /// `discovery` is `Some` when discv5 discovery is enabled: the discv5 - /// server is started here, and its handle seeds the dial loop's state and - /// schedules its first tick. `None` leaves the dial loop permanently - /// dormant, so peering relies solely on the static bootnode list dialed by - /// `build_swarm`. + /// `discovery` is `Some` when discv5 runs: the server is started here, and + /// its handle seeds the dial loop's state and schedules its first tick. It + /// is started before the swarm adapter so a fatal discovery failure (a busy + /// UDP port, say) surfaces before any actor is running. `None` leaves the + /// dial loop unscheduled, so peering relies solely on the static bootnode + /// list `build_swarm` dialed. /// - /// Discovery is started before the swarm adapter so a fatal discovery - /// failure (a busy UDP port, say) surfaces before any actor is running. + /// Always `Some` on beacon, which has no other way to find a peer: + /// published mainnet bootnode ENRs carry no `quic` entry, so none of them + /// is statically dialable. Opt-in on lean, where nothing else speaks discv5 + /// and co-located devnet nodes would otherwise all claim one UDP port. pub async fn spawn( built: BuiltSwarm, store: Store, node_names: HashMap, discovery: Option, + attestation_pool: SharedAttestationPool, ) -> Result { - if discovery.is_none() { - info!("discv5 discovery disabled; peering from the static bootnode list only"); - } - // `OptionFuture` awaits the spawn only when there is one to await, so the - // disabled case stays a plain `None` without a branch of its own. - let discovery = OptionFuture::from(discovery.map(spawn_discovery)) - .await - .transpose()?; + let discovery = match discovery { + Some(config) => Some(spawn_discovery(config).await?), + None => { + info!("discv5 discovery disabled; peering from the static bootnode list only"); + None + } + }; let (swarm_stream, swarm_handle) = swarm_adapter::start_swarm_adapter(built.swarm, node_names.clone()); - let discovery_enabled = discovery.is_some(); + // Seeded from the anchor the store was bootstrapped at, so the first + // range request starts where the chain does rather than at slot 0. + let beacon_fetched_through = match store.chain() { + Chain::Beacon => store.beacon_head().map_or(0, |(slot, _)| slot), + Chain::Lean => 0, + }; + // Read before `built.wire` moves into the server below; absent on a + // lean wire, which `seen_attestations_capacity` floors for. + let backbone_attestation_subnets = built + .wire + .beacon() + .map_or(0, |beacon| beacon.attestation_subnets.len()); + let server = P2PServer { swarm_handle, store, blockchain: None, - attestation_topics: built.attestation_topics, - attestation_committee_count: built.attestation_committee_count, - block_topic: built.block_topic, - aggregation_topic: built.aggregation_topic, - connected_peers: HashSet::new(), + wire: built.wire, + connected_peers: HashMap::new(), + peer_custody: HashMap::new(), pending_root_requests: HashMap::new(), + pending_column_requests: HashMap::new(), outbound_requests: HashMap::new(), range_sync_state: None, + beacon_fetched_through, bootnode_addrs: built.bootnode_addrs, node_names, discovery: discovery.map(|handle| DiscoveryState::new(handle, built.local_peer_id)), + seen_blocks: SeenBlocks::new(SEEN_BLOCKS_CAPACITY), + seen_columns: SeenColumns::new(SEEN_COLUMNS_CAPACITY), + seen_aggregates: SeenAggregates::new( + SEEN_AGGREGATES_CAPACITY, + SEEN_AGGREGATES_CAPACITY, + ), + seen_attestations: SeenAttestations::new(seen_attestations_capacity( + backbone_attestation_subnets, + )), + gossip_validation_permits: Arc::new(tokio::sync::Semaphore::new( + GOSSIP_VALIDATION_PERMITS, + )), + column_check_permits: Arc::new(tokio::sync::Semaphore::new(COLUMN_CHECK_PERMITS)), + attestation_validation_permits: Arc::new(tokio::sync::Semaphore::new( + ATTESTATION_VALIDATION_PERMITS, + )), + attestation_pool, + aggregator_subnets: HashMap::new(), }; + let discovery_enabled = server.discovery.is_some(); let handle = server.start(); + send_after( + AGGREGATOR_SUBNET_SWEEP_INTERVAL, + handle.context(), + p2p_protocol::LeaveExpiredAggregatorSubnets, + ); + // The dial loop's first tick. Nothing else schedules one, so without + // discovery the loop never runs. if discovery_enabled { send_after( - DISCOVERY_DIAL_INTERVAL, + DIAL_INTERVAL_AT_ZERO_PEERS, handle.context(), p2p_protocol::DiscoverPeers, ); @@ -515,20 +1146,84 @@ pub struct P2PServer { // BlockChain protocol ref (set via InitBlockChain message) pub(crate) blockchain: Option, - pub(crate) attestation_topics: HashMap, - pub(crate) attestation_committee_count: u64, - pub(crate) block_topic: libp2p::gossipsub::IdentTopic, - pub(crate) aggregation_topic: libp2p::gossipsub::IdentTopic, + pub(crate) wire: Wire, - pub(crate) connected_peers: HashSet, + /// Every peer holding at least one established connection, and which side + /// opened the first one. + /// + /// Keyed by peer rather than connection: a peer may hold up to + /// [`beacon::swarm::MAX_CONNECTIONS_PER_PEER`] of them, and every consumer + /// here asks "can I talk to this peer", not "over how many sockets". The + /// direction is the first connection's, which is the one that decides + /// whether this peer was our choice or the network's. + pub(crate) connected_peers: HashMap, + /// The columns each peer custodies, as computed from its own node id and + /// its advertised custody group count. + /// + /// Deterministic on both sides, which is the whole point: the spec notes + /// that "due to the deterministic custody functions, a node knows exactly + /// what a peer should be able to respond to", so a column request can be + /// aimed at a peer that actually holds it instead of scattered at random. + /// At mainnet's `CUSTODY_REQUIREMENT` a peer holds 8 of 128 columns, so + /// asking an arbitrary one for a specific column nearly always comes back + /// empty. + /// + /// Absent for any peer that has not answered `metadata/3` and arrived with + /// no usable `cgc`; [`columns_custodied_by`] treats absent as "no opinion", + /// never as "custodies nothing". + pub(crate) peer_custody: HashMap>, pub(crate) pending_root_requests: HashMap, - pub(crate) outbound_requests: HashMap, + /// One entry per block root with an in-flight or backed-off + /// `DataColumnsByRoot` lookup. Mirrors `pending_root_requests`'s role for + /// the block path: `fetch_missing_columns` dedupes against it, and + /// `handle_column_fetch_failure` is the only place an entry is retired. + pub(crate) pending_column_requests: HashMap, + pub(crate) outbound_requests: HashMap, pub(crate) range_sync_state: Option, + + /// Highest beacon slot handed to the chain actor, whether or not it has + /// been imported yet. + pub(crate) beacon_fetched_through: u64, bootnode_addrs: HashMap>, node_names: HashMap, - /// Set when discovery is enabled. `None` disables the dial loop entirely. + /// The dial loop's state. `None` when discv5 is off, which only a lean + /// node started without `--discovery.enable` is. pub(crate) discovery: Option, + + /// The first valid block per `(slot, proposer)` accepted from gossip. + pub(crate) seen_blocks: SeenBlocks, + /// The first valid sidecar per `(slot, proposer, index)` accepted from + /// gossip. Bounded by capacity, so a fabricated slot cannot grow it. + pub(crate) seen_columns: SeenColumns, + /// Accepted `beacon_aggregate_and_proof`s, by `(target_epoch, + /// aggregator_index)` and by `(hash_tree_root(data), committee_index)`. + pub(crate) seen_aggregates: SeenAggregates, + /// Accepted `beacon_attestation_{subnet_id}`s, by `(target_epoch, + /// attester_index)`. + pub(crate) seen_attestations: SeenAttestations, + /// Permits for block and column stateful gossip checks in flight on + /// blocking threads. + pub(crate) gossip_validation_permits: Arc, + /// Permits for data column chain checks in flight on blocking threads. + pub(crate) column_check_permits: Arc, + /// Permits for aggregate and subnet-attestation stateful gossip checks in + /// flight on blocking threads. Separate from + /// [`Self::gossip_validation_permits`]; see + /// [`ATTESTATION_VALIDATION_PERMITS`]. + pub(crate) attestation_validation_permits: Arc, + + /// Unaggregated attestations for this node's validator clients' + /// aggregators, shared with the Beacon API that aggregates from it. Filled + /// by `verdict::forward` from the aggregator subnets below; lean never + /// touches it. + pub(crate) attestation_pool: SharedAttestationPool, + + /// The attestation subnets joined for a validator client's aggregators, + /// each with the last slot it is needed for. Short-lived by design: never + /// advertised in `attnets`, and left once the slot has passed. The + /// backbone subnets are separate and never left. + pub(crate) aggregator_subnets: HashMap, } impl P2PServer { @@ -538,6 +1233,64 @@ impl P2PServer { .map(String::as_str) .unwrap_or("unknown") } + + /// Republish the peer gauges from the state that actually decides them. + /// + /// Set from a full re-count rather than incremented and decremented per + /// event, because a gauge kept by arithmetic is only ever as right as the + /// least reliable event that touches it: one missed decrement and it is + /// wrong until restart, in the direction that hides a problem. These are + /// the gauges used to tell whether connections leak, so they must not be + /// able to leak themselves. + /// + /// Cheap enough to call on every connect and disconnect: the work is + /// proportional to the peer count, which is bounded by + /// [`beacon::swarm::max_connections`]. The swarm's own connection counters + /// are the other half of the leak question, and the swarm lives in + /// [`swarm_adapter`]'s task rather than here, so those are published from + /// its metric tick instead. + pub(crate) fn refresh_peer_metrics(&self) { + let (mut inbound, mut outbound) = (0, 0); + for direction in self.connected_peers.values() { + match direction { + ConnectionDirection::Inbound => inbound += 1, + ConnectionDirection::Outbound => outbound += 1, + } + } + metrics::set_peers_by_direction(inbound, outbound); + self.refresh_custody_column_metrics(); + } + + /// Publish, per column this node samples, how many connected peers are + /// known to custody it. + /// + /// This is the supply side of the data-availability gate: a block is held + /// until every sampled column arrives, so a column sitting at zero peers is + /// a stall waiting to happen, and it is invisible in a total peer count. + /// + /// Only the columns this node samples get a series. Publishing all 128 + /// would bury the eight that can actually block an import, and the custody + /// set is fixed for the life of the node, so the label set is stable. + /// + /// Counted through [`columns_custodied_by`], the same answer the fetch path + /// picks its peers with, so the gauge cannot say a column has custodians + /// that a lookup for it would not find. + /// + /// Called from every writer of either input, which is both ends of a + /// connection and [`req_resp::handlers::record_peer_custody`]. The last of + /// those is the one that matters: a peer's custody arrives with its + /// `metadata/3` answer, *after* it connects, so a gauge refreshed on + /// connection events alone would read every column at the zero it had + /// before the peer said anything — which is the exact reading this gauge + /// was added to mean "a stall waiting to happen". + pub(crate) fn refresh_custody_column_metrics(&self) { + let Some(wire) = self.wire.beacon() else { + return; + }; + for column in wire.custody_columns.iter().copied() { + metrics::set_custody_column_peers(column, columns_custodied_by(self, column).len()); + } + } } // Protocol trait for internal messages only (retry scheduling). @@ -547,9 +1300,15 @@ pub(crate) trait P2PProtocol: Send + Sync { #[allow(dead_code)] // invoked via send_after, not called directly fn retry_block_fetch(&self, root: H256) -> Result<(), ActorError>; #[allow(dead_code)] // invoked via send_after, not called directly + fn retry_data_column_fetch(&self, block_root: H256) -> Result<(), ActorError>; + #[allow(dead_code)] // invoked via send_after, not called directly fn retry_peer_redial(&self, peer_id: PeerId) -> Result<(), ActorError>; #[allow(dead_code)] // invoked via send_after, not called directly fn discover_peers(&self) -> Result<(), ActorError>; + #[allow(dead_code)] // invoked via send_after, not called directly + fn leave_expired_aggregator_subnets(&self) -> Result<(), ActorError>; + #[allow(dead_code)] // invoked via send_after, not called directly + fn retry_beacon_range_batch(&self) -> Result<(), ActorError>; } #[actor(protocol = P2PProtocol)] @@ -575,6 +1334,29 @@ impl P2PServer { } } + #[send_handler] + async fn handle_retry_data_column_fetch( + &mut self, + msg: p2p_protocol::RetryDataColumnFetch, + _ctx: &Context, + ) { + let block_root = msg.block_root; + // Same "might have completed during backoff" guard as + // `handle_retry_block_fetch`, and the same reason for it: the reissued + // request must ask for what is still missing, which + // `pending_column_requests` is the only place that remembers. + let Some(pending) = self.pending_column_requests.get(&block_root) else { + trace!(%block_root, "Data column fetch completed during backoff, skipping retry"); + return; + }; + let columns = pending.columns.clone(); + + if !fetch_data_columns_from_peer(self, block_root, columns).await { + tracing::error!(%block_root, "Failed to retry data column fetch, giving up"); + self.pending_column_requests.remove(&block_root); + } + } + #[send_handler] async fn handle_retry_peer_redial( &mut self, @@ -584,7 +1366,7 @@ impl P2PServer { let peer_id = msg.peer_id; // Skip if already reconnected - if self.connected_peers.contains(&peer_id) { + if self.connected_peers.contains_key(&peer_id) { trace!(%peer_id, "Bootnode reconnected during redial delay, skipping"); return; } @@ -597,18 +1379,60 @@ impl P2PServer { } #[send_handler] - async fn handle_discover_peers( + async fn handle_leave_expired_aggregator_subnets( &mut self, - _msg: p2p_protocol::DiscoverPeers, + _msg: p2p_protocol::LeaveExpiredAggregatorSubnets, ctx: &Context, ) { - // Reschedule first, so an early return never stops the loop. send_after( - DISCOVERY_DIAL_INTERVAL, + AGGREGATOR_SUBNET_SWEEP_INTERVAL, ctx.clone(), - p2p_protocol::DiscoverPeers, + p2p_protocol::LeaveExpiredAggregatorSubnets, ); - dial_tick(self).await; + gossipsub::leave_expired_aggregator_subnets(self); + gossipsub::prune_attestation_pool(self); + } + + #[send_handler] + async fn handle_discover_peers( + &mut self, + _msg: p2p_protocol::DiscoverPeers, + ctx: &Context, + ) { + // `P2P::spawn` schedules the first tick only with discovery on, so this + // never returns. If it did, not rescheduling is what "off" means. + let Some(target_peers) = self.discovery.as_ref().map(DiscoveryState::target_peers) else { + return; + }; + let dialed = dial_tick(self, target_peers).await; + // Rescheduled on every path out of the tick, so nothing above can stop + // the loop. The gap is a function of how full the peer table is rather + // than a flat heartbeat: near `MAX_DIAL_RATE_PER_SECOND` while short of + // peers, easing off as they arrive. See `dial::dial_interval`. + // + // Unless the tick dialed nothing, which the curve cannot tell on its + // own: it reads a shortfall against `target_peers`, and a network with + // fewer peers than that to offer leaves that shortfall open forever. + // Pacing on it alone would hold the loop at its floor for the life of + // the process, re-drawing a candidate pool of peers it is already + // connected to. One dial opened puts it straight back on the curve. + let interval = if dialed { + dial_interval(dial_progress(self, target_peers)) + } else { + DIAL_INTERVAL_AT_TARGET + }; + send_after(interval, ctx.clone(), p2p_protocol::DiscoverPeers); + } + + /// The deadline of a range batch held back for custody. Scheduled once, + /// when the batch starts waiting; see [`RANGE_BATCH_CUSTODY_WAIT`]. + #[send_handler] + async fn handle_retry_beacon_range_batch( + &mut self, + _msg: p2p_protocol::RetryBeaconRangeBatch, + ctx: &Context, + ) { + resume_range_batch_held_for_custody(self, ctx).await; } } @@ -639,16 +1463,106 @@ impl Handler for P2PServer { } } +impl Handler for P2PServer { + async fn handle(&mut self, msg: PublishBeaconAggregate, _ctx: &Context) { + publish_beacon_aggregate(self, msg.aggregate).await; + } +} + +impl Handler for P2PServer { + async fn handle(&mut self, msg: PublishBeaconBlock, _ctx: &Context) { + publish_beacon_block(self, msg.block).await; + } +} + +impl Handler for P2PServer { + async fn handle(&mut self, msg: SubscribeAttestationSubnets, _ctx: &Context) { + gossipsub::join_aggregator_subnets(self, msg.subnets); + } +} + +impl Handler for P2PServer { + async fn handle(&mut self, msg: PublishBeaconAttestation, _ctx: &Context) { + publish_beacon_attestation(self, msg.subnet_id, msg.attestation).await; + } +} + impl Handler for P2PServer { async fn handle(&mut self, msg: FetchBlock, _ctx: &Context) { - let root = msg.root; - // Deduplicate - if already pending, ignore - if self.pending_root_requests.contains_key(&root) { - trace!(%root, "Block fetch already in progress, ignoring duplicate"); + fetch_missing(self, msg.request).await; + } +} + +impl Handler for P2PServer { + async fn handle(&mut self, msg: CheckDataColumnSidecars, _ctx: &Context) { + beacon::column_checks::check_and_forward(self, msg.sidecars); + } +} + +/// Ask for whatever a [`FetchRequest`] says is missing. +/// +/// Both halves are by-root lookups with their own dedup and retry ladder, and +/// a request may name either or both. There is no by-range arm here: a caller +/// on the chain side has a root, not a span, and the span worth asking for is +/// the one this crate is already syncing, so +/// [`req_resp::request_beacon_data_columns_by_range`] rides every +/// `BeaconBlocksByRange` batch instead of waiting to be asked. +async fn fetch_missing(server: &mut P2PServer, request: FetchRequest) { + let FetchRequest { + block_root, + needs_block, + columns, + } = request; + if needs_block { + fetch_missing_block(server, block_root).await; + } + if !columns.is_empty() { + fetch_missing_columns(server, block_root, columns).await; + } +} + +/// The by-root block half of a [`FetchRequest`]. +async fn fetch_missing_block(server: &mut P2PServer, root: H256) { + // Deduplicate - if already pending, ignore + if server.pending_root_requests.contains_key(&root) { + trace!(%root, "Block fetch already in progress, ignoring duplicate"); + return; + } + fetch_block_from_peer(server, root).await; +} + +/// The by-root column half of a [`FetchRequest`]. +async fn fetch_missing_columns(server: &mut P2PServer, block_root: H256, columns: Vec) { + // Same one-lookup-per-root rule as the block half, but merged rather + // than dropped: the availability gate may re-ask for a root already + // in flight with a wider column set than the first ask named, as + // columns trickle in, and dropping the difference would rest on an + // unenforced invariant that a re-ask is always a subset of what is + // already pending. A column added this way misses the request + // already on the wire, which cannot be widened after it was sent; + // it rides the next event for this root instead — a retry of this + // lookup, which resends whatever `columns` now holds, or, once this + // lookup resolves and the entry is gone, a fresh `FetchRequest` + // from a caller that rechecks what is still missing. + // + // "In flight" has to mean it, though: an entry whose round never + // reported back would otherwise deduplicate this root against a lookup + // that will never ask anything again. Past `STALE_COLUMN_LOOKUP` the + // entry is dropped and this ask starts a lookup of its own. + if let Some(pending) = server.pending_column_requests.get_mut(&block_root) { + if pending.last_asked.elapsed() < STALE_COLUMN_LOOKUP { + for column in columns { + if !pending.columns.contains(&column) { + trace!(%block_root, column, "Merging a new column into an in-flight data column fetch"); + pending.columns.push(column); + } + } return; } - fetch_block_from_peer(self, root).await; + debug!(%block_root, "Replacing a data column lookup that stopped asking"); + server.pending_column_requests.remove(&block_root); } + fetch_data_columns_from_peer(server, block_root, columns).await; } // --- Manual Handler for swarm events --- @@ -665,13 +1579,8 @@ async fn handle_swarm_event( ctx: &Context, ) { match event { - SwarmEvent::Behaviour(BehaviourEvent::ReqResp(req_resp_event)) => { - req_resp::handle_req_resp_message(server, req_resp_event, ctx).await; - } - SwarmEvent::Behaviour(BehaviourEvent::Gossipsub( - message @ libp2p::gossipsub::Event::Message { .. }, - )) => { - gossipsub::handle_gossipsub_message(server, message).await; + SwarmEvent::Behaviour(behaviour_event) => { + handle_behaviour_event(server, behaviour_event, ctx).await; } SwarmEvent::ConnectionEstablished { peer_id, @@ -679,43 +1588,74 @@ async fn handle_swarm_event( num_established, .. } => { - let direction = connection_direction(&endpoint); + let direction = ConnectionDirection::from(&endpoint); // Read off the connection's own address rather than which one we // dialed: with both QUIC and TCP offered, libp2p races every // address in a dial and may connect over either. This is what // answers "did the TCP fallback actually help", as a metric because // the trace field alone is invisible at default verbosity. let transport = transport_label(endpoint.get_remote_address()); - metrics::inc_peer_connection_transport(direction, transport); + metrics::inc_peer_connection_transport(direction.as_str(), transport); if num_established.get() == 1 { - server.connected_peers.insert(peer_id); + server.connected_peers.insert(peer_id, direction); + server.refresh_peer_metrics(); let peer_count = server.connected_peers.len(); metrics::notify_peer_connected( server.resolve_node_name(Some(&peer_id)), - direction, + direction.as_str(), "success", ); - // Send status request on first connection to this peer - let our_status = build_status(&server.store); - let our_finalized_slot = our_status.finalized.slot; - let our_head_slot = our_status.head.slot; - trace!( - %peer_id, - %direction, - %transport, - peer_count, - our_finalized_slot, - our_head_slot, - "Peer connected" - ); - server - .swarm_handle - .send_request( - peer_id, - Request::Status(our_status), - libp2p::StreamProtocol::new(STATUS_PROTOCOL_V1), + // Compute the beacon status and its log fields first, so no + // borrow of `server.wire` is alive across the send. + let beacon_status = server.wire.beacon().map(|wire| { + ( + beacon::handler::build_status( + &server.store, + wire, + beacon::handler::StatusVersion::V1, + ), + hex::encode(wire.fork_digest), ) - .await; + }); + match beacon_status { + Some((status, digest)) => { + trace!( + %peer_id, + %direction, + %transport, + peer_count, + fork_digest = %digest, + "Peer connected" + ); + beacon::handler::send_status(server, peer_id, status).await; + // Behind the handshake rather than in place of it: this + // is what tells us which columns the peer custodies, and + // an inbound peer has no ENR here to read a `cgc` from. + beacon::handler::request_metadata(server, peer_id).await; + } + None => { + let our_status = build_status(&server.store); + let our_finalized_slot = our_status.finalized.slot; + let our_head_slot = our_status.head.slot; + trace!( + %peer_id, + %direction, + %transport, + peer_count, + our_finalized_slot, + our_head_slot, + "Peer connected" + ); + server + .swarm_handle + .send_request( + peer_id, + Request::LeanStatus(our_status), + ReqRespProtocol::LeanStatus, + ) + .await; + } + } } else { trace!(%peer_id, %direction, %transport, "Added peer connection"); } @@ -727,8 +1667,8 @@ async fn handle_swarm_event( cause, .. } => { - let direction = connection_direction(&endpoint); - let reason = match cause { + let closed_direction = ConnectionDirection::from(&endpoint); + let reason = match &cause { None => "remote_close", Some(err) => { // Categorize disconnection reasons @@ -745,16 +1685,42 @@ async fn handle_swarm_event( } } }; + let cause_label = disconnect_cause(cause.as_ref()); if num_established == 0 { - server.connected_peers.remove(&peer_id); + // Report the direction this peer was *counted* under, not the + // one the last socket happened to carry. A peer may hold both + // an inbound and an outbound connection, and whichever closes + // last decides `closed_direction`; attributing the disconnect + // to that would let the per-direction connect and disconnect + // counters drift apart, and those counters are exactly what a + // "peers held" figure gets derived from. + let direction = server + .connected_peers + .remove(&peer_id) + .unwrap_or(closed_direction); forget_discovered_peer(server, &peer_id); + server.refresh_peer_metrics(); let peer_count = server.connected_peers.len(); metrics::notify_peer_disconnected( server.resolve_node_name(Some(&peer_id)), - direction, + direction.as_str(), reason, ); - + // Charged here rather than on every closed connection, so this + // totals to the same count as the metric above and the two can + // be read against each other directly. + metrics::inc_peer_disconnect_cause(direction.as_str(), cause_label); + + // `debug!` rather than the `trace!` below, and carrying the + // cause itself: `cause_label` deliberately cannot name what an + // `io_other` was, so this is the only place that answer exists. + debug!( + %peer_id, + %direction, + %cause_label, + cause = ?cause, + "Peer connection closed" + ); trace!( %peer_id, %direction, @@ -773,7 +1739,7 @@ async fn handle_swarm_event( trace!(%peer_id, "Scheduled bootnode redial in {}s", PEER_REDIAL_INTERVAL_SECS); } } else { - trace!(%peer_id, %direction, %reason, "Peer connection closed but other connections remain"); + trace!(%peer_id, direction = %closed_direction, %reason, "Peer connection closed but other connections remain"); } } SwarmEvent::OutgoingConnectionError { peer_id, error, .. } => { @@ -797,13 +1763,13 @@ async fn handle_swarm_event( // fail here (the bootnode redial path is one way), and dropping // a live peer's `attnets` would make `covered_subnets` // under-count subnets we do in fact cover. - if !server.connected_peers.contains(&pid) { + if !server.connected_peers.contains_key(&pid) { forget_discovered_peer(server, &pid); } // Schedule redial if this was a bootnode if server.bootnode_addrs.contains_key(&pid) - && !server.connected_peers.contains(&pid) + && !server.connected_peers.contains_key(&pid) { send_after( Duration::from_secs(PEER_REDIAL_INTERVAL_SECS), @@ -815,12 +1781,34 @@ async fn handle_swarm_event( } } SwarmEvent::IncomingConnectionError { peer_id, error, .. } => { - metrics::notify_peer_connected( - server.resolve_node_name(peer_id.as_ref()), - "inbound", - "error", + // A connection our own limit refused is policy working, not a + // fault. Once the cap is reached every further dial arrives here, + // so counting these as errors would bury the real ones under the + // steady rate of peers we are deliberately turning away, and warn + // once per rejection while doing it. See `beacon::swarm::connection_limits`. + let refused_at_capacity = matches!( + &error, + libp2p::swarm::ListenError::Denied { cause } + if cause + .downcast_ref::() + .is_some() ); - debug!(%error, "Incoming connection error"); + if refused_at_capacity { + metrics::notify_peer_connected( + server.resolve_node_name(peer_id.as_ref()), + "inbound", + "refused_at_capacity", + ); + let peer_count = server.connected_peers.len(); + debug!(peer_count, "Refused an inbound connection at capacity"); + } else { + metrics::notify_peer_connected( + server.resolve_node_name(peer_id.as_ref()), + "inbound", + "error", + ); + debug!(%error, "Incoming connection error"); + } } _ => { trace!(?event, "Ignored swarm event"); @@ -828,16 +1816,81 @@ async fn handle_swarm_event( } } -// --- Node identity helpers --- - -/// Derive each entry's `PeerId` from its secp256k1 private key. +/// Dispatch one [`BehaviourEvent`], tagging every req/resp field's event with +/// the [`ReqRespProtocol`] variant that names it. /// -/// Drops entries whose key fails to parse, with a `warn!` per drop. -pub fn derive_peer_ids(names_and_privkeys: HashMap) -> HashMap { - names_and_privkeys - .into_iter() - .filter_map(|(name, mut privkey)| { - match secp256k1::SecretKey::try_from_bytes(&mut privkey.0) { +/// The tag is the whole of what each req/resp arm decides, so the match yields +/// it as a value and the one call that consumes it sits below, rather than +/// each arm repeating the call with a different constant. +/// +/// Deliberately exhaustive, with no wildcard arm: `handle_swarm_event`'s own +/// catch-all would otherwise silently swallow a `BehaviourEvent` variant this +/// function forgot to name, for the same reason the fork's own `DialError` +/// conversion is matched exhaustively (see `DialOutcome`'s `From` impl in +/// `swarm_adapter.rs`). Adding a fifteenth [`ReqResp`] field forces this to +/// grow an arm rather than falling through unnoticed. +async fn handle_behaviour_event( + server: &mut P2PServer, + event: BehaviourEvent, + ctx: &Context, +) { + let (protocol, event) = match event { + // Registered for interop only; see `Behaviour`'s doc comment for why + // its events are never read. + BehaviourEvent::Identify(_) => return, + // A deny from this behaviour already denied the connection at the + // swarm level; nothing here needs to react to it a second time. + BehaviourEvent::ConnectionLimits(_) => return, + BehaviourEvent::Gossipsub(libp2p::gossipsub::Event::Message { + propagation_source, + message_id, + message, + }) => { + return gossipsub::handle_gossip_message( + server, + ctx, + propagation_source, + message_id, + message, + ) + .await; + } + BehaviourEvent::Gossipsub(_) => return, + BehaviourEvent::ReqResp(event) => match event { + ReqRespEvent::LeanStatus(e) => (ReqRespProtocol::LeanStatus, e), + ReqRespEvent::LeanBlocksByRoot(e) => (ReqRespProtocol::LeanBlocksByRoot, e), + ReqRespEvent::LeanBlocksByRange(e) => (ReqRespProtocol::LeanBlocksByRange, e), + ReqRespEvent::BeaconStatusV1(e) => (ReqRespProtocol::BeaconStatusV1, e), + ReqRespEvent::BeaconStatusV2(e) => (ReqRespProtocol::BeaconStatusV2, e), + ReqRespEvent::BeaconPing(e) => (ReqRespProtocol::BeaconPing, e), + ReqRespEvent::BeaconMetadataV1(e) => (ReqRespProtocol::BeaconMetadataV1, e), + ReqRespEvent::BeaconMetadataV2(e) => (ReqRespProtocol::BeaconMetadataV2, e), + ReqRespEvent::BeaconMetadataV3(e) => (ReqRespProtocol::BeaconMetadataV3, e), + ReqRespEvent::BeaconGoodbye(e) => (ReqRespProtocol::BeaconGoodbye, e), + ReqRespEvent::BeaconBlocksByRange(e) => (ReqRespProtocol::BeaconBlocksByRange, e), + ReqRespEvent::BeaconBlocksByRoot(e) => (ReqRespProtocol::BeaconBlocksByRoot, e), + ReqRespEvent::DataColumnSidecarsByRange(e) => { + (ReqRespProtocol::DataColumnSidecarsByRange, e) + } + ReqRespEvent::DataColumnSidecarsByRoot(e) => { + (ReqRespProtocol::DataColumnSidecarsByRoot, e) + } + }, + }; + + req_resp::handle_req_resp_message(server, protocol, event, ctx).await; +} + +// --- Node identity helpers --- + +/// Derive each entry's `PeerId` from its secp256k1 private key. +/// +/// Drops entries whose key fails to parse, with a `warn!` per drop. +pub fn derive_peer_ids(names_and_privkeys: HashMap) -> HashMap { + names_and_privkeys + .into_iter() + .filter_map(|(name, mut privkey)| { + match secp256k1::SecretKey::try_from_bytes(&mut privkey.0) { Ok(privkey) => { let pubkey = Keypair::from(secp256k1::Keypair::from(privkey)).public(); Some((PeerId::from_public_key(&pubkey), name)) @@ -994,12 +2047,13 @@ fn parse_enr(enr_str: &str) -> Result { /// Empty when neither is, which is a discv5-only seed: it can still answer /// FINDNODE, but there is nothing for the swarm to dial. /// -/// The order is not a preference. libp2p pushes up to `dial_concurrency_factor` -/// of these into one `FuturesUnordered` and takes whichever handshake finishes -/// first; the default factor is larger than this list can ever be, so both -/// transports are always attempted and the position here decides nothing. That -/// race is the point: a peer advertising a `quic` port nothing answers still -/// connects over `tcp` without waiting out a connect timeout first. +/// The order *is* the preference, and `quic` leads it. libp2p starts +/// `dial_concurrency_factor` of these at a time, which +/// [`DIAL_ADDRESS_CONCURRENCY`] pins to one, so a peer's `tcp` address is +/// reached only once the QUIC attempt ahead of it has failed. Under the default +/// factor both went into one `FuturesUnordered` and the faster handshake won, +/// which sounds neutral and is not: TCP won most of those races and every win +/// was another mplex connection. /// /// Shared by both dial paths, so a change to what counts as dialable cannot /// apply to static bootnodes and discovered peers differently: static bootnodes @@ -1106,11 +2160,50 @@ pub(crate) fn tcp_multiaddr(ip: IpAddr, tcp_port: u16, peer_id: PeerId) -> Multi .expect("a freshly built multiaddr carries no p2p component") } -fn connection_direction(endpoint: &libp2p::core::ConnectedPoint) -> &'static str { - if endpoint.is_dialer() { - "outbound" - } else { - "inbound" +/// Which side opened a connection. +/// +/// Carried per peer rather than derived where needed, because the two are not +/// interchangeable and only one of them is ours to choose. Inbound supply on +/// mainnet is effectively unbounded and arrives with no say in who it is; an +/// outbound peer is one this node picked, which is the only lever it has on its +/// own custody-column coverage. Mixing the two into a single count is what lets +/// inbound demand quietly crowd the dial loop out of its own reservation, so the +/// direction is kept alongside the peer. See +/// [`beacon::swarm::connection_limits`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum ConnectionDirection { + /// The remote dialed us. + Inbound, + /// We dialed the remote. + Outbound, +} + +impl ConnectionDirection { + /// The label this direction carries in metrics and logs. + /// + /// The two strings are leanMetrics-specified label values on + /// `lean_peer_connection_events_total`, so they are fixed, not cosmetic. + pub const fn as_str(self) -> &'static str { + match self { + Self::Inbound => "inbound", + Self::Outbound => "outbound", + } + } +} + +impl fmt::Display for ConnectionDirection { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl From<&libp2p::core::ConnectedPoint> for ConnectionDirection { + fn from(endpoint: &libp2p::core::ConnectedPoint) -> Self { + if endpoint.is_dialer() { + Self::Outbound + } else { + Self::Inbound + } } } @@ -1130,10 +2223,173 @@ fn transport_label(addr: &Multiaddr) -> &'static str { "unknown" } -fn compute_message_id(message: &libp2p::gossipsub::Message) -> libp2p::gossipsub::MessageId { - const MESSAGE_DOMAIN_INVALID_SNAPPY: [u8; 4] = [0x00, 0x00, 0x00, 0x00]; - const MESSAGE_DOMAIN_VALID_SNAPPY: [u8; 4] = [0x01, 0x00, 0x00, 0x00]; +/// What ended a connection, as the label +/// [`metrics::inc_peer_disconnect_cause`] counts it under. +/// +/// Read off the `ConnectionError` variant and the error types inside it, +/// rather than sniffed out of the whole `Display` string the way the +/// leanMetrics-specified `reason` beside it still has to be. That string test +/// is why nine closes in ten on a mainnet follower read only `error`: it looks +/// for "timeout" and "reset" and calls everything else a fault, and a peer +/// hanging up on us produces neither word. +/// +/// `clean_close` is `None`, which libp2p reports when a connection ended with +/// no error at all. For a beacon peer that is the ordinary shape of a +/// deliberate disconnect, so it should be read against `lean_peer_goodbye_total` +/// rather than on its own. +/// +/// An I/O close's kind is rarely the answer on its own. `StreamMuxerBox` wraps +/// every muxer error in `io::Error::other`, so almost every close arrives as +/// `ErrorKind::Other` with the muxer's own error behind it, and that inner +/// error is read by downcasting to the two muxers this node runs; see +/// [`io_disconnect_cause`]. +/// +/// `io_other` is the residue: an I/O error that is neither one of the kinds +/// below nor one of those two muxers' errors. It should stay near empty; a +/// rise means a close shape this function does not know yet, and the `debug!` +/// at the call site prints the full cause for exactly that case. +/// +/// Matched exhaustively on purpose. `ConnectionError` is not `#[non_exhaustive]`, +/// so a new variant upstream should fail this build rather than quietly join +/// the residue. +fn disconnect_cause(cause: Option<&ConnectionError>) -> &'static str { + match cause { + None => "clean_close", + Some(ConnectionError::KeepAliveTimeout) => "keep_alive_timeout", + Some(ConnectionError::IO(err)) => io_disconnect_cause(err), + } +} + +/// The label for an I/O close: its kind when that names something, otherwise +/// whatever the muxer error inside it says. +/// +/// Each transport is boxed on its own by the swarm builder, so the error +/// behind an `Other` is exactly one of two types: `libp2p::quic::Error` for a +/// QUIC connection, or the TCP stack's muxer selection, +/// `Either` (mplex reports plain I/O errors). +fn io_disconnect_cause(err: &io::Error) -> &'static str { + if let Some(label) = io_kind_label(err.kind()) { + return label; + } + let Some(inner) = err.get_ref() else { + return "io_other"; + }; + if let Some(err) = inner.downcast_ref::() { + return quic_disconnect_cause(err); + } + if let Some(err) = inner.downcast_ref::>() { + return tcp_muxer_disconnect_cause(err); + } + "io_other" +} +/// The I/O kinds worth a label of their own. `None` for the rest, `Other` +/// included, which is where the muxer's error has to be read instead. +fn io_kind_label(kind: io::ErrorKind) -> Option<&'static str> { + match kind { + io::ErrorKind::ConnectionReset => Some("connection_reset"), + io::ErrorKind::ConnectionAborted => Some("connection_aborted"), + io::ErrorKind::BrokenPipe => Some("broken_pipe"), + io::ErrorKind::NotConnected => Some("not_connected"), + io::ErrorKind::TimedOut => Some("timed_out"), + io::ErrorKind::UnexpectedEof => Some("unexpected_eof"), + // `io::ErrorKind` *is* `#[non_exhaustive]`, so this arm is required + // rather than chosen. + _ => None, + } +} + +/// A QUIC connection's close. +/// +/// Matched exhaustively, like [`disconnect_cause`], so a new variant fails the +/// build. Only `Connection` and `Io` can end an established connection; the +/// rest are dial and listener errors, kept apart from the residue anyway so a +/// surprise shows up under its own transport. +fn quic_disconnect_cause(err: &libp2p::quic::Error) -> &'static str { + use libp2p::quic::Error; + match err { + Error::Connection(err) => quic_close_label(&err.to_string()), + Error::Io(err) => io_kind_label(err.kind()).unwrap_or("quic_other"), + Error::Reach(_) + | Error::HandshakeTimedOut + | Error::NoActiveListenerForDialAsListener + | Error::HolePunchInProgress(_) => "quic_other", + } +} + +/// Which `quinn::ConnectionError` a QUIC close was, read off its `Display`. +/// +/// Read off the text because it is the only way in: `libp2p::quic`'s +/// `ConnectionError` keeps the quinn error in a private field and forwards +/// nothing but `Display`. What makes this safe to match is that each of +/// quinn-proto 0.11's messages opens with a fixed string of quinn's own, and +/// anything the peer supplies (the close reason and code) only follows the +/// colon. So the prefix is chosen by quinn, never by the remote. +/// +/// `quic_application_close` is the peer's application closing the connection +/// (libp2p's normal close, and go-libp2p's connection gater); the reason is +/// in the `debug!` line, deliberately not in a label. `quic_transport_close` +/// is a transport-level `CONNECTION_CLOSE`. +fn quic_close_label(display: &str) -> &'static str { + if display.starts_with("closed by peer: ") { + "quic_application_close" + } else if display.starts_with("aborted by peer: ") { + "quic_transport_close" + } else if display == "reset by peer" { + "quic_reset" + } else if display == "timed out" { + "quic_timed_out" + } else if display == "closed" { + "quic_local_close" + } else { + // Version mismatch, a locally detected transport error, exhausted + // connection ids: none of them a peer leaving. + "quic_other" + } +} + +/// A TCP connection's close, through whichever muxer it negotiated. +/// +/// yamux hides its variants too, but forwards `source` down to the I/O error +/// beneath an `Io` or a `Decode` failure, so the chain is walked for one +/// before falling back to the one variant worth naming, a clean `Closed`. +fn tcp_muxer_disconnect_cause(err: &Either) -> &'static str { + match err { + Either::Right(err) => io_kind_label(err.kind()).unwrap_or("mplex_other"), + Either::Left(err) => { + let mut source = std::error::Error::source(err); + while let Some(err) = source { + if let Some(label) = err + .downcast_ref::() + .and_then(|err| io_kind_label(err.kind())) + { + return label; + } + source = err.source(); + } + // yamux's own `Closed` message, in both versions libp2p carries. + if err.to_string() == "connection is closed" { + "yamux_closed" + } else { + "yamux_other" + } + } + } +} + +/// `MESSAGE_DOMAIN_INVALID_SNAPPY`: what [`compute_message_id`] prefixes a +/// message that does not decompress with. +/// +/// Public, like [`MESSAGE_DOMAIN_VALID_SNAPPY`], because a beacon +/// `config.yaml` carries both: startup refuses a network that sets either to +/// something else, since this node would compute message ids its peers do not. +pub const MESSAGE_DOMAIN_INVALID_SNAPPY: [u8; 4] = [0x00, 0x00, 0x00, 0x00]; + +/// `MESSAGE_DOMAIN_VALID_SNAPPY`: what [`compute_message_id`] prefixes a +/// message that decompresses with. +pub const MESSAGE_DOMAIN_VALID_SNAPPY: [u8; 4] = [0x01, 0x00, 0x00, 0x00]; + +fn compute_message_id(message: &libp2p::gossipsub::Message) -> libp2p::gossipsub::MessageId { let mut hasher = sha2::Sha256::new(); let decompressed = gossipsub::decompress_message(&message.data).ok(); @@ -1151,6 +2407,191 @@ fn compute_message_id(message: &libp2p::gossipsub::Message) -> libp2p::gossipsub libp2p::gossipsub::MessageId(hash[..20].to_vec()) } +/// Test scaffolding shared by the beacon gossip handler's `triage_*` tests +/// (`gossipsub::handler`) and the verdict module's `settle` tests +/// (`beacon::verdict`): a real, unconnected beacon `P2PServer`, and a sidecar +/// shaped to clear structural validation. Lives here, rather than duplicated +/// in each of those two test modules, because both need the identical +/// beacon-shaped environment; `req_resp::handlers::tests::unconnected_server` +/// keeps its own lean-flavored copy, since that one builds a different wire. +#[cfg(test)] +pub(crate) mod test_support { + use std::collections::{HashMap, HashSet}; + use std::net::{IpAddr, Ipv4Addr}; + use std::sync::Arc; + + use ethlambda_storage::Store; + use ethlambda_storage::backend::InMemoryBackend; + use ethlambda_types::beacon::config::Config; + use ethlambda_types::beacon::containers::{fulu, shared}; + use ethlambda_types::beacon::preset; + use ethlambda_types::beacon::primitives::{KzgCommitment, KzgProof, Root}; + use ethlambda_types::checkpoint::Checkpoint; + use ethlambda_types::enr::EnrForkId; + use ethlambda_types::primitives::H256; + use libssz_types::SszVector; + + use crate::beacon::swarm::BeaconWireConfig; + use crate::{P2PServer, SwarmConfig, WireConfig, build_swarm}; + + /// A real, unconnected beacon `P2PServer`, built the same way + /// `req_resp::handlers::tests::unconnected_server` builds a lean one: + /// port `0` throughout, so this cannot collide with a running node or a + /// sibling test, and no bootnodes or peers. + /// + /// Neither `triage_*` nor `settle` ever reads `swarm_handle` or + /// `discovery`, but both are required fields, and building the real thing + /// is no more expensive than faking one would be. + pub(crate) async fn unconnected_beacon_server( + config: Config, + finalized_slot: u64, + ) -> P2PServer { + let built = build_swarm(SwarmConfig { + node_key: vec![9u8; 32], + bootnodes: Vec::new(), + listening_socket: "127.0.0.1:0".parse().expect("valid socket"), + target_peers: crate::discovery::DEFAULT_DISCOVERY_TARGET_PEERS, + agent_version: "ethlambda/test", + wire: WireConfig::Beacon(Box::new(BeaconWireConfig { + fork_digest: [0u8; 4], + fork: config.fork_at_epoch(0), + config: config.clone(), + genesis_time: config.genesis_time, + genesis_validators_root: Root::ZERO, + custody_columns: Vec::new(), + attestation_subnets: Vec::new(), + })), + }) + .expect("swarm builds"); + + let (_swarm_stream, swarm_handle) = + crate::swarm_adapter::start_swarm_adapter(built.swarm, HashMap::new()); + + let discovery = crate::discovery::spawn_discovery(crate::discovery::DiscoverySpawnConfig { + node_key: secp256k1::SecretKey::new(&mut rand::rngs::OsRng) + .secret_bytes() + .to_vec(), + bind_ip: IpAddr::from(Ipv4Addr::LOCALHOST), + discovery_port: 0, + p2p_port: 0, + subscription_subnets: HashSet::new(), + attestation_committee_count: 1, + bootnodes: Vec::new(), + advertise_ip: None, + target_peers: 0, + fork_id: EnrForkId::local(), + custody_group_count: None, + }) + .await + .expect("discovery spawns"); + + let backend = Arc::new(InMemoryBackend::new()); + let anchor_checkpoint = Checkpoint { + root: H256::ZERO, + slot: finalized_slot, + }; + // The caller's own `config`, genesis time included, not a fresh + // `Config::mainnet()`: a caller that builds a clock-sensitive `config` + // (a recent `genesis_time`, say) needs the store's clock to agree with + // it, since `cheap_checks`/`stateful_checks` read the store's own + // config rather than the wire's. + let store = Store::init_beacon( + backend, + config.genesis_time, + config, + H256::ZERO, + anchor_checkpoint, + finalized_slot, + ); + // Read before `built.wire` moves into the server below, mirroring + // `P2P::spawn`. + let backbone_attestation_subnets = built + .wire + .beacon() + .map_or(0, |beacon| beacon.attestation_subnets.len()); + + P2PServer { + swarm_handle, + store, + blockchain: None, + wire: built.wire, + connected_peers: HashMap::new(), + peer_custody: HashMap::new(), + pending_root_requests: HashMap::new(), + pending_column_requests: HashMap::new(), + outbound_requests: HashMap::new(), + range_sync_state: None, + beacon_fetched_through: 0, + bootnode_addrs: HashMap::new(), + node_names: HashMap::new(), + discovery: Some(crate::discovery::dial::DiscoveryState::new( + discovery, + built.local_peer_id, + )), + seen_blocks: ethlambda_state_transition::beacon::gossip::SeenBlocks::new( + crate::SEEN_BLOCKS_CAPACITY, + ), + seen_columns: ethlambda_state_transition::beacon::gossip::SeenColumns::new( + crate::SEEN_COLUMNS_CAPACITY, + ), + seen_aggregates: + ethlambda_state_transition::beacon::gossip::aggregate::SeenAggregates::new( + crate::SEEN_AGGREGATES_CAPACITY, + crate::SEEN_AGGREGATES_CAPACITY, + ), + seen_attestations: + ethlambda_state_transition::beacon::gossip::attestation::SeenAttestations::new( + crate::seen_attestations_capacity(backbone_attestation_subnets), + ), + gossip_validation_permits: std::sync::Arc::new(tokio::sync::Semaphore::new( + crate::GOSSIP_VALIDATION_PERMITS, + )), + column_check_permits: std::sync::Arc::new(tokio::sync::Semaphore::new( + crate::COLUMN_CHECK_PERMITS, + )), + attestation_validation_permits: std::sync::Arc::new(tokio::sync::Semaphore::new( + crate::ATTESTATION_VALIDATION_PERMITS, + )), + attestation_pool: Default::default(), + aggregator_subnets: HashMap::new(), + } + } + + /// A sidecar that clears `verify_data_column_sidecar`'s structural checks + /// (one commitment, one proof, one column cell, all the same length) but + /// carries no real KZG material: nothing in `triage_data_column`'s reject + /// path under test verifies the cryptography, only the shape and the + /// header's slot. + pub(crate) fn valid_shaped_sidecar(slot: u64, index: u64) -> fulu::DataColumnSidecar { + let cell: fulu::Cell = + SszVector::try_from(vec![0u8; preset::BYTES_PER_CELL]).expect("exact cell size"); + fulu::DataColumnSidecar { + index, + column: vec![cell].try_into().expect("within the per-block limit"), + kzg_commitments: vec![KzgCommitment::default()] + .try_into() + .expect("within the per-block limit"), + kzg_proofs: vec![KzgProof::default()] + .try_into() + .expect("within the per-block limit"), + signed_block_header: shared::SignedBeaconBlockHeader { + message: shared::BeaconBlockHeader { + slot, + proposer_index: 7, + ..Default::default() + }, + signature: Default::default(), + }, + kzg_commitments_inclusion_proof: vec![ + H256::ZERO; + preset::KZG_COMMITMENTS_INCLUSION_PROOF_DEPTH + ] + .try_into() + .expect("exactly the required depth"), + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -1162,25 +2603,134 @@ mod tests { PeerId::from_public_key(&Keypair::generate_ed25519().public()) } + /// Zero backbone subnets (a lean node, or a beacon node that backbones + /// none) must not shrink the cache to a useless zero capacity, and every + /// additional subnet scales it by the same per-subnet, per-epoch bound. + #[test] + fn seen_attestations_capacity_floors_at_one_subnet_and_scales_with_more() { + let per_subnet = 2 * SLOTS_PER_EPOCH as usize * MAX_VALIDATORS_PER_COMMITTEE; + assert_eq!(seen_attestations_capacity(0).get(), per_subnet); + assert_eq!(seen_attestations_capacity(1).get(), per_subnet); + assert_eq!(seen_attestations_capacity(3).get(), per_subnet * 3); + } + + /// The split the specified `reason` label cannot make. Each of these is a + /// distinct answer to "who ended this and why", and all but the first two + /// collapse into `error` next door. + #[test] + fn a_close_is_labelled_by_the_cause_libp2p_reported() { + assert_eq!(disconnect_cause(None), "clean_close"); + assert_eq!( + disconnect_cause(Some(&ConnectionError::KeepAliveTimeout)), + "keep_alive_timeout" + ); + for (kind, label) in [ + (io::ErrorKind::ConnectionReset, "connection_reset"), + (io::ErrorKind::ConnectionAborted, "connection_aborted"), + (io::ErrorKind::BrokenPipe, "broken_pipe"), + (io::ErrorKind::NotConnected, "not_connected"), + (io::ErrorKind::TimedOut, "timed_out"), + (io::ErrorKind::UnexpectedEof, "unexpected_eof"), + ] { + let err = ConnectionError::IO(io::Error::new(kind, "test")); + assert_eq!(disconnect_cause(Some(&err)), label, "{kind:?}"); + } + } + + /// `io::ErrorKind` is `#[non_exhaustive]`, and an error that is neither a + /// named kind nor one of the two muxers' errors has to land somewhere, so + /// the residue is a real bucket rather than an unreachable arm. + #[test] + fn an_unclassified_io_error_falls_to_the_residue() { + for kind in [io::ErrorKind::Other, io::ErrorKind::InvalidData] { + let err = ConnectionError::IO(io::Error::new(kind, "some other close")); + assert_eq!(disconnect_cause(Some(&err)), "io_other", "{kind:?}"); + } + } + + /// The shape nine closes in ten actually take on mainnet: the muxer's + /// error boxed inside an `io::Error` of kind `Other`, the way + /// `StreamMuxerBox` wraps it. Reading only the outer kind put every one of + /// these in `io_other`. + #[test] + fn a_muxer_error_inside_an_other_io_error_is_read_through() { + let boxed = |err: Either| { + ConnectionError::IO(io::Error::other(err)) + }; + for (kind, label) in [ + (io::ErrorKind::UnexpectedEof, "unexpected_eof"), + (io::ErrorKind::ConnectionReset, "connection_reset"), + (io::ErrorKind::Other, "mplex_other"), + ] { + let err = boxed(Either::Right(io::Error::new(kind, "mplex"))); + assert_eq!(disconnect_cause(Some(&err)), label, "mplex {kind:?}"); + } + + // `libp2p::quic::Error::Connection` cannot be built outside its crate + // (the quinn error is a private field), so its reading is pinned + // through `quic_close_label` below; these are the variants that can. + let quic = |err: libp2p::quic::Error| ConnectionError::IO(io::Error::other(err)); + let reset = io::Error::new(io::ErrorKind::ConnectionReset, "quic socket"); + assert_eq!( + disconnect_cause(Some(&quic(libp2p::quic::Error::Io(reset)))), + "connection_reset" + ); + assert_eq!( + disconnect_cause(Some(&quic(libp2p::quic::Error::HandshakeTimedOut))), + "quic_other" + ); + } + + /// quinn-proto 0.11's `ConnectionError` messages, as a mainnet follower + /// logs them. The last case is the one the prefix match exists for: the + /// reason is the peer's to choose, so it must not be able to pass for + /// another variant. + #[test] + fn a_quic_close_is_labelled_by_quinns_prefix_not_the_peers_reason() { + for (display, label) in [ + ( + "closed by peer: connection gated (code 4103)", + "quic_application_close", + ), + ("closed by peer: 0", "quic_application_close"), + ("aborted by peer: NO_ERROR", "quic_transport_close"), + ("reset by peer", "quic_reset"), + ("timed out", "quic_timed_out"), + ("closed", "quic_local_close"), + ("CIDs exhausted", "quic_other"), + ( + "closed by peer: reset by peer (code 1)", + "quic_application_close", + ), + ] { + assert_eq!(quic_close_label(display), label, "{display}"); + } + } + /// Proves the TCP transport `build_swarm` now adds actually completes a /// connection end to end, rather than only compiling. Builds two real - /// swarms via the production entry point (port `0`, so this cannot collide - /// with a running node or a sibling test), learns the first swarm's TCP - /// listen address off its own `NewListenAddr` event, dials it from the - /// second swarm, and polls both until each reports `ConnectionEstablished`. - /// A regression to QUIC-only, or a misconfigured TCP transport, hangs here - /// until the timeout rather than racing to a false positive. + /// swarms via the production entry point (port `0`, so this cannot + /// collide with a running node or a sibling test), learns the first + /// swarm's TCP listen address off its own `NewListenAddr` event, dials it + /// from the second swarm, and polls both until each reports + /// `ConnectionEstablished`. A regression to QUIC-only, or a + /// misconfigured TCP transport, hangs here until the timeout rather than + /// racing to a false positive. #[tokio::test] - async fn two_swarms_connect_over_tcp() { + async fn two_lean_swarms_connect_over_tcp() { fn build(node_key_byte: u8) -> BuiltSwarm { build_swarm(SwarmConfig { node_key: vec![node_key_byte; 32], bootnodes: Vec::new(), listening_socket: "127.0.0.1:0".parse().expect("valid socket"), - validator_ids: Vec::new(), - attestation_committee_count: 1, - subscription_subnets: HashSet::new(), - milliseconds_per_slot: DEFAULT_MILLISECONDS_PER_SLOT, + target_peers: crate::discovery::DEFAULT_DISCOVERY_TARGET_PEERS, + agent_version: "ethlambda/test", + wire: WireConfig::Lean(LeanWireConfig { + validator_ids: Vec::new(), + attestation_committee_count: 1, + subscription_subnets: HashSet::new(), + milliseconds_per_slot: DEFAULT_MILLISECONDS_PER_SLOT, + }), }) .expect("swarm builds") } @@ -1188,8 +2738,8 @@ mod tests { let mut dialer = build(1); let mut listener = build(2); - // Both a QUIC and a TCP `NewListenAddr` arrive for `listener`; only the - // TCP one is wanted here. + // Both a QUIC and a TCP `NewListenAddr` arrive for `listener`; only + // the TCP one is wanted here. let listener_tcp_addr = loop { if let SwarmEvent::NewListenAddr { address, .. } = listener.swarm.select_next_some().await @@ -1230,6 +2780,367 @@ mod tests { .expect("both swarms must connect over TCP within the timeout"); } + /// The identify reply carries `SwarmConfig::agent_version`, not + /// rust-libp2p's default `rust-libp2p/`. + #[tokio::test] + async fn identify_reports_the_configured_agent_version() { + fn build(node_key_byte: u8) -> BuiltSwarm { + build_swarm(SwarmConfig { + node_key: vec![node_key_byte; 32], + bootnodes: Vec::new(), + listening_socket: "127.0.0.1:0".parse().expect("valid socket"), + target_peers: crate::discovery::DEFAULT_DISCOVERY_TARGET_PEERS, + agent_version: "ethlambda/test", + wire: WireConfig::Lean(LeanWireConfig { + validator_ids: Vec::new(), + attestation_committee_count: 1, + subscription_subnets: HashSet::new(), + milliseconds_per_slot: DEFAULT_MILLISECONDS_PER_SLOT, + }), + }) + .expect("swarm builds") + } + + let mut dialer = build(1); + let mut listener = build(2); + + let listener_addr = loop { + if let SwarmEvent::NewListenAddr { address, .. } = + listener.swarm.select_next_some().await + { + break address + .with_p2p(listener.local_peer_id) + .expect("failed to add peer ID to multiaddr"); + } + }; + dialer.swarm.dial(listener_addr).expect("dial is accepted"); + + let received = async { + loop { + tokio::select! { + event = dialer.swarm.select_next_some() => { + if let SwarmEvent::Behaviour(BehaviourEvent::Identify( + libp2p::identify::Event::Received { info, .. }, + )) = event + { + return info.agent_version; + } + } + _ = listener.swarm.select_next_some() => {} + } + } + }; + let agent_version = tokio::time::timeout(Duration::from_secs(10), received) + .await + .expect("identify must complete within the timeout"); + assert_eq!(agent_version, "ethlambda/test"); + } + + #[test] + fn gossip_is_held_for_a_verdict_only_when_asked() { + let ttl = Duration::from_secs(1); + assert!(gossipsub_config(ttl, true).validate_messages()); + assert!(!gossipsub_config(ttl, false).validate_messages()); + } + + /// How many times [`concurrent_beacon_requests_on_different_protocols_never_cross`] + /// repeats its connect-and-fire cycle. + /// + /// The crossing this proves against is timing-dependent: which of two + /// substreams negotiates first depends on scheduling, not on send order + /// (see `Behaviour`'s doc comment), so one repetition proves nothing on + /// its own. A fresh connection per repetition, rather than reusing one + /// connection for every pair, is what gives each repetition its own + /// independent chance at the race. + const CROSSING_TEST_REPETITIONS: usize = 64; + + /// Regression test for the defect this branch exists to fix: two + /// outbound requests, on two different protocols, fired back to back on + /// one connection — exactly [`beacon::handler::send_status`] then + /// [`beacon::handler::request_metadata`]'s real call pattern from + /// `ConnectionEstablished` below — must each reach the wire under their + /// own protocol's framing, never swapped. + /// + /// Before the per-protocol split, both requests travelled through one + /// shared `req_resp: request_response::Behaviour` field via the fork's + /// `send_request_with_protocol`, which only narrows the *offered* + /// protocol per call; the outbound `Handler` still pairs a negotiated + /// substream with the *next unpaired* queued request + /// (`requested_outbound.pop_front()` in the pinned fork's + /// `protocols/request-response/src/handler.rs`), which is the send + /// order only when negotiation happens to complete in send order too. + /// When it doesn't, `write_request` is handed the wrong (protocol, + /// request) pair: a `Status` value written under the `metadata/3` + /// protocol is refused by `beacon_encoding::encode_status` (wrong + /// version), which fails the write locally, and a `MetaData` value + /// written under `status/1` encodes to an empty payload regardless of + /// protocol and reaches the peer, which then fails to decode it as a + /// real `Status` body. Either way, this test's answering side never + /// manages to echo the right payload back, and the `Some(true)` + /// assertions below fail. + /// + /// Splitting the shared field into one per protocol (this branch's + /// change) makes this structurally unreachable rather than merely less + /// likely: each protocol's own `Handler` has its own queue, with never + /// more than the one request this test ever puts on it, so there is no + /// shared FIFO left to reorder. + #[tokio::test] + async fn concurrent_beacon_requests_on_different_protocols_never_cross() { + use crate::beacon::messages::{BeaconMetaData, BeaconStatus, MetaDataV3, StatusV1}; + use crate::beacon::protocols; + use crate::req_resp::{Response, ResponsePayload}; + use ethlambda_types::beacon::config::Config; + use ethlambda_types::beacon::fork::ForkName; + use ethlambda_types::beacon::primitives::Root; + use libp2p::request_response::{self, ResponseChannel}; + + fn build(node_key_byte: u8) -> BuiltSwarm { + build_swarm(SwarmConfig { + node_key: vec![node_key_byte; 32], + bootnodes: Vec::new(), + listening_socket: "127.0.0.1:0".parse().expect("valid socket"), + target_peers: crate::discovery::DEFAULT_DISCOVERY_TARGET_PEERS, + agent_version: "ethlambda/test", + wire: WireConfig::Beacon(Box::new(beacon::swarm::BeaconWireConfig { + fork_digest: [0x11, 0x22, 0x33, 0x44], + fork: ForkName::Fulu, + config: Config::mainnet(), + genesis_time: 0, + genesis_validators_root: Root::ZERO, + custody_columns: Vec::new(), + attestation_subnets: Vec::new(), + })), + }) + .expect("swarm builds") + } + + /// Every [`BehaviourEvent`] variant that carries a req/resp event, + /// tagged with the [`ReqRespProtocol`] that names it. A test-local + /// mirror of [`handle_behaviour_event`]'s own tagging match, kept + /// separate because that one needs a live `P2PServer` and this test + /// drives bare swarms. + fn tag_req_resp_event( + event: BehaviourEvent, + ) -> Option<(ReqRespProtocol, request_response::Event)> { + let BehaviourEvent::ReqResp(event) = event else { + return None; + }; + Some(match event { + ReqRespEvent::LeanStatus(e) => (ReqRespProtocol::LeanStatus, e), + ReqRespEvent::LeanBlocksByRoot(e) => (ReqRespProtocol::LeanBlocksByRoot, e), + ReqRespEvent::LeanBlocksByRange(e) => (ReqRespProtocol::LeanBlocksByRange, e), + ReqRespEvent::BeaconStatusV1(e) => (ReqRespProtocol::BeaconStatusV1, e), + ReqRespEvent::BeaconStatusV2(e) => (ReqRespProtocol::BeaconStatusV2, e), + ReqRespEvent::BeaconPing(e) => (ReqRespProtocol::BeaconPing, e), + ReqRespEvent::BeaconMetadataV1(e) => (ReqRespProtocol::BeaconMetadataV1, e), + ReqRespEvent::BeaconMetadataV2(e) => (ReqRespProtocol::BeaconMetadataV2, e), + ReqRespEvent::BeaconMetadataV3(e) => (ReqRespProtocol::BeaconMetadataV3, e), + ReqRespEvent::BeaconGoodbye(e) => (ReqRespProtocol::BeaconGoodbye, e), + ReqRespEvent::BeaconBlocksByRange(e) => (ReqRespProtocol::BeaconBlocksByRange, e), + ReqRespEvent::BeaconBlocksByRoot(e) => (ReqRespProtocol::BeaconBlocksByRoot, e), + ReqRespEvent::DataColumnSidecarsByRange(e) => { + (ReqRespProtocol::DataColumnSidecarsByRange, e) + } + ReqRespEvent::DataColumnSidecarsByRoot(e) => { + (ReqRespProtocol::DataColumnSidecarsByRoot, e) + } + }) + } + + /// Answer whatever the listener actually decoded, so even a garbled, + /// crossed request gets *some* answer back rather than leaving the + /// dialer to time out. `send_response` is a passthrough to the + /// channel's own oneshot sender (see `execute_command`'s doc comment + /// on its `SendResponse` arm), so any field answers it identically. + fn answer( + swarm: &mut libp2p::Swarm, + request: Request, + channel: ResponseChannel, + ) { + let response = match request { + Request::Status(status) => Response::success(ResponsePayload::Status(status)), + Request::MetaData(_) => { + Response::success(ResponsePayload::MetaData(BeaconMetaData::V3(MetaDataV3 { + seq_number: 0, + attnets: Default::default(), + syncnets: Default::default(), + custody_group_count: 4, + }))) + } + // Neither protocol this test sends is ever requested by the + // peer here, so any other shape means the wire pairing has + // already scrambled the request into a third variant + // entirely; drop the channel rather than guess an answer. + _ => return, + }; + let _ = swarm + .behaviour_mut() + .req_resp + .beacon_status_v1 + .send_response(channel, response); + } + + let mut crossings = 0usize; + + for _ in 0..CROSSING_TEST_REPETITIONS { + let mut dialer = build(1); + let mut listener = build(2); + + let listener_addr = loop { + if let SwarmEvent::NewListenAddr { address, .. } = + listener.swarm.select_next_some().await + && address.iter().any(|p| matches!(p, Protocol::Tcp(_))) + { + break address + .with_p2p(listener.local_peer_id) + .expect("adds a peer id"); + } + }; + dialer.swarm.dial(listener_addr).expect("dial is accepted"); + + let (mut dialer_connected, mut listener_connected) = (false, false); + while !(dialer_connected && listener_connected) { + tokio::select! { + event = dialer.swarm.select_next_some() => { + if matches!(event, SwarmEvent::ConnectionEstablished { .. }) { + dialer_connected = true; + } + } + event = listener.swarm.select_next_some() => { + if matches!(event, SwarmEvent::ConnectionEstablished { .. }) { + listener_connected = true; + } + } + } + } + + // Fired back to back, with no `.await` of anything but the send + // call itself in between: the same pattern `ConnectionEstablished` + // uses for `send_status` then `request_metadata` in production. + let status = Request::Status(BeaconStatus::V1(StatusV1 { + fork_digest: [0x11, 0x22, 0x33, 0x44], + finalized_root: Root::ZERO, + finalized_epoch: 0, + head_root: Root::ZERO, + head_slot: 0, + })); + let status_id = ReqRespRequestId { + protocol: ReqRespProtocol::BeaconStatusV1, + id: dialer + .swarm + .behaviour_mut() + .req_resp + .beacon_status_v1 + .send_request(&listener.local_peer_id, status), + }; + let metadata_id = ReqRespRequestId { + protocol: ReqRespProtocol::BeaconMetadataV3, + id: dialer + .swarm + .behaviour_mut() + .req_resp + .beacon_metadata_v3 + .send_request( + &listener.local_peer_id, + Request::MetaData(protocols::METADATA_V3), + ), + }; + + let (mut status_correct, mut metadata_correct) = (None, None); + let drive = async { + loop { + if status_correct.is_some() && metadata_correct.is_some() { + return; + } + tokio::select! { + event = dialer.swarm.select_next_some() => { + let SwarmEvent::Behaviour(event) = event else { continue }; + let Some((protocol, event)) = tag_req_resp_event(event) else { continue }; + match event { + request_response::Event::Message { + message: request_response::Message::Response { request_id, response }, + .. + } => { + let id = ReqRespRequestId { protocol, id: request_id }; + // Correct means both "answered" and + // "answered with the payload shape this + // id's own request expects": a response + // that arrives but names the wrong + // payload is exactly what a crossed + // request looks like from here. + if id == status_id { + status_correct = Some(matches!( + response, + Response::Success { + payload: ResponsePayload::Status(_) + } + )); + } else if id == metadata_id { + metadata_correct = Some(matches!( + response, + Response::Success { + payload: ResponsePayload::MetaData(_) + } + )); + } + } + request_response::Event::OutboundFailure { request_id, .. } => { + let id = ReqRespRequestId { protocol, id: request_id }; + if id == status_id { + status_correct = Some(false); + } else if id == metadata_id { + metadata_correct = Some(false); + } + } + _ => {} + } + } + event = listener.swarm.select_next_some() => { + if let SwarmEvent::Behaviour(event) = event + && let Some((_, request_response::Event::Message { + message: request_response::Message::Request { request, channel, .. }, + .. + })) = tag_req_resp_event(event) + { + answer(&mut listener.swarm, request, channel); + } + } + } + } + }; + tokio::time::timeout(Duration::from_secs(5), drive) + .await + .expect("both requests must resolve within the timeout"); + + if status_correct != Some(true) || metadata_correct != Some(true) { + crossings += 1; + } + } + + assert_eq!( + crossings, 0, + "{crossings}/{CROSSING_TEST_REPETITIONS} repetitions crossed protocols" + ); + } + + #[test] + fn a_lean_wire_reports_its_topics_and_no_beacon_wire() { + // The enum is what makes "subscribed to lean topics and beacon topics + // at once" unrepresentable. `P2PServer` dispatches on it once per + // handler, the same way `BlockChainServer` dispatches on the state + // variant. + let wire = Wire::Lean(LeanWire { + attestation_topics: HashMap::new(), + attestation_committee_count: 4, + block_topic: block_topic(), + aggregation_topic: aggregation_topic(), + }); + assert!(wire.beacon().is_none()); + let lean = wire.lean().expect("a lean wire"); + assert_eq!(lean.attestation_committee_count, 4); + assert!(lean.block_topic.to_string().starts_with("/leanconsensus/")); + } + /// A bootnode file naming one peer twice must not abort the node. /// /// `DialOpts::peer_id` dials under the default `DisconnectedAndNotDialing` @@ -1277,10 +3188,14 @@ mod tests { node_key: vec![7u8; 32], bootnodes, listening_socket: "127.0.0.1:0".parse().expect("valid socket"), - validator_ids: Vec::new(), - attestation_committee_count: 1, - subscription_subnets: HashSet::new(), - milliseconds_per_slot: DEFAULT_MILLISECONDS_PER_SLOT, + target_peers: crate::discovery::DEFAULT_DISCOVERY_TARGET_PEERS, + agent_version: "ethlambda/test", + wire: WireConfig::Lean(LeanWireConfig { + validator_ids: Vec::new(), + attestation_committee_count: 1, + subscription_subnets: HashSet::new(), + milliseconds_per_slot: DEFAULT_MILLISECONDS_PER_SLOT, + }), }) .expect("a duplicated bootnode entry must not fail the build"); @@ -1431,6 +3346,42 @@ mod tests { assert_eq!(state.peer_set.get(¤t_peer), Some(&2999)); } + #[test] + fn a_batch_held_for_custody_waits_until_its_deadline_and_no_longer() { + let mut state = RangeSyncState::new(10..3000, random_peer(), 500); + let start = Instant::now(); + + // Only the first hold starts the wait, which is what schedules the + // deadline once rather than on every re-check. + assert_eq!(state.wait_for_custody(start), CustodyWait::Started); + assert!(state.is_waiting_for_custody()); + let halfway = start + RANGE_BATCH_CUSTODY_WAIT / 2; + assert_eq!(state.wait_for_custody(halfway), CustodyWait::Waiting); + let deadline = start + RANGE_BATCH_CUSTODY_WAIT; + assert_eq!(state.wait_for_custody(deadline), CustodyWait::Expired); + + assert_eq!( + state.end_custody_wait(deadline), + Some(RANGE_BATCH_CUSTODY_WAIT) + ); + assert!(!state.is_waiting_for_custody()); + assert_eq!(state.end_custody_wait(deadline), None); + } + + #[test] + fn each_batch_gets_a_custody_wait_of_its_own() { + let mut state = RangeSyncState::new(10..3000, random_peer(), 500); + let start = Instant::now(); + state.wait_for_custody(start); + let sent_at = start + 2 * RANGE_BATCH_CUSTODY_WAIT; + assert_eq!(state.wait_for_custody(sent_at), CustodyWait::Expired); + state.end_custody_wait(sent_at); + + // A later batch that finds custody uncovered again waits in full, + // rather than inheriting the expired wait of the batch before it. + assert_eq!(state.wait_for_custody(sent_at), CustodyWait::Started); + } + #[test] fn parse_enrs_extracts_ip_port_and_public_key() { // Values taken from a local devnet run with lean-quickstart @@ -1613,6 +3564,28 @@ mod tests { assert!(bootnode_dial_addrs(&bootnodes[1], seed_only).is_empty()); } + #[test] + fn a_peer_advertising_both_transports_is_dialed_over_quic_first() { + // Position in this list used to decide nothing, because libp2p raced + // every address at once. `DIAL_ADDRESS_CONCURRENCY` made it decide + // which transport the peer is reached over whenever it answers on + // both, so the order is asserted rather than left to read like + // incidental construction order. + let peer_id = random_peer(); + let ip = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)); + + let addrs = dial_addrs(ip, Some(9000), Some(9001), peer_id); + + assert_eq!( + addrs, + vec![ + quic_multiaddr(ip, 9000, peer_id), + tcp_multiaddr(ip, 9001, peer_id), + ], + "quic has to come first, or the reservation is the wrong way round" + ); + } + #[test] fn parse_enrs_skips_malformed_records_but_keeps_the_valid_one() { // The rewrite's whole point is that one bad line in the bootnode file @@ -1633,4 +3606,108 @@ mod tests { assert_eq!(bootnodes[0].ip, IpAddr::from(Ipv4Addr::LOCALHOST)); assert_eq!(bootnodes[0].quic_port, Some(9001)); } + + /// A connection the limits refuse must leave no trace in any + /// request/response field. + /// + /// `request_response::Behaviour` records a connection when its handler is + /// built and forgets it only on `ConnectionClosed`. A refused connection + /// never gets one: the swarm reports it with a `ListenFailure` and nothing + /// else. So a field asked before the limits keeps a connection the swarm + /// never had, and once the peer's real connections close it still counts + /// one. With debug assertions that trips request-response's own + /// `debug_assert` in `on_connection_closed` and kills the P2P task; without + /// them, requests to that peer can be routed to the phantom and vanish + /// without an `OutboundFailure`. + /// + /// Drives the composed [`Behaviour`] the way the swarm does: a peer + /// already holding [`beacon::swarm::MAX_CONNECTIONS_PER_PEER`] connections + /// opens one more, then the ones it held close. + #[tokio::test] + async fn a_connection_the_limits_refuse_leaves_no_request_response_state() { + use ethlambda_types::beacon::config::Config; + use ethlambda_types::beacon::fork::ForkName; + use ethlambda_types::beacon::primitives::Root; + use libp2p::core::ConnectedPoint; + use libp2p::swarm::{ + ConnectionId, ListenError, + behaviour::{ConnectionClosed, ConnectionEstablished, FromSwarm, ListenFailure}, + }; + + let mut built = build_swarm(SwarmConfig { + node_key: vec![7u8; 32], + bootnodes: Vec::new(), + listening_socket: "127.0.0.1:0".parse().expect("valid socket"), + target_peers: crate::discovery::DEFAULT_DISCOVERY_TARGET_PEERS, + agent_version: "ethlambda/test", + wire: WireConfig::Beacon(Box::new(beacon::swarm::BeaconWireConfig { + fork_digest: [0x11, 0x22, 0x33, 0x44], + fork: ForkName::Fulu, + config: Config::mainnet(), + genesis_time: 0, + genesis_validators_root: Root::ZERO, + custody_columns: Vec::new(), + attestation_subnets: Vec::new(), + })), + }) + .expect("swarm builds"); + let behaviour = built.swarm.behaviour_mut(); + + let peer = random_peer(); + let local_addr: Multiaddr = "/ip4/127.0.0.1/tcp/9001".parse().expect("valid multiaddr"); + let send_back_addr: Multiaddr = "/ip4/192.0.2.1/tcp/9001".parse().expect("valid multiaddr"); + let endpoint = ConnectedPoint::Listener { + local_addr: local_addr.clone(), + send_back_addr: send_back_addr.clone(), + }; + + let max_per_peer = beacon::swarm::MAX_CONNECTIONS_PER_PEER as usize; + let held: Vec = (0..max_per_peer).map(ConnectionId::new_unchecked).collect(); + for (other_established, &connection_id) in held.iter().enumerate() { + let admitted = behaviour.handle_established_inbound_connection( + connection_id, + peer, + &local_addr, + &send_back_addr, + ); + assert!(admitted.is_ok(), "within the per-peer limit"); + behaviour.on_swarm_event(FromSwarm::ConnectionEstablished(ConnectionEstablished { + peer_id: peer, + connection_id, + endpoint: &endpoint, + failed_addresses: &[], + other_established, + })); + } + + let refused = ConnectionId::new_unchecked(max_per_peer); + let cause = behaviour + .handle_established_inbound_connection(refused, peer, &local_addr, &send_back_addr) + .err() + .expect("the per-peer limit refuses one connection too many"); + let error = ListenError::Denied { cause }; + behaviour.on_swarm_event(FromSwarm::ListenFailure(ListenFailure { + local_addr: &local_addr, + send_back_addr: &send_back_addr, + error: &error, + connection_id: refused, + peer_id: Some(peer), + })); + + for (closed, &connection_id) in held.iter().enumerate() { + behaviour.on_swarm_event(FromSwarm::ConnectionClosed(ConnectionClosed { + peer_id: peer, + connection_id, + endpoint: &endpoint, + cause: None, + remaining_established: held.len() - closed - 1, + })); + } + + let blocks_by_range = &behaviour.req_resp.beacon_blocks_by_range; + assert!( + !blocks_by_range.is_connected(&peer), + "a request/response field still holds the connection the limits refused" + ); + } } diff --git a/crates/net/p2p/src/metrics.rs b/crates/net/p2p/src/metrics.rs index 01c4685e6..3f1c060da 100644 --- a/crates/net/p2p/src/metrics.rs +++ b/crates/net/p2p/src/metrics.rs @@ -249,6 +249,56 @@ pub fn notify_peer_disconnected(node_name: &str, direction: &str, reason: &str) LEAN_CONNECTED_PEERS.with_label_values(&[node_name]).dec(); } +/// Count a closed connection against what actually ended it. +/// +/// A separate metric rather than more values on +/// `lean_peer_disconnection_events_total`'s `reason`, for the same reason +/// [`inc_peer_connection_transport`] is separate: that one is leanMetrics- +/// specified down to its label values, so adding to them would put ethlambda +/// off-spec. +/// +/// The specified set is `timeout`/`remote_close`/`local_close`/`error`, and on +/// a mainnet follower nine in ten closes land in `error`, which says only that +/// libp2p handed back a cause. This splits that bucket: see +/// [`crate::disconnect_cause`] for the values and what each one means. +pub fn inc_peer_disconnect_cause(direction: &str, cause: &str) { + static LEAN_PEER_DISCONNECT_CAUSE: LazyLock = LazyLock::new(|| { + register_int_counter_vec!( + "lean_peer_disconnect_cause_total", + "Closed peer connections by the cause libp2p reported for the close", + &["direction", "cause"] + ) + .unwrap() + }); + LEAN_PEER_DISCONNECT_CAUSE + .with_label_values(&[direction, cause]) + .inc(); +} + +/// Count a `goodbye/1` received, against the reason the peer gave. +/// +/// The only place a peer states *why* it is leaving. Everything else about a +/// disconnect is inferred from how the socket ended, and the two readings that +/// matter most are indistinguishable there: a peer that is merely full closes +/// exactly like one that has scored us badly or banned us. +/// +/// One-directional by construction, so there is no `direction` label: this node +/// never sends a `goodbye`, and the protocol is registered inbound-only. +/// +/// See [`crate::beacon::messages::Goodbye::reason_label`] for the values, which +/// are bounded there because the wire code is not. +pub fn inc_peer_goodbye(reason: &str) { + static LEAN_PEER_GOODBYE: LazyLock = LazyLock::new(|| { + register_int_counter_vec!( + "lean_peer_goodbye_total", + "Goodbye messages received, by the reason code the peer sent", + &["reason"] + ) + .unwrap() + }); + LEAN_PEER_GOODBYE.with_label_values(&[reason]).inc(); +} + /// Counts dials initiated from discv5 discovery, as opposed to static bootnode /// dials. Connection outcomes are already covered by the peer connect and /// disconnect metrics. @@ -291,3 +341,277 @@ pub fn update_gossip_mesh_peers<'a>( .set(count); } } + +static LEAN_BEACON_GOSSIP_MESSAGES_TOTAL: LazyLock = LazyLock::new(|| { + register_int_counter_vec!( + "lean_beacon_gossip_messages_total", + "Beacon gossip messages received, by topic and decode outcome", + &["topic", "result"] + ) + .unwrap() +}); + +static LEAN_BEACON_STATUS_DIGEST_MISMATCH_TOTAL: LazyLock = LazyLock::new(|| { + register_int_counter!( + "lean_beacon_status_digest_mismatch_total", + "Beacon Status requests whose fork digest did not match ours" + ) + .unwrap() +}); + +static LEAN_BEACON_FORK_DIGEST: LazyLock = LazyLock::new(|| { + register_int_gauge_vec!( + "lean_beacon_fork_digest", + "The fork digest this node computed at startup, as a label", + &["digest"] + ) + .unwrap() +}); + +/// Count one gossip message. `result` is `decoded`, `decode_failed`, or +/// `decompress_failed`. +pub fn inc_beacon_gossip(topic: &str, result: &str) { + LEAN_BEACON_GOSSIP_MESSAGES_TOTAL + .with_label_values(&[topic, result]) + .inc(); +} + +static LEAN_BEACON_GOSSIP_VALIDATION_TOTAL: LazyLock = LazyLock::new(|| { + register_int_counter_vec!( + "lean_beacon_gossip_validation_total", + "Beacon gossip verdicts, by topic kind, outcome and reason", + &["kind", "outcome", "reason"] + ) + .unwrap() +}); + +static LEAN_BEACON_GOSSIP_VALIDATION_SECONDS: LazyLock = LazyLock::new(|| { + register_histogram_vec!( + "lean_beacon_gossip_validation_seconds", + "Time from a beacon gossip message's arrival to its verdict", + &["kind"], + vec![ + 0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.0, 4.0, 8.0 + ] + ) + .unwrap() +}); + +static LEAN_BEACON_GOSSIP_VERDICT_EXPIRED_TOTAL: LazyLock = LazyLock::new(|| { + register_int_counter_vec!( + "lean_beacon_gossip_verdict_expired_total", + "Beacon gossip verdicts reported after gossipsub had evicted the message", + &["kind"] + ) + .unwrap() +}); + +/// Every reason `beacon::column_checks` can count a sidecar under: the +/// `Outcome` reason labels `column::chain_checks` drops with, less +/// `already_stored`, which is a duplicate rather than a rejection. Seeded at +/// zero by [`init`], so a reason this node never fires is still visible on a +/// dashboard. +const DATA_COLUMN_REJECT_REASONS: &[&str] = &[ + "malformed", + "future_slot", + "finalized", + "not_after_parent", + "unknown_proposer", + "bad_signature", + "wrong_proposer", + "finalized_not_ancestor", + "parent_not_ready", + "inclusion_proof", + "kzg", + "internal", +]; + +static LEAN_DATA_COLUMNS_REJECTED_TOTAL: LazyLock = LazyLock::new(|| { + register_int_counter_vec!( + "lean_data_columns_rejected_total", + "Data column sidecars the chain checks dropped, by reason (gossip verdicts are counted by lean_beacon_gossip_validation_total)", + &["reason"] + ) + .unwrap() +}); + +/// Count one sidecar the chain checks dropped. `reason` is one of +/// [`DATA_COLUMN_REJECT_REASONS`]. +pub fn inc_data_column_rejected(reason: &str) { + LEAN_DATA_COLUMNS_REJECTED_TOTAL + .with_label_values(&[reason]) + .inc(); +} + +/// Register the metrics that should be visible before their first +/// observation. +pub fn init() { + LazyLock::force(&LEAN_DATA_COLUMNS_REJECTED_TOTAL); + for &reason in DATA_COLUMN_REJECT_REASONS { + LEAN_DATA_COLUMNS_REJECTED_TOTAL.with_label_values(&[reason]); + } +} + +/// Count one beacon gossip verdict and how long it took from arrival. +pub fn observe_beacon_gossip_verdict( + kind: &str, + outcome: &str, + reason: &str, + elapsed: std::time::Duration, +) { + LEAN_BEACON_GOSSIP_VALIDATION_TOTAL + .with_label_values(&[kind, outcome, reason]) + .inc(); + LEAN_BEACON_GOSSIP_VALIDATION_SECONDS + .with_label_values(&[kind]) + .observe(elapsed.as_secs_f64()); +} + +/// Count one verdict gossipsub could no longer act on. +pub fn inc_beacon_gossip_verdict_expired(kind: &str) { + LEAN_BEACON_GOSSIP_VERDICT_EXPIRED_TOTAL + .with_label_values(&[kind]) + .inc(); +} + +pub fn inc_beacon_status_digest_mismatch() { + LEAN_BEACON_STATUS_DIGEST_MISMATCH_TOTAL.inc(); +} + +/// Publish the computed fork digest as a label, so a dashboard can tell at a +/// glance whether a node is stranded on a boundary it failed to cross. +pub fn set_beacon_fork_digest(digest: &str) { + LEAN_BEACON_FORK_DIGEST.with_label_values(&[digest]).set(1); +} + +static LEAN_DATA_COLUMN_FETCH_FAILURES_TOTAL: LazyLock = LazyLock::new(|| { + register_int_counter_vec!( + "lean_data_column_fetch_failures_total", + "Data column sidecar lookups abandoned, by reason", + &["reason"] + ) + .unwrap() +}); + +/// Count one `DataColumnsByRoot` lookup this node gave up on. `reason` is +/// `"no_peers"` (nothing connected to ask) or `"max_retries"` (the retry +/// ladder ran out). +pub fn inc_data_column_fetch_failure(reason: &str) { + LEAN_DATA_COLUMN_FETCH_FAILURES_TOTAL + .with_label_values(&[reason]) + .inc(); +} + +/// Test-only readback of [`inc_data_column_fetch_failure`]'s counter. The +/// metric otherwise has no consumer inside the crate itself (Prometheus +/// scrapes it), so this is the only way a test can observe that a failure was +/// actually counted rather than merely that the code path returned. +#[cfg(test)] +pub(crate) fn data_column_fetch_failures_total(reason: &str) -> u64 { + LEAN_DATA_COLUMN_FETCH_FAILURES_TOTAL + .with_label_values(&[reason]) + .get() +} + +// --- Peer composition and custody-column supply --- + +/// Connected peers split by which side opened the connection. +/// +/// Separate from `lean_connected_peers`, which is labelled by node name and +/// exists to answer "who are we talking to". This one answers "how did we get +/// them", and the difference is operational: inbound supply is unbounded and +/// unchosen, while an outbound peer is one this node picked and is the only +/// kind it can aim at a column it needs. A node pinned at its inbound cap with +/// zero outbound peers looks perfectly healthy on a total peer count and cannot +/// steer its own custody coverage at all. +static LEAN_PEERS_BY_DIRECTION: LazyLock = LazyLock::new(|| { + register_int_gauge_vec!( + "lean_peers_by_direction", + "Connected peers by the direction the connection was opened in", + &["direction"] + ) + .unwrap() +}); + +/// Established connections as libp2p itself counts them. +/// +/// The connection limits are enforced against these, not against +/// [`LEAN_PEERS_BY_DIRECTION`], so publishing both is what makes a leaked +/// connection visible: one the swarm still charges against the cap but that no +/// live peer is using would show up here and nowhere else. The two are not +/// expected to be equal, since this counts connections and the other counts +/// peers, and a peer may hold more than one; what matters is that the gap +/// stays small and does not grow. +static LEAN_SWARM_ESTABLISHED_CONNECTIONS: LazyLock = LazyLock::new(|| { + register_int_gauge_vec!( + "lean_swarm_established_connections", + "Established connections as counted by the libp2p swarm, which is what \ + the connection limits are enforced against", + &["direction"] + ) + .unwrap() +}); + +/// Connected peers known to custody each column this node samples. +static LEAN_CUSTODY_COLUMN_PEERS: LazyLock = LazyLock::new(|| { + register_int_gauge_vec!( + "lean_custody_column_peers", + "Connected peers known to custody each data column this node samples", + &["column"] + ) + .unwrap() +}); + +/// Set the peers-by-direction gauges from a full re-count. +pub fn set_peers_by_direction(inbound: usize, outbound: usize) { + LEAN_PEERS_BY_DIRECTION + .with_label_values(&["inbound"]) + .set(inbound as i64); + LEAN_PEERS_BY_DIRECTION + .with_label_values(&["outbound"]) + .set(outbound as i64); +} + +/// Set the swarm's own established-connection gauges. +pub fn set_swarm_established_connections(inbound: u32, outbound: u32) { + LEAN_SWARM_ESTABLISHED_CONNECTIONS + .with_label_values(&["inbound"]) + .set(i64::from(inbound)); + LEAN_SWARM_ESTABLISHED_CONNECTIONS + .with_label_values(&["outbound"]) + .set(i64::from(outbound)); +} + +/// Set how many connected peers are known to custody `column`. +/// +/// A peer counts only once it has answered `metadata/3` or arrived with a +/// usable `cgc`, matching `P2PServer::peer_custody`. That makes this a floor on +/// real supply rather than an estimate of it, which is the right direction for +/// a gauge whose job is to show a column running dry. +pub fn set_custody_column_peers(column: u64, peers: usize) { + LEAN_CUSTODY_COLUMN_PEERS + .with_label_values(&[&column.to_string()]) + .set(peers as i64); +} + +/// How long this actor spent turning one aggregate's bytes into a container. +/// +/// The first section of the aggregate path, and the only one that happens +/// before the chain actor's mailbox. Its two siblings +/// (`lean_beacon_aggregate_mailbox_wait_seconds` and +/// `lean_beacon_aggregate_processing_seconds`) live in `ethlambda-blockchain`, +/// where the rest of the path runs; together the three say which layer a slow +/// aggregate was slow in. +pub fn observe_beacon_aggregate_decode(duration: std::time::Duration) { + static LEAN_BEACON_AGGREGATE_DECODE_SECONDS: LazyLock = LazyLock::new(|| { + register_histogram!( + "lean_beacon_aggregate_decode_seconds", + "Time spent decoding one gossip aggregate off the wire", + vec![ + 0.0001, 0.00025, 0.0005, 0.001, 0.0025, 0.005, 0.01, 0.025, 0.05 + ] + ) + .unwrap() + }); + LEAN_BEACON_AGGREGATE_DECODE_SECONDS.observe(duration.as_secs_f64()); +} diff --git a/crates/net/p2p/src/req_resp/behaviour.rs b/crates/net/p2p/src/req_resp/behaviour.rs new file mode 100644 index 000000000..cae1002b8 --- /dev/null +++ b/crates/net/p2p/src/req_resp/behaviour.rs @@ -0,0 +1,271 @@ +//! The request/response side of the swarm: one `request_response::Behaviour` +//! per protocol id, plus the construction that decides which of them register +//! a protocol at all. + +use libp2p::{StreamProtocol, request_response, swarm::NetworkBehaviour}; + +use crate::{ + WireConfig, beacon, + lean::protocols::{ + BLOCKS_BY_RANGE_V1 as BLOCKS_BY_RANGE_PROTOCOL_V1, + BLOCKS_BY_ROOT_V1 as BLOCKS_BY_ROOT_PROTOCOL_V1, STATUS_V1 as STATUS_PROTOCOL_V1, + }, + req_resp::Codec, +}; + +/// Every request/response protocol this node can speak, one +/// `request_response::Behaviour` field per protocol id. +/// +/// A field per id, rather than one field registering every id: a shared +/// behaviour hands an outbound request to a positional FIFO queue +/// (`requested_outbound` in the pinned fork's +/// `protocols/request-response/src/handler.rs`) that is drained in +/// substream-negotiation order, not send order, so two requests on different +/// protocols sent back to back on one connection can be matched to each +/// other's substreams once their negotiations complete out of order. A field +/// per protocol makes that structurally unreachable: `NetworkBehaviour`'s +/// derive nests each field's `ConnectionHandler` behind +/// `ConnectionHandlerSelect`, which offers the union of every child's protocol +/// ids to multistream-select and routes a negotiated substream back to exactly +/// the one child that offered it (`swarm/src/handler/select.rs`), so each +/// field's own FIFO queue only ever sees requests sent on its own one +/// protocol. Nesting this whole struct as a single field of +/// [`crate::Behaviour`] keeps that: the outer derive selects into this one, +/// and this one selects into its fourteen. +/// +/// Each field is built with either its real, one-entry protocol list or an +/// empty one, decided by the [`WireConfig`] [`ReqResp::new`] is handed: a lean +/// node's beacon-only fields, and a beacon node's lean-only fields, register +/// nothing, so the aggregate multistream-select offer a peer sees is exactly +/// the protocol set for this node's own wire, unchanged from before the split. +/// See [`crate::ReqRespProtocol`], which names these fields for anything that +/// needs to pick one at runtime (sending a request, or tagging an inbound +/// event with the field it arrived on). +/// +/// The fields are `pub(crate)` because picking one by +/// [`crate::ReqRespProtocol`] is what sending a request means; see +/// `execute_command` in `swarm_adapter.rs`. +#[derive(NetworkBehaviour)] +pub(crate) struct ReqResp { + pub(crate) lean_status: request_response::Behaviour, + pub(crate) lean_blocks_by_root: request_response::Behaviour, + pub(crate) lean_blocks_by_range: request_response::Behaviour, + pub(crate) beacon_status_v1: request_response::Behaviour, + pub(crate) beacon_status_v2: request_response::Behaviour, + pub(crate) beacon_ping: request_response::Behaviour, + pub(crate) beacon_metadata_v1: request_response::Behaviour, + pub(crate) beacon_metadata_v2: request_response::Behaviour, + pub(crate) beacon_metadata_v3: request_response::Behaviour, + pub(crate) beacon_goodbye: request_response::Behaviour, + pub(crate) beacon_blocks_by_range: request_response::Behaviour, + pub(crate) beacon_blocks_by_root: request_response::Behaviour, + pub(crate) data_column_sidecars_by_range: request_response::Behaviour, + pub(crate) data_column_sidecars_by_root: request_response::Behaviour, +} + +impl ReqResp { + /// One `with_codec` call per field, each registering at most the one + /// protocol its field is named for. + /// + /// The codec is built by the caller rather than here: the two beacon block + /// protocols frame their chunks against the fork schedule and the chain, + /// so whatever decides that those protocols are registered has to decide + /// that the context is there. See [`Codec`]. + pub(crate) fn new(codec: Codec, wire: &WireConfig) -> Self { + let is_lean = matches!(wire, WireConfig::Lean(_)); + let is_beacon = !is_lean; + + Self { + lean_status: request_response::Behaviour::with_codec( + codec.clone(), + one_protocol( + is_lean, + STATUS_PROTOCOL_V1, + request_response::ProtocolSupport::Full, + ), + handshake_protocol_config(), + ), + lean_blocks_by_root: request_response::Behaviour::with_codec( + codec.clone(), + one_protocol( + is_lean, + BLOCKS_BY_ROOT_PROTOCOL_V1, + request_response::ProtocolSupport::Full, + ), + fetch_protocol_config(), + ), + lean_blocks_by_range: request_response::Behaviour::with_codec( + codec.clone(), + one_protocol( + is_lean, + BLOCKS_BY_RANGE_PROTOCOL_V1, + request_response::ProtocolSupport::Full, + ), + fetch_protocol_config(), + ), + beacon_status_v1: request_response::Behaviour::with_codec( + codec.clone(), + one_protocol( + is_beacon, + beacon::protocols::STATUS_V1, + request_response::ProtocolSupport::Full, + ), + handshake_protocol_config(), + ), + beacon_status_v2: request_response::Behaviour::with_codec( + codec.clone(), + one_protocol( + is_beacon, + beacon::protocols::STATUS_V2, + request_response::ProtocolSupport::Full, + ), + handshake_protocol_config(), + ), + beacon_ping: request_response::Behaviour::with_codec( + codec.clone(), + one_protocol( + is_beacon, + beacon::protocols::PING_V1, + request_response::ProtocolSupport::Full, + ), + handshake_protocol_config(), + ), + beacon_metadata_v1: request_response::Behaviour::with_codec( + codec.clone(), + one_protocol( + is_beacon, + beacon::protocols::METADATA_V1, + request_response::ProtocolSupport::Full, + ), + handshake_protocol_config(), + ), + beacon_metadata_v2: request_response::Behaviour::with_codec( + codec.clone(), + one_protocol( + is_beacon, + beacon::protocols::METADATA_V2, + request_response::ProtocolSupport::Full, + ), + handshake_protocol_config(), + ), + beacon_metadata_v3: request_response::Behaviour::with_codec( + codec.clone(), + one_protocol( + is_beacon, + beacon::protocols::METADATA_V3, + request_response::ProtocolSupport::Full, + ), + handshake_protocol_config(), + ), + // Inbound only: this node logs a peer's reason code and never + // sends one itself. See `beacon::protocols::registrations`'s doc + // comment. + beacon_goodbye: request_response::Behaviour::with_codec( + codec.clone(), + one_protocol( + is_beacon, + beacon::protocols::GOODBYE_V1, + request_response::ProtocolSupport::Inbound, + ), + handshake_protocol_config(), + ), + beacon_blocks_by_range: request_response::Behaviour::with_codec( + codec.clone(), + one_protocol( + is_beacon, + beacon::protocols::BLOCKS_BY_RANGE_V2, + request_response::ProtocolSupport::Full, + ), + fetch_protocol_config(), + ), + beacon_blocks_by_root: request_response::Behaviour::with_codec( + codec.clone(), + one_protocol( + is_beacon, + beacon::protocols::BLOCKS_BY_ROOT_V2, + request_response::ProtocolSupport::Full, + ), + fetch_protocol_config(), + ), + data_column_sidecars_by_range: request_response::Behaviour::with_codec( + codec.clone(), + one_protocol( + is_beacon, + beacon::protocols::DATA_COLUMN_SIDECARS_BY_RANGE_V1, + request_response::ProtocolSupport::Full, + ), + fetch_protocol_config(), + ), + data_column_sidecars_by_root: request_response::Behaviour::with_codec( + codec, + one_protocol( + is_beacon, + beacon::protocols::DATA_COLUMN_SIDECARS_BY_ROOT_V1, + request_response::ProtocolSupport::Full, + ), + fetch_protocol_config(), + ), + } + } +} + +/// Per-connection concurrent-stream budget for a protocol whose exchange runs +/// once per connection lifetime (`status`, `ping`, `metadata`, `goodbye`). +/// +/// Left at the request-response layer's own unmodified default, every one of +/// these fields would carry that same ceiling, which is sized for a single +/// shared behaviour speaking every protocol at once, not for one behaviour +/// per protocol: split across this many handshake-only fields plus the two +/// [`FETCH_MAX_CONCURRENT_STREAMS`] fields, the *aggregate* per-connection +/// budget would inflate well past what a handshake, sent once per connection, +/// ever needs open at a time. A retried handshake after `UnsupportedProtocols` +/// (see `retry_status_on_other_version`) is the only case that can ever hold +/// two of these open on one field at once, so this stays a small multiple of +/// that rather than the layer's own default. +const HANDSHAKE_MAX_CONCURRENT_STREAMS: usize = 8; + +/// Per-connection concurrent-stream budget for a protocol that carries real +/// fetch traffic: both block protocols and both data column sidecar +/// protocols, on either wire. +/// +/// Sized for a range-sync batch's request plus a handful of concurrent by-root +/// lookups (missing parents, missing columns) on the same connection, which is +/// comfortably under the request-response layer's own unmodified default. That +/// default is sized for one behaviour carrying every protocol's traffic, not +/// for one of the several fields these fetch protocols are now split across. +const FETCH_MAX_CONCURRENT_STREAMS: usize = 32; + +/// The `request_response::Config` for a [`ReqResp`] field whose protocol is a +/// once-per-connection handshake. See [`HANDSHAKE_MAX_CONCURRENT_STREAMS`]. +fn handshake_protocol_config() -> request_response::Config { + request_response::Config::default() + .with_max_concurrent_streams(HANDSHAKE_MAX_CONCURRENT_STREAMS) +} + +/// The `request_response::Config` for a [`ReqResp`] field whose protocol +/// carries real fetch traffic. See [`FETCH_MAX_CONCURRENT_STREAMS`]. +fn fetch_protocol_config() -> request_response::Config { + request_response::Config::default().with_max_concurrent_streams(FETCH_MAX_CONCURRENT_STREAMS) +} + +/// The protocol list for one [`ReqResp`] field: this protocol alone when +/// `active` (this node speaks the wire it belongs to), or none at all +/// otherwise. +/// +/// Every field is built through this, on both wires, so a field that belongs +/// to the wire this node is *not* speaking is constructed with an empty list +/// rather than left out: [`ReqResp`] is one monomorphic struct for both wires +/// (see its doc comment), and an empty protocol list is what keeps that field +/// from ever being offered to a peer or accepting a request, which is the +/// whole of what "not speaking that wire" has to mean here. +fn one_protocol( + active: bool, + protocol: &'static str, + support: request_response::ProtocolSupport, +) -> Vec<(StreamProtocol, request_response::ProtocolSupport)> { + if active { + vec![(StreamProtocol::new(protocol), support)] + } else { + Vec::new() + } +} diff --git a/crates/net/p2p/src/req_resp/codec.rs b/crates/net/p2p/src/req_resp/codec.rs index 7805cf6d1..5aea1084e 100644 --- a/crates/net/p2p/src/req_resp/codec.rs +++ b/crates/net/p2p/src/req_resp/codec.rs @@ -1,33 +1,123 @@ use std::io; +use std::sync::Arc; use libp2p::futures::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; use libssz::{SszDecode, SszEncode}; -use tracing::{debug, trace, warn}; +use tracing::trace; use super::{ - encoding::{MAX_PAYLOAD_SIZE, decode_payload, write_payload}, - messages::{ - BLOCKS_BY_RANGE_PROTOCOL_V1, BLOCKS_BY_ROOT_PROTOCOL_V1, ErrorMessage, Request, Response, - ResponseCode, ResponsePayload, STATUS_PROTOCOL_V1, Status, - }, + encoding::{decode_payload, invalid, write_payload}, + messages::{ErrorMessage, Request, Response, ResponseCode, ResponsePayload}, }; +use crate::beacon::messages::{ + BeaconBlocksByRangeRequest, DataColumnsByRangeRequest, DataColumnsByRootIdentifiers, Goodbye, + Ping, +}; +use crate::beacon::{BeaconContext, encoding as beacon_encoding, protocols}; +use crate::lean::messages::{BlocksByRootRequest, RequestedBlockRoots}; +use crate::lean::{encoding as lean_encoding, protocols as lean_protocols}; use crate::metrics; -use ethlambda_types::block::SignedBlock; + +/// Protocols whose response payload has one shape forever write no context +/// bytes, which is every protocol here except the two beacon block ones and +/// the two beacon data column sidecar ones. +const NO_CONTEXT: &[u8] = &[]; /// Short label extracted from a libp2p protocol id, used as the `protocol` /// label on req/resp size metrics. fn protocol_label(protocol: &str) -> &'static str { - match protocol { - STATUS_PROTOCOL_V1 => "status", - BLOCKS_BY_ROOT_PROTOCOL_V1 => "blocks_by_root", - BLOCKS_BY_RANGE_PROTOCOL_V1 => "blocks_by_range", - _ => "unknown", + lean_protocols::label(protocol) + .or_else(|| protocols::label(protocol)) + .unwrap_or("unknown") +} + +/// Write one success chunk: the code byte, the context bytes, then the +/// compressed payload. +/// +/// `response_chunk ::= | | +/// | `, and this writes it in that order. `context` is empty +/// on every lean protocol and on the beacon protocols whose payload shape does +/// not depend on the fork; it is the four-byte `ForkDigest` on the two block +/// protocols and the two data column sidecar protocols. Passing an empty slice +/// emits nothing, which is exactly what "`` is empty by +/// default" means. +/// +/// The single-chunk response payloads differ only in how their body is encoded, +/// so they all end here. The block and data column sidecar payloads write a +/// chunk per item, which is why this is a helper rather than the tail of +/// `write_response`. +pub(crate) async fn write_success_chunk( + io: &mut T, + label: &'static str, + context: &[u8], + encoded: Vec, +) -> io::Result<()> +where + T: AsyncWrite + Unpin + Send, +{ + io.write_all(&[ResponseCode::SUCCESS.into()]).await?; + if !context.is_empty() { + io.write_all(context).await?; } + let compressed_size = write_payload(io, &encoded).await?; + metrics::observe_reqresp_response_chunk_size(label, encoded.len(), compressed_size); + Ok(()) } -#[derive(Debug, Clone, Default)] -pub struct Codec; +/// The request/response codec, for whichever chain the node is on. +/// +/// One codec for both, mirroring the single [`Request`] and the single +/// dispatch above it. It is stateless for lean and for most of beacon's +/// protocols; the two block protocols and the two data column sidecar +/// protocols are the exception, because a chunk's `` are a +/// function of the chunk's own slot, the fork schedule and the chain, none of +/// which the payload alone supplies. +/// +/// Deliberately not `Default`. `request_response::Behaviour::new` would +/// construct one through that impl, and a beacon node whose codec came out +/// contextless would negotiate the block protocols and then fail every chunk; +/// [`crate::build_swarm`] uses `with_codec` and [`Codec::lean`] or +/// [`Codec::beacon`] instead, so +/// the context is decided in the same match that decides the protocol set. +#[derive(Debug, Clone)] +pub struct Codec { + /// `None` on a lean node, which registers none of the protocols that read + /// it. + beacon: Option>, +} + +impl Codec { + /// The codec for a lean node: no beacon protocol is registered, so there is + /// no context to carry. + pub fn lean() -> Self { + Self { beacon: None } + } + + /// The codec for a beacon node, holding what the block protocols need. + pub fn beacon(context: BeaconContext) -> Self { + Self { + beacon: Some(Arc::new(context)), + } + } + + /// The beacon context, or the error a block or sidecar chunk cannot be + /// framed without. + /// + /// Unreachable in a correctly built node: the block and data column + /// sidecar protocols are only registered on the beacon arm of + /// [`crate::build_swarm`], which is the same arm that supplies the + /// context. Surfaced as an error rather than an `expect` because it is + /// reachable from a peer's stream, and a codec panic takes the whole swarm + /// down. + fn beacon_context(&self, protocol: &str) -> io::Result<&BeaconContext> { + self.beacon.as_deref().ok_or_else(|| { + invalid(format!( + "{protocol} needs a beacon fork context, which this node has none of" + )) + }) + } +} impl libp2p::request_response::Codec for Codec { type Protocol = libp2p::StreamProtocol; @@ -42,34 +132,72 @@ impl libp2p::request_response::Codec for Codec { where T: AsyncRead + Unpin + Send, { + // MetaData's request body is empty on the wire by spec: no varint, no + // snappy frame, nothing to read at all. Every other protocol's body is + // a real SSZ field (possibly itself zero bytes, like an empty + // BlocksByRoot root list), which the spec still frames the normal way, + // so only this arm returns before `decode_payload` runs. Resolved to + // the `'static` constant so the variant can hold it. + let metadata_protocol = match protocol.as_ref() { + protocols::METADATA_V1 => Some(protocols::METADATA_V1), + protocols::METADATA_V2 => Some(protocols::METADATA_V2), + protocols::METADATA_V3 => Some(protocols::METADATA_V3), + _ => None, + }; + if let Some(metadata_protocol) = metadata_protocol { + metrics::observe_reqresp_request_size(protocol_label(protocol.as_ref()), 0, 0); + return Ok(Request::MetaData(metadata_protocol)); + } + let decoded = decode_payload(io).await?; let payload = decoded.uncompressed; let label = protocol_label(protocol.as_ref()); metrics::observe_reqresp_request_size(label, payload.len(), decoded.compressed_size); + // Each chain answers for its own protocol ids and `None` for anything + // else, so neither module needs to know the other exists. + if let Some(request) = lean_encoding::decode_request(protocol.as_ref(), &payload) { + return request; + } match protocol.as_ref() { - STATUS_PROTOCOL_V1 => { - let status = Status::from_ssz_bytes(&payload).map_err(|err| { - io::Error::new(io::ErrorKind::InvalidData, format!("{err:?}")) - })?; - Ok(Request::Status(status)) - } - BLOCKS_BY_ROOT_PROTOCOL_V1 => { - let request = SszDecode::from_ssz_bytes(&payload).map_err(|err| { - io::Error::new(io::ErrorKind::InvalidData, format!("{err:?}")) - })?; - Ok(Request::BlocksByRoot(request)) + protocols::STATUS_V1 | protocols::STATUS_V2 => Ok(Request::Status( + beacon_encoding::decode_status(protocol.as_ref(), &payload)?, + )), + protocols::PING_V1 => Ok(Request::Ping( + Ping::from_ssz_bytes(&payload).map_err(|err| invalid(format!("{err:?}")))?, + )), + // METADATA_V1/V2/V3 are handled above, before any bytes are read. + protocols::GOODBYE_V1 => Ok(Request::Goodbye( + Goodbye::from_ssz_bytes(&payload).map_err(|err| invalid(format!("{err:?}")))?, + )), + // Twenty-four bytes here against lean's sixteen, the third of them + // the deprecated `step`. It is carried up rather than checked here: + // a peer that sets it wrong is answered with INVALID_REQUEST by the + // handler, which refusing at decode could not do. + protocols::BLOCKS_BY_RANGE_V2 => { + let wire = BeaconBlocksByRangeRequest::from_ssz_bytes(&payload) + .map_err(|err| invalid(format!("{err:?}")))?; + Ok(Request::BlocksByRange(wire.into())) } - BLOCKS_BY_RANGE_PROTOCOL_V1 => { - let request = SszDecode::from_ssz_bytes(&payload).map_err(|err| { - io::Error::new(io::ErrorKind::InvalidData, format!("{err:?}")) - })?; - Ok(Request::BlocksByRange(request)) + // The bare list, with no container around it: this body is an SSZ + // *field* where lean's is an SSZ container holding the same list. + protocols::BLOCKS_BY_ROOT_V2 => Ok(Request::BlocksByRoot(BlocksByRootRequest { + roots: RequestedBlockRoots::from_ssz_bytes(&payload) + .map_err(|err| invalid(format!("{err:?}")))?, + })), + // The bare list again, this time of identifiers rather than + // roots; unwrapped into a plain `Vec` because nothing above the + // codec needs the SSZ bound once decode has already enforced it. + protocols::DATA_COLUMN_SIDECARS_BY_ROOT_V1 => { + let identifiers = DataColumnsByRootIdentifiers::from_ssz_bytes(&payload) + .map_err(|err| invalid(format!("{err:?}")))?; + Ok(Request::DataColumnsByRoot(identifiers.into_inner())) } - _ => Err(io::Error::new( - io::ErrorKind::InvalidData, - format!("unknown protocol: {}", protocol.as_ref()), + protocols::DATA_COLUMN_SIDECARS_BY_RANGE_V1 => Ok(Request::DataColumnsByRange( + DataColumnsByRangeRequest::from_ssz_bytes(&payload) + .map_err(|err| invalid(format!("{err:?}")))?, )), + _ => Err(invalid(format!("unknown protocol: {}", protocol.as_ref()))), } } @@ -83,14 +211,62 @@ impl libp2p::request_response::Codec for Codec { { let label = protocol_label(protocol.as_ref()); match protocol.as_ref() { - STATUS_PROTOCOL_V1 => decode_status_response(io, label).await, - BLOCKS_BY_ROOT_PROTOCOL_V1 | BLOCKS_BY_RANGE_PROTOCOL_V1 => { - decode_blocks_response(io, label).await + lean_protocols::STATUS_V1 => { + decode_single_chunk(io, protocol.as_ref(), label, |_, payload| { + lean_encoding::decode_status_response(payload) + }) + .await } - _ => Err(io::Error::new( - io::ErrorKind::InvalidData, - format!("unknown protocol: {}", protocol.as_ref()), - )), + lean_protocols::BLOCKS_BY_ROOT_V1 | lean_protocols::BLOCKS_BY_RANGE_V1 => { + lean_encoding::decode_blocks_response(io, label).await + } + protocols::STATUS_V1 | protocols::STATUS_V2 => { + decode_single_chunk(io, protocol.as_ref(), label, |protocol, payload| { + beacon_encoding::decode_status(protocol, payload).map(ResponsePayload::Status) + }) + .await + } + protocols::PING_V1 => { + decode_single_chunk(io, protocol.as_ref(), label, |_, payload| { + Ping::from_ssz_bytes(payload) + .map(ResponsePayload::Pong) + .map_err(|err| invalid(format!("{err:?}"))) + }) + .await + } + protocols::METADATA_V1 | protocols::METADATA_V2 | protocols::METADATA_V3 => { + decode_single_chunk(io, protocol.as_ref(), label, |protocol, payload| { + beacon_encoding::decode_metadata(protocol, payload) + .map(ResponsePayload::MetaData) + }) + .await + } + protocols::BLOCKS_BY_RANGE_V2 | protocols::BLOCKS_BY_ROOT_V2 => { + let context = self.beacon_context(protocol.as_ref())?; + let blocks = beacon_encoding::decode_blocks_response( + io, + label, + &context.config, + context.genesis_validators_root, + ) + .await?; + Ok(Response::success(ResponsePayload::Blocks(blocks))) + } + protocols::DATA_COLUMN_SIDECARS_BY_RANGE_V1 + | protocols::DATA_COLUMN_SIDECARS_BY_ROOT_V1 => { + let context = self.beacon_context(protocol.as_ref())?; + let sidecars = beacon_encoding::decode_data_column_sidecars_response( + io, + label, + &context.config, + context.genesis_validators_root, + ) + .await?; + Ok(Response::success(ResponsePayload::DataColumnSidecars( + sidecars, + ))) + } + _ => Err(invalid(format!("unknown protocol: {}", protocol.as_ref()))), } } @@ -105,10 +281,58 @@ impl libp2p::request_response::Codec for Codec { { trace!(?req, "Writing request"); - let encoded = match req { - Request::Status(status) => status.to_ssz(), - Request::BlocksByRoot(request) => request.to_ssz(), - Request::BlocksByRange(request) => request.to_ssz(), + // MetaData has no request body at all by spec, unlike, say, an empty + // BlocksByRoot root list, which is still a real, if zero-length, SSZ + // field the spec frames the normal way. `write_payload` always writes + // a varint length and a snappy stream header even for an empty slice, + // so encoding this to `Vec::new()` and falling into the shared + // `write_payload` call below would put eleven bytes on the wire where + // the spec puts none. Returning here keeps this the only variant that + // skips it. + if let Request::MetaData(_) = &req { + let label = protocol_label(protocol.as_ref()); + metrics::observe_reqresp_request_size(label, 0, 0); + return Ok(()); + } + + // One arm per variant, each delegating to its own chain's module: this + // is the whole of what the codec knows about either encoding. The two + // block requests are the exception, because one variant is carried by + // both wires and only the negotiated protocol says which framing to + // write. + let encoded = match &req { + Request::LeanStatus(status) => lean_encoding::encode_status(status), + Request::BlocksByRoot(request) => match protocol.as_ref() { + lean_protocols::BLOCKS_BY_ROOT_V1 => lean_encoding::encode_blocks_by_root(request), + // The bare list: beacon's body is an SSZ field, so the + // container lean wraps it in comes back off here. + protocols::BLOCKS_BY_ROOT_V2 => request.roots.to_ssz(), + other => return Err(invalid(format!("not a blocks_by_root protocol: {other}"))), + }, + Request::BlocksByRange(request) => match protocol.as_ref() { + lean_protocols::BLOCKS_BY_RANGE_V1 => { + lean_encoding::encode_blocks_by_range(request) + } + protocols::BLOCKS_BY_RANGE_V2 => BeaconBlocksByRangeRequest::from(request).to_ssz(), + other => return Err(invalid(format!("not a blocks_by_range protocol: {other}"))), + }, + Request::Status(status) => beacon_encoding::encode_status(protocol.as_ref(), status)?, + // Versionless bodies, so there is nothing for the beacon module to + // decide and they encode straight from the container. + Request::Ping(ping) => ping.to_ssz(), + // Handled and returned from above, before this match is reached. + Request::MetaData(_) => unreachable!("Request::MetaData returns earlier in this fn"), + Request::Goodbye(goodbye) => goodbye.to_ssz(), + // The bound is re-applied here rather than trusted from wherever + // the `Vec` was built: it is only enforced on the way in by + // `read_request`'s `DataColumnsByRootIdentifiers::from_ssz_bytes`, + // and nothing stops a caller building an oversized `Vec` directly. + Request::DataColumnsByRoot(identifiers) => { + DataColumnsByRootIdentifiers::try_from(identifiers.clone()) + .map_err(|err| invalid(format!("{err:?}")))? + .to_ssz() + } + Request::DataColumnsByRange(request) => request.to_ssz(), }; let compressed_size = write_payload(io, &encoded).await?; @@ -128,48 +352,53 @@ impl libp2p::request_response::Codec for Codec { { let label = protocol_label(protocol.as_ref()); match resp { - Response::Success { payload } => { - match &payload { - ResponsePayload::Status(status) => { - // Send success code (0) - io.write_all(&[ResponseCode::SUCCESS.into()]).await?; - let encoded = status.to_ssz(); - let compressed_size = write_payload(io, &encoded).await?; - metrics::observe_reqresp_response_chunk_size( - label, - encoded.len(), - compressed_size, - ); - Ok(()) + Response::Success { payload } => match &payload { + ResponsePayload::LeanStatus(status) => { + write_success_chunk(io, label, NO_CONTEXT, lean_encoding::encode_status(status)) + .await + } + // One payload, two framings, picked by the negotiated + // protocol the same way the two block requests are. + ResponsePayload::Blocks(blocks) => match protocol.as_ref() { + lean_protocols::BLOCKS_BY_ROOT_V1 | lean_protocols::BLOCKS_BY_RANGE_V1 => { + lean_encoding::write_blocks_response(io, label, blocks).await } - ResponsePayload::Blocks(blocks) => { - // Write each block as a separate chunk. - // Encode first, then check size before writing the SUCCESS - // code byte. This avoids corrupting the stream if a block - // exceeds MAX_PAYLOAD_SIZE (the SUCCESS byte would already - // be on the wire with no payload following). - for block in blocks { - let encoded = block.to_ssz(); - if encoded.len() > MAX_PAYLOAD_SIZE - 1024 { - warn!( - size = encoded.len(), - "Skipping oversized block in block response" - ); - continue; - } - io.write_all(&[ResponseCode::SUCCESS.into()]).await?; - let compressed_size = write_payload(io, &encoded).await?; - metrics::observe_reqresp_response_chunk_size( - label, - encoded.len(), - compressed_size, - ); - } - // Empty response if no blocks found (stream just ends) - Ok(()) + protocols::BLOCKS_BY_RANGE_V2 | protocols::BLOCKS_BY_ROOT_V2 => { + let context = self.beacon_context(protocol.as_ref())?; + beacon_encoding::write_blocks_response( + io, + label, + &context.config, + context.genesis_validators_root, + blocks, + ) + .await } + other => Err(invalid(format!("not a block protocol: {other}"))), + }, + ResponsePayload::Status(status) => { + let encoded = beacon_encoding::encode_status(protocol.as_ref(), status)?; + write_success_chunk(io, label, NO_CONTEXT, encoded).await } - } + ResponsePayload::Pong(ping) => { + write_success_chunk(io, label, NO_CONTEXT, ping.to_ssz()).await + } + ResponsePayload::MetaData(metadata) => { + let encoded = beacon_encoding::encode_metadata(protocol.as_ref(), metadata)?; + write_success_chunk(io, label, NO_CONTEXT, encoded).await + } + ResponsePayload::DataColumnSidecars(sidecars) => { + let context = self.beacon_context(protocol.as_ref())?; + beacon_encoding::write_data_column_sidecars_response( + io, + label, + &context.config, + context.genesis_validators_root, + sidecars, + ) + .await + } + }, Response::Error { code, message } => { // Send error code io.write_all(&[code.into()]).await?; @@ -185,36 +414,30 @@ impl libp2p::request_response::Codec for Codec { } } -/// Decodes a Status protocol response from a single-chunk response stream. -/// -/// Reads the response code byte and payload, returning either a success response -/// with the peer's Status or an error response with the error code and message. -/// Unlike multi-chunk protocols, any error code from the peer is treated as a -/// valid response rather than a connection failure. -/// -/// # Returns -/// -/// Returns `Ok(Response::Success)` containing the peer's `Status` if the response -/// code is `SUCCESS`. -/// -/// Returns `Ok(Response::Error)` containing the error code and message if the peer -/// returned a non-success response code. -/// -/// # Errors +/// Read a single-chunk response: one result-code byte, then one payload. /// -/// Returns `Err` if: -/// - I/O error occurs while reading the response code or payload -/// - Peer's error message cannot be SSZ-decoded (InvalidData) -/// - Peer's Status payload cannot be SSZ-decoded (InvalidData) -async fn decode_status_response(io: &mut T, protocol_label: &str) -> io::Result +/// Lean's `Status` and every beacon protocol but the two block ones answer with +/// exactly one chunk, so there is no EOF loop here; the multi-chunk shape is +/// [`crate::lean::encoding::decode_blocks_response`] and its beacon +/// counterpart, both of which run the shared +/// [`crate::req_resp::encoding::read_chunked_response`] loop. `decode` turns the +/// body into a payload, and is handed the negotiated protocol id because the +/// beacon containers pick their version off it. +async fn decode_single_chunk( + io: &mut T, + protocol: &str, + protocol_label: &str, + decode: F, +) -> io::Result where T: AsyncRead + Unpin + Send, + F: FnOnce(&str, &[u8]) -> io::Result, { let mut result_byte = 0_u8; io.read_exact(std::slice::from_mut(&mut result_byte)) .await?; - let code = ResponseCode::from(result_byte); + let decoded = decode_payload(io).await?; let payload = decoded.uncompressed; metrics::observe_reqresp_response_chunk_size( @@ -224,82 +447,357 @@ where ); if code != ResponseCode::SUCCESS { - let message = ErrorMessage::from_ssz_bytes(&payload).map_err(|err| { - io::Error::new( - io::ErrorKind::InvalidData, - format!("Invalid error message: {err:?}"), - ) - })?; + let message = ErrorMessage::from_ssz_bytes(&payload) + .map_err(|err| invalid(format!("Invalid error message: {err:?}")))?; let error_str = String::from_utf8_lossy(&message).into_owned(); trace!(?code, %error_str, "Received error response"); return Ok(Response::error(code, message)); } - let status = Status::from_ssz_bytes(&payload) - .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, format!("{err:?}")))?; - Ok(Response::success(ResponsePayload::Status(status))) + Ok(Response::success(decode(protocol, &payload)?)) } -/// Decodes a block protocol response from a multi-chunk response stream. -/// -/// Reads chunks until EOF, collecting successfully decoded blocks. Each chunk has -/// its own response code - chunks with error codes are logged and skipped rather -/// than terminating the stream. This allows partial success when some requested -/// blocks are unavailable. The stream ends naturally at EOF (peer closes after -/// sending all available blocks). -/// -/// # Returns -/// -/// Always returns `Ok(Response::Success)` containing a vector of successfully -/// decoded blocks. The vector may be empty if no SUCCESS chunks were received -/// before EOF (either no chunks sent, or all chunks had non-SUCCESS codes) -/// -/// # Errors -/// -/// Returns `Err` if: -/// - I/O error occurs while reading response codes or payloads (except `UnexpectedEof` -/// which signals normal stream termination) -/// - Block payload cannot be SSZ-decoded into `SignedBlock` (InvalidData) -/// -/// Note: Error chunks from the peer (non-SUCCESS response codes) do not cause this -/// function to return `Err` - they are logged and skipped. -async fn decode_blocks_response(io: &mut T, protocol_label: &str) -> io::Result -where - T: AsyncRead + Unpin + Send, -{ - let mut blocks = Vec::new(); - - loop { - // Read chunk result code - let mut result_byte = 0_u8; - if let Err(e) = io.read_exact(std::slice::from_mut(&mut result_byte)).await { - if e.kind() == io::ErrorKind::UnexpectedEof { - break; +#[cfg(test)] +mod tests { + use super::*; + use crate::beacon::messages::{ + AttnetsBits, BeaconMetaData, BeaconStatus, DataColumnsByRangeRequest, Goodbye, MetaDataV3, + Ping, StatusV1, SyncnetsBits, + }; + use crate::beacon::protocols; + use ethlambda_types::beacon::config::Config; + use ethlambda_types::beacon::containers::fulu::{ + self, ColumnIndices, DataColumnsByRootIdentifier, + }; + use ethlambda_types::beacon::containers::shared; + use ethlambda_types::beacon::preset; + use ethlambda_types::beacon::primitives::Root; + use futures::io::Cursor; + use libp2p::StreamProtocol; + use libp2p::request_response::Codec as _; + + /// Ethereum mainnet's `genesis_validators_root`. + fn mainnet_gvr() -> Root { + Root::from_slice( + &hex::decode("4b363db94e286120d76eb905340fdd4e54bfe9f06bf33ff6cf5ad27f511bfe95") + .expect("valid hex"), + ) + } + + /// A codec built the way `build_swarm`'s beacon arm builds one. + fn codec() -> Codec { + Codec::beacon(BeaconContext { + config: Config::mainnet(), + genesis_validators_root: mainnet_gvr(), + }) + } + + fn status() -> BeaconStatus { + BeaconStatus::V1(StatusV1 { + fork_digest: [0x8c, 0x9f, 0x62, 0xfe], + finalized_root: Root::ZERO, + finalized_epoch: 0, + head_root: Root::ZERO, + head_slot: 0, + }) + } + + /// Write a request, then read it back off the same buffer. + async fn request_round_trip(protocol: &'static str, request: Request) -> Request { + let stream_protocol = StreamProtocol::new(protocol); + let mut buffer = Cursor::new(Vec::new()); + codec() + .write_request(&stream_protocol, &mut buffer, request) + .await + .expect("writes"); + let mut buffer = Cursor::new(buffer.into_inner()); + codec() + .read_request(&stream_protocol, &mut buffer) + .await + .expect("reads") + } + + /// Write a response, then read it back off the same buffer. + async fn response_round_trip(protocol: &'static str, response: Response) -> Response { + let stream_protocol = StreamProtocol::new(protocol); + let mut buffer = Cursor::new(Vec::new()); + codec() + .write_response(&stream_protocol, &mut buffer, response) + .await + .expect("writes"); + let mut buffer = Cursor::new(buffer.into_inner()); + codec() + .read_response(&stream_protocol, &mut buffer) + .await + .expect("reads") + } + + #[tokio::test] + async fn a_status_v1_request_round_trips_through_the_snappy_framing() { + let decoded = request_round_trip(protocols::STATUS_V1, Request::Status(status())).await; + assert!(matches!(decoded, Request::Status(BeaconStatus::V1(_)))); + } + + #[tokio::test] + async fn the_protocol_version_selects_the_status_shape() { + // A v1 payload on a v2 stream would be eight bytes short, so the + // version has to come from the negotiated protocol rather than from + // whichever variant the caller happened to build. + let stream_protocol = StreamProtocol::new(protocols::STATUS_V2); + let mut buffer = Cursor::new(Vec::new()); + let result = codec() + .write_request(&stream_protocol, &mut buffer, Request::Status(status())) + .await; + assert!( + result.is_err(), + "writing a v1 Status on a v2 stream must be refused, not truncated" + ); + } + + #[tokio::test] + async fn a_ping_round_trips() { + let decoded = + request_round_trip(protocols::PING_V1, Request::Ping(Ping { seq_number: 5 })).await; + assert!(matches!(decoded, Request::Ping(Ping { seq_number: 5 }))); + + let decoded = response_round_trip( + protocols::PING_V1, + Response::success(ResponsePayload::Pong(Ping { seq_number: 5 })), + ) + .await; + assert!(matches!( + decoded, + Response::Success { + payload: ResponsePayload::Pong(Ping { seq_number: 5 }) } - return Err(e); - } + )); + } - let code = ResponseCode::from(result_byte); - let decoded = decode_payload(io).await?; - let payload = decoded.uncompressed; - metrics::observe_reqresp_response_chunk_size( - protocol_label, - payload.len(), - decoded.compressed_size, + #[tokio::test] + async fn a_metadata_request_carries_no_payload() { + // The spec's MetaData request is empty: `write_request` returns before + // writing anything, and `read_request` returns before reading + // anything, so the two agree on an empty stream without either side + // touching `write_payload`/`decode_payload`. This round-trip alone + // would pass even with the old, wrong framing (a varint zero plus a + // bare snappy header), since both ends of one process agree with + // themselves either way; see `a_metadata_request_writes_zero_bytes_on_the_wire` + // below for the assertion that actually pins the wire bytes. + let decoded = request_round_trip( + protocols::METADATA_V3, + Request::MetaData(protocols::METADATA_V3), + ) + .await; + assert!(matches!(decoded, Request::MetaData(protocols::METADATA_V3))); + } + + #[tokio::test] + async fn a_metadata_request_writes_zero_bytes_on_the_wire() { + // What the round-trip test above cannot catch: a peer sending the + // spec's empty body would see exactly zero bytes, not the eleven a + // varint-zero-plus-snappy-header framing used to put on the wire. + let stream_protocol = StreamProtocol::new(protocols::METADATA_V3); + let mut buffer = Cursor::new(Vec::new()); + codec() + .write_request( + &stream_protocol, + &mut buffer, + Request::MetaData(protocols::METADATA_V3), + ) + .await + .expect("writes"); + assert!( + buffer.into_inner().is_empty(), + "a MetaData request must write zero bytes, not a varint+snappy header" ); + } + + #[tokio::test] + async fn read_request_does_not_block_on_a_truly_empty_metadata_stream() { + // The regression this whole fix is about: a peer that actually sends + // the spec's zero bytes must decode cleanly rather than hang + // `read_varint` waiting for a length byte that will never arrive. + // An empty buffer stands in for "the peer wrote nothing and closed", + // which is exactly what `read_request` must accept without reading + // past it. + let stream_protocol = StreamProtocol::new(protocols::METADATA_V3); + let mut buffer = Cursor::new(Vec::::new()); + let decoded = codec() + .read_request(&stream_protocol, &mut buffer) + .await + .expect("reads a truly empty MetaData body"); + assert!(matches!(decoded, Request::MetaData(protocols::METADATA_V3))); + } + + #[tokio::test] + async fn a_metadata_v3_response_round_trips() { + let metadata = BeaconMetaData::V3(MetaDataV3 { + seq_number: 0, + attnets: AttnetsBits::default(), + syncnets: SyncnetsBits::default(), + custody_group_count: 4, + }); + let decoded = response_round_trip( + protocols::METADATA_V3, + Response::success(ResponsePayload::MetaData(metadata)), + ) + .await; + let Response::Success { + payload: ResponsePayload::MetaData(BeaconMetaData::V3(v3)), + } = decoded + else { + panic!("expected a v3 MetaData"); + }; + assert_eq!(v3.custody_group_count, 4); + } + + #[tokio::test] + async fn a_goodbye_round_trips() { + let decoded = request_round_trip( + protocols::GOODBYE_V1, + Request::Goodbye(Goodbye { reason: 128 }), + ) + .await; + assert!(matches!(decoded, Request::Goodbye(Goodbye { reason: 128 }))); + } + + #[tokio::test] + async fn a_by_root_column_request_round_trips() { + let request = Request::DataColumnsByRoot(vec![DataColumnsByRootIdentifier { + block_root: Root::repeat_byte(1), + columns: ColumnIndices::try_from(vec![0, 3]).unwrap(), + }]); + let decoded = request_round_trip(protocols::DATA_COLUMN_SIDECARS_BY_ROOT_V1, request).await; + match decoded { + Request::DataColumnsByRoot(identifiers) => { + assert_eq!(identifiers.len(), 1); + assert_eq!(identifiers[0].columns.to_vec(), vec![0, 3]); + } + other => panic!("decoded as {other:?}"), + } + } + + #[tokio::test] + async fn a_by_range_column_request_round_trips() { + let request = Request::DataColumnsByRange(DataColumnsByRangeRequest { + start_slot: 10, + count: 5, + columns: ColumnIndices::try_from(vec![1, 2, 3]).unwrap(), + }); + let decoded = + request_round_trip(protocols::DATA_COLUMN_SIDECARS_BY_RANGE_V1, request).await; + match decoded { + Request::DataColumnsByRange(wire) => { + assert_eq!(wire.start_slot, 10); + assert_eq!(wire.count, 5); + assert_eq!(wire.columns.to_vec(), vec![1, 2, 3]); + } + other => panic!("decoded as {other:?}"), + } + } - if code != ResponseCode::SUCCESS { - let error_message = ErrorMessage::from_ssz_bytes(&payload) - .map(|msg| String::from_utf8_lossy(&msg).into_owned()) - .unwrap_or_else(|_| "".to_string()); - debug!(?code, %error_message, "Skipping block chunk with non-success code"); - continue; + /// A minimal sidecar naming `slot` and `index`; every other field is its + /// type's default, since neither test below reads past what + /// `write_data_column_sidecars_response` and + /// `decode_data_column_sidecars_response` themselves touch: the slot + /// (for the per-item context digest) and the index (to tell sidecars + /// apart). + fn data_column_sidecar(slot: u64, index: u64) -> fulu::DataColumnSidecar { + fulu::DataColumnSidecar { + index, + column: Default::default(), + kzg_commitments: Default::default(), + kzg_proofs: Default::default(), + signed_block_header: shared::SignedBeaconBlockHeader { + message: shared::BeaconBlockHeader { + slot, + ..Default::default() + }, + signature: Default::default(), + }, + kzg_commitments_inclusion_proof: vec![ + Root::ZERO; + preset::KZG_COMMITMENTS_INCLUSION_PROOF_DEPTH + ] + .try_into() + .expect("exactly the required depth"), } + } - let block = SignedBlock::from_ssz_bytes(&payload) - .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, format!("{err:?}")))?; - blocks.push(block); + /// Exercises `write_data_column_sidecars_response` into + /// `decode_data_column_sidecars_response` directly, unlike + /// [`a_by_range_column_request_round_trips`] and + /// [`a_by_root_column_request_round_trips`] above, which only round-trip + /// the thin SSZ-derive request wrappers and never touch this pair. + /// + /// The two sidecars straddle mainnet's altair fork boundary on purpose, + /// so their digests actually differ: each chunk's `` is + /// derived from *that sidecar's own* slot + /// (`write_data_column_sidecars_response`'s doc explains why — a + /// backfill answer labels each chunk with its own fork), so an + /// implementation that computed one digest for the whole response + /// (from, say, the first sidecar's epoch) would still round-trip a + /// batch that never crosses a fork boundary but fail this one. + #[tokio::test] + async fn a_data_column_sidecars_response_round_trips_with_a_per_item_context() { + let sidecar_a = data_column_sidecar(3, 0); + let post_altair_slot = Config::mainnet().altair_fork_epoch * preset::SLOTS_PER_EPOCH + 1; + let sidecar_b = data_column_sidecar(post_altair_slot, 7); + + let decoded = response_round_trip( + protocols::DATA_COLUMN_SIDECARS_BY_RANGE_V1, + Response::success(ResponsePayload::DataColumnSidecars(vec![ + sidecar_a.clone(), + sidecar_b.clone(), + ])), + ) + .await; + + match decoded { + Response::Success { + payload: ResponsePayload::DataColumnSidecars(sidecars), + } => { + assert_eq!(sidecars, vec![sidecar_a, sidecar_b]); + } + other => panic!("decoded as {other:?}"), + } } - Ok(Response::success(ResponsePayload::Blocks(blocks))) + /// A peer's `genesis_validators_root` differing from ours means every + /// digest it labels a chunk with is for the wrong chain, even though the + /// chunk decodes cleanly on its own. `decode_data_column_sidecars_response` + /// checks the digest against what *this* node's own root implies, so + /// reading the same bytes back through a codec built with a different + /// root must abort the stream rather than hand back a sidecar under the + /// wrong context — mirroring the block response's own fork-mismatch + /// check, which nothing here exercised before. + #[tokio::test] + async fn a_data_column_sidecars_response_aborts_on_a_fork_digest_mismatch() { + let sidecar = data_column_sidecar(3, 0); + let stream_protocol = StreamProtocol::new(protocols::DATA_COLUMN_SIDECARS_BY_RANGE_V1); + + let mut buffer = Cursor::new(Vec::new()); + codec() + .write_response( + &stream_protocol, + &mut buffer, + Response::success(ResponsePayload::DataColumnSidecars(vec![sidecar])), + ) + .await + .expect("writes"); + + let mut buffer = Cursor::new(buffer.into_inner()); + let mut mismatched_gvr_codec = Codec::beacon(BeaconContext { + config: Config::mainnet(), + genesis_validators_root: Root::repeat_byte(0xee), + }); + let result = mismatched_gvr_codec + .read_response(&stream_protocol, &mut buffer) + .await; + + assert!( + result.is_err(), + "a fork digest mismatch must abort the stream rather than decode successfully" + ); + } } diff --git a/crates/net/p2p/src/req_resp/encoding.rs b/crates/net/p2p/src/req_resp/encoding.rs index 02d9343a6..3e24ce026 100644 --- a/crates/net/p2p/src/req_resp/encoding.rs +++ b/crates/net/p2p/src/req_resp/encoding.rs @@ -2,9 +2,22 @@ use std::io; use libp2p::futures::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; use snap::read::FrameEncoder; +use tracing::debug; + +use super::messages::{ErrorMessage, ResponseCode}; +use crate::metrics; +use libssz::SszDecode as _; pub const MAX_PAYLOAD_SIZE: usize = 10 * 1024 * 1024; // 10 MB +/// An `InvalidData` error, which is what every decode failure on this path is. +/// +/// Shared with `lean::encoding` and `beacon::encoding`, which produce the same +/// error for the same reason. +pub fn invalid(message: impl Into) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, message.into()) +} + // https://github.com/ethereum/consensus-specs/blob/master/specs/phase0/p2p-interface.md#max_message_size pub const MAX_COMPRESSED_PAYLOAD_SIZE: usize = 32 + MAX_PAYLOAD_SIZE + MAX_PAYLOAD_SIZE / 6 + 1024; // ~12 MB @@ -138,6 +151,120 @@ where Ok((uncompressed, frame.len())) } +/// The width of a `` field. +/// +/// One value rather than a per-protocol number, because `ForkDigest`-context is +/// the only context shape either chain uses: "A fixed-width 4 byte +/// ``, set to the `ForkDigest` matching the chunk". The spec does +/// leave the field "defined per req-resp method", so a method that ever defines +/// a different one would need more than [`ChunkLimits::has_context`] can say. +pub const FORK_DIGEST_CONTEXT_LEN: usize = 4; + +/// What a chunked response is allowed to look like, on the two axes the two +/// chains disagree about. +pub struct ChunkLimits { + /// Whether a successful chunk carries a [`FORK_DIGEST_CONTEXT_LEN`]-byte + /// `ForkDigest` before its payload. False on every lean protocol, true on + /// beacon's block and data-column-sidecar protocols. + pub has_context: bool, + /// The most chunks a peer may send before the answer is refused. + /// + /// The loop reads until the peer closes, so without this a peer can stream + /// chunks for as long as the stream lives and every one of them is held in + /// memory. Set to the widest answer the protocol can legitimately produce, + /// which is the request ceiling: a peer sending more than was ever askable + /// for is not answering a request. + pub max_chunks: usize, +} + +/// Read a response that is one chunk per item, until the peer closes. +/// +/// Both chains' block responses have this shape, and the framing around them is +/// identical down to which byte comes first, so the loop lives here and each +/// chain supplies only what genuinely differs: its [`ChunkLimits`], and how a +/// chunk body becomes a value. +/// +/// `response_chunk ::= | | +/// | `. The context bytes are read only after a SUCCESS code, +/// because the spec leaves them empty on an error chunk, and `decode` is handed +/// whatever was read, empty slice included. +/// +/// A chunk with a non-success code is logged and skipped rather than ending the +/// stream, so a peer that holds some of what was asked for can answer with that +/// much. The stream ends at EOF. `Err` only on an I/O error other than +/// `UnexpectedEof`, on more than `max_chunks` chunks, or when `decode` refuses +/// one, in which case nothing read so far is returned: a peer that mis-encodes +/// one chunk has not shown itself trustworthy about the others. +pub async fn read_chunked_response( + io: &mut T, + protocol_label: &str, + limits: ChunkLimits, + decode: F, +) -> io::Result> +where + T: AsyncRead + Unpin + Send, + F: Fn(&[u8], &[u8]) -> io::Result, +{ + let ChunkLimits { + has_context, + max_chunks, + } = limits; + let mut items = Vec::new(); + // Counts every chunk, not just the ones that decoded: a stream of error + // chunks costs the same to read as a stream of blocks. + let mut chunks = 0_usize; + + loop { + let mut result_byte = 0_u8; + if let Err(err) = io.read_exact(std::slice::from_mut(&mut result_byte)).await { + if err.kind() == io::ErrorKind::UnexpectedEof { + break; + } + return Err(err); + } + let code = ResponseCode::from(result_byte); + + chunks += 1; + if chunks > max_chunks { + return Err(invalid(format!( + "{protocol_label} response exceeded {max_chunks} chunks" + ))); + } + + // Only on the success path: an error chunk carries an `ErrorMessage` + // straight after the code byte, with the context field left empty. The + // buffer is on the stack and reused, so a long response does not + // allocate once per chunk for four bytes. + let mut context_buf = [0_u8; FORK_DIGEST_CONTEXT_LEN]; + let context: &[u8] = if has_context && code == ResponseCode::SUCCESS { + io.read_exact(&mut context_buf).await?; + &context_buf + } else { + &[] + }; + + let decoded = decode_payload(io).await?; + let payload = decoded.uncompressed; + metrics::observe_reqresp_response_chunk_size( + protocol_label, + payload.len(), + decoded.compressed_size, + ); + + if code != ResponseCode::SUCCESS { + let error_message = ErrorMessage::from_ssz_bytes(&payload) + .map(|msg| String::from_utf8_lossy(&msg).into_owned()) + .unwrap_or_else(|_| "".to_string()); + debug!(?code, %error_message, "Skipping response chunk with non-success code"); + continue; + } + + items.push(decode(context, &payload)?); + } + + Ok(items) +} + /// Write a varint-prefixed, snappy-compressed SSZ payload. Returns the size /// of the snappy-compressed bytes (excluding the varint length prefix). pub async fn write_payload(io: &mut T, encoded: &[u8]) -> io::Result diff --git a/crates/net/p2p/src/req_resp/handlers.rs b/crates/net/p2p/src/req_resp/handlers.rs index 56913ad02..13e986fdb 100644 --- a/crates/net/p2p/src/req_resp/handlers.rs +++ b/crates/net/p2p/src/req_resp/handlers.rs @@ -1,30 +1,73 @@ -use std::collections::HashSet; - -use ethlambda_network_api::BlockSource; -use ethlambda_storage::Store; +//! What `P2PServer` does with a request/response message, whichever chain it +//! came from. +//! +//! One dispatch and one set of handlers: `handle_req_resp_message` matches the +//! flat `Request` and `ResponsePayload`, so the enum variant is the only place +//! the two chains are told apart. Handler names follow the same convention the +//! variants do, lean prefixed and beacon bare; a handler that serves both wires +//! carries neither chain's name, which is why `handle_blocks_by_root_response` +//! reads as it does. What is chain-specific below the dispatch is the *body* of +//! a handler, never the path to it; encoding lives further down still, in +//! `crate::lean::encoding` and `crate::beacon::encoding`. + +use std::collections::{HashMap, HashSet}; + +use ethlambda_network_api::{BlockArrival, BlockSource}; +use ethlambda_storage::{Chain, Store}; use libp2p::{PeerId, request_response}; use rand::seq::SliceRandom; use spawned_concurrency::tasks::{Context, send_after}; -use std::time::Duration; -use tracing::{debug, error, trace, warn}; - +use std::time::{Duration, Instant}; +use tracing::{debug, error, info, trace, warn}; + +use ethlambda_state_transition::beacon::das; +use ethlambda_types::beacon::config::Config; +use ethlambda_types::beacon::constants; +use ethlambda_types::beacon::containers::SignedBeaconBlock; +use ethlambda_types::beacon::containers::fulu::{ + ColumnIndices, DataColumnSidecar, DataColumnsByRootIdentifier, +}; +use ethlambda_types::beacon::fork::ForkName; use ethlambda_types::checkpoint::Checkpoint; use ethlambda_types::primitives::HashTreeRoot as _; use ethlambda_types::{block::SignedBlock, primitives::H256}; use super::{ - BLOCKS_BY_RANGE_PROTOCOL_V1, BLOCKS_BY_ROOT_PROTOCOL_V1, BlocksByRangeRequest, - BlocksByRootRequest, MAX_REQUEST_BLOCKS, Request, Response, ResponsePayload, Status, + Request, Response, ResponsePayload, messages::{ResponseCode, error_message}, }; +use crate::beacon::BeaconWire; +use crate::beacon::column_checks; +use crate::beacon::decode::{decode_data_column_sidecar, fork_at_slot}; +use crate::beacon::handler::{self as beacon_handler, StatusVersion}; +use crate::beacon::messages::{ + BeaconMetaData, BeaconStatus, DataColumnsByRangeRequest, Goodbye, Ping, +}; +use crate::beacon::protocols::{ + MAX_REQUEST_BLOCKS as MAX_BEACON_REQUEST_BLOCKS, MAX_REQUEST_BLOCKS_DENEB, +}; +use crate::discovery::enr::node_id_from_peer_id; +use crate::lean::messages::{BlocksByRootRequest, RequestedBlockRoots, Status}; +use crate::lean::protocols::MAX_REQUEST_BLOCKS; +use crate::req_resp::messages::BlocksByRangeRequest; use crate::{ - BACKOFF_MULTIPLIER, INITIAL_BACKOFF_MS, MAX_FETCH_RETRIES, MAX_SYNC_RANGE, P2PServer, - PendingRequest, PendingRequestKind, RangeSyncState, p2p_protocol, - req_resp::RequestedBlockRoots, + BACKOFF_MULTIPLIER, CustodyWait, INITIAL_BACKOFF_MS, MAX_FETCH_RETRIES, MAX_SYNC_RANGE, + P2PServer, PendingColumnRequest, PendingRequest, PendingRequestKind, RANGE_BATCH_CUSTODY_WAIT, + RangeSyncState, ReqRespProtocol, ReqRespRequestId, UNKNOWN_CUSTODY_RANGE_PEERS, metrics, + p2p_protocol, }; - +use libp2p::request_response::ResponseChannel; + +/// `protocol` names which [`ReqResp`](super::ReqResp) field `event` came +/// from, which is what turns the bare `OutboundRequestId` a +/// [`request_response::Event::Message`] response or +/// [`request_response::Event::OutboundFailure`] carries back into the +/// composite [`ReqRespRequestId`] `server.outbound_requests` is actually keyed +/// on; see [`ReqRespProtocol`]'s doc comment for why a bare id is no longer +/// safe to look up on its own. pub async fn handle_req_resp_message( server: &mut P2PServer, + protocol: ReqRespProtocol, event: request_response::Event, ctx: &Context, ) { @@ -35,23 +78,94 @@ pub async fn handle_req_resp_message( } => { let peer_count = server.connected_peers.len(); match request { - Request::Status(status) => { + Request::LeanStatus(status) => { trace!(kind = "status_request", peer_count, "P2P message received"); - handle_status_request(server, status, channel, peer).await; + handle_lean_status_request(server, status, channel, peer).await; } + // One variant for both wires, so which handler answers it + // comes from `server.wire` rather than from the message. + // See `Wire::is_beacon`. Request::BlocksByRoot(request) => { trace!( kind = "blocks_by_root_request", peer_count, "P2P message received" ); - handle_blocks_by_root_request(server, request, channel, peer).await; + if server.wire.is_beacon() { + handle_beacon_blocks_by_root_request(server, peer, request, channel) + .await; + } else { + handle_lean_blocks_by_root_request(server, request, channel, peer) + .await; + } } Request::BlocksByRange(request) => { trace!( kind = "blocks_by_range_request", peer_count, "P2P message received" ); - handle_blocks_by_range_request(server, request, channel, peer).await; + if server.wire.is_beacon() { + handle_beacon_blocks_by_range_request(server, peer, request, channel) + .await; + } else { + handle_lean_blocks_by_range_request(server, request, channel, peer) + .await; + } + } + // The beacon protocols. One arm each rather than a grouping + // variant that the beacon handler would have to + // re-discriminate: the protocol id already decided which + // this is, in the codec. + Request::Status(peer_status) => { + trace!( + kind = "beacon_status_request", + peer_count, "P2P message received" + ); + handle_status_request(server, peer, peer_status, channel).await; + } + Request::Ping(ping) => { + trace!(kind = "beacon_ping", peer_count, "P2P message received"); + handle_ping(server, peer, ping, channel).await; + } + Request::MetaData(protocol) => { + trace!( + kind = "beacon_metadata_request", + peer_count, "P2P message received" + ); + handle_metadata_request(server, peer, protocol, channel).await; + } + Request::Goodbye(goodbye) => { + trace!(kind = "beacon_goodbye", peer_count, "P2P message received"); + // No response: goodbye is one-way, and dropping + // `channel` closes the stream, which is what the peer + // is waiting for. + handle_goodbye(peer, goodbye); + } + // Beacon-only, like the four request arms above: lean + // custodies no data columns, so there is no lean-side + // handler to branch to the way `BlocksByRoot`/`BlocksByRange` + // do. + Request::DataColumnsByRoot(identifiers) => { + trace!( + kind = "data_column_sidecars_by_root_request", + peer_count, "P2P message received" + ); + handle_data_column_sidecars_by_root_request( + server, + peer, + identifiers, + channel, + ) + .await; + } + Request::DataColumnsByRange(request) => { + trace!( + kind = "data_column_sidecars_by_range_request", + peer_count, "P2P message received" + ); + handle_data_column_sidecars_by_range_request( + server, peer, request, channel, + ) + .await; } } } @@ -59,30 +173,146 @@ pub async fn handle_req_resp_message( request_id, response, } => { + // See `handle_req_resp_message`'s own doc comment: `request_id` + // alone is not a safe `outbound_requests` key any more, so it + // is composited with the protocol this event's own field + // named before anything below looks it up. + let request_id = ReqRespRequestId { + protocol, + id: request_id, + }; let peer_count = server.connected_peers.len(); match response { Response::Success { payload } => match payload { - ResponsePayload::Status(status) => { + ResponsePayload::LeanStatus(status) => { trace!(kind = "status_response", peer_count, "P2P message received"); - handle_status_response(server, status, peer).await; + handle_lean_status_response(server, status, peer).await; } - ResponsePayload::Blocks(blocks) => { - trace!(kind = "blocks_response", peer_count, "P2P message received"); - + ResponsePayload::Status(status) => { + trace!( + kind = "beacon_status_response", + peer_count, "P2P message received" + ); + handle_status_response(server, peer, status, ctx).await; + } + ResponsePayload::Pong(ping) => { + trace!(kind = "beacon_pong", peer_count, "P2P message received"); + handle_pong(peer, ping); + } + ResponsePayload::MetaData(metadata) => { + trace!( + kind = "beacon_metadata_response", + peer_count, "P2P message received" + ); + handle_metadata_response(server, peer, metadata); + // A peer's custody usually becomes known here, so + // a range batch held back for it may go now. + resume_range_batch_held_for_custody(server, ctx).await; + } + ResponsePayload::DataColumnSidecars(sidecars) => { + trace!( + kind = "data_column_sidecars_response", + peer_count, "P2P message received" + ); + // Two senders produce this payload: a `Columns` + // by-root lookup for one held block, and a + // `ColumnRange` prefetch riding alongside a range + // sync batch. Removed here, like `Blocks` removes + // its own entry, rather than left for a later + // event: this response is the terminal outcome for + // the id either way. match server.outbound_requests.remove(&request_id) { - Some(PendingRequestKind::Range { + Some(PendingRequestKind::Columns(block_root)) => { + handle_data_column_sidecars_response( + server, peer, block_root, sidecars, ctx, + ) + .await; + } + Some(PendingRequestKind::ColumnRange { start_slot, end_slot, }) => { - handle_blocks_by_range_response( - server, blocks, peer, start_slot, end_slot, + handle_data_column_sidecars_range_response( + server, peer, start_slot, end_slot, sidecars, ) .await; } + // Unreachable by construction: a request_id's + // protocol is fixed at send time, and only the + // two column kinds are ever sent on this + // protocol, so the codec could not have + // produced this payload for a `Root`/`Range` + // id. Not re-inserted: the exchange this id + // named is already over, and putting a `Root` + // or `Range` entry back here would strand it + // exactly the way #608 fixed, since no further + // event will ever name this id again. + Some( + PendingRequestKind::Root(_) | PendingRequestKind::Range { .. }, + ) => { + error!( + %peer, + ?request_id, + count = sidecars.len(), + "Data column sidecars response answered a non-column request id" + ); + } + None => { + debug!( + %peer, + ?request_id, + count = sidecars.len(), + "Received data column sidecars response for unknown request_id" + ); + } + } + } + ResponsePayload::Blocks(blocks) => { + trace!(kind = "blocks_response", peer_count, "P2P message received"); + // Dispatched on what was asked for first and on the + // wire second, because only the range answer is + // handled differently by the two chains: a by-root + // answer is one shared handler, since the block it + // carries either has the root that was asked for or + // the request has failed, on either wire. + match server.outbound_requests.remove(&request_id) { Some(PendingRequestKind::Root(root)) => { handle_blocks_by_root_response(server, blocks, peer, root, ctx) .await; } + Some(PendingRequestKind::Range { + start_slot, + end_slot, + }) => { + if server.wire.is_beacon() { + handle_beacon_blocks_by_range_response( + server, peer, blocks, start_slot, end_slot, ctx, + ) + .await; + } else { + // `new_block` takes lean's concrete + // block, so the shared payload is + // peeled here, at the one point that + // needs the narrower type. + let blocks = lean_blocks(blocks); + handle_lean_blocks_by_range_response( + server, blocks, peer, start_slot, end_slot, + ) + .await; + } + } + // Unreachable: a column request negotiates the + // data column protocol, which answers with + // `DataColumnSidecars`, never with `Blocks`. + Some( + PendingRequestKind::Columns(_) + | PendingRequestKind::ColumnRange { .. }, + ) => { + error!( + %peer, + "Blocks response answered a data column request id" + ); + } None => { debug!(%peer, ?request_id, "Received blocks response for unknown request_id"); } @@ -104,6 +334,18 @@ pub async fn handle_req_resp_message( // forever and deduplicates every later fetch. handle_fetch_failure(server, root, peer, ctx).await; } + Some(PendingRequestKind::Columns(block_root)) => { + // Same reasoning as the `Root` arm above: an + // error response is the whole exchange, so + // this is the only place that can retire it. + handle_column_fetch_failure(server, block_root, peer, ctx).await; + } + // Nothing to retire: a range prefetch has no + // pending entry and nothing waits on it. A peer + // refusing the range (ResourceUnavailable, say) + // just means these columns come from gossip or + // from the by-root path instead. + Some(PendingRequestKind::ColumnRange { .. }) => {} None => {} } } @@ -116,6 +358,12 @@ pub async fn handle_req_resp_message( error, .. } => { + // Same compositing as the `Message::Response` arm above, and for + // the same reason. + let request_id = ReqRespRequestId { + protocol, + id: request_id, + }; debug!(%peer, ?request_id, %error, "Outbound request failed"); // Check if this was a block fetch request @@ -135,7 +383,39 @@ pub async fn handle_req_resp_message( "BlocksByRange request failed; retry is disabled" ); } - None => {} + Some(PendingRequestKind::Columns(block_root)) => { + handle_column_fetch_failure(server, block_root, peer, ctx).await; + } + // Nothing waits on a range prefetch, so a failure is only + // worth a line: the blocks it rode alongside have their own + // failure path, and any column this would have delivered is + // still reachable by root once a block is held for it. + Some(PendingRequestKind::ColumnRange { + start_slot, + end_slot, + }) => { + debug!( + %peer, + start_slot, + end_slot, + "DataColumnsByRange request failed; columns fall back to the by-root path" + ); + } + // The handshake is the only *tracked* request kind absent + // here: every other outcome for a `Root`, `Range` or `Columns` + // id is handled above, so reaching `None` means either the + // untracked handshake or an id this process never recorded. + // Only the former is worth acting on: a peer that has dropped + // `status/1` refuses the stream outright, and without a + // handshake it never enters the sync peer set at all. + None => { + if matches!( + error, + request_response::OutboundFailure::UnsupportedProtocols + ) { + crate::beacon::handler::retry_status_on_other_version(server, peer).await; + } + } } } request_response::Event::InboundFailure { @@ -154,7 +434,53 @@ pub async fn handle_req_resp_message( } } -async fn handle_status_request( +/// The lean blocks in a shared block payload. +/// +/// `ResponsePayload::Blocks` spans both chains, but a lean import path needs +/// lean's own `SignedBlock`, so the narrowing happens once here. A block of any +/// other fork on this path means a peer answered a lean protocol with a beacon +/// block; it is dropped with a log rather than silently, since nothing else +/// would notice. +fn lean_blocks(blocks: Vec) -> Vec { + blocks + .into_iter() + .filter_map(|block| match block { + SignedBeaconBlock::Lean(block) => Some(block), + other => { + debug!( + slot = other.slot(), + fork = %other.fork_name(), + "Dropping a non-lean block from a lean block response" + ); + None + } + }) + .collect() +} + +/// Answer a request with a success payload. +/// +/// Every request handler on either chain ends here, which is most of what the +/// two have in common above encoding. +fn respond(server: &mut P2PServer, channel: ResponseChannel, payload: ResponsePayload) { + server + .swarm_handle + .send_response(channel, Response::success(payload)); +} + +/// Answer a request with an error code and a reason. +fn refuse( + server: &mut P2PServer, + channel: ResponseChannel, + code: ResponseCode, + reason: &str, +) { + server + .swarm_handle + .send_response(channel, Response::error(code, error_message(reason))); +} + +async fn handle_lean_status_request( server: &mut P2PServer, request: Status, channel: request_response::ResponseChannel, @@ -162,11 +488,10 @@ async fn handle_status_request( ) { trace!(finalized_slot=%request.finalized.slot, head_slot=%request.head.slot, "Received status request from peer {peer}"); let our_status = build_status(&server.store); - let response = Response::success(ResponsePayload::Status(our_status)); - server.swarm_handle.send_response(channel, response); + respond(server, channel, ResponsePayload::LeanStatus(our_status)); } -async fn handle_status_response(server: &mut P2PServer, status: Status, peer: PeerId) { +async fn handle_lean_status_response(server: &mut P2PServer, status: Status, peer: PeerId) { trace!(finalized_slot=%status.finalized.slot, head_slot=%status.head.slot, "Received status response from peer {peer}"); let our_head_slot = server.store.head_slot(); @@ -200,7 +525,7 @@ async fn handle_status_response(server: &mut P2PServer, status: Status, peer: Pe trace!(%peer, start_slot, gap, "Long-range sync: using BlocksByRange"); } -async fn handle_blocks_by_root_request( +async fn handle_lean_blocks_by_root_request( server: &mut P2PServer, request: BlocksByRootRequest, channel: request_response::ResponseChannel, @@ -211,20 +536,22 @@ async fn handle_blocks_by_root_request( let mut blocks = Vec::new(); for root in request.roots.iter() { - if let Ok(Some(signed_block)) = server.store.get_signed_block(root) { - blocks.push(signed_block); + // A missing block is silently skipped, per spec. A block of the wrong + // fork is not filtered here either: `write_blocks_response` refuses to + // put one on this chain's wire, which is the only place it could do + // harm. + if let Ok(Some(block)) = server.store.get_signed_block(root) { + blocks.push(block); } - // Missing blocks are silently skipped (per spec) } let found = blocks.len(); trace!(%peer, num_roots, found, "Responding to BlocksByRoot request"); - let response = Response::success(ResponsePayload::Blocks(blocks)); - server.swarm_handle.send_response(channel, response); + respond(server, channel, ResponsePayload::Blocks(blocks)); } -async fn handle_blocks_by_range_request( +async fn handle_lean_blocks_by_range_request( server: &mut P2PServer, request: BlocksByRangeRequest, channel: request_response::ResponseChannel, @@ -238,11 +565,12 @@ async fn handle_blocks_by_range_request( ); if request.count == 0 || request.count > MAX_REQUEST_BLOCKS { - let response = Response::error( + refuse( + server, + channel, ResponseCode::INVALID_REQUEST, - error_message("invalid BlocksByRange request"), + "invalid BlocksByRange request", ); - server.swarm_handle.send_response(channel, response); return; } @@ -256,15 +584,20 @@ async fn handle_blocks_by_range_request( "Responding to BlocksByRange request" ); - let response = Response::success(ResponsePayload::Blocks(blocks)); - server.swarm_handle.send_response(channel, response); + respond(server, channel, ResponsePayload::Blocks(blocks)); } -fn canonical_blocks_by_range(store: &Store, start_slot: u64, count: u64) -> Vec { - if count == 0 { - return Vec::new(); - } - +/// The canonical blocks in `[start_slot, start_slot + count)`, on either chain. +/// +/// One reader for both `blocks_by_range` protocols. The `BlockRoots` index it +/// walks is written for either chain and keyed by slot alone, and the store +/// dispatches on which chain's rows sit behind it, so there is nothing left +/// here for a chain to decide. +/// +/// A window that overflows `u64` yields an empty answer rather than a panic, +/// which is not a nicety: `start_slot` and `count` are attacker-supplied. A +/// `count` of zero takes the same path, since there is no last offset to add. +fn canonical_blocks_by_range(store: &Store, start_slot: u64, count: u64) -> Vec { let Some(end_slot) = count .checked_sub(1) .and_then(|last_offset| start_slot.checked_add(last_offset)) @@ -285,9 +618,23 @@ fn canonical_blocks_by_range(store: &Store, start_slot: u64, count: u64) -> Vec< .unwrap_or_default() } +/// Take delivery of a by-root answer, on either chain. +/// +/// One handler for both wires, because a by-root answer is the same exchange +/// on each: a request carries exactly one root, so the peer either sent the +/// block under that root or it answered nothing, and the two outcomes are the +/// same either way. An answer that carries no matching block — an empty one +/// included, which is only the same case with nothing to search — is a failed +/// attempt and must go through [`handle_fetch_failure`], or the root stays in +/// `pending_root_requests` and deduplicates every later fetch of it. +/// +/// The import at the end is where the chains part, and the split is already +/// made for us: `blockchain` is `None` on a beacon node, which has no +/// `BlockChain` actor to import into, so a beacon block fetched by root is +/// checked and dropped exactly as a gossiped one is. async fn handle_blocks_by_root_response( server: &mut P2PServer, - blocks: Vec, + blocks: Vec, peer: PeerId, requested_root: H256, ctx: &Context, @@ -299,7 +646,7 @@ async fn handle_blocks_by_root_response( // anything else the peer sent is unsolicited. let answer = blocks .into_iter() - .find(|block| block.message.hash_tree_root() == requested_root); + .find(|block| block.message_hash_tree_root() == requested_root); let Some(block) = answer else { debug!( %peer, @@ -314,14 +661,35 @@ async fn handle_blocks_by_root_response( // Clean up tracking for this root server.pending_root_requests.remove(&requested_root); - if let Some(ref blockchain) = server.blockchain { - let _ = blockchain - .new_block(block, BlockSource::Sync) - .inspect_err(|err| error!(%err, "Failed to forward fetched block to blockchain")); + let Some(ref blockchain) = server.blockchain else { + debug!( + %peer, + slot = block.slot(), + block_root = %ethlambda_types::ShortRoot(&requested_root.0), + "Block fetched by root has no importer; dropping" + ); + return; + }; + + // A non-lean block on a lean node means a peer answered a lean protocol + // with a beacon block; it is dropped with a log rather than silently, for + // the reason `lean_blocks` gives. A beacon node asked on its own protocol + // and forwards whatever fork came back. + if !server.wire.is_beacon() && !matches!(block, SignedBeaconBlock::Lean(_)) { + debug!( + slot = block.slot(), + fork = %block.fork_name(), + "Dropping a non-lean block from a lean block response" + ); + return; } + + let _ = blockchain + .new_block(block, BlockSource::Sync, BlockArrival::now()) + .inspect_err(|err| error!(%err, "Failed to forward fetched block to blockchain")); } -async fn handle_blocks_by_range_response( +async fn handle_lean_blocks_by_range_response( server: &mut P2PServer, blocks: Vec, peer: PeerId, @@ -351,7 +719,11 @@ async fn handle_blocks_by_range_response( } let block_root = block.message.hash_tree_root(); - if let Err(err) = blockchain.new_block(block, BlockSource::Sync) { + if let Err(err) = blockchain.new_block( + SignedBeaconBlock::Lean(block), + BlockSource::Sync, + BlockArrival::now(), + ) { error!( %err, %slot, %peer, block_root = %ethlambda_types::ShortRoot(&block_root.0), @@ -362,7 +734,7 @@ async fn handle_blocks_by_range_response( if let Some(state) = &mut server.range_sync_state { state.complete_batch(end_slot); - if state.current_range.is_empty() || state.peer_set.is_empty() { + if range_session_exhausted(state) { server.range_sync_state = None; return; } @@ -371,6 +743,15 @@ async fn handle_blocks_by_range_response( request_next_range_batch(server).await; } +/// Whether a range sync session has nothing left to do: either the requested +/// range is now empty, or every peer that had something left to offer has +/// dropped out of `peer_set`. Shared by both chains' range-response handlers, +/// since a session left in place once it is exhausted disables the resync +/// path permanently, on either wire. +fn range_session_exhausted(state: &RangeSyncState) -> bool { + state.current_range.is_empty() || state.peer_set.is_empty() +} + /// Build a Status message from the current Store state. pub fn build_status(store: &Store) -> Status { let finalized = store.latest_finalized().expect("finalized block exists"); @@ -391,6 +772,13 @@ pub fn build_status(store: &Store) -> Status { /// Fetch a missing block from a random connected peer. /// Handles tracking in both pending_requests and request_id_map. +/// +/// Peer selection is chain-agnostic (which peers already failed to answer for +/// this root has nothing to do with which wire is speaking), but the actual +/// send is not: `Handler` in `lib.rs` calls this unconditionally, +/// so a beacon node must not put a lean-framed `BlocksByRoot` request on its +/// beacon streams, which is what asking via `Request::BlocksByRoot` + +/// [`ReqRespProtocol::LeanBlocksByRoot`] unconditionally would do. pub async fn fetch_block_from_peer(server: &mut P2PServer, root: H256) -> bool { if server.connected_peers.is_empty() { debug!(%root, "Cannot fetch block: no connected peers"); @@ -403,12 +791,12 @@ pub async fn fetch_block_from_peer(server: &mut P2PServer, root: H256) -> bool { .get(&root) .map(|p| &p.failed_peers); let pool: Vec<_> = if failed.is_none_or(|f| f.is_empty()) { - server.connected_peers.iter().copied().collect() + server.connected_peers.keys().copied().collect() } else { let failed = failed.unwrap(); server .connected_peers - .iter() + .keys() .copied() .filter(|p| !failed.contains(p)) .collect() @@ -422,7 +810,7 @@ pub async fn fetch_block_from_peer(server: &mut P2PServer, root: H256) -> bool { if let Some(pending) = server.pending_root_requests.get_mut(&root) { pending.failed_peers.clear(); } - server.connected_peers.iter().copied().collect() + server.connected_peers.keys().copied().collect() } else { pool }; @@ -434,31 +822,51 @@ pub async fn fetch_block_from_peer(server: &mut P2PServer, root: H256) -> bool { return false; } }; + let excluded = server.connected_peers.len() - pool.len(); - // Create BlocksByRoot request with single root - let mut roots = RequestedBlockRoots::new(); - if let Err(err) = roots.push(root) { - error!(%root, ?err, "Failed to create BlocksByRoot request"); - return false; - } - let request = BlocksByRootRequest { roots }; + let sent = if server.wire.is_beacon() { + trace!(%peer, %root, excluded, "Sending BeaconBlocksByRoot request for missing block"); + request_beacon_block_by_root(server, peer, root) + .await + .is_some() + } else { + // Create BlocksByRoot request with single root + let mut roots = RequestedBlockRoots::new(); + if let Err(err) = roots.push(root) { + error!(%root, ?err, "Failed to create BlocksByRoot request"); + return false; + } + let request = BlocksByRootRequest { roots }; - let excluded = server.connected_peers.len() - pool.len(); - trace!(%peer, %root, excluded, "Sending BlocksByRoot request for missing block"); - let Some(request_id) = server - .swarm_handle - .send_request( - peer, - Request::BlocksByRoot(request), - libp2p::StreamProtocol::new(BLOCKS_BY_ROOT_PROTOCOL_V1), - ) - .await - else { - debug!(%root, "Failed to send BlocksByRoot request (swarm adapter closed)"); - return false; + trace!(%peer, %root, excluded, "Sending BlocksByRoot request for missing block"); + let Some(request_id) = server + .swarm_handle + .send_request( + peer, + Request::BlocksByRoot(request), + ReqRespProtocol::LeanBlocksByRoot, + ) + .await + else { + debug!(%root, "Failed to send BlocksByRoot request (swarm adapter closed)"); + return false; + }; + // Map request_id to root for failure handling. `request_beacon_block_by_root` + // does this itself in the beacon arm above. + server + .outbound_requests + .insert(request_id, PendingRequestKind::Root(root)); + true }; - // Track the request if not already tracked (new request) + if !sent { + debug!(%root, "Failed to send by-root request (swarm adapter closed)"); + return false; + } + + // Track the request if not already tracked (new request). Common to both + // arms: this is what dedupes a repeated fetch and what the retry path + // reads. server .pending_root_requests .entry(root) @@ -467,10 +875,240 @@ pub async fn fetch_block_from_peer(server: &mut P2PServer, root: H256) -> bool { failed_peers: HashSet::new(), }); - // Map request_id to root for failure handling + true +} + +/// Record what columns `peer` custodies, given the custody group count it +/// advertised. +/// +/// Both inputs are public: the count comes from the peer's `metadata/3` answer +/// or its ENR `cgc`, and the node id is recovered from the `PeerId` itself. +/// `custody_columns` is the same function the peer ran to decide what to keep, +/// so this reproduces its answer rather than approximating it. +/// +/// An out-of-range count or an unreadable node id leaves no entry at all, +/// which reads as "unknown" and not as "custodies nothing" — see +/// [`columns_custodied_by`]. +pub(crate) fn record_peer_custody(server: &mut P2PServer, peer: PeerId, custody_group_count: u64) { + if !(constants::CUSTODY_REQUIREMENT..=constants::NUMBER_OF_CUSTODY_GROUPS) + .contains(&custody_group_count) + { + debug!(%peer, custody_group_count, "Ignoring an out-of-range custody group count"); + return; + } + let Some(node_id) = node_id_from_peer_id(&peer) else { + debug!(%peer, "Cannot recover a node id for this peer; its custody stays unknown"); + return; + }; + match das::custody_columns(node_id, custody_group_count) { + Ok(columns) => { + trace!(%peer, custody_group_count, count = columns.len(), "Recorded peer custody"); + server.peer_custody.insert(peer, columns); + // Republished here rather than only on connection events: this is + // where a peer's custody actually becomes known, and for the + // `metadata/3` path that is after it connected. + server.refresh_custody_column_metrics(); + } + Err(err) => debug!(%peer, custody_group_count, ?err, "Cannot compute this peer's custody"), + } +} + +/// The peers known to custody `column`, among those currently connected. +/// +/// A peer with no entry is omitted rather than assumed: its custody is unknown, +/// and [`fetch_data_columns_from_peer`] falls back to the whole connected set +/// when this comes back empty, so an unknown peer is still reachable — just not +/// preferred over one we know holds the column. +pub(crate) fn columns_custodied_by(server: &P2PServer, column: u64) -> Vec { server - .outbound_requests - .insert(request_id, PendingRequestKind::Root(root)); + .connected_peers + .keys() + .filter(|peer| { + server + .peer_custody + .get(*peer) + .is_some_and(|columns| columns.contains(&column)) + }) + .copied() + .collect() +} + +/// Split `columns` across the peers that custody them, so each request goes +/// somewhere it can actually be answered. +/// +/// Returns one entry per chosen peer with the columns that peer holds, plus the +/// columns no connected peer is known to custody. Mirrors lighthouse's +/// `select_columns_by_range_peers_to_request`: pick per column, prefer the peer +/// carrying the fewest of this lookup's columns so one peer is not asked for +/// everything, and report the gap rather than papering over it. +fn group_columns_by_custody_peer( + server: &P2PServer, + columns: &[u64], + exclude: &HashSet, +) -> (HashMap>, Vec) { + let mut by_peer: HashMap> = HashMap::new(); + let mut uncovered = Vec::new(); + + for &column in columns { + let candidates: Vec = columns_custodied_by(server, column) + .into_iter() + .filter(|peer| !exclude.contains(peer)) + .collect(); + // `min_by_key` over the load already assigned in this same call, with + // the peer id breaking ties so the choice is deterministic for a given + // peer set rather than dependent on HashSet iteration order. + let chosen = candidates + .iter() + .min_by_key(|peer| (by_peer.get(*peer).map_or(0, Vec::len), **peer)); + match chosen { + Some(&peer) => by_peer.entry(peer).or_default().push(column), + None => uncovered.push(column), + } + } + + (by_peer, uncovered) +} + +/// Ask a connected peer for specific columns of `block_root`, mirroring +/// [`fetch_block_from_peer`]'s peer selection, dedup and retry bookkeeping +/// against `pending_column_requests` rather than `pending_root_requests`. +/// +/// Beacon-only, unconditionally: unlike [`fetch_block_from_peer`], which has +/// to dispatch on `server.wire` because both chains serve a block-shaped +/// request, `DataColumnsByRoot` has no lean counterpart, so there is no wire +/// to branch on. `fetch_missing_columns` in `lib.rs` calls this directly for +/// the same reason. +pub async fn fetch_data_columns_from_peer( + server: &mut P2PServer, + block_root: H256, + columns: Vec, +) -> bool { + if server.connected_peers.is_empty() { + debug!(%block_root, "Cannot fetch data columns: no connected peers"); + metrics::inc_data_column_fetch_failure("no_peers"); + return false; + } + + // Same pool-narrowing-then-fallback shape as `fetch_block_from_peer`. + let failed = server + .pending_column_requests + .get(&block_root) + .map(|p| &p.failed_peers); + let pool: Vec<_> = if failed.is_none_or(|f| f.is_empty()) { + server.connected_peers.keys().copied().collect() + } else { + let failed = failed.unwrap(); + server + .connected_peers + .keys() + .copied() + .filter(|p| !failed.contains(p)) + .collect() + }; + + let pool = if pool.is_empty() { + debug!(%block_root, "All peers failed for this lookup, retrying with full peer set"); + if let Some(pending) = server.pending_column_requests.get_mut(&block_root) { + pending.failed_peers.clear(); + } + server.connected_peers.keys().copied().collect() + } else { + pool + }; + let excluded = server.connected_peers.len() - pool.len(); + + // Aim each column at a peer that custodies it. Whatever no connected peer + // is known to custody falls back to one random peer from the pool, which + // is all this function could ever do before: it may hold the column and + // simply not have told us its count yet. + let exclude: HashSet = server + .connected_peers + .keys() + .filter(|peer| !pool.contains(peer)) + .copied() + .collect(); + let (mut by_peer, uncovered) = group_columns_by_custody_peer(server, &columns, &exclude); + + if !uncovered.is_empty() { + match pool.choose(&mut rand::thread_rng()) { + Some(&peer) => { + debug!( + %block_root, + %peer, + count = uncovered.len(), + "No connected peer is known to custody these columns; asking one at random" + ); + by_peer.entry(peer).or_default().extend(uncovered); + } + None => { + debug!(%block_root, "Failed to select random peer"); + return false; + } + } + } + + let mut sent_count = 0usize; + for (peer, columns) in by_peer { + let count = columns.len(); + let column_indices = match ColumnIndices::try_from(columns) { + Ok(indices) => indices, + Err(err) => { + // A caller asking for more than one block's worth of columns is a + // programming error on the chain-actor side, not a peer or wire + // fault, so this does not retry. + error!(%block_root, ?err, "Too many columns requested in one DataColumnsByRoot lookup"); + return false; + } + }; + let identifier = DataColumnsByRootIdentifier { + block_root, + columns: column_indices, + }; + + trace!(%peer, %block_root, excluded, count, "Sending DataColumnsByRoot request for missing columns"); + let Some(request_id) = server + .swarm_handle + .send_request( + peer, + Request::DataColumnsByRoot(vec![identifier]), + ReqRespProtocol::DataColumnSidecarsByRoot, + ) + .await + else { + debug!(%block_root, %peer, "Failed to send DataColumnsByRoot request (swarm adapter closed)"); + continue; + }; + server + .outbound_requests + .insert(request_id, PendingRequestKind::Columns(block_root)); + sent_count += 1; + } + + if sent_count == 0 { + return false; + } + + // `or_insert` rather than unconditional insert: a retry re-enters here + // with the same root already tracked, and must not reset `attempts` back + // to a fresh lookup's value. + let pending = + server + .pending_column_requests + .entry(block_root) + .or_insert(PendingColumnRequest { + columns, + attempts: 1, + failed_peers: HashSet::new(), + in_flight: 0, + last_asked: Instant::now(), + }); + // Set rather than added to: every request from the previous round has + // already reported back, since this round only starts once the last one + // did (see `PendingColumnRequest::in_flight`). + pending.in_flight = sent_count; + // Each round's own timestamp, not the lookup's first: a ladder still + // working through the peer set is alive, however long it has been running. + pending.last_asked = Instant::now(); true } @@ -484,10 +1122,7 @@ async fn request_next_range_batch(server: &mut P2PServer) -> bool { return true; }; - let request = BlocksByRangeRequest { - start_slot: batch.start, - count: batch.end - batch.start, - }; + let request = BlocksByRangeRequest::new(batch.start, batch.end - batch.start); let count = request.count; trace!( @@ -507,7 +1142,7 @@ async fn request_next_range_batch(server: &mut P2PServer) -> bool { .send_request( peer, Request::BlocksByRange(request), - libp2p::StreamProtocol::new(BLOCKS_BY_RANGE_PROTOCOL_V1), + ReqRespProtocol::LeanBlocksByRange, ) .await else { @@ -536,28 +1171,206 @@ async fn request_next_range_batch(server: &mut P2PServer) -> bool { true } -fn fail_range_request(server: &mut P2PServer, peer: &PeerId) { - let should_clear = if let Some(state) = &mut server.range_sync_state { - state.fail_peer(peer); - state.peer_set.is_empty() +/// The beacon counterpart of [`request_next_range_batch`]. +/// +/// Same [`RangeSyncState::next_batch`] planning, but sent through +/// [`request_beacon_blocks_by_range`] rather than a raw `swarm_handle.send_request`: +/// that is what makes the beacon protocol id apply instead of lean's, and what +/// makes [`MAX_REQUEST_BLOCKS_DENEB`] the real per-request ceiling rather than +/// the larger `MAX_REQUEST_BLOCKS` that `next_batch` plans a batch against. +/// `request_beacon_blocks_by_range` already records the `outbound_requests` +/// entry with whatever it actually sent (clamped or not), so +/// [`RangeSyncState::complete_batch`] still advances by the true request span +/// on the next response even when this batch was clamped smaller than +/// `next_batch` planned. +/// +/// A batch that needs columns is held back, blocks included, until every one +/// of this node's custody columns has a known custodian among the connected +/// peers, or until [`RANGE_BATCH_CUSTODY_WAIT`] runs out. Lighthouse's range +/// sync holds its batches back the same way, and for the same reason: blocks +/// and columns go out together, and a column request sent before custody is +/// known asks peers that do not keep the columns. Holding returns `true`, since +/// nothing failed. The batch is re-checked when a peer's metadata arrives (see +/// [`resume_range_batch_held_for_custody`]), on every call that would have sent +/// it anyway, and at the deadline. +async fn request_next_beacon_range_batch(server: &mut P2PServer, ctx: &Context) -> bool { + let Some((peer, batch)) = server + .range_sync_state + .as_ref() + .and_then(RangeSyncState::next_batch) + else { + return true; + }; + + let uncovered = if range_batch_needs_columns(server, &batch) { + custody_columns_without_known_custodian(server) } else { - false + Vec::new() + }; + let Some(state) = &mut server.range_sync_state else { + return true; }; + let now = Instant::now(); + if !uncovered.is_empty() { + match state.wait_for_custody(now) { + CustodyWait::Started => { + debug!( + start_slot = batch.start, + uncovered = uncovered.len(), + "Holding a range batch until its custody columns have known custodians" + ); + send_after( + RANGE_BATCH_CUSTODY_WAIT, + ctx.clone(), + p2p_protocol::RetryBeaconRangeBatch, + ); + return true; + } + CustodyWait::Waiting => return true, + CustodyWait::Expired => {} + } + } + if let Some(waited) = state.end_custody_wait(now) { + // `info!` rather than `debug!`: this is the whole of a follower's + // catch-up start being delayed, it happens about once per restart, and + // production runs at `INFO`. + info!( + start_slot = batch.start, + waited_ms = waited.as_millis() as u64, + uncovered = uncovered.len(), + "Sending a range batch held back for custody" + ); + } - if should_clear { - server.range_sync_state = None; + let planned = batch.end - batch.start; + // `planned`, not `count`: `next_batch` plans against `MAX_REQUEST_BLOCKS` + // and the send clamps to `MAX_REQUEST_BLOCKS_DENEB`, so the number that + // goes on the wire is the one `request_beacon_blocks_by_range` traces. + trace!( + %peer, + start_slot = batch.start, + planned, + "Planning a BeaconBlocksByRange request (single batch)" + ); + + if request_beacon_blocks_by_range(server, peer, batch.start, planned) + .await + .is_none() + { + debug!( + %peer, + start_slot = batch.start, + planned, + "Failed to send BeaconBlocksByRange request" + ); + fail_range_request(server, &peer); + return false; + } + + // Pull the columns for the same span alongside the blocks, so they are + // already stored when each block reaches the availability gate rather than + // chased one root at a time after it has been held. Not gated on success: + // a batch whose custody wait expired still syncs its blocks, and the + // by-root path remains behind every one that turns out to be missing. + request_beacon_data_columns_by_range(server, batch.start, planned).await; + + if let Some(state) = &mut server.range_sync_state { + state.in_flight = true; } + + true } -/// Retire one failed attempt at fetching `root`. +/// Re-check a range batch held back for custody, and send it if it may go now. +/// Nothing happens unless a batch is held. /// -/// Every path that ends an attempt must come through here: a root left in -/// `pending_root_requests` is deduplicated out of every later fetch, so a -/// silent exit loses that block for the life of the process. -async fn handle_fetch_failure( +/// Called at the batch's deadline and after every peer metadata answer, which +/// is where a peer's custody usually becomes known. The other place it is +/// learned, a peer's ENR `cgc`, is read on connection, and the `Status` +/// exchange that follows a connection re-checks the batch through +/// [`handle_status_response`] already. +/// +/// Only a *held* batch: a range session also sits idle after a failed batch, +/// until the next `Status` answer restarts it, and restarting it from here +/// would change that behavior on every metadata answer. +pub(crate) async fn resume_range_batch_held_for_custody( server: &mut P2PServer, - root: H256, - peer: PeerId, + ctx: &Context, +) { + let held = server + .range_sync_state + .as_ref() + .is_some_and(RangeSyncState::is_waiting_for_custody); + if held { + request_next_beacon_range_batch(server, ctx).await; + } +} + +/// Whether a range batch over `batch` asks for data columns at all. +fn range_batch_needs_columns(server: &P2PServer, batch: &std::ops::Range) -> bool { + server + .wire + .beacon() + .is_some_and(|wire| range_needs_columns(&wire.config, &wire.custody_columns, batch)) +} + +/// Whether blocks over `batch` can carry columns this node custodies: its last +/// slot is at or after fulu, and the custody set is not empty. Lighthouse's +/// range sync skips its custody-peer check before PeerDAS for the same reason: +/// a batch with no columns to fetch has no custodian to wait for. +fn range_needs_columns( + config: &Config, + custody_columns: &[u64], + batch: &std::ops::Range, +) -> bool { + let last_slot = batch.end.saturating_sub(1); + !custody_columns.is_empty() && fork_at_slot(config, last_slot) >= ForkName::Fulu +} + +/// This node's custody columns that no connected peer is known to custody. +fn custody_columns_without_known_custodian(server: &P2PServer) -> Vec { + let Some(wire) = server.wire.beacon() else { + return Vec::new(); + }; + columns_without_known_custodian(server, &wire.custody_columns) +} + +/// The columns among `columns` that no connected peer is known to custody. +/// +/// Counted through [`columns_custodied_by`], the same answer +/// [`request_beacon_data_columns_by_range`] aims its requests with, so a batch +/// is released exactly when that request would find a custodian for every +/// column. +fn columns_without_known_custodian(server: &P2PServer, columns: &[u64]) -> Vec { + columns + .iter() + .copied() + .filter(|&column| columns_custodied_by(server, column).is_empty()) + .collect() +} + +fn fail_range_request(server: &mut P2PServer, peer: &PeerId) { + let should_clear = if let Some(state) = &mut server.range_sync_state { + state.fail_peer(peer); + state.peer_set.is_empty() + } else { + false + }; + + if should_clear { + server.range_sync_state = None; + } +} + +/// Retire one failed attempt at fetching `root`. +/// +/// Every path that ends an attempt must come through here: a root left in +/// `pending_root_requests` is deduplicated out of every later fetch, so a +/// silent exit loses that block for the life of the process. +async fn handle_fetch_failure( + server: &mut P2PServer, + root: H256, + peer: PeerId, ctx: &Context, ) { // A root nobody is waiting on means a late or duplicate failure, which @@ -585,17 +1398,1409 @@ async fn handle_fetch_failure( send_after(backoff, ctx.clone(), p2p_protocol::RetryBlockFetch { root }); } +/// Retire one failed attempt at fetching `block_root`'s missing columns. +/// +/// Mirrors [`handle_fetch_failure`]'s funnel and invariant: every path that +/// ends an attempt must come through here, or `block_root` stays in +/// `pending_column_requests` and `fetch_missing_columns` folds every later +/// call into it rather than starting a second lookup, for the life of the +/// process. +/// +/// `MAX_FETCH_RETRIES` attempts, each bounded by the request-response layer's +/// own per-request timeout and spaced by the doubling backoff below, is the +/// whole bound on how long a lookup persists; see the comment above that +/// constant's definition. A held block is reclaimed by finality on its own +/// schedule regardless, and the ladder stays well inside that window. +async fn handle_column_fetch_failure( + server: &mut P2PServer, + block_root: H256, + peer: PeerId, + ctx: &Context, +) { + let Some(pending) = server.pending_column_requests.get_mut(&block_root) else { + return; + }; + + match retire_column_attempt(pending, peer) { + ColumnAttemptOutcome::RoundIncomplete { in_flight } => { + trace!( + %block_root, + %peer, + in_flight, + "One peer of this column lookup failed; waiting for the rest of the round" + ); + } + ColumnAttemptOutcome::GiveUp { attempts } => { + error!(%block_root, %peer, attempts, + "Data column fetch failed after max retries, giving up"); + server.pending_column_requests.remove(&block_root); + metrics::inc_data_column_fetch_failure("max_retries"); + } + ColumnAttemptOutcome::Retry { attempts, backoff } => { + debug!(%block_root, %peer, attempts, ?backoff, "Data column fetch failed, scheduling retry"); + send_after( + backoff, + ctx.clone(), + p2p_protocol::RetryDataColumnFetch { block_root }, + ); + } + } +} + +/// What a failed request means for the lookup it belonged to. +#[derive(Debug, PartialEq, Eq)] +enum ColumnAttemptOutcome { + /// Other requests from this same attempt are still open, so the attempt + /// has no outcome yet. + RoundIncomplete { in_flight: usize }, + /// The ladder is exhausted; the caller retires the lookup. + GiveUp { attempts: u32 }, + /// The attempt is spent and another is due after `backoff`. + Retry { attempts: u32, backoff: Duration }, +} + +/// Charge `peer`'s failure against `pending` and say what follows. +/// +/// Split out from [`handle_column_fetch_failure`] so the round arithmetic can +/// be tested without an actor context: everything the decision depends on is in +/// `pending`, and everything it causes (the store mutation, the metric, the +/// timer) is the caller's. +/// +/// One attempt is one round, however many peers it was split across. While any +/// request of the round is still open, a sibling's failure is not the attempt's +/// outcome: the round may still be answered, and treating each failure as its +/// own attempt would burn the ladder several times faster *and* schedule one +/// fan-out per failure, which squares with every retry. +fn retire_column_attempt(pending: &mut PendingColumnRequest, peer: PeerId) -> ColumnAttemptOutcome { + pending.failed_peers.insert(peer); + pending.in_flight = pending.in_flight.saturating_sub(1); + + if pending.in_flight > 0 { + return ColumnAttemptOutcome::RoundIncomplete { + in_flight: pending.in_flight, + }; + } + + if pending.attempts >= MAX_FETCH_RETRIES { + return ColumnAttemptOutcome::GiveUp { + attempts: pending.attempts, + }; + } + + let backoff_ms = INITIAL_BACKOFF_MS * BACKOFF_MULTIPLIER.pow(pending.attempts - 1); + let attempts = pending.attempts; + pending.attempts += 1; + + ColumnAttemptOutcome::Retry { + attempts, + backoff: Duration::from_millis(backoff_ms), + } +} + +/// The beacon wire, or a refusal sent on `channel`. +/// +/// A beacon request reaching a lean node means a peer negotiated a protocol +/// this process does not serve, which is the peer's error to hear about rather +/// than a stream to drop silently. Every request handler below opens with this, +/// which is what the beacon module's single request entry point did once at +/// the top of its match, before the dispatch grew an arm per protocol. +fn beacon_wire_or_refuse( + server: &mut P2PServer, + peer: PeerId, + channel: ResponseChannel, +) -> Option<(&BeaconWire, ResponseChannel)> { + if server.wire.beacon().is_none() { + warn!(%peer, "Beacon request arrived on a lean node; refusing"); + refuse( + server, + channel, + ResponseCode::INVALID_REQUEST, + "this node does not speak the beacon protocols", + ); + return None; + } + // Re-taken as a shared borrow now that the `send_response` above, which + // needs `&mut server`, is behind us. + server.wire.beacon().map(|wire| (wire, channel)) +} + +/// Answer `status/N` with our own, and record what the peer told us. +/// +/// Does not use [`beacon_wire_or_refuse`]: that helper takes `&mut P2PServer` +/// and returns a `&BeaconWire` tied to its whole lifetime, which is fine for +/// [`handle_ping`] and [`handle_metadata_request`] below, neither of which +/// needs another field of `server` alive at the same time. This handler does: +/// `build_status` now reads `&server.store` alongside `wire`, and a `wire` +/// borrowed through that helper's `&mut P2PServer` parameter would hold the +/// whole server borrowed for as long as it lives, not just `server.wire`, +/// which would make `&server.store` a conflicting borrow. Reading +/// `server.wire.beacon()` directly, as below, borrows only that one field, so +/// `&server.store` can be taken alongside it. +async fn handle_status_request( + server: &mut P2PServer, + peer: PeerId, + peer_status: BeaconStatus, + channel: ResponseChannel, +) { + if server.wire.beacon().is_none() { + warn!(%peer, "Beacon request arrived on a lean node; refusing"); + refuse( + server, + channel, + ResponseCode::INVALID_REQUEST, + "this node does not speak the beacon protocols", + ); + return; + } + // Re-taken as a shared borrow of just `server.wire`, now that the + // `refuse` above (which needs `&mut server`) is behind us. + let wire = server.wire.beacon().expect("checked above"); + + if peer_status.fork_digest() != wire.fork_digest { + // Not grounds for closing the stream: the peer told us who it is and we + // answer honestly. Counting it is how a digest that has moved under us + // becomes visible. + warn!( + %peer, + peer_digest = %hex::encode(peer_status.fork_digest()), + our_digest = %hex::encode(wire.fork_digest), + "Peer is on another fork digest" + ); + metrics::inc_beacon_status_digest_mismatch(); + } else { + trace!( + %peer, + peer_head_slot = peer_status.head_slot(), + peer_finalized_epoch = peer_status.finalized_epoch(), + "Beacon status received" + ); + } + let our_status = + beacon_handler::build_status(&server.store, wire, StatusVersion::of(&peer_status)); + respond(server, channel, ResponsePayload::Status(our_status)); +} + +/// Answer `ping/1` with our metadata sequence number. +async fn handle_ping( + server: &mut P2PServer, + peer: PeerId, + ping: Ping, + channel: ResponseChannel, +) { + let Some((wire, channel)) = beacon_wire_or_refuse(server, peer, channel) else { + return; + }; + debug!(%peer, peer_seq_number = ping.seq_number, "Ping received"); + let pong = Ping { + seq_number: wire.metadata_seq_number, + }; + respond(server, channel, ResponsePayload::Pong(pong)); +} + +/// Answer `metadata/N` in the version the peer negotiated. +async fn handle_metadata_request( + server: &mut P2PServer, + peer: PeerId, + protocol: &'static str, + channel: ResponseChannel, +) { + let Some((wire, channel)) = beacon_wire_or_refuse(server, peer, channel) else { + return; + }; + let Some(metadata) = beacon_handler::build_metadata(wire, protocol) else { + warn!(%peer, protocol, "No metadata shape for this protocol"); + return; + }; + respond(server, channel, ResponsePayload::MetaData(metadata)); +} + +/// Record a `goodbye/1`. One-way, so the caller drops the channel. +/// +/// `debug!` rather than `trace!`: this is a peer stating why it is dropping us, +/// which is the one disconnect signal that is not inferred, and the follower +/// runs at `INFO` in production where a `trace!` reaches nobody. The counter is +/// what makes it readable without raising the level at all. +fn handle_goodbye(peer: PeerId, goodbye: Goodbye) { + let label = goodbye.reason_label(); + metrics::inc_peer_goodbye(label); + debug!(%peer, reason = goodbye.reason, label, "Peer said goodbye"); +} + +/// The `[start_slot, end_exclusive)` a beacon range sync should now cover, +/// given how far this node has fetched and a peer's advertised head. `None` +/// when the peer is not ahead of `fetched_through`. +/// +/// Takes `fetched_through` rather than a `Store`, deliberately: this is +/// `server.beacon_fetched_through`, not `server.store.head_slot()`. Delivery +/// to the chain actor is a message and import is work, so the store's own +/// head lags a delivered batch by the whole actor mailbox. Driven off the +/// store's head, the live follower kept re-requesting the part of the range +/// still draining and pulled 11,213 blocks off the wire to import 100; see +/// `beacon_fetched_through`'s own doc comment on `P2PServer`. Bounded by +/// [`MAX_SYNC_RANGE`], the same ceiling `handle_lean_status_response` bounds +/// its own request span by. +fn beacon_sync_target(fetched_through: u64, peer_head_slot: u64) -> Option> { + if peer_head_slot <= fetched_through { + return None; + } + let gap = peer_head_slot - fetched_through; + let start_slot = fetched_through.saturating_add(1); + let end_exclusive = start_slot.saturating_add(gap.min(MAX_SYNC_RANGE)); + Some(start_slot..end_exclusive) +} + +/// Record the peer's answer to our handshake, and start or extend the +/// anchor-to-head range sync when [`beacon_sync_target`] says it leaves this +/// node behind. +/// +/// The beacon counterpart of `handle_lean_status_response`: same merge-or- +/// create on `range_sync_state` and the same kick of the first batch, +/// differing only in what "behind" is measured against (see +/// [`beacon_sync_target`]) and in which function sends the batch +/// ([`request_next_beacon_range_batch`], so the beacon protocol id and +/// `MAX_REQUEST_BLOCKS_DENEB` apply). +async fn handle_status_response( + server: &mut P2PServer, + peer: PeerId, + status: BeaconStatus, + ctx: &Context, +) { + let Some(wire) = server.wire.beacon() else { + return; + }; + if status.fork_digest() != wire.fork_digest { + warn!( + %peer, + peer_digest = %hex::encode(status.fork_digest()), + our_digest = %hex::encode(wire.fork_digest), + "Handshake answered from another fork digest" + ); + metrics::inc_beacon_status_digest_mismatch(); + return; + } + let peer_head_slot = status.head_slot(); + trace!( + %peer, + peer_head_slot, + peer_finalized_epoch = status.finalized_epoch(), + "Beacon handshake complete" + ); + + let Some(target_range) = beacon_sync_target(server.beacon_fetched_through, peer_head_slot) + else { + return; + }; + + debug!( + %peer, + peer_head_slot, + fetched_through = server.beacon_fetched_through, + start_slot = target_range.start, + end_exclusive = target_range.end, + "Beacon peer status head is ahead of what has been fetched" + ); + + let end_exclusive = target_range.end; + match &mut server.range_sync_state { + Some(state) => state.merge_peer(peer, peer_head_slot, end_exclusive), + None => { + server.range_sync_state = Some(RangeSyncState::new(target_range, peer, peer_head_slot)); + } + } + + request_next_beacon_range_batch(server, ctx).await; + trace!(%peer, "Beacon long-range sync: using BeaconBlocksByRange"); +} + +/// Record a pong. Nothing is driven off one yet. +fn handle_pong(peer: PeerId, ping: Ping) { + debug!(%peer, peer_seq_number = ping.seq_number, "Pong received"); +} + +/// Record a peer's metadata. Nothing is driven off one yet. +/// Take delivery of a peer's `MetaData`, and learn its custody from it. +/// +/// Only v3 carries `custody_group_count`; v1 and v2 predate data availability +/// sampling and say nothing about it, so a peer answering in those versions +/// keeps whatever its ENR seeded and is otherwise treated as unknown. That is +/// the same graceful handling lighthouse gives a metadata/v2 peer. +fn handle_metadata_response(server: &mut P2PServer, peer: PeerId, metadata: BeaconMetaData) { + debug!(%peer, "Peer metadata received"); + if let BeaconMetaData::V3(v3) = metadata { + record_peer_custody(server, peer, v3.custody_group_count); + } +} + +/// The beacon store this node can serve blocks and data column sidecars out +/// of, or a refusal. +/// +/// Two things have to hold before a block or data column sidecar request can +/// be answered: this process speaks the beacon wire at all, and the data +/// directory behind it actually holds a beacon chain. The anchor makes the +/// second true for an ordinary `ethlambda beacon` run, so this is a guard +/// against a lean directory rather than the common path. It is a refusal +/// rather than an empty answer because the difference matters to the peer: +/// `RESOURCE_UNAVAILABLE` is the spec's own code for a peer "unable to reply +/// to block requests" (data column sidecar requests name the same code for +/// the same reason), where an empty stream claims we looked and had nothing, +/// and `INVALID_REQUEST` would blame the asker for a request that was fine. +fn beacon_block_store_or_refuse( + server: &mut P2PServer, + peer: PeerId, + channel: ResponseChannel, +) -> Option> { + let (_, channel) = beacon_wire_or_refuse(server, peer, channel)?; + if server.store.chain() != Chain::Beacon { + debug!(%peer, "Beacon block request arrived with no beacon chain behind it; refusing"); + refuse( + server, + channel, + ResponseCode::RESOURCE_UNAVAILABLE, + "this node holds no beacon chain", + ); + return None; + } + Some(channel) +} + +/// Answer `beacon_blocks_by_range/2` off the canonical chain. +/// +/// The counterpart of [`handle_lean_blocks_by_range_request`], and the same +/// shape: reject an empty or oversized window, then read the canonical branch +/// for the slots asked for. What differs is the ceiling. A `count` above +/// `MAX_REQUEST_BLOCKS` is a protocol violation and is refused; a `count` merely +/// above `MAX_REQUEST_BLOCKS_DENEB` is a peer on older logic, and the spec +/// allows "Clients MAY limit the number of blocks in the response", so it is +/// truncated rather than refused. +/// +/// `step` is deprecated and legal only as 1. It is judged here rather than at +/// decode so the peer learns which rule it broke: a codec refusal drops the +/// stream with no response on it, where this answers `INVALID_REQUEST`. Phase0 +/// does permit answering a larger step with a single block, but that leniency +/// is for a transition that finished years ago, and the spec's own requirement +/// on the requester is a MUST. +async fn handle_beacon_blocks_by_range_request( + server: &mut P2PServer, + peer: PeerId, + request: BlocksByRangeRequest, + channel: ResponseChannel, +) { + let Some(channel) = beacon_block_store_or_refuse(server, peer, channel) else { + return; + }; + + if request.count == 0 || request.count > MAX_BEACON_REQUEST_BLOCKS { + refuse( + server, + channel, + ResponseCode::INVALID_REQUEST, + "invalid BeaconBlocksByRange request", + ); + return; + } + if request.step != 1 { + debug!(%peer, step = request.step, "BeaconBlocksByRange named a deprecated step"); + refuse( + server, + channel, + ResponseCode::INVALID_REQUEST, + "BeaconBlocksByRange step must be 1", + ); + return; + } + + let count = request.count.min(MAX_REQUEST_BLOCKS_DENEB); + let blocks = canonical_blocks_by_range(&server.store, request.start_slot, count); + + trace!( + %peer, + start_slot = request.start_slot, + count = request.count, + served = count, + found = blocks.len(), + "Responding to BeaconBlocksByRange request" + ); + + respond(server, channel, ResponsePayload::Blocks(blocks)); +} + +/// Answer `beacon_blocks_by_root/2` with whichever of the roots is held. +/// +/// The counterpart of [`handle_lean_blocks_by_root_request`]: a root this node +/// does not hold is skipped rather than answered with an error chunk, since the +/// response is "a list of `SignedBeaconBlock` whose length is less than or equal +/// to the number of requested blocks". The order the peer asked in is the order +/// it gets back, which is why the response cannot be described as a slot range. +async fn handle_beacon_blocks_by_root_request( + server: &mut P2PServer, + peer: PeerId, + request: BlocksByRootRequest, + channel: ResponseChannel, +) { + let Some(channel) = beacon_block_store_or_refuse(server, peer, channel) else { + return; + }; + + let requested = request.roots.len(); + let mut blocks = Vec::new(); + for root in request.roots.iter().take(MAX_REQUEST_BLOCKS_DENEB as usize) { + match server.store.get_signed_block(root) { + Ok(Some(SignedBeaconBlock::Lean(_))) => error!( + %root, + "BeaconBlocksByRoot found a lean block in a beacon store" + ), + Ok(Some(block)) => blocks.push(block), + // A root we do not hold is not an error to report: the spec answers + // it by simply leaving the block out. + Ok(None) | Err(_) => {} + } + } + + trace!( + %peer, + requested, + found = blocks.len(), + "Responding to BeaconBlocksByRoot request" + ); + + respond(server, channel, ResponsePayload::Blocks(blocks)); +} + +/// Answer `data_column_sidecars_by_root/1` with whichever of the named +/// sidecars this node custodies. +/// +/// The counterpart of [`handle_beacon_blocks_by_root_request`], and the same +/// per-item leniency: a column this node never custodied is left out rather +/// than answered with an error, since a peer asking for it has no way to know +/// which of a custody set actually arrived here. The spec's own words for +/// this are "Clients MUST respond with at least one sidecar, if they have +/// it." An identifier naming a root this node holds no header for is skipped +/// whole, for the same reason a missing root is skipped on the block path. +async fn handle_data_column_sidecars_by_root_request( + server: &mut P2PServer, + peer: PeerId, + identifiers: Vec, + channel: ResponseChannel, +) { + let Some(channel) = beacon_block_store_or_refuse(server, peer, channel) else { + return; + }; + + let requested: usize = identifiers + .iter() + .map(|identifier| identifier.columns.len()) + .sum(); + let mut sidecars = Vec::new(); + for identifier in &identifiers { + // The store keys a sidecar by slot, root and column, but the wire + // names only the root: the slot has to be recovered from whatever + // this node holds under that root before the columns can be looked + // up at all. A root with no block is not this node's to answer for. + // + // Through `block_entry`, which decodes per chain, and not + // `Store::get_block_header`, which is lean-only: this handler only + // ever runs on a beacon store, and that table holds the whole signed + // block there, so the lean accessor reads a whole block as a fixed-size + // header and panics the P2P actor. A peer's request must not be able + // to do that. + let Some((slot, _)) = server.store.block_entry(&identifier.block_root) else { + continue; + }; + for &column in identifier.columns.iter() { + let Ok(Some(encoded)) = + server + .store + .get_data_column_sidecar(slot, &identifier.block_root, column) + else { + continue; + }; + match decode_data_column_sidecar(&encoded) { + Ok(sidecar) => sidecars.push(sidecar), + Err(err) => error!( + %peer, + slot, + column, + %err, + "Stored data column sidecar failed to decode" + ), + } + } + } + + trace!( + %peer, + requested, + found = sidecars.len(), + "Responding to DataColumnSidecarsByRoot request" + ); + + respond( + server, + channel, + ResponsePayload::DataColumnSidecars(sidecars), + ); +} + +/// Answer `data_column_sidecars_by_range/1` from the slot window this node +/// has custodied. +/// +/// The counterpart of [`handle_beacon_blocks_by_range_request`]: the same +/// `count` clamp to [`MAX_REQUEST_BLOCKS_DENEB`], which keeps the answer +/// within `max_request_data_column_sidecars()` sidecars regardless of +/// how many columns were asked for. What differs is the floor. A window this +/// node's canonical chain simply skipped is an empty answer, business as +/// usual; a `start_slot` before [`ethlambda_storage::Store::anchor_slot`] +/// cannot be served from any point in the response onward, since this node's +/// chain does not reach back that far at all. `RESOURCE_UNAVAILABLE` is the +/// code the specification names for exactly that peer, and the honest answer +/// for a node that started custodying at a checkpoint rather than at genesis. +/// +/// The floor is where this directory's chain begins rather than the lowest +/// slot a sidecar was actually written at, so it matches the +/// `earliest_available_slot` this node advertises in `Status`. The two differ +/// only in the window between the anchor and the first column this node +/// custodied, where a request is answered with an empty list rather than +/// refused; a node that range-synced has already backfilled columns alongside +/// blocks across that window, so it is narrow in practice and never claims +/// data the node does not hold. +async fn handle_data_column_sidecars_by_range_request( + server: &mut P2PServer, + peer: PeerId, + request: DataColumnsByRangeRequest, + channel: ResponseChannel, +) { + let Some(channel) = beacon_block_store_or_refuse(server, peer, channel) else { + return; + }; + + if request.count == 0 { + refuse( + server, + channel, + ResponseCode::INVALID_REQUEST, + "invalid DataColumnSidecarsByRange request", + ); + return; + } + + let earliest = server.store.anchor_slot(); + if request.start_slot < earliest { + debug!( + %peer, + start_slot = request.start_slot, + earliest, + "DataColumnSidecarsByRange request starts before what this node has custodied" + ); + refuse( + server, + channel, + ResponseCode::RESOURCE_UNAVAILABLE, + "requested range starts before this node's earliest custodied slot", + ); + return; + } + + let count = request.count.min(MAX_REQUEST_BLOCKS_DENEB); + let end_slot = request.start_slot.saturating_add(count); + let columns = request.columns.to_vec(); + let sidecars: Vec<_> = server + .store + .data_column_sidecars_in_range(request.start_slot, end_slot, &columns) + .inspect_err(|err| { + warn!( + start_slot = request.start_slot, + end_slot, + ?err, + "Failed to get data column sidecars by slot range" + ) + }) + .unwrap_or_default() + .into_iter() + .filter_map(|encoded| { + decode_data_column_sidecar(&encoded) + .inspect_err( + |err| error!(%peer, %err, "Stored data column sidecar failed to decode"), + ) + .ok() + }) + .collect(); + + trace!( + %peer, + start_slot = request.start_slot, + count = request.count, + served = count, + found = sidecars.len(), + "Responding to DataColumnSidecarsByRange request" + ); + + respond( + server, + channel, + ResponsePayload::DataColumnSidecars(sidecars), + ); +} + +/// Ask `peer` for the beacon blocks in `[start_slot, start_slot + count)`. +/// +/// Returns the request id, so a caller can pair the answer with what it asked +/// for. `count` is clamped to `MAX_REQUEST_BLOCKS_DENEB`, the ceiling that has +/// applied since deneb and so the only one that matters on a live network: +/// asking for more is a protocol violation the peer is entitled to refuse. +pub async fn request_beacon_blocks_by_range( + server: &mut P2PServer, + peer: PeerId, + start_slot: u64, + count: u64, +) -> Option { + let count = count.min(MAX_REQUEST_BLOCKS_DENEB); + if count == 0 { + return None; + } + let request = BlocksByRangeRequest::new(start_slot, count); + trace!(%peer, start_slot, count, "Sending BeaconBlocksByRange request"); + let request_id = server + .swarm_handle + .send_request( + peer, + Request::BlocksByRange(request), + ReqRespProtocol::BeaconBlocksByRange, + ) + .await?; + server.outbound_requests.insert( + request_id, + PendingRequestKind::Range { + start_slot, + end_slot: start_slot.saturating_add(count - 1), + }, + ); + Some(request_id) +} + +/// Ask the peers that custody them for this node's columns over a slot range. +/// +/// The companion of [`request_beacon_blocks_by_range`], sent for the same span +/// at the same time. The specification names this protocol for exactly this: +/// "`DataColumnSidecarsByRange` is primarily used to sync data columns that may +/// have been missed on gossip and to sync within the +/// `MIN_EPOCHS_FOR_DATA_COLUMN_SIDECARS_REQUESTS` window", which is what a +/// follower backfilling from a checkpoint anchor is doing. +/// +/// Without it the only source of a historical column was the by-root lookup a +/// block triggers *after* it has already arrived and been held, one block at a +/// time, against a peer chosen without regard to custody. Prefetching here +/// means the columns are usually already stored by the time their block is +/// checked, so the gate passes on the first try and the by-root path is left +/// for the genuine gaps. +/// +/// Best effort by construction: it sends what it can, and the return value says +/// only whether anything went out. A range sync batch does not depend on this +/// succeeding, because a missing column still has the by-root path behind it. +/// Up to `limit` connected peers this node has recorded no custody for. +/// +/// A peer lands here until its `metadata/3` answer or its ENR `cgc` has been +/// read, and some peers never leave: one answering `metadata/3` on a version +/// this node's codec rejects supplies nothing either way. "No opinion" is not +/// "custodies nothing", so these are the peers worth a speculative ask when no +/// known custodian covers a column — and the only ones, since a peer that has +/// said what it keeps has already answered the question. +fn peers_of_unknown_custody(server: &P2PServer, limit: usize) -> Vec { + server + .connected_peers + .keys() + .filter(|peer| !server.peer_custody.contains_key(peer)) + .copied() + .take(limit) + .collect() +} + +/// Ask for this node's custody columns across a span of slots. +/// +/// Sent by [`request_next_beacon_range_batch`] alongside every +/// `BeaconBlocksByRange` it plans, over the same span, so a synced block's +/// columns are already stored when it reaches the availability gate rather +/// than chased one root at a time after it has been held. Not driven from +/// outside this crate: a caller there has a block root, not a span, and the +/// span worth asking for is the one this crate is already syncing. +pub(crate) async fn request_beacon_data_columns_by_range( + server: &mut P2PServer, + start_slot: u64, + count: u64, +) -> bool { + let Some(wire) = server.wire.beacon() else { + return false; + }; + let columns = wire.custody_columns.clone(); + // The same ceiling `request_beacon_blocks_by_range` applies to its own + // count, so the two requests cover exactly one span and this one cannot + // ask for columns of slots whose blocks were never requested. + let count = count.min(MAX_REQUEST_BLOCKS_DENEB); + if columns.is_empty() || count == 0 { + return false; + } + + // No exclusions: unlike a by-root retry, this has no failed-peer history to + // avoid, and a peer that cannot answer simply returns nothing. + let (mut by_peer, uncovered) = group_columns_by_custody_peer(server, &columns, &HashSet::new()); + if !uncovered.is_empty() { + // "Uncovered" means no peer is *known* to custody these, and custody is + // only known once a peer's `metadata/3` answer or its ENR `cgc` has + // been recorded. A peer that has told us neither is not a peer that + // lacks the column; it is a peer this node cannot aim at yet, and on + // mainnet there is always a handful of those — freshly connected, or + // answering `metadata/3` on a version this node rejects. + // + // So the uncovered columns go to a couple of them, which is the same + // bet `fetch_data_columns_from_peer` makes for the same reason. Not to + // a peer already known to custody something else: that one has told us + // what it keeps, and asking it for what it does not keep really is + // waste. Bounded rather than broadcast, since a range request is + // megabytes of answer when it does land. + let unknown = peers_of_unknown_custody(server, UNKNOWN_CUSTODY_RANGE_PEERS); + debug!( + start_slot, + count, + uncovered = uncovered.len(), + asked = unknown.len(), + "No connected peer is known to custody some of this node's columns for the range" + ); + for peer in unknown { + by_peer + .entry(peer) + .or_default() + .extend(uncovered.iter().copied()); + } + } + + let end_slot = start_slot.saturating_add(count - 1); + let mut sent = false; + for (peer, columns) in by_peer { + let Ok(column_indices) = ColumnIndices::try_from(columns) else { + // This node's own custody set is bounded by NUMBER_OF_COLUMNS, so + // a subset of it cannot overflow the request's list. + error!("This node's custody set does not fit one DataColumnsByRange request"); + continue; + }; + let request = DataColumnsByRangeRequest { + start_slot, + count, + columns: column_indices, + }; + trace!(%peer, start_slot, count, "Sending DataColumnsByRange request"); + let Some(request_id) = server + .swarm_handle + .send_request( + peer, + Request::DataColumnsByRange(request), + ReqRespProtocol::DataColumnSidecarsByRange, + ) + .await + else { + debug!(%peer, "Failed to send DataColumnsByRange request (swarm adapter closed)"); + continue; + }; + server.outbound_requests.insert( + request_id, + PendingRequestKind::ColumnRange { + start_slot, + end_slot, + }, + ); + sent = true; + } + + sent +} + +/// Ask `peer` for one beacon block by root. +/// +/// One root per request rather than a batch, matching +/// [`fetch_block_from_peer`]'s shape on the lean side: the tracking that pairs +/// an answer with a request is keyed on a single root, and a block fetched by +/// root is always fetched because one specific parent is missing. +pub async fn request_beacon_block_by_root( + server: &mut P2PServer, + peer: PeerId, + root: H256, +) -> Option { + let mut roots = RequestedBlockRoots::new(); + if let Err(err) = roots.push(root) { + error!(%root, ?err, "Failed to create BeaconBlocksByRoot request"); + return None; + } + trace!(%peer, %root, "Sending BeaconBlocksByRoot request"); + let request_id = server + .swarm_handle + .send_request( + peer, + Request::BlocksByRoot(BlocksByRootRequest { roots }), + ReqRespProtocol::BeaconBlocksByRoot, + ) + .await?; + server + .outbound_requests + .insert(request_id, PendingRequestKind::Root(root)); + Some(request_id) +} + +/// Take delivery of a `DataColumnsByRoot` response and run every sidecar it +/// carried through the chain checks (`beacon::column_checks`), which send the +/// chain actor the ones that pass. The specification requires a sidecar +/// obtained by any other means to be treated as if it had arrived on gossip; +/// the chain checks are gossip's rules less the two that only mean something +/// on a topic (the subnet and the seen cache), and the chain actor keeps what +/// reaches it without checking it again, so they are the only checks a +/// fetched sidecar gets. +/// +/// Unlike a block-by-root answer, which names exactly one document that +/// either matches the requested root or doesn't, the specification permits a +/// peer to return fewer sidecars than were asked for ("Clients MUST respond +/// with at least one sidecar, if they have it" — not with every one), so a +/// non-empty answer is treated as this lookup's success regardless of +/// whether it is the full requested set. Whatever is still missing is the +/// next task's concern: it is driven by a fresh `fetch_block` call naming +/// what is still missing once the held block is re-checked, not by this +/// function retrying for the remainder. Zero sidecars is the one case that is +/// not success: it means this peer had none of what was asked, and is charged +/// as a failed attempt the same way an empty `BlocksByRoot` answer is, backed +/// off and retried against another peer. +/// Take delivery of a `DataColumnsByRange` prefetch and forward what it carries. +/// +/// No failure handling, unlike the by-root path: nothing is waiting on this +/// answer. An empty or short response means those columns simply arrive later +/// (or by root, when a block turns out to need one), not that an attempt was +/// spent. Retrying here would re-request a whole range over a single gap. +/// +/// A sidecar outside the requested span is dropped on its own rather than +/// failing the batch, the same way a block outside its range is: the rest of +/// the answer may still be what was asked for. Every other check is the chain +/// checks' (`beacon::column_checks`), the same ones a fetched-by-root sidecar +/// passes through. +async fn handle_data_column_sidecars_range_response( + server: &mut P2PServer, + peer: PeerId, + start_slot: u64, + end_slot: u64, + sidecars: Vec, +) { + let received = sidecars.len(); + trace!(%peer, start_slot, end_slot, received, "Received DataColumnsByRange response"); + + if server.blockchain.is_none() { + debug!(%peer, "No blockchain handler available"); + return; + } + + let in_range: Vec = sidecars + .into_iter() + .filter(|sidecar| { + let slot = sidecar.signed_block_header.message.slot; + let keep = slot >= start_slot && slot <= end_slot; + if !keep { + debug!(%peer, slot, start_slot, end_slot, "Dropping an out-of-range data column sidecar"); + } + keep + }) + .collect(); + if in_range.is_empty() { + return; + } + + // One batch through the checks, which forward what passes as one + // message. A range answer is the largest batch this node ever takes + // delivery of, and it arrives precisely when the chain actor is busiest + // draining a backlog, so a message per sidecar would put that many + // mailbox hops between the answer and the imports waiting on it. + column_checks::check_and_forward(server, in_range); +} + +async fn handle_data_column_sidecars_response( + server: &mut P2PServer, + peer: PeerId, + block_root: H256, + sidecars: Vec, + ctx: &Context, +) { + let received = sidecars.len(); + trace!(%peer, %block_root, received, "Received DataColumnsByRoot response"); + + if sidecars.is_empty() { + debug!(%peer, %block_root, "DataColumnsByRoot response carried no sidecars"); + handle_column_fetch_failure(server, block_root, peer, ctx).await; + return; + } + + // The lookup is done, whether or not every requested column arrived; see + // this function's own doc comment for why a partial answer still retires + // the entry rather than triggering a same-lookup retry for the rest. + server.pending_column_requests.remove(&block_root); + + if server.blockchain.is_none() { + debug!(%peer, %block_root, "No blockchain handler available"); + return; + } + + column_checks::check_and_forward(server, sidecars); +} + +/// Take delivery of a range answer, checking it against what was asked for, +/// and forward whatever passes to the chain actor. +/// +/// The counterpart of [`handle_lean_blocks_by_range_response`]. The by-root +/// answer has no counterpart here, because it needs none: +/// [`handle_blocks_by_root_response`] answers for both wires. A block outside +/// the range is dropped on its own rather than failing the batch, since the +/// rest of the answer may still be what was requested. The chunk-level checks +/// that *do* fail the whole answer, on the fork digest and the SSZ shape, +/// already ran in the codec. +async fn handle_beacon_blocks_by_range_response( + server: &mut P2PServer, + peer: PeerId, + blocks: Vec, + start_slot: u64, + end_slot: u64, + ctx: &Context, +) { + trace!(%peer, count = blocks.len(), "Received beacon blocks response"); + + if blocks.is_empty() { + fail_range_request(server, &peer); + debug!(%peer, start_slot, end_slot, "Received empty BeaconBlocksByRange response"); + return; + } + + let Some(ref blockchain) = server.blockchain else { + // No actor to forward into. A range session makes no progress either + // way, so it is dropped rather than spun uselessly on further + // batches, matching handle_lean_blocks_by_range_response. + server.range_sync_state = None; + debug!(%peer, "No blockchain handler available"); + return; + }; + + let received = blocks.len(); + let mut accepted = 0usize; + let mut highest_forwarded_slot: Option = None; + + for block in blocks { + let slot = block.slot(); + if slot < start_slot || slot > end_slot { + debug!(%peer, %slot, start_slot, end_slot, "Beacon block outside requested range"); + continue; + } + + highest_forwarded_slot = + Some(highest_forwarded_slot.map_or(slot, |max: u64| max.max(slot))); + accepted += 1; + + // No block root in the failure log: reaching it means the actor + // mailbox send failed, which `%peer` and `%slot` already identify, + // and `message_hash_tree_root` is a whole-block merkleization + // (execution payload included) that would then be paid for every + // block on the sync path. [`handle_blocks_by_root_response`] computes + // one because it has an answer to check; a range batch does not. + let _ = blockchain + .new_block(block, BlockSource::Sync, BlockArrival::now()) + .inspect_err( + |err| error!(%err, %slot, %peer, "Failed to forward beacon block to blockchain"), + ); + } + + debug!(%peer, received, accepted, "Beacon blocks received"); + + // Highest slot *handed to* the actor, not the highest imported: see + // `beacon_fetched_through`'s own doc comment on `P2PServer` for why range + // sync must be driven off this rather than the store's head. + if let Some(highest) = highest_forwarded_slot { + server.beacon_fetched_through = server.beacon_fetched_through.max(highest); + } + if let Some(state) = &mut server.range_sync_state { + state.complete_batch(end_slot); + if range_session_exhausted(state) { + server.range_sync_state = None; + return; + } + } + request_next_beacon_range_batch(server, ctx).await; +} + #[cfg(test)] mod tests { use super::*; + use crate::ConnectionDirection; use ethlambda_storage::{ForkCheckpoints, backend::InMemoryBackend}; use ethlambda_types::constants::DEFAULT_MILLISECONDS_PER_SLOT; + use ethlambda_types::enr::EnrForkId; use ethlambda_types::{ block::{Block, BlockBody, MultiMessageAggregate}, state::State, }; + use std::collections::HashMap; + use std::net::{IpAddr, Ipv4Addr}; use std::sync::Arc; + /// A real, unconnected `P2PServer`: a loopback swarm bound to an + /// OS-assigned port, discv5 the same way, and no bootnodes or peers. Ports + /// `0` throughout, so this cannot collide with a running node or a + /// sibling test. + /// + /// This is the smallest server `fetch_data_columns_from_peer` can run + /// against: unlike `handle_fetch_failure` and its beacon counterpart, + /// which also take a live actor `Context` (and which the crate has never + /// had a harness for; see 64a6bce9), the fetch path only touches + /// `&mut P2PServer`, so building one real server is the whole cost of + /// testing it. + async fn unconnected_server() -> P2PServer { + let built = crate::build_swarm(crate::SwarmConfig { + node_key: vec![3u8; 32], + bootnodes: Vec::new(), + listening_socket: "127.0.0.1:0".parse().expect("valid socket"), + target_peers: crate::discovery::DEFAULT_DISCOVERY_TARGET_PEERS, + agent_version: "ethlambda/test", + wire: crate::WireConfig::Lean(crate::LeanWireConfig { + validator_ids: Vec::new(), + attestation_committee_count: 1, + subscription_subnets: HashSet::new(), + milliseconds_per_slot: DEFAULT_MILLISECONDS_PER_SLOT, + }), + }) + .expect("swarm builds"); + + let (_swarm_stream, swarm_handle) = + crate::swarm_adapter::start_swarm_adapter(built.swarm, HashMap::new()); + + let discovery = crate::discovery::spawn_discovery(crate::discovery::DiscoverySpawnConfig { + node_key: secp256k1::SecretKey::new(&mut rand::rngs::OsRng) + .secret_bytes() + .to_vec(), + bind_ip: IpAddr::from(Ipv4Addr::LOCALHOST), + discovery_port: 0, + p2p_port: 0, + subscription_subnets: HashSet::new(), + attestation_committee_count: 1, + bootnodes: Vec::new(), + advertise_ip: None, + target_peers: 0, + fork_id: EnrForkId::local(), + custody_group_count: None, + }) + .await + .expect("discovery spawns"); + + let backend = Arc::new(InMemoryBackend::new()); + let store = Store::from_anchor_state( + backend, + State::from_genesis(0, vec![]), + DEFAULT_MILLISECONDS_PER_SLOT, + ); + // Read before `built.wire` moves into the server below; this is a + // lean wire, so `seen_attestations_capacity` floors it to one subnet. + let backbone_attestation_subnets = built + .wire + .beacon() + .map_or(0, |beacon| beacon.attestation_subnets.len()); + + P2PServer { + swarm_handle, + store, + blockchain: None, + wire: built.wire, + connected_peers: HashMap::new(), + peer_custody: HashMap::new(), + pending_root_requests: HashMap::new(), + pending_column_requests: HashMap::new(), + outbound_requests: HashMap::new(), + range_sync_state: None, + beacon_fetched_through: 0, + bootnode_addrs: HashMap::new(), + node_names: HashMap::new(), + discovery: Some(crate::discovery::dial::DiscoveryState::new( + discovery, + built.local_peer_id, + )), + seen_blocks: ethlambda_state_transition::beacon::gossip::SeenBlocks::new( + crate::SEEN_BLOCKS_CAPACITY, + ), + seen_columns: ethlambda_state_transition::beacon::gossip::SeenColumns::new( + crate::SEEN_COLUMNS_CAPACITY, + ), + seen_aggregates: + ethlambda_state_transition::beacon::gossip::aggregate::SeenAggregates::new( + crate::SEEN_AGGREGATES_CAPACITY, + crate::SEEN_AGGREGATES_CAPACITY, + ), + seen_attestations: + ethlambda_state_transition::beacon::gossip::attestation::SeenAttestations::new( + crate::seen_attestations_capacity(backbone_attestation_subnets), + ), + gossip_validation_permits: std::sync::Arc::new(tokio::sync::Semaphore::new( + crate::GOSSIP_VALIDATION_PERMITS, + )), + column_check_permits: std::sync::Arc::new(tokio::sync::Semaphore::new( + crate::COLUMN_CHECK_PERMITS, + )), + attestation_validation_permits: std::sync::Arc::new(tokio::sync::Semaphore::new( + crate::ATTESTATION_VALIDATION_PERMITS, + )), + attestation_pool: Default::default(), + aggregator_subnets: HashMap::new(), + } + } + + #[tokio::test] + async fn fetch_data_columns_with_no_peers_is_dropped_and_counted() { + let mut server = unconnected_server().await; + let block_root = H256::repeat_byte(0xAB); + + let before = metrics::data_column_fetch_failures_total("no_peers"); + + // Must not panic despite there being nobody to ask, and must not + // leave a `pending_column_requests` entry behind: nothing will ever + // answer a request that was never sent, so an entry here would be + // stuck forever, deduplicating away every future retry. + let sent = fetch_data_columns_from_peer(&mut server, block_root, vec![1, 2, 3]).await; + + assert!(!sent, "no connected peers means nothing can be sent"); + assert!( + server.pending_column_requests.is_empty(), + "a send that never happened must not be tracked as pending" + ); + assert_eq!( + metrics::data_column_fetch_failures_total("no_peers"), + before + 1, + "the no-peers path must count itself as a failed attempt" + ); + } + + #[tokio::test] + async fn columns_are_grouped_onto_the_peers_that_custody_them() { + let mut server = unconnected_server().await; + let holder_of_1 = PeerId::random(); + let holder_of_2 = PeerId::random(); + let holder_of_both = PeerId::random(); + for peer in [holder_of_1, holder_of_2, holder_of_both] { + server + .connected_peers + .insert(peer, ConnectionDirection::Inbound); + } + server.peer_custody.insert(holder_of_1, vec![1]); + server.peer_custody.insert(holder_of_2, vec![2]); + server.peer_custody.insert(holder_of_both, vec![1, 2]); + + let (by_peer, uncovered) = group_columns_by_custody_peer(&server, &[1, 2], &HashSet::new()); + + // Column 3 is nobody's, so it is reported rather than pinned on a peer + // that would answer empty. + let (_, uncovered_3) = group_columns_by_custody_peer(&server, &[3], &HashSet::new()); + assert_eq!(uncovered_3, vec![3]); + + assert!(uncovered.is_empty()); + // Every column went somewhere that holds it. + for (peer, columns) in &by_peer { + let custody = &server.peer_custody[peer]; + assert!(columns.iter().all(|column| custody.contains(column))); + } + let total: usize = by_peer.values().map(Vec::len).sum(); + assert_eq!(total, 2); + } + + #[tokio::test] + async fn a_peer_whose_custody_is_unknown_is_never_assumed_to_hold_a_column() { + let mut server = unconnected_server().await; + let unknown = PeerId::random(); + server + .connected_peers + .insert(unknown, ConnectionDirection::Inbound); + + // Absent must read as "no opinion", not "custodies nothing" and not + // "custodies everything": the caller has its own random fallback for + // this, and silently treating unknown as a holder would put us back to + // asking arbitrary peers for specific columns. + let (by_peer, uncovered) = group_columns_by_custody_peer(&server, &[7], &HashSet::new()); + + assert!(by_peer.is_empty()); + assert_eq!(uncovered, vec![7]); + } + + #[tokio::test] + async fn a_range_prefetch_falls_back_only_to_peers_that_have_said_nothing() { + // The follower stalled on mainnet with a block whose columns no known + // custodian held: the by-root path asks someone anyway, the range path + // asked no one, and the columns only arrived when the peer set churned. + // A peer that has told us what it keeps has answered the question; one + // that has told us nothing has not, and is the only worthwhile guess. + let mut server = unconnected_server().await; + let silent = PeerId::random(); + let known = PeerId::random(); + server + .connected_peers + .insert(silent, ConnectionDirection::Inbound); + server + .connected_peers + .insert(known, ConnectionDirection::Inbound); + server.peer_custody.insert(known, vec![4]); + + assert_eq!(peers_of_unknown_custody(&server, 2), vec![silent]); + } + + #[tokio::test] + async fn the_unknown_custody_fallback_asks_no_more_peers_than_it_is_allowed() { + let mut server = unconnected_server().await; + for _ in 0..5 { + server + .connected_peers + .insert(PeerId::random(), ConnectionDirection::Inbound); + } + + // A range answer is megabytes when it lands, so the speculative ask is + // a couple of peers, not every peer that has said nothing. + assert_eq!(peers_of_unknown_custody(&server, 2).len(), 2); + } + + #[tokio::test] + async fn a_column_is_covered_only_by_a_connected_peer_known_to_keep_it() { + // A fresh follower's first range batch went out with two peers and + // almost no custody known, and came back with no columns. The batch is + // now held until this says every custody column has somewhere to go, + // so it must not count a peer that has said nothing, nor one that has + // left. + let mut server = unconnected_server().await; + let keeps_4 = PeerId::random(); + let silent = PeerId::random(); + let departed = PeerId::random(); + server + .connected_peers + .insert(keeps_4, ConnectionDirection::Inbound); + server + .connected_peers + .insert(silent, ConnectionDirection::Inbound); + server.peer_custody.insert(keeps_4, vec![4]); + server.peer_custody.insert(departed, vec![5]); + + assert_eq!( + columns_without_known_custodian(&server, &[4, 5, 6]), + vec![5, 6] + ); + } + + #[test] + fn only_a_range_reaching_fulu_waits_for_column_custodians() { + let config = Config::mainnet(); + let first_fulu_slot = + config.fulu_fork_epoch * ethlambda_types::beacon::preset::SLOTS_PER_EPOCH; + let custody = [4, 5]; + + // Every slot before fulu: no columns exist, so nothing to wait for. + assert!(!range_needs_columns( + &config, + &custody, + &(first_fulu_slot - 64..first_fulu_slot) + )); + // A batch whose last slot is fulu's first does fetch columns. + assert!(range_needs_columns( + &config, + &custody, + &(first_fulu_slot - 63..first_fulu_slot + 1) + )); + // A node custodying nothing never asks for columns. + assert!(!range_needs_columns( + &config, + &[], + &(first_fulu_slot..first_fulu_slot + 64) + )); + } + + #[tokio::test] + async fn an_excluded_peer_is_not_chosen_even_when_it_custodies_the_column() { + let mut server = unconnected_server().await; + let failed = PeerId::random(); + server + .connected_peers + .insert(failed, ConnectionDirection::Inbound); + server.peer_custody.insert(failed, vec![4]); + + // The exclusion set is how a retry avoids the peer that just failed; + // custody must not override it, or a retry would loop on one peer. + let (by_peer, uncovered) = + group_columns_by_custody_peer(&server, &[4], &HashSet::from([failed])); + + assert!(by_peer.is_empty()); + assert_eq!(uncovered, vec![4]); + } + + #[test] + fn a_split_lookup_spends_one_attempt_however_many_peers_it_used() { + // Three requests open for one root, the shape custody-aware splitting + // produces. Before `in_flight`, each failure was its own attempt and + // scheduled its own fan-out, so one unanswered lookup against eight + // custodians became eight retries, then sixty-four. + let mut pending = PendingColumnRequest { + columns: vec![1, 2, 3], + attempts: 1, + failed_peers: HashSet::new(), + in_flight: 3, + last_asked: Instant::now(), + }; + + assert_eq!( + retire_column_attempt(&mut pending, PeerId::random()), + ColumnAttemptOutcome::RoundIncomplete { in_flight: 2 } + ); + assert_eq!( + retire_column_attempt(&mut pending, PeerId::random()), + ColumnAttemptOutcome::RoundIncomplete { in_flight: 1 } + ); + assert_eq!(pending.attempts, 1, "the round's attempt is not spent yet"); + + // Only the last failure of the round is the attempt's outcome, and it + // schedules exactly one retry. + assert_eq!( + retire_column_attempt(&mut pending, PeerId::random()), + ColumnAttemptOutcome::Retry { + attempts: 1, + backoff: Duration::from_millis(INITIAL_BACKOFF_MS), + } + ); + assert_eq!(pending.attempts, 2); + } + + #[test] + fn a_column_lookup_gives_up_once_the_ladder_is_exhausted() { + let mut pending = PendingColumnRequest { + columns: vec![1], + attempts: MAX_FETCH_RETRIES, + failed_peers: HashSet::new(), + in_flight: 1, + last_asked: Instant::now(), + }; + + assert_eq!( + retire_column_attempt(&mut pending, PeerId::random()), + ColumnAttemptOutcome::GiveUp { + attempts: MAX_FETCH_RETRIES + } + ); + } + + #[test] + fn an_out_of_range_custody_group_count_records_nothing() { + // A count outside CUSTODY_REQUIREMENT..=NUMBER_OF_CUSTODY_GROUPS + // describes no custody set the spec defines. Recording a guess would + // aim requests at a peer that never agreed to hold those columns. + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime"); + let mut server = runtime.block_on(unconnected_server()); + let peer = PeerId::random(); + + record_peer_custody(&mut server, peer, 0); + assert!(!server.peer_custody.contains_key(&peer)); + + record_peer_custody(&mut server, peer, constants::NUMBER_OF_CUSTODY_GROUPS + 1); + assert!(!server.peer_custody.contains_key(&peer)); + } + fn signed_block(slot: u64, parent_root: H256) -> SignedBlock { SignedBlock { message: Block { @@ -621,39 +2826,83 @@ mod tests { let block_1 = signed_block(1, store.head().expect("head block exists")); let root_1 = block_1.message.hash_tree_root(); store - .insert_signed_block(root_1, block_1) + .insert_signed_block(root_1, SignedBeaconBlock::Lean(block_1)) .expect("insert test block should succeed"); let block_2 = signed_block(2, root_1); let root_2 = block_2.message.hash_tree_root(); store - .insert_signed_block(root_2, block_2) + .insert_signed_block(root_2, SignedBeaconBlock::Lean(block_2)) .expect("insert test block should succeed"); let side_block_3 = signed_block(3, root_1); let side_root_3 = side_block_3.message.hash_tree_root(); store - .insert_signed_block(side_root_3, side_block_3) + .insert_signed_block(side_root_3, SignedBeaconBlock::Lean(side_block_3)) .expect("insert test block should succeed"); let block_4 = signed_block(4, root_2); let root_4 = block_4.message.hash_tree_root(); store - .insert_signed_block(root_4, block_4) + .insert_signed_block(root_4, SignedBeaconBlock::Lean(block_4)) .expect("insert test block should succeed"); store .update_checkpoints(ForkCheckpoints::head_only(root_4)) .expect("update_checkpoints should succeed"); let blocks = canonical_blocks_by_range(&store, 1, 4); - let slots: Vec<_> = blocks.iter().map(|block| block.message.slot).collect(); + let slots: Vec<_> = blocks.iter().map(SignedBeaconBlock::slot).collect(); let roots: Vec<_> = blocks .iter() - .map(|block| block.message.hash_tree_root()) + .map(SignedBeaconBlock::message_hash_tree_root) .collect(); assert_eq!(slots, vec![1, 2, 4]); assert_eq!(roots, vec![root_1, root_2, root_4]); assert!(!roots.contains(&side_root_3)); } + + #[test] + fn beacon_sync_target_keys_off_fetched_through_not_store_head() { + // A batch already handed to the chain actor but not yet imported + // leaves the store's own head behind `fetched_through`; the sync + // target must still be computed from `fetched_through`. See + // `beacon_sync_target`'s own doc comment for why the store's head is + // the wrong signal to drive this off: it is what pulled 11,213 + // blocks off the wire to import 100 on the live follower. + assert_eq!(beacon_sync_target(100, 150), Some(101..151)); + // A peer at or behind what has already been fetched has nothing to + // offer, regardless of what the store's own (possibly much lower) + // head happens to be. + assert_eq!(beacon_sync_target(150, 150), None); + assert_eq!(beacon_sync_target(150, 100), None); + } + + #[test] + fn beacon_sync_target_is_bounded_by_max_sync_range() { + let target = beacon_sync_target(0, u64::MAX).expect("peer is far ahead"); + assert_eq!(target, 1..(1 + MAX_SYNC_RANGE)); + } + + #[test] + fn a_range_session_is_exhausted_by_an_empty_range_or_an_empty_peer_set() { + let peer = PeerId::random(); + // Far more range than one batch covers. + let mut state = RangeSyncState::new(10..1074, peer, 2000); + state.in_flight = true; + state.complete_batch(73); + assert!(!range_session_exhausted(&state)); + + // The whole range in one batch, which leaves nothing to ask for. + let mut whole_range = RangeSyncState::new(10..74, peer, 200); + whole_range.in_flight = true; + whole_range.complete_batch(73); + assert!(range_session_exhausted(&whole_range)); + + // The range itself is not exhausted, but its only peer is gone. + let lone_peer = PeerId::random(); + let mut peer_gone = RangeSyncState::new(10..20, lone_peer, 15); + peer_gone.fail_peer(&lone_peer); + assert!(range_session_exhausted(&peer_gone)); + } } diff --git a/crates/net/p2p/src/req_resp/messages.rs b/crates/net/p2p/src/req_resp/messages.rs index b2179cd65..54f49bc18 100644 --- a/crates/net/p2p/src/req_resp/messages.rs +++ b/crates/net/p2p/src/req_resp/messages.rs @@ -1,17 +1,94 @@ -use ethlambda_types::{ShortRoot, block::SignedBlock, checkpoint::Checkpoint, primitives::H256}; -use libssz_derive::{SszDecode, SszEncode}; +use ethlambda_types::ShortRoot; +use ethlambda_types::beacon::containers::SignedBeaconBlock; +use ethlambda_types::beacon::containers::fulu::{DataColumnSidecar, DataColumnsByRootIdentifier}; use libssz_types::SszList; -pub const STATUS_PROTOCOL_V1: &str = "/leanconsensus/req/status/1/ssz_snappy"; -pub const BLOCKS_BY_ROOT_PROTOCOL_V1: &str = "/leanconsensus/req/blocks_by_root/1/ssz_snappy"; -pub const BLOCKS_BY_RANGE_PROTOCOL_V1: &str = "/leanconsensus/req/blocks_by_range/1/ssz_snappy"; -pub const MAX_REQUEST_BLOCKS: u64 = 1024; // Maximum number of blocks in a single request (1024). +use crate::beacon::messages::{ + BeaconMetaData, BeaconStatus, DataColumnsByRangeRequest, Goodbye, Ping, +}; +use crate::lean::messages::{BlocksByRootRequest, Status}; +/// A contiguous slot window, as either chain's `blocks_by_range` asks for it. +/// +/// Carries `step` even though only beacon's wire has the field, and only ever +/// legally as 1. It is here rather than hidden in beacon's encoder so that a +/// peer sending another value can be told so with an `INVALID_REQUEST` +/// response: refusing it at decode would drop the stream instead, which says +/// nothing about why. Lean's decoder fills it with 1, which is what lean's +/// absence of the field means, and lean's encoder drops it again. +/// +/// Deliberately **not** SSZ-derived, unlike the two chains' own containers. +/// Neither wire carries these three fields in this shape: lean's body is two +/// of them and beacon's is a different container that happens to agree. A +/// derive here would put a `to_ssz` on the shared type that is correct for +/// neither wire and wrong by 8 bytes on lean's, which is exactly the class of +/// mistake the split exists to prevent. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BlocksByRangeRequest { + pub start_slot: u64, + pub count: u64, + /// Deprecated by the beacon spec and absent from lean's wire; legal only + /// as 1. See the container's doc comment. + pub step: u64, +} + +impl BlocksByRangeRequest { + /// The blocks in `[start_slot, start_slot + count)`. + pub fn new(start_slot: u64, count: u64) -> Self { + Self { + start_slot, + count, + step: 1, + } + } +} + +/// Every request either chain can send, on one flat enum. +/// +/// One variant per protocol. Flat rather than a `Lean(..)`/`Beacon(..)` pair of +/// sub-enums, because a request is dispatched once, on its protocol: grouping +/// them meant the codec built two enums to encode one request, and every +/// dispatch re-discriminated what the protocol id had already settled. +/// +/// Only the variants that mean *different things* on the two wires are +/// prefixed, which is `Status` alone: lean's carries two checkpoints and +/// beacon's a fork digest and a head/finalized pair. The beacon variants keep +/// the name their protocol has in the beacon-chain spec, which is what a reader +/// comparing this against that spec is looking for. +/// +/// The two block requests are **shared**. What a peer is asking for is the same +/// question on either chain, a slot window or a list of roots, and only the +/// framing of that question differs: lean wraps the root list in a container +/// where beacon sends it bare, and beacon's range body carries a deprecated +/// `step` that lean's does not. Framing is the encoder's business, so the range +/// request is a struct of this module's own, belonging to neither wire, the +/// root request is lean's container on both, and each chain's encoder +/// translates. Which chain a request arrived on is not recorded here either: +/// a node speaks one wire for its whole life, so `P2PServer::wire` already +/// answers it and a tag on the message would be a second copy of that. #[derive(Debug, Clone)] pub enum Request { - Status(Status), + LeanStatus(Status), + /// The roots asked for. Bounded at 1024 by `RequestedBlockRoots`, which is + /// both lean's cap and beacon's `MAX_REQUEST_BLOCKS`. BlocksByRoot(BlocksByRootRequest), BlocksByRange(BlocksByRangeRequest), + Status(BeaconStatus), + Ping(Ping), + /// The negotiated `metadata/N` protocol id. + /// + /// The request is empty on the wire, but the responder has to answer in the + /// version the peer asked for, and `request_response::Event::Message` does + /// not carry the protocol id. The codec does, so it records it here. + MetaData(&'static str), + Goodbye(Goodbye), + /// The identifiers asked for. A plain `Vec` here, unlike + /// [`crate::beacon::messages::DataColumnsByRootIdentifiers`], which is + /// what carries the wire's SSZ list bound; nothing above the codec needs + /// to re-check a bound the wire type already enforces on decode and the + /// codec already enforces on encode. + DataColumnsByRoot(Vec), + DataColumnsByRange(DataColumnsByRangeRequest), } #[derive(Debug, Clone)] @@ -41,7 +118,7 @@ impl Response { /// Bounded summary for logs. /// /// Prefer this over `Debug` anywhere a `Response` reaches a log line. The derived -/// `Debug` on a `Blocks` payload expands every block header and every attestation +/// `Debug` on a `LeanBlocks` payload expands every block header and every attestation /// bitlist byte-by-byte, so a full `BlocksByRange` answer renders as hundreds of /// kilobytes on a single line — enough to be rejected outright by a log backend. /// `SignedBlock`'s own `Debug` already truncates the opaque proof bytes for the @@ -50,10 +127,10 @@ impl std::fmt::Display for Response { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Success { - payload: ResponsePayload::Status(status), + payload: ResponsePayload::LeanStatus(status), } => write!( f, - "Success(Status head={}/{} finalized={}/{})", + "Success(LeanStatus head={}/{} finalized={}/{})", status.head.slot, ShortRoot(&status.head.root.0), status.finalized.slot, @@ -65,14 +142,50 @@ impl std::fmt::Display for Response { write!(f, "Success(Blocks count={}", blocks.len())?; // Reported as first/last rather than a range: a BlocksByRoot // response follows the requested root order, so the slots are - // not necessarily contiguous or ascending. + // not necessarily contiguous or ascending. The fork names the + // chain too, since `ForkName::Lean` is one of its values. if let (Some(first), Some(last)) = (blocks.first(), blocks.last()) { - let first_slot = first.message.slot; - let last_slot = last.message.slot; - write!(f, " first_slot={first_slot} last_slot={last_slot}")?; + write!( + f, + " first_slot={} last_slot={} fork={}", + first.slot(), + last.slot(), + first.fork_name(), + )?; } write!(f, ")") } + Self::Success { + payload: ResponsePayload::Status(status), + } => write!( + f, + "Success(Status head_slot={} finalized_epoch={} fork_digest={})", + status.head_slot(), + status.finalized_epoch(), + hex::encode(status.fork_digest()), + ), + Self::Success { + payload: ResponsePayload::Pong(ping), + } => write!(f, "Success(Pong seq_number={})", ping.seq_number), + Self::Success { + payload: ResponsePayload::MetaData(metadata), + } => { + // The version is what a mismatch here would be about; the + // bitfields behind it are not worth a log line. + let (version, seq_number) = match metadata { + BeaconMetaData::V1(metadata) => (1, metadata.seq_number), + BeaconMetaData::V2(metadata) => (2, metadata.seq_number), + BeaconMetaData::V3(metadata) => (3, metadata.seq_number), + }; + write!(f, "Success(MetaData v{version} seq_number={seq_number})") + } + Self::Success { + payload: ResponsePayload::DataColumnSidecars(sidecars), + } => { + // Count only, never the sidecars themselves: one sidecar's + // `Debug` alone runs to tens of kilobytes of cell bytes. + write!(f, "Success(DataColumnSidecars count={})", sidecars.len()) + } Self::Error { code, message } => { let message = String::from_utf8_lossy(message); write!(f, "Error({code:?}: {message})") @@ -130,21 +243,37 @@ impl std::fmt::Debug for ResponseCode { } } +/// Every success payload either chain can send, on one flat enum. +/// +/// Mirrors [`Request`]: one variant per protocol, lean's prefixed. Not one +/// variant per request variant, because [`Request::Goodbye`] is answered by +/// closing the stream rather than by a payload. #[derive(Debug, Clone)] #[allow(clippy::large_enum_variant)] pub enum ResponsePayload { - Status(Status), - Blocks(Vec), + LeanStatus(Status), + /// The blocks answering any of the four block protocols, on either chain. + /// + /// One variant for all of them. `SignedBeaconBlock` already carries a + /// `Lean` variant, so it spans both chains without a wrapper, and the two + /// stores hand blocks back in exactly this type. Which protocol produced + /// the answer is known from the outbound request id, and which chain from + /// `P2PServer::wire`, so neither needs a variant of its own. + /// + /// Encoding still differs and still belongs to each chain's module: lean + /// writes a bare chunk per block, beacon prefixes each with a fork digest. + Blocks(Vec), + Status(BeaconStatus), + Pong(Ping), + MetaData(BeaconMetaData), + /// The sidecars answering either column protocol. + /// + /// One variant for both, mirroring how `Blocks` covers all four block + /// protocols: which one produced the answer is known from the outbound + /// request id, and the chunk framing is the same either way. + DataColumnSidecars(Vec), } -#[derive(Debug, Clone, SszEncode, SszDecode)] -pub struct Status { - pub finalized: Checkpoint, - pub head: Checkpoint, -} - -pub type RequestedBlockRoots = SszList; - /// Error message type for non-success responses. /// SSZ-encoded as List[byte, 256] per spec. pub type ErrorMessage = SszList; @@ -169,14 +298,3 @@ pub fn error_message(msg: impl AsRef) -> ErrorMessage { ErrorMessage::try_from(truncated.to_vec()).expect("error message fits in 256 bytes") } - -#[derive(Debug, Clone, SszEncode, SszDecode)] -pub struct BlocksByRootRequest { - pub roots: RequestedBlockRoots, -} - -#[derive(Debug, Clone, SszEncode, SszDecode)] -pub struct BlocksByRangeRequest { - pub start_slot: u64, - pub count: u64, -} diff --git a/crates/net/p2p/src/req_resp/mod.rs b/crates/net/p2p/src/req_resp/mod.rs index 11acb79ff..005fbbc0f 100644 --- a/crates/net/p2p/src/req_resp/mod.rs +++ b/crates/net/p2p/src/req_resp/mod.rs @@ -1,13 +1,14 @@ -mod codec; -mod encoding; +pub(crate) mod behaviour; +pub(crate) mod codec; +pub(crate) mod encoding; pub mod handlers; -mod messages; +pub(crate) mod messages; +pub(crate) use behaviour::{ReqResp, ReqRespEvent}; pub use codec::Codec; pub use encoding::{MAX_COMPRESSED_PAYLOAD_SIZE, MAX_PAYLOAD_SIZE}; -pub use handlers::{build_status, fetch_block_from_peer, handle_req_resp_message}; -pub use messages::{ - BLOCKS_BY_RANGE_PROTOCOL_V1, BLOCKS_BY_ROOT_PROTOCOL_V1, BlocksByRangeRequest, - BlocksByRootRequest, MAX_REQUEST_BLOCKS, Request, RequestedBlockRoots, Response, - ResponsePayload, STATUS_PROTOCOL_V1, Status, +pub use handlers::{ + build_status, fetch_block_from_peer, fetch_data_columns_from_peer, handle_req_resp_message, + request_beacon_block_by_root, request_beacon_blocks_by_range, }; +pub use messages::{Request, Response, ResponsePayload}; diff --git a/crates/net/p2p/src/swarm_adapter.rs b/crates/net/p2p/src/swarm_adapter.rs index fb01077f6..f060c3fb0 100644 --- a/crates/net/p2p/src/swarm_adapter.rs +++ b/crates/net/p2p/src/swarm_adapter.rs @@ -2,15 +2,18 @@ use std::collections::HashMap; use std::time::Duration; use libp2p::{ - PeerId, StreamProtocol, + PeerId, futures::StreamExt, - request_response::{self, OutboundRequestId}, + request_response, swarm::{SwarmEvent, dial_opts::DialOpts}, }; use tokio::{sync::mpsc, time::MissedTickBehavior}; -use tracing::{debug, error}; +use tracing::{debug, error, warn}; -use crate::{Behaviour, BehaviourEvent, metrics, req_resp::Request, req_resp::Response}; +use crate::{ + Behaviour, BehaviourEvent, ReqRespProtocol, ReqRespRequestId, metrics, req_resp::Request, + req_resp::Response, +}; /// Interval between gossipsub mesh peer metric refreshes. const MESH_METRIC_REFRESH_INTERVAL: Duration = Duration::from_secs(10); @@ -20,6 +23,10 @@ pub enum SwarmCommand { topic: libp2p::gossipsub::IdentTopic, data: Vec, }, + /// Join a topic after startup: the aggregator subnets, which a validator + /// client names slot by slot. + Subscribe(libp2p::gossipsub::IdentTopic), + Unsubscribe(libp2p::gossipsub::IdentTopic), Dial { /// Carries the full set of addresses worth trying for one dial attempt /// (a peer's QUIC and TCP ports both, say): libp2p races every address @@ -34,14 +41,22 @@ pub enum SwarmCommand { SendRequest { peer: PeerId, request: Request, - protocol: StreamProtocol, - /// Callback to report the assigned OutboundRequestId. - request_id_tx: Option>, + protocol: ReqRespProtocol, + /// Callback to report the assigned [`ReqRespRequestId`]. + request_id_tx: Option>, }, SendResponse { channel: request_response::ResponseChannel, response: Response, }, + /// A verdict for a gossip message gossipsub is holding (beacon only). + ReportValidation { + message_id: libp2p::gossipsub::MessageId, + propagation_source: PeerId, + acceptance: libp2p::gossipsub::MessageAcceptance, + /// Topic kind, for the expired-verdict metric. + kind: &'static str, + }, } /// What the swarm did with a dial, as far as a caller's bookkeeping cares. @@ -100,6 +115,20 @@ impl SwarmHandle { .inspect_err(|_| debug!("Swarm adapter closed, cannot publish")); } + pub fn subscribe(&self, topic: libp2p::gossipsub::IdentTopic) { + let _ = self + .cmd_tx + .send(SwarmCommand::Subscribe(topic)) + .inspect_err(|_| debug!("Swarm adapter closed, cannot subscribe")); + } + + pub fn unsubscribe(&self, topic: libp2p::gossipsub::IdentTopic) { + let _ = self + .cmd_tx + .send(SwarmCommand::Unsubscribe(topic)) + .inspect_err(|_| debug!("Swarm adapter closed, cannot unsubscribe")); + } + pub fn dial(&self, opts: DialOpts) { let _ = self .cmd_tx @@ -138,14 +167,15 @@ impl SwarmHandle { rx.await.unwrap_or(DialOutcome::Unreachable) } - /// Send a request and return the assigned OutboundRequestId. - /// Must be called from an async context (actor handlers are async). + /// Send a request on `protocol`'s own field and return the assigned + /// [`ReqRespRequestId`]. Must be called from an async context (actor + /// handlers are async). pub async fn send_request( &self, peer: PeerId, request: Request, - protocol: StreamProtocol, - ) -> Option { + protocol: ReqRespProtocol, + ) -> Option { let (tx, rx) = tokio::sync::oneshot::channel(); if self .cmd_tx @@ -173,6 +203,24 @@ impl SwarmHandle { .send(SwarmCommand::SendResponse { channel, response }) .inspect_err(|_| debug!("Swarm adapter closed, cannot send response")); } + + pub fn report_validation( + &self, + message_id: libp2p::gossipsub::MessageId, + propagation_source: PeerId, + acceptance: libp2p::gossipsub::MessageAcceptance, + kind: &'static str, + ) { + let _ = self + .cmd_tx + .send(SwarmCommand::ReportValidation { + message_id, + propagation_source, + acceptance, + kind, + }) + .inspect_err(|_| debug!("Swarm adapter closed, cannot report a gossip verdict")); + } } pub fn start_swarm_adapter( @@ -215,6 +263,20 @@ async fn swarm_loop( swarm.behaviour().gossipsub.all_mesh_peers(), &node_names, ); + // Published from here because this task owns the swarm, and on + // a timer rather than per connection event so the figure cannot + // drift when an event is missed: these are the counters the + // connection limits are actually enforced against, so they are + // worth reading from the source on a schedule rather than + // reconstructing. Compared against `lean_peers_by_direction`, a + // persistent gap is a connection charged to the cap that no + // live peer is using. + let info = swarm.network_info(); + let counters = info.connection_counters(); + metrics::set_swarm_established_connections( + counters.num_established_incoming(), + counters.num_established_outgoing(), + ); } } } @@ -231,6 +293,16 @@ fn execute_command(swarm: &mut libp2p::Swarm, cmd: SwarmCommand) { .inspect_err(|err| debug!(%err, "Swarm adapter: publish failed")) .ok(); } + SwarmCommand::Subscribe(topic) => { + let _ = swarm + .behaviour_mut() + .gossipsub + .subscribe(&topic) + .inspect_err(|err| warn!(%topic, %err, "Swarm adapter: subscribe failed")); + } + SwarmCommand::Unsubscribe(topic) => { + swarm.behaviour_mut().gossipsub.unsubscribe(&topic); + } SwarmCommand::Dial { opts, outcome_tx } => { let outcome = match swarm.dial(opts) { Ok(()) => DialOutcome::Queued, @@ -249,20 +321,87 @@ fn execute_command(swarm: &mut libp2p::Swarm, cmd: SwarmCommand) { protocol, request_id_tx, } => { - let request_id = swarm - .behaviour_mut() - .req_resp - .send_request_with_protocol(&peer, request, protocol); + // Upstream `send_request`, never the fork's own + // `send_request_with_protocol`: each field already offers exactly + // the one protocol `protocol` names, so pinning to it per call is + // no longer needed. Which field is exhaustive on purpose; see + // `ReqRespProtocol`'s doc comment. + let behaviour = &mut swarm.behaviour_mut().req_resp; + let id = match protocol { + ReqRespProtocol::LeanStatus => behaviour.lean_status.send_request(&peer, request), + ReqRespProtocol::LeanBlocksByRoot => { + behaviour.lean_blocks_by_root.send_request(&peer, request) + } + ReqRespProtocol::LeanBlocksByRange => { + behaviour.lean_blocks_by_range.send_request(&peer, request) + } + ReqRespProtocol::BeaconStatusV1 => { + behaviour.beacon_status_v1.send_request(&peer, request) + } + ReqRespProtocol::BeaconStatusV2 => { + behaviour.beacon_status_v2.send_request(&peer, request) + } + ReqRespProtocol::BeaconPing => behaviour.beacon_ping.send_request(&peer, request), + ReqRespProtocol::BeaconMetadataV1 => { + behaviour.beacon_metadata_v1.send_request(&peer, request) + } + ReqRespProtocol::BeaconMetadataV2 => { + behaviour.beacon_metadata_v2.send_request(&peer, request) + } + ReqRespProtocol::BeaconMetadataV3 => { + behaviour.beacon_metadata_v3.send_request(&peer, request) + } + ReqRespProtocol::BeaconGoodbye => { + behaviour.beacon_goodbye.send_request(&peer, request) + } + ReqRespProtocol::BeaconBlocksByRange => behaviour + .beacon_blocks_by_range + .send_request(&peer, request), + ReqRespProtocol::BeaconBlocksByRoot => { + behaviour.beacon_blocks_by_root.send_request(&peer, request) + } + ReqRespProtocol::DataColumnSidecarsByRange => behaviour + .data_column_sidecars_by_range + .send_request(&peer, request), + ReqRespProtocol::DataColumnSidecarsByRoot => behaviour + .data_column_sidecars_by_root + .send_request(&peer, request), + }; if let Some(tx) = request_id_tx { - let _ = tx.send(request_id); + let _ = tx.send(ReqRespRequestId { protocol, id }); } } SwarmCommand::SendResponse { channel, response } => { + // `Behaviour::send_response` is a passthrough to the oneshot + // sender the `ResponseChannel` already carries + // (`ch.sender.send(rs)` in the pinned fork, untouched from + // upstream); it reads no state of the `Behaviour` instance it is + // called on, so any field answers identically. `lean_status` is + // picked as a stable, arbitrary anchor rather than routing this by + // protocol too. let _ = swarm .behaviour_mut() .req_resp + .lean_status .send_response(channel, response) .inspect_err(|response| debug!(%response, "Swarm adapter: send_response failed")); } + SwarmCommand::ReportValidation { + message_id, + propagation_source, + acceptance, + kind, + } => { + // `false`: gossipsub no longer holds the message, because the + // verdict came after its message-cache entry was evicted, so an + // Accept propagates nothing. + let held = swarm + .behaviour_mut() + .gossipsub + .report_message_validation_result(&message_id, &propagation_source, acceptance); + if !held { + metrics::inc_beacon_gossip_verdict_expired(kind); + } + } } } diff --git a/crates/net/rpc/Cargo.toml b/crates/net/rpc/Cargo.toml index e6e5f0ea6..b90259eb9 100644 --- a/crates/net/rpc/Cargo.toml +++ b/crates/net/rpc/Cargo.toml @@ -14,13 +14,17 @@ axum = "0.8.1" tokio.workspace = true tokio-util.workspace = true ethlambda-blockchain.workspace = true +ethlambda-engine.workspace = true ethlambda-fork-choice.workspace = true ethlambda-metrics.workspace = true +ethlambda-network-api.workspace = true ethlambda-state-transition.workspace = true ethlambda-storage.workspace = true ethlambda-test-fixtures.workspace = true ethlambda-types.workspace = true libssz.workspace = true +libssz-derive.workspace = true +libssz-types.workspace = true serde.workspace = true serde_json.workspace = true hex.workspace = true @@ -29,6 +33,10 @@ jemalloc_pprof.workspace = true futures-util = "0.3" [dev-dependencies] +ethlambda-validator.workspace = true +spawned-concurrency.workspace = true +ethlambda-state-transition = { workspace = true, features = ["test-utils"] } ethlambda-types.workspace = true +libssz-types.workspace = true tower = { version = "0.5", features = ["util"] } http-body-util = "0.1" diff --git a/crates/net/rpc/src/base.rs b/crates/net/rpc/src/base.rs index 0ccf47edf..962baf74b 100644 --- a/crates/net/rpc/src/base.rs +++ b/crates/net/rpc/src/base.rs @@ -5,9 +5,12 @@ use axum::{ routing::get, }; use ethlambda_storage::Store; +use ethlambda_types::beacon::containers::SignedBeaconBlock; use ethlambda_types::primitives::H256; use libssz::SszEncode; +use crate::shared::content::ssz_response; + pub(crate) fn routes() -> Router { Router::new() .route("/lean/v0/health", get(crate::metrics::get_health)) @@ -23,10 +26,14 @@ pub(crate) async fn get_latest_finalized_state( axum::extract::State(store): axum::extract::State, ) -> impl IntoResponse { let finalized = store.latest_finalized().expect("finalized block exists"); - let mut state = store + let state = store .get_state(&finalized.root) .expect("finalized state exists") .unwrap(); + // This endpoint is under the lean `/lean/v0/` surface, so a beacon state + // here would mean the store's chain tag lied, mirroring + // `get_latest_finalized_block`'s handling of `get_signed_block` below. + let mut state = state.expect_lean().clone(); // Zero state_root to match the canonical post-state representation. // The spec's state_transition sets state_root to zero during process_block_header, @@ -39,12 +46,40 @@ pub(crate) async fn get_latest_finalized_state( pub(crate) async fn get_latest_finalized_block( axum::extract::State(store): axum::extract::State, + headers: axum::http::HeaderMap, ) -> impl IntoResponse { let finalized = store.latest_finalized().expect("finalized block exists"); // Genesis has no stored signature; `get_signed_block` synthesizes a // placeholder blank proof so this always returns 200. match store.get_signed_block(&finalized.root) { - Ok(Some(block)) => ssz_response(block.to_ssz()), + Ok(Some(SignedBeaconBlock::Lean(block))) => { + // SSZ unless JSON is asked for by name, which is the opposite of + // the beacon surface's default and deliberately so: + // `bin/ethlambda/src/checkpoint_sync.rs` reads these bytes, and + // other clients' lean checkpoint sync may send no `Accept` at + // all. Moving the default would break every one of them. + // + // Not routed through `Encoding::from_accept` for that same + // reason: that helper defaults to JSON. The asymmetry is the + // point, so it is spelled out rather than hidden behind a shared + // name that means something else here. + let wants_json = headers + .get(header::ACCEPT) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.contains("application/json")); + + if wants_json { + json_response(block) + } else { + ssz_response(block.to_ssz()) + } + } + // This endpoint is under the lean `/lean/v0/` surface, so a beacon + // block here would mean the store's chain tag lied. + Ok(Some(other)) => panic!( + "lean/v0/blocks/finalized found a {} block in a lean store", + other.fork_name() + ), Ok(None) => axum::http::StatusCode::NOT_FOUND.into_response(), Err(_) => axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response(), } @@ -67,12 +102,3 @@ pub(crate) fn json_response(value: T) -> axum::response::Re ); response } - -fn ssz_response(bytes: Vec) -> axum::response::Response { - let mut response = bytes.into_response(); - response.headers_mut().insert( - header::CONTENT_TYPE, - HeaderValue::from_static(crate::SSZ_CONTENT_TYPE), - ); - response -} diff --git a/crates/net/rpc/src/beacon/blocks.rs b/crates/net/rpc/src/beacon/blocks.rs new file mode 100644 index 000000000..790747914 --- /dev/null +++ b/crates/net/rpc/src/beacon/blocks.rs @@ -0,0 +1,224 @@ +//! `/eth/v2/beacon/blocks/{block_id}` and its `/root` sibling. + +use axum::{ + Router, + extract::{Path, State}, + http::{HeaderMap, header}, + response::{IntoResponse, Response}, + routing::get, +}; +use ethlambda_storage::Store; +use ethlambda_types::{beacon::containers::SignedBeaconBlock, primitives::H256}; + +use crate::{ + beacon::{ApiError, Envelope}, + shared::{ + block_id::BlockId, + content::{Encoding, ssz_response, with_consensus_version}, + }, +}; + +pub(crate) fn routes() -> Router { + Router::new() + .route("/eth/v2/beacon/blocks/{block_id}", get(get_block)) + .route("/eth/v1/beacon/blocks/{block_id}/root", get(get_block_root)) +} + +/// Resolve `block_id` and load the block it names. +/// +/// Shared by both handlers here and by `headers.rs`, so the id semantics are +/// written once. +pub(crate) fn load(store: &Store, block_id: &str) -> Result<(H256, SignedBeaconBlock), ApiError> { + let id = BlockId::parse(block_id)?; + let root = id.resolve_beacon(store)?; + let block = store + .get_signed_block(&root) + .map_err(|_| ApiError::Internal("store read failed"))? + .ok_or(ApiError::NotFound("block not found"))?; + Ok((root, block)) +} + +async fn get_block( + Path(block_id): Path, + State(store): State, + headers: HeaderMap, +) -> Response { + let (root, block) = match load(&store, &block_id) { + Ok(found) => found, + Err(err) => return err.into_response(), + }; + let fork = block.fork_name(); + + let accept = headers.get(header::ACCEPT).and_then(|v| v.to_str().ok()); + let response = match Encoding::from_accept(accept) { + Encoding::Ssz => ssz_response(block.to_ssz()), + Encoding::Json => crate::json_response(Envelope { + version: fork.as_str(), + execution_optimistic: store.is_beacon_optimistic(root), + finalized: is_finalized(&store, block.slot()), + data: block, + }), + }; + + with_consensus_version(response, fork) +} + +async fn get_block_root(Path(block_id): Path, State(store): State) -> Response { + let (root, block) = match load(&store, &block_id) { + Ok(found) => found, + Err(err) => return err.into_response(), + }; + + crate::json_response(serde_json::json!({ + "execution_optimistic": store.is_beacon_optimistic(root), + "finalized": is_finalized(&store, block.slot()), + "data": { "root": root }, + })) +} + +/// Whether a block at `slot` is at or below the finalized checkpoint. +/// +/// `latest_finalized` is slot-denominated on both chains: a beacon +/// checkpoint's epoch is stored as that epoch's own start slot, which is what +/// `Store::as_beacon_checkpoint` converts back from. +pub(crate) fn is_finalized(store: &Store, slot: u64) -> bool { + store + .latest_finalized() + .map(|finalized| slot <= finalized.slot) + .unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::beacon_fixture; + use axum::{body::Body, http::Request, http::StatusCode}; + use http_body_util::BodyExt as _; + use tower::ServiceExt as _; + + const ANCHOR_SLOT: u64 = 64; + + async fn get(uri: &str, accept: Option<&str>) -> axum::response::Response { + let fixture = beacon_fixture(ANCHOR_SLOT); + let app = routes().with_state(fixture.store); + let mut request = Request::builder().uri(uri); + if let Some(accept) = accept { + request = request.header("accept", accept); + } + app.oneshot(request.body(Body::empty()).unwrap()) + .await + .unwrap() + } + + #[tokio::test] + async fn a_block_by_slot_comes_back_as_json_by_default() { + let response = get("/eth/v2/beacon/blocks/65", None).await; + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["data"]["message"]["slot"], "65", "integers are quoted"); + assert_eq!(json["execution_optimistic"], false); + assert_eq!(json["version"], "phase0"); + } + + #[tokio::test] + async fn the_same_block_comes_back_as_ssz_on_request() { + let response = get("/eth/v2/beacon/blocks/65", Some("application/octet-stream")).await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response + .headers() + .get(axum::http::header::CONTENT_TYPE) + .unwrap(), + crate::SSZ_CONTENT_TYPE + ); + assert_eq!( + response.headers().get("eth-consensus-version").unwrap(), + "phase0" + ); + } + + #[tokio::test] + async fn named_ids_resolve() { + // `head` is the child; `finalized` and `justified` are the anchor, + // which is what `init_beacon` seeds both checkpoints with. + let head = get("/eth/v2/beacon/blocks/head", None).await; + assert_eq!(head.status(), StatusCode::OK); + let body = head.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["data"]["message"]["slot"], "65"); + + let finalized = get("/eth/v2/beacon/blocks/finalized", None).await; + assert_eq!(finalized.status(), StatusCode::OK); + let body = finalized.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["data"]["message"]["slot"], "64"); + assert_eq!(json["finalized"], true, "the anchor is the finalized block"); + } + + #[tokio::test] + async fn a_malformed_id_is_a_400_and_an_absent_one_a_404() { + assert_eq!( + get("/eth/v2/beacon/blocks/nope", None).await.status(), + StatusCode::BAD_REQUEST + ); + assert_eq!( + get("/eth/v2/beacon/blocks/999999", None).await.status(), + StatusCode::NOT_FOUND + ); + } + + /// The anchor's own slot resolves, even though `BlockRoots` never holds it. + /// + /// This is the slot `bin/ethlambda/src/checkpoint_sync.rs` asks a peer for + /// right after reading its finalized state, so a 404 here makes this node + /// unusable as a checkpoint-sync source. Found against a real mainnet + /// pair: before the fallback in `resolve_beacon`, a second node syncing + /// from a first died with "peer served no block at the anchor slot". + #[tokio::test] + async fn the_anchors_own_slot_resolves_through_the_named_roots() { + let response = get("/eth/v2/beacon/blocks/64", None).await; + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["data"]["message"]["slot"], "64"); + } + + /// A slot the store genuinely has nothing at is still a 404. The fallback + /// accepts a named root only when that root's block really sits at the + /// slot asked for, so it cannot answer with the anchor for another slot. + #[tokio::test] + async fn a_slot_below_the_anchor_is_still_absent() { + assert_eq!( + get("/eth/v2/beacon/blocks/63", None).await.status(), + StatusCode::NOT_FOUND + ); + } + + #[tokio::test] + async fn a_block_by_root_resolves() { + let fixture = beacon_fixture(ANCHOR_SLOT); + let uri = format!("/eth/v2/beacon/blocks/0x{:x}", fixture.anchor_root); + let app = routes().with_state(fixture.store); + let response = app + .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap()) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["data"]["message"]["slot"], "64"); + } + + #[tokio::test] + async fn the_root_endpoint_returns_the_resolved_root() { + let response = get("/eth/v1/beacon/blocks/65/root", None).await; + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert!(json["data"]["root"].as_str().unwrap().starts_with("0x")); + } +} diff --git a/crates/net/rpc/src/beacon/config.rs b/crates/net/rpc/src/beacon/config.rs new file mode 100644 index 000000000..3ddb354b0 --- /dev/null +++ b/crates/net/rpc/src/beacon/config.rs @@ -0,0 +1,286 @@ +//! `/eth/v1/config/spec`. +//! +//! The Beacon API asks for three things in one flat object: the network's +//! configuration, the preset the node was built against, and the +//! specification's constants. Validator clients read all three: lighthouse's, +//! for one, refuses a beacon node whose `PRESET_BASE` does not match its own, +//! and an absent key counts as a mismatch. +//! +//! The configuration, `PRESET_BASE` and `CONFIG_NAME` included, comes straight +//! off the `Config` the store was bootstrapped with, so a node started with +//! `--network ` reports that network's values rather than mainnet's. +//! Where the node runs on a compile-time constant rather than reading +//! `Config` (the custody counts, `MAX_REQUEST_BLOCKS`, ...), startup refuses a +//! network whose `Config` differs from the constant, so reporting the +//! `Config`'s value is reporting the one the node uses. +//! `GENESIS_TIME` is absent by design: it is not a `config.yaml` key, and +//! `/eth/v1/beacon/genesis` is where it is reported. +//! +//! The preset and constant keys are the ones lighthouse reports, less two +//! kinds: gloas-only keys, since this build cannot process gloas (the same +//! reason `Config` leaves out `GLOAS_*`), and keys the specification does not +//! define at all (`GAS_LIMIT_ADJUSTMENT_FACTOR`, `RESP_TIMEOUT`, +//! `TTFB_TIMEOUT`). Constants lighthouse leaves out, such as +//! `JUSTIFICATION_BITS_LENGTH`, are left out here too. + +use axum::{Router, extract::State, response::Response, routing::get}; +use ethlambda_storage::Store; +use ethlambda_types::beacon::{config::Config, constants, preset, serde_helpers::HexPrefixed}; +use serde_json::{Map, Value}; + +pub(crate) fn routes() -> Router { + Router::new().route("/eth/v1/config/spec", get(get_spec)) +} + +async fn get_spec(State(store): State) -> Response { + // Through the `Arc` rather than cloning: `Config` carries a blob of + // scalars plus the blob schedule, and nothing here needs to own it. + let data = spec(store.config().as_ref()); + crate::json_response(serde_json::json!({ "data": data })) +} + +/// Pairs each named item of `$module` with `$render` of its value, keyed by +/// the item's own name, which is the specification's. +macro_rules! entries { + ($render:expr; $module:ident: $($name:ident),+ $(,)?) => { + [$((stringify!($name), ($render)($module::$name))),+] + }; +} + +/// The `data` object: `config`'s own keys, then everything else in +/// [`extra_entries`]. +fn spec(config: &Config) -> Map { + let Value::Object(mut data) = + serde_json::to_value(config).expect("Config serializes to a JSON value") + else { + unreachable!("Config serializes as a struct, so as a JSON object"); + }; + data.extend(extra_entries().map(|(key, value)| (key.to_owned(), Value::String(value)))); + data +} + +/// Every key the endpoint reports that is not a [`Config`] field: the +/// preset, then the constants. +/// +/// A key must not also be a `Config` field, or it would silently replace that +/// field's value; `no_key_is_reported_twice` checks this. +fn extra_entries() -> impl Iterator { + // Phase0 through fulu, in the order of `preset.rs`. + let preset = entries!(decimal; preset: + MAX_COMMITTEES_PER_SLOT, + TARGET_COMMITTEE_SIZE, + MAX_VALIDATORS_PER_COMMITTEE, + SHUFFLE_ROUND_COUNT, + HYSTERESIS_QUOTIENT, + HYSTERESIS_DOWNWARD_MULTIPLIER, + HYSTERESIS_UPWARD_MULTIPLIER, + MIN_DEPOSIT_AMOUNT, + MAX_EFFECTIVE_BALANCE, + EFFECTIVE_BALANCE_INCREMENT, + MIN_ATTESTATION_INCLUSION_DELAY, + SLOTS_PER_EPOCH, + MIN_SEED_LOOKAHEAD, + MAX_SEED_LOOKAHEAD, + EPOCHS_PER_ETH1_VOTING_PERIOD, + SLOTS_PER_HISTORICAL_ROOT, + MIN_EPOCHS_TO_INACTIVITY_PENALTY, + EPOCHS_PER_HISTORICAL_VECTOR, + EPOCHS_PER_SLASHINGS_VECTOR, + HISTORICAL_ROOTS_LIMIT, + VALIDATOR_REGISTRY_LIMIT, + BASE_REWARD_FACTOR, + WHISTLEBLOWER_REWARD_QUOTIENT, + PROPOSER_REWARD_QUOTIENT, + INACTIVITY_PENALTY_QUOTIENT, + MIN_SLASHING_PENALTY_QUOTIENT, + PROPORTIONAL_SLASHING_MULTIPLIER, + MAX_PROPOSER_SLASHINGS, + MAX_ATTESTER_SLASHINGS, + MAX_ATTESTATIONS, + MAX_DEPOSITS, + MAX_VOLUNTARY_EXITS, + INACTIVITY_PENALTY_QUOTIENT_ALTAIR, + MIN_SLASHING_PENALTY_QUOTIENT_ALTAIR, + PROPORTIONAL_SLASHING_MULTIPLIER_ALTAIR, + SYNC_COMMITTEE_SIZE, + EPOCHS_PER_SYNC_COMMITTEE_PERIOD, + MIN_SYNC_COMMITTEE_PARTICIPANTS, + UPDATE_TIMEOUT, + INACTIVITY_PENALTY_QUOTIENT_BELLATRIX, + MIN_SLASHING_PENALTY_QUOTIENT_BELLATRIX, + PROPORTIONAL_SLASHING_MULTIPLIER_BELLATRIX, + MAX_BYTES_PER_TRANSACTION, + MAX_TRANSACTIONS_PER_PAYLOAD, + BYTES_PER_LOGS_BLOOM, + MAX_EXTRA_DATA_BYTES, + MAX_BLS_TO_EXECUTION_CHANGES, + MAX_WITHDRAWALS_PER_PAYLOAD, + MAX_VALIDATORS_PER_WITHDRAWALS_SWEEP, + MAX_BLOB_COMMITMENTS_PER_BLOCK, + KZG_COMMITMENT_INCLUSION_PROOF_DEPTH, + FIELD_ELEMENTS_PER_BLOB, + MIN_ACTIVATION_BALANCE, + MAX_EFFECTIVE_BALANCE_ELECTRA, + MIN_SLASHING_PENALTY_QUOTIENT_ELECTRA, + WHISTLEBLOWER_REWARD_QUOTIENT_ELECTRA, + PENDING_DEPOSITS_LIMIT, + PENDING_PARTIAL_WITHDRAWALS_LIMIT, + PENDING_CONSOLIDATIONS_LIMIT, + MAX_ATTESTER_SLASHINGS_ELECTRA, + MAX_ATTESTATIONS_ELECTRA, + MAX_DEPOSIT_REQUESTS_PER_PAYLOAD, + MAX_WITHDRAWAL_REQUESTS_PER_PAYLOAD, + MAX_CONSOLIDATION_REQUESTS_PER_PAYLOAD, + MAX_PENDING_PARTIALS_PER_WITHDRAWALS_SWEEP, + MAX_PENDING_DEPOSITS_PER_EPOCH, + KZG_COMMITMENTS_INCLUSION_PROOF_DEPTH, + FIELD_ELEMENTS_PER_CELL, + FIELD_ELEMENTS_PER_EXT_BLOB, + CELLS_PER_EXT_BLOB, + NUMBER_OF_COLUMNS, + ); + + let domains = entries!(hex_string; constants: + DOMAIN_BEACON_PROPOSER, + DOMAIN_BEACON_ATTESTER, + DOMAIN_RANDAO, + DOMAIN_DEPOSIT, + DOMAIN_VOLUNTARY_EXIT, + DOMAIN_SELECTION_PROOF, + DOMAIN_AGGREGATE_AND_PROOF, + DOMAIN_APPLICATION_MASK, + DOMAIN_SYNC_COMMITTEE, + DOMAIN_SYNC_COMMITTEE_SELECTION_PROOF, + DOMAIN_CONTRIBUTION_AND_PROOF, + DOMAIN_BLS_TO_EXECUTION_CHANGE, + ); + + // One-byte constants, so each goes out as a one-byte array would. + let withdrawal_prefixes = entries!(|prefix: u8| hex_string([prefix]); constants: + BLS_WITHDRAWAL_PREFIX, + ETH1_ADDRESS_WITHDRAWAL_PREFIX, + COMPOUNDING_WITHDRAWAL_PREFIX, + ); + + // `VERSIONED_HASH_VERSION_KZG` is a `Bytes1` like the withdrawal prefixes, + // but it goes out as a decimal, which is how lighthouse reports it. + let other_constants = entries!(decimal; constants: + TARGET_AGGREGATORS_PER_COMMITTEE, + TARGET_AGGREGATORS_PER_SYNC_SUBCOMMITTEE, + SYNC_COMMITTEE_SUBNET_COUNT, + VERSIONED_HASH_VERSION_KZG, + UNSET_DEPOSIT_REQUESTS_START_INDEX, + FULL_EXIT_REQUEST_AMOUNT, + ); + + preset + .into_iter() + .chain(domains) + .chain(withdrawal_prefixes) + .chain(other_constants) +} + +/// A quoted decimal, the Beacon API's encoding for every integer. +fn decimal(value: impl ToString) -> String { + value.to_string() +} + +/// `0x`-prefixed hex, the Beacon API's encoding for every byte string. +fn hex_string(bytes: impl AsRef<[u8]>) -> String { + HexPrefixed(bytes.as_ref()).to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::beacon_fixture; + use axum::{ + body::Body, + http::{Request, StatusCode}, + }; + use http_body_util::BodyExt as _; + use tower::ServiceExt as _; + + async fn get_spec_json() -> serde_json::Value { + let fixture = beacon_fixture(64); + let app = routes().with_state(fixture.store); + let response = app + .oneshot( + Request::builder() + .uri("/eth/v1/config/spec") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + serde_json::from_slice(&body).unwrap() + } + + #[tokio::test] + async fn the_spec_is_screaming_snake_case_with_quoted_values() { + let json = get_spec_json().await; + + assert_eq!(json["data"]["SECONDS_PER_SLOT"], "12"); + assert!( + json["data"]["GENESIS_FORK_VERSION"] + .as_str() + .unwrap() + .starts_with("0x") + ); + assert!(json["data"]["DEPOSIT_CHAIN_ID"].is_string()); + // No bare numbers, lists aside: the Beacon API quotes every value. + for (key, value) in json["data"].as_object().unwrap() { + assert!( + value.is_string() || value.is_array(), + "{key} is {value}, not a string" + ); + } + } + + #[tokio::test] + async fn the_spec_carries_the_preset_and_constants() { + let json = get_spec_json().await; + let data = &json["data"]; + + // What lighthouse's validator client checks before anything else. The + // fixture's store is bootstrapped with `Config::mainnet()`. + assert_eq!(data["PRESET_BASE"], "mainnet"); + assert_eq!(data["CONFIG_NAME"], "mainnet"); + + assert_eq!(data["SLOTS_PER_EPOCH"], preset::SLOTS_PER_EPOCH.to_string()); + assert_eq!(data["DOMAIN_BEACON_ATTESTER"], "0x01000000"); + assert_eq!(data["DOMAIN_APPLICATION_MASK"], "0x00000001"); + assert_eq!(data["COMPOUNDING_WITHDRAWAL_PREFIX"], "0x02"); + assert_eq!(data["VERSIONED_HASH_VERSION_KZG"], "1"); + assert_eq!( + data["UNSET_DEPOSIT_REQUESTS_START_INDEX"], + "18446744073709551615" + ); + } + + #[test] + fn no_key_is_reported_twice() { + let config = Config::mainnet(); + let Value::Object(config_keys) = serde_json::to_value(&config).unwrap() else { + panic!("Config should serialize as an object"); + }; + let extra: Vec<_> = extra_entries().map(|(key, _)| key).collect(); + + for key in &extra { + assert!( + !config_keys.contains_key(*key), + "{key} is both a Config field and an extra entry" + ); + } + let distinct: std::collections::HashSet<_> = extra.iter().collect(); + assert_eq!( + distinct.len(), + extra.len(), + "an extra entry is listed twice" + ); + assert_eq!(spec(&config).len(), config_keys.len() + extra.len()); + } +} diff --git a/crates/net/rpc/src/beacon/genesis.rs b/crates/net/rpc/src/beacon/genesis.rs new file mode 100644 index 000000000..8dff90612 --- /dev/null +++ b/crates/net/rpc/src/beacon/genesis.rs @@ -0,0 +1,89 @@ +//! `/eth/v1/beacon/genesis`. + +use axum::{ + Router, + extract::State, + response::{IntoResponse, Response}, + routing::get, +}; +use ethlambda_storage::Store; +use ethlambda_types::beacon::serde_helpers::HexPrefixed; + +use crate::beacon::ApiError; + +pub(crate) fn routes() -> Router { + Router::new().route("/eth/v1/beacon/genesis", get(get_genesis)) +} + +async fn get_genesis(State(store): State) -> Response { + let config = store.config(); + + // `genesis_validators_root` is a property of the chain carried by every + // state, so the finalized anchor answers it whether or not this directory + // holds the genesis block itself. A checkpoint-synced one does not, which + // is why this does not go looking for slot 0. + let root = match store.latest_finalized() { + Ok(checkpoint) => checkpoint.root, + Err(_) => return ApiError::Internal("no anchor").into_response(), + }; + let genesis_validators_root = match store.get_state(&root) { + Ok(Some(state)) => state.genesis_validators_root(), + Ok(None) => return ApiError::Internal("no anchor state").into_response(), + Err(_) => return ApiError::Internal("store read failed").into_response(), + }; + + crate::json_response(serde_json::json!({ + "data": { + "genesis_time": config.genesis_time.to_string(), + "genesis_validators_root": genesis_validators_root, + "genesis_fork_version": HexPrefixed(&config.genesis_fork_version).to_string(), + } + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::beacon_fixture; + use axum::{ + body::Body, + http::{Request, StatusCode}, + }; + use http_body_util::BodyExt as _; + use tower::ServiceExt as _; + + #[tokio::test] + async fn genesis_reports_time_root_and_fork_version() { + let fixture = beacon_fixture(64); + let app = routes().with_state(fixture.store); + let response = app + .oneshot( + Request::builder() + .uri("/eth/v1/beacon/genesis") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + + assert_eq!( + json["data"]["genesis_time"], "1606824023", + "quoted, and the value init_beacon was given" + ); + assert!( + json["data"]["genesis_validators_root"] + .as_str() + .unwrap() + .starts_with("0x") + ); + // Four bytes, so eight hex digits after the `0x`. + assert_eq!( + json["data"]["genesis_fork_version"].as_str().unwrap().len(), + 10 + ); + } +} diff --git a/crates/net/rpc/src/beacon/headers.rs b/crates/net/rpc/src/beacon/headers.rs new file mode 100644 index 000000000..5345e4e74 --- /dev/null +++ b/crates/net/rpc/src/beacon/headers.rs @@ -0,0 +1,181 @@ +//! `/eth/v1/beacon/headers/{block_id}`. +//! +//! The one endpoint here that is genuinely shared with lean: every field of +//! the message comes from `SignedBeaconBlock`'s accessors, which dispatch +//! including lean, plus `body_root()`. Only the envelope and the signature +//! are beacon-specific. + +use axum::{ + Router, + extract::{Path, State}, + response::{IntoResponse, Response}, + routing::get, +}; +use ethlambda_storage::Store; +use ethlambda_types::primitives::H256; +use serde::Serialize; + +use crate::beacon::blocks::{is_finalized, load}; + +pub(crate) fn routes() -> Router { + Router::new().route("/eth/v1/beacon/headers/{block_id}", get(get_header)) +} + +/// The five fields of a `BeaconBlockHeader`, in the Beacon API's encoding. +/// +/// Spelled out here rather than built from the stored +/// `containers::BeaconBlockHeader`, because a beacon store keeps the whole +/// signed block rather than a separate header row, and lean's header carries +/// a different set of fields. +#[derive(Serialize)] +struct HeaderMessage { + #[serde(with = "ethlambda_types::beacon::serde_helpers::quoted_or_bare")] + slot: u64, + #[serde(with = "ethlambda_types::beacon::serde_helpers::quoted_or_bare")] + proposer_index: u64, + parent_root: H256, + state_root: H256, + body_root: H256, +} + +async fn get_header(Path(block_id): Path, State(store): State) -> Response { + let (root, block) = match load(&store, &block_id) { + Ok(found) => found, + Err(err) => return err.into_response(), + }; + + let message = HeaderMessage { + slot: block.slot(), + proposer_index: block.proposer_index(), + parent_root: block.parent_root(), + state_root: block.state_root(), + body_root: block.body_root(), + }; + + // Asked of the index rather than assumed, even for a block reached by + // root: `BlockRoots` holds one root per slot on the branch ending at the + // head, so a sibling, or a block at a slot the index does not cover, + // answers `false` rather than being taken on trust. + let canonical = store + .canonical_root_at_slot(block.slot()) + .ok() + .flatten() + .is_some_and(|canonical| canonical == root); + + // `signature()` panics on a lean block by design (`dispatch_block!`), + // which is unreachable here: this router is only ever mounted on a beacon + // store. + crate::json_response(serde_json::json!({ + "execution_optimistic": store.is_beacon_optimistic(root), + "finalized": is_finalized(&store, block.slot()), + "data": { + "root": root, + "canonical": canonical, + "header": { + "message": message, + "signature": block.signature(), + } + } + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::beacon_fixture; + use axum::{ + body::Body, + http::{Request, StatusCode}, + }; + use http_body_util::BodyExt as _; + use tower::ServiceExt as _; + + const ANCHOR_SLOT: u64 = 64; + + async fn get(uri: String) -> axum::response::Response { + let fixture = beacon_fixture(ANCHOR_SLOT); + let app = routes().with_state(fixture.store); + app.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap()) + .await + .unwrap() + } + + async fn body_json(response: axum::response::Response) -> serde_json::Value { + let body = response.into_body().collect().await.unwrap().to_bytes(); + serde_json::from_slice(&body).unwrap() + } + + #[tokio::test] + async fn a_header_carries_the_five_fields_and_the_signature() { + let response = get("/eth/v1/beacon/headers/head".to_string()).await; + assert_eq!(response.status(), StatusCode::OK); + let json = body_json(response).await; + + let header = &json["data"]["header"]["message"]; + assert_eq!(header["slot"], "65", "integers are quoted"); + assert_eq!(header["proposer_index"], "0"); + assert!(header["parent_root"].as_str().unwrap().starts_with("0x")); + assert!(header["state_root"].as_str().unwrap().starts_with("0x")); + assert!(header["body_root"].as_str().unwrap().starts_with("0x")); + assert!( + json["data"]["header"]["signature"] + .as_str() + .unwrap() + .starts_with("0x") + ); + assert!(json["data"]["root"].as_str().unwrap().starts_with("0x")); + } + + /// `body_root` is the field no other accessor can produce, so it has to be + /// the body's own merkle root rather than, say, the block's. + #[tokio::test] + async fn the_body_root_is_the_bodys_own_merkle_root() { + let json = body_json(get("/eth/v1/beacon/headers/head".to_string()).await).await; + + let expected = ethlambda_types::primitives::HashTreeRoot::hash_tree_root( + ðlambda_types::beacon::containers::phase0::BeaconBlockBody::default(), + ); + assert_eq!( + json["data"]["header"]["message"]["body_root"], + format!("0x{expected:x}") + ); + } + + /// `canonical` is read off the index rather than assumed. The anchor is + /// reachable by root but is not in `BlockRoots`, so it answers `false` + /// while the head answers `true`. + #[tokio::test] + async fn canonical_is_checked_against_the_index() { + let head = body_json(get("/eth/v1/beacon/headers/head".to_string()).await).await; + assert_eq!(head["data"]["canonical"], true); + + let fixture = beacon_fixture(ANCHOR_SLOT); + let uri = format!("/eth/v1/beacon/headers/0x{:x}", fixture.anchor_root); + let app = routes().with_state(fixture.store); + let response = app + .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap()) + .await + .unwrap(); + let anchor = body_json(response).await; + assert_eq!( + anchor["data"]["canonical"], false, + "the anchor's slot is not in BlockRoots, so the index cannot vouch for it" + ); + } + + #[tokio::test] + async fn a_malformed_id_is_a_400_and_an_absent_one_a_404() { + assert_eq!( + get("/eth/v1/beacon/headers/nope".to_string()) + .await + .status(), + StatusCode::BAD_REQUEST + ); + assert_eq!( + get("/eth/v1/beacon/headers/999999".to_string()) + .await + .status(), + StatusCode::NOT_FOUND + ); + } +} diff --git a/crates/net/rpc/src/beacon/mod.rs b/crates/net/rpc/src/beacon/mod.rs new file mode 100644 index 000000000..c18485b1d --- /dev/null +++ b/crates/net/rpc/src/beacon/mod.rs @@ -0,0 +1,148 @@ +//! The Ethereum Beacon API, served by `ethlambda beacon`. +//! +//! One file per endpoint group. Everything here reads a beacon `Store` through +//! the chain-agnostic accessors only: `Store::head_state`, `head_slot`, +//! `get_block` and `get_block_header` are lean-only and panic on a beacon +//! store, so none of them may appear below this line. + +use axum::{ + Router, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use ethlambda_storage::Store; +use serde::Serialize; + +use crate::shared::block_id::IdError; + +pub(crate) mod blocks; +pub(crate) mod config; +pub(crate) mod genesis; +pub(crate) mod headers; +pub(crate) mod node; +pub(crate) mod pool; +pub(crate) mod proposal; +pub(crate) mod states; +pub(crate) mod validator; +#[cfg(test)] +mod validator_client_tests; + +/// The wrapper every fork-versioned Beacon API payload travels in. +/// +/// The fork travels here rather than inside `data` because the containers are +/// serialized untagged: an `/eth/v2/beacon/blocks/{id}` body is the block +/// itself, and `version` is how a caller knows which fork's shape it just +/// parsed. +#[derive(Debug, Serialize)] +pub(crate) struct Envelope { + /// The fork the `data` container belongs to. + pub(crate) version: &'static str, + /// Whether the execution payload behind this block is still unverified. + pub(crate) execution_optimistic: bool, + /// Whether this block is at or below the finalized checkpoint. + pub(crate) finalized: bool, + pub(crate) data: T, +} + +/// A Beacon API error body. +/// +/// `{"code": …, "message": …}`, deliberately not the lean surface's +/// `{"error": …}`: they are two different APIs and each external consumer +/// parses the shape its own specification names. +#[derive(Debug)] +pub(crate) enum ApiError { + BadRequest(&'static str), + NotFound(&'static str), + Internal(&'static str), + /// A request this node cannot answer right now, typically because its + /// execution client did not (the Beacon API's 503). + ServiceUnavailable(&'static str), +} + +impl From for ApiError { + fn from(err: IdError) -> Self { + match err { + IdError::Malformed => ApiError::BadRequest("invalid block id"), + IdError::NotFound => ApiError::NotFound("block not found"), + } + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + let (status, message) = match self { + ApiError::BadRequest(m) => (StatusCode::BAD_REQUEST, m), + ApiError::NotFound(m) => (StatusCode::NOT_FOUND, m), + ApiError::Internal(m) => (StatusCode::INTERNAL_SERVER_ERROR, m), + ApiError::ServiceUnavailable(m) => (StatusCode::SERVICE_UNAVAILABLE, m), + }; + let body = serde_json::json!({ "code": status.as_u16(), "message": message }); + let mut response = crate::json_response(body); + *response.status_mut() = status; + response + } +} + +/// Every route this surface serves. +/// +/// Deliberately not a superset of [`crate::build_api_router`]: the `/lean/v0` +/// handlers read lean state variants and metadata keys a beacon directory +/// does not carry, so serving both off one store would answer lean questions +/// with beacon data, or panic trying. +pub(crate) fn routes(version: &'static str, peer_id: String) -> Router { + Router::new() + .merge(blocks::routes()) + .merge(headers::routes()) + .merge(states::routes()) + .merge(genesis::routes()) + .merge(config::routes()) + .merge(node::routes(version, peer_id)) + .merge(validator::routes()) + .merge(pool::routes()) + .merge(proposal::routes()) +} + +#[cfg(test)] +mod tests { + use super::*; + use http_body_util::BodyExt as _; + + #[tokio::test] + async fn an_error_body_uses_the_beacon_shape() { + let response = ApiError::NotFound("block not found").into_response(); + assert_eq!(response.status(), axum::http::StatusCode::NOT_FOUND); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + // The Beacon API's shape, not the lean surface's `{"error": ...}`. + assert_eq!(json["code"], 404); + assert_eq!(json["message"], "block not found"); + } + + #[test] + fn a_malformed_id_is_a_400_and_an_absent_one_a_404() { + assert_eq!( + ApiError::from(IdError::Malformed).into_response().status(), + axum::http::StatusCode::BAD_REQUEST + ); + assert_eq!( + ApiError::from(IdError::NotFound).into_response().status(), + axum::http::StatusCode::NOT_FOUND + ); + } + + #[test] + fn the_envelope_names_the_fork_and_the_flags() { + let envelope = Envelope { + version: "deneb", + execution_optimistic: true, + finalized: false, + data: serde_json::json!({"slot": "1"}), + }; + let json = serde_json::to_value(&envelope).unwrap(); + assert_eq!(json["version"], "deneb"); + assert_eq!(json["execution_optimistic"], true); + assert_eq!(json["finalized"], false); + assert_eq!(json["data"]["slot"], "1"); + } +} diff --git a/crates/net/rpc/src/beacon/node.rs b/crates/net/rpc/src/beacon/node.rs new file mode 100644 index 000000000..564d23d1a --- /dev/null +++ b/crates/net/rpc/src/beacon/node.rs @@ -0,0 +1,170 @@ +//! `/eth/v1/node/{syncing,identity,version,health}`. + +use axum::{ + Extension, Router, + extract::State, + http::StatusCode, + response::{IntoResponse, Response}, + routing::get, +}; +use ethlambda_blockchain::{SyncStatusController, metrics::SyncStatus}; +use ethlambda_storage::Store; + +pub(crate) fn routes(version: &'static str, peer_id: String) -> Router { + Router::new() + .route("/eth/v1/node/syncing", get(get_syncing)) + .route("/eth/v1/node/health", get(get_health)) + .route("/eth/v1/node/version", get(move || get_version(version))) + .route( + "/eth/v1/node/identity", + get(move || get_identity(peer_id.clone())), + ) +} + +/// The wall-clock slot, from genesis and the configured slot duration. +/// +/// Millisecond-denominated, the way `/lean/v0/node/syncing` computes the same +/// number: `slot_duration_ms` is the value a loaded network can actually +/// change, and `seconds_per_slot` would truncate a sub-second cadence to zero. +pub(crate) fn wall_slot(store: &Store) -> u64 { + let config = store.config(); + let genesis_ms = config.genesis_time_ms(); + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| elapsed.as_millis() as u64) + .unwrap_or(genesis_ms); + now_ms.saturating_sub(genesis_ms) / config.slot_duration_ms.max(1) +} + +async fn get_syncing( + State(store): State, + Extension(sync_status): Extension, +) -> Response { + // `beacon_head`, not `head_slot`: the latter is lean-only and panics here. + let head_slot = store.beacon_head().map(|(slot, _root)| slot).unwrap_or(0); + let sync_distance = wall_slot(&store).saturating_sub(head_slot); + + crate::json_response(serde_json::json!({ + "data": { + "head_slot": head_slot.to_string(), + "sync_distance": sync_distance.to_string(), + "is_syncing": sync_status.get() == SyncStatus::Syncing, + "is_optimistic": store.has_beacon_optimistic_roots(), + "el_offline": false, + } + })) +} + +async fn get_health(Extension(sync_status): Extension) -> Response { + // 206 while syncing, 200 once caught up. 503 would mean uninitialized, and + // a store that answers at all is initialized. + let status = match sync_status.get() { + SyncStatus::Syncing => StatusCode::PARTIAL_CONTENT, + _ => StatusCode::OK, + }; + status.into_response() +} + +async fn get_version(version: &'static str) -> Response { + crate::json_response(serde_json::json!({ "data": { "version": version } })) +} + +async fn get_identity(peer_id: String) -> Response { + // `enr` and the two address lists are empty, which is not spec-valid: the + // ENR is built for discv5 and owned by the P2P actor, and `BuiltSwarm` + // hands `run_node` only a `local_peer_id`. Serving the record means + // widening the `ethlambda-p2p` surface and threading it through startup, + // which is a change of its own. Recorded in docs/spec_deviations.md. + crate::json_response(serde_json::json!({ + "data": { + "peer_id": peer_id, + "enr": "", + "p2p_addresses": [], + "discovery_addresses": [], + "metadata": { + "seq_number": "0", + "attnets": "0x0000000000000000", + "syncnets": "0x00", + }, + } + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::beacon_fixture; + use axum::{body::Body, http::Request}; + use http_body_util::BodyExt as _; + use tower::ServiceExt as _; + + const ANCHOR_SLOT: u64 = 64; + + async fn get_with(uri: &str, sync: SyncStatusController) -> axum::response::Response { + let fixture = beacon_fixture(ANCHOR_SLOT); + let app = routes("ethlambda/test", "test-peer".to_string()) + .with_state(fixture.store) + .layer(Extension(sync)); + app.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap()) + .await + .unwrap() + } + + async fn get(uri: &str) -> axum::response::Response { + get_with(uri, SyncStatusController::default()).await + } + + async fn body_json(response: axum::response::Response) -> serde_json::Value { + let body = response.into_body().collect().await.unwrap().to_bytes(); + serde_json::from_slice(&body).unwrap() + } + + #[tokio::test] + async fn syncing_quotes_its_numbers_and_reads_the_beacon_head() { + let response = get("/eth/v1/node/syncing").await; + assert_eq!(response.status(), StatusCode::OK); + let json = body_json(response).await; + + // The fixture's head is the child block, one slot above the anchor. + assert_eq!(json["data"]["head_slot"], "65"); + assert!(json["data"]["sync_distance"].is_string()); + assert_eq!(json["data"]["is_syncing"], false); + assert_eq!(json["data"]["is_optimistic"], false); + assert_eq!(json["data"]["el_offline"], false); + } + + #[tokio::test] + async fn syncing_follows_the_controller() { + let syncing = SyncStatusController::new(SyncStatus::Syncing); + let json = body_json(get_with("/eth/v1/node/syncing", syncing).await).await; + assert_eq!(json["data"]["is_syncing"], true); + } + + #[tokio::test] + async fn version_reports_the_client_string() { + let json = body_json(get("/eth/v1/node/version").await).await; + assert_eq!(json["data"]["version"], "ethlambda/test"); + } + + #[tokio::test] + async fn health_is_200_when_caught_up_and_206_while_syncing() { + assert_eq!(get("/eth/v1/node/health").await.status(), StatusCode::OK); + + let syncing = SyncStatusController::new(SyncStatus::Syncing); + assert_eq!( + get_with("/eth/v1/node/health", syncing).await.status(), + StatusCode::PARTIAL_CONTENT + ); + } + + #[tokio::test] + async fn identity_carries_the_peer_id_and_empty_network_fields() { + let json = body_json(get("/eth/v1/node/identity").await).await; + assert_eq!(json["data"]["peer_id"], "test-peer"); + // Deliberately empty, and not spec-valid; see docs/spec_deviations.md. + assert_eq!(json["data"]["enr"], ""); + assert_eq!(json["data"]["p2p_addresses"], serde_json::json!([])); + assert_eq!(json["data"]["discovery_addresses"], serde_json::json!([])); + assert_eq!(json["data"]["metadata"]["seq_number"], "0"); + } +} diff --git a/crates/net/rpc/src/beacon/pool.rs b/crates/net/rpc/src/beacon/pool.rs new file mode 100644 index 000000000..86809218f --- /dev/null +++ b/crates/net/rpc/src/beacon/pool.rs @@ -0,0 +1,746 @@ +//! `POST /eth/v2/beacon/pool/attestations`, how a validator client hands this +//! node its attestations to gossip; `GET /eth/v2/validator/aggregate_attestation`, +//! how an aggregator gets them back combined; and +//! `POST /eth/v2/validator/aggregate_and_proofs`, how it publishes the result. +//! +//! Each attestation is checked against the electra `beacon_attestation_{subnet_id}` +//! gossip conditions (p2p-interface) that can be evaluated here, then +//! published on its subnet. Validating before publishing is not optional: a +//! peer that relays invalid attestations has its gossipsub score cut, and +//! enough of that disconnects it. +//! +//! Conditions not checked, each because it needs state this node does not +//! keep: the "first valid attestation from this validator for this target +//! epoch" deduplication (no seen cache; the validator client already signs at +//! most once per slot), and "the target is a descendant of the finalized +//! checkpoint" (the voted block being in the store already implies it, since +//! only blocks descending from finalization are imported). + +use axum::{ + Extension, Router, + body::Bytes, + extract::{Query, State}, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response}, + routing::{get, post}, +}; +use ethlambda_network_api::RpcToP2PRef; +use ethlambda_state_transition::beacon::{ + attestation_pool::SharedAttestationPool, + bls, + gossip::attestation::compute_subnet_for_attestation, + gossip::{Outcome, aggregate}, + helpers::accessors::{CommitteeCacheExt as _, get_domain}, +}; +use ethlambda_storage::Store; +use ethlambda_types::{ + beacon::{ + constants::{DOMAIN_BEACON_ATTESTER, MAXIMUM_GOSSIP_CLOCK_DISPARITY}, + containers::{ + BeaconState, SignedAggregateAndProof, + electra::{self, SingleAttestation}, + }, + fork::ForkName, + primitives::{CommitteeIndex, Epoch, Root, Slot}, + signing::{compute_epoch_at_slot, compute_signing_root, compute_start_slot_at_epoch}, + }, + primitives::HashTreeRoot as _, +}; +use serde::{Deserialize, Serialize}; +use tracing::{debug, warn}; + +use crate::beacon::{ApiError, validator::head}; + +pub(crate) fn routes() -> Router { + Router::new() + .route( + "/eth/v2/beacon/pool/attestations", + post(post_pool_attestations), + ) + .route( + "/eth/v2/validator/aggregate_attestation", + get(get_aggregate_attestation), + ) + .route( + "/eth/v2/validator/aggregate_and_proofs", + post(post_aggregate_and_proofs), + ) +} + +/// What validating a submitted attestation establishes about it: where it is +/// published, and where it sits in its committee, which the pool needs. +struct Checked { + subnet_id: u64, + committee_position: usize, + committee_len: usize, +} + +/// One rejected attestation, in the Beacon API's `IndexedErrorMessage` shape: +/// its position in the submitted array, and why. +#[derive(Debug, Serialize)] +struct Failure { + index: usize, + message: &'static str, +} + +async fn post_pool_attestations( + State(store): State, + Extension(p2p): Extension, + Extension(pool): Extension, + headers: HeaderMap, + body: Bytes, +) -> Response { + if let Err(err) = require_electra_or_later(&headers) { + return err.into_response(); + } + let Ok(attestations) = serde_json::from_slice::>(&body) else { + return ApiError::BadRequest("invalid request body").into_response(); + }; + let (_head_root, state) = match head(&store) { + Ok(found) => found, + Err(err) => return err.into_response(), + }; + + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| elapsed.as_millis() as u64) + .unwrap_or(0); + let mut failures = Vec::new(); + for (index, attestation) in attestations.into_iter().enumerate() { + let slot = attestation.data.slot; + let validator = attestation.attester_index; + let checked = validate(&store, &state, &attestation, now_ms); + // Pooled as well as published: gossip never delivers a node its own + // messages, so without this an aggregator served by this node would + // be missing its own validator client's votes. + let published = checked.and_then(|checked| { + pool.lock().expect("attestation pool lock poisoned").insert( + &attestation, + checked.committee_position, + checked.committee_len, + ); + p2p.publish_beacon_attestation(checked.subnet_id, attestation) + .map_err(|_| "the network actor is not running") + }); + match published { + Ok(()) => debug!(%slot, validator, "Accepted attestation for gossip"), + Err(message) => { + warn!(%slot, validator, reason = message, "Refused a submitted attestation"); + failures.push(Failure { index, message }); + } + } + } + + batch_response( + failures, + "some attestations failed validation and were not published", + ) +} + +/// Where `attestation` belongs, if it passes every gossip condition +/// this node can check, or the first one it fails. +/// +/// `state` is the fork-choice head's post-state, whose shuffling is the +/// attestation's for any target epoch from the head's previous to its next. +/// The committees come from the store's shared cache, which the chain actor +/// fills too, so a batch of one slot's attestations derives its shuffling at +/// most once. +fn validate( + store: &Store, + state: &BeaconState, + attestation: &SingleAttestation, + now_ms: u64, +) -> Result { + let data = &attestation.data; + let config = store.config(); + + // [IGNORE] Not from the future, and from the current or previous epoch, + // both with MAXIMUM_GOSSIP_CLOCK_DISPARITY of allowance (deneb's form). + let slot_start_ms = config + .genesis_time_ms() + .saturating_add(data.slot.saturating_mul(config.slot_duration_ms)); + if slot_start_ms > now_ms + MAXIMUM_GOSSIP_CLOCK_DISPARITY { + return Err("attestation slot is in the future"); + } + let clock_epoch = |ms: u64| { + let slot = ms.saturating_sub(config.genesis_time_ms()) / config.slot_duration_ms.max(1); + compute_epoch_at_slot(slot) + }; + let earliest_epoch = clock_epoch(now_ms.saturating_sub(MAXIMUM_GOSSIP_CLOCK_DISPARITY)); + let attestation_epoch = compute_epoch_at_slot(data.slot); + if attestation_epoch + 1 < earliest_epoch { + return Err("attestation is older than the previous epoch"); + } + + // [REJECT] data.index == 0, and the target epoch is the slot's. + if data.index != 0 { + return Err("data.index must be zero from electra on"); + } + if data.target.epoch != attestation_epoch { + return Err("target epoch does not match the attestation slot"); + } + + // The head state's shuffling covers its previous, current and next epoch. + let state_epoch = compute_epoch_at_slot(state.slot()); + if data.target.epoch + 1 < state_epoch || data.target.epoch > state_epoch + 1 { + return Err("target epoch is too far from this node's head to check"); + } + + // [IGNORE] The voted block has been seen. [REJECT] The target is that + // block's checkpoint for the target epoch. + if !store.has_block(&data.beacon_block_root) { + return Err("the voted block is unknown to this node"); + } + if checkpoint_block(store, data.beacon_block_root, data.target.epoch) != Some(data.target.root) + { + return Err("target root is not the voted block's checkpoint"); + } + + // [REJECT] The committee index is in range, and the attester is in it. + let epoch_committees = store.committee_cache().committees(state, data.target.epoch); + let committees_per_slot = epoch_committees.committees_per_slot(); + if attestation.committee_index >= committees_per_slot { + return Err("committee index is out of range"); + } + let committee = epoch_committees + .committee(data.slot, attestation.committee_index) + .map_err(|_| "committee computation failed")?; + let committee_position = committee + .iter() + .position(|&member| member == attestation.attester_index) + .ok_or("attester is not in the named committee")?; + let committee_len = committee.len(); + + // [REJECT] The signature is valid, under the attester domain at the target + // epoch. + let pubkey = state + .validator(attestation.attester_index) + .map_err(|_| "attester index is unknown")? + .pubkey; + let domain = get_domain(state, DOMAIN_BEACON_ATTESTER, Some(data.target.epoch)); + let signing_root = compute_signing_root(data.hash_tree_root(), domain); + if !bls::verify(&pubkey, signing_root, &attestation.signature) { + return Err("invalid signature"); + } + + Ok(Checked { + subnet_id: compute_subnet_for_attestation( + committees_per_slot, + data.slot, + attestation.committee_index, + &config, + ), + committee_position, + committee_len, + }) +} + +/// The endpoints here take electra's containers, which exist only from that +/// fork on; `Eth-Consensus-Version` is required to say so. +fn require_electra_or_later(headers: &HeaderMap) -> Result<(), ApiError> { + let fork = headers + .get("eth-consensus-version") + .and_then(|value| value.to_str().ok()) + .and_then(ForkName::parse); + if fork.is_some_and(|fork| fork >= ForkName::Electra) { + Ok(()) + } else { + Err(ApiError::BadRequest( + "Eth-Consensus-Version must name electra or a later fork", + )) + } +} + +/// `200` when nothing failed, else the Beacon API's `IndexedErrorMessage` +/// naming each failed item by position; the rest were still published. +fn batch_response(failures: Vec, message: &'static str) -> Response { + if failures.is_empty() { + return StatusCode::OK.into_response(); + } + let body = serde_json::json!({ "code": 400, "message": message, "failures": failures }); + let mut response = crate::json_response(body); + *response.status_mut() = StatusCode::BAD_REQUEST; + response +} + +/// `POST /eth/v2/validator/aggregate_and_proofs`: a validator client's signed +/// aggregates, validated with the same `beacon_aggregate_and_proof` gossip +/// conditions this node applies to its peers' (`gossip::aggregate`), then +/// gossiped. +/// +/// Each is checked against a fresh seen-cache rather than P2P's: that cache +/// holds what peers sent, and a node never receives its own messages, so it +/// would say nothing about these; what it guards against, a second aggregate +/// for one aggregator and epoch, the validator client already guards against +/// by signing one per duty. +async fn post_aggregate_and_proofs( + State(store): State, + Extension(p2p): Extension, + Extension(pool): Extension, + headers: HeaderMap, + body: Bytes, +) -> Response { + if let Err(err) = require_electra_or_later(&headers) { + return err.into_response(); + } + let Ok(aggregates) = serde_json::from_slice::>(&body) + else { + return ApiError::BadRequest("invalid request body").into_response(); + }; + + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| elapsed.as_millis() as u64) + .unwrap_or(0); + let capacity = std::num::NonZeroUsize::MIN; + let mut failures = Vec::new(); + for (index, aggregate) in aggregates.into_iter().enumerate() { + let inner = aggregate.message.aggregate.clone(); + let aggregate = SignedAggregateAndProof::Electra(aggregate); + let slot = aggregate.slot(); + let aggregator = aggregate.aggregator_index(); + let seen = aggregate::SeenAggregates::new(capacity, capacity); + let checked = aggregate::cheap_checks(&seen, &store, &aggregate, now_ms) + .and_then(|()| aggregate::stateful_checks(&store, &aggregate).map(|_| ())); + let published = checked + .map_err(|outcome: Outcome| { + warn!(%slot, aggregator, ?outcome, "Refused a submitted aggregate"); + "aggregate failed validation" + }) + .and_then(|()| { + // Recorded for block production, which packs the aggregates + // this node has validated. + pool.lock() + .expect("attestation pool lock poisoned") + .insert_aggregate(inner); + p2p.publish_beacon_aggregate(aggregate) + .map_err(|_| "the network actor is not running") + }); + match published { + Ok(()) => debug!(%slot, aggregator, "Accepted aggregate for gossip"), + Err(message) => failures.push(Failure { index, message }), + } + } + batch_response( + failures, + "some aggregates failed validation and were not published", + ) +} + +#[derive(Debug, Deserialize)] +struct AggregateQuery { + attestation_data_root: Root, + slot: Slot, + committee_index: CommitteeIndex, +} + +/// `GET /eth/v2/validator/aggregate_attestation`: every vote this node holds +/// for `attestation_data_root` from `committee_index`'s committee at `slot`, +/// aggregated. A 404 when it holds none, which is what the endpoint specifies +/// and what an aggregator reads as "nothing to publish". +async fn get_aggregate_attestation( + State(store): State, + Extension(pool): Extension, + Query(query): Query, +) -> Response { + let aggregate = pool + .lock() + .expect("attestation pool lock poisoned") + .aggregate( + query.attestation_data_root, + query.slot, + query.committee_index, + ); + let Some(aggregate) = aggregate else { + return ApiError::NotFound("no matching attestations to aggregate").into_response(); + }; + let fork = store + .config() + .fork_at_epoch(compute_epoch_at_slot(query.slot)); + let response = crate::json_response(serde_json::json!({ + "version": fork.as_str(), + "data": aggregate, + })); + crate::shared::content::with_consensus_version(response, fork) +} + +/// The root of the latest block at or before `epoch`'s first slot on the chain +/// ending in `root`: the spec's `get_checkpoint_block`, walked through the +/// store's block index. `None` if the walk leaves the stored chain. +fn checkpoint_block(store: &Store, mut root: Root, epoch: Epoch) -> Option { + let boundary = compute_start_slot_at_epoch(epoch); + loop { + let (slot, parent) = store.block_entry(&root)?; + if slot <= boundary { + return Some(root); + } + root = parent; + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + use crate::test_utils::{RecordingNetwork, beacon_store_at}; + use axum::{body::Body, http::Request}; + use ethlambda_state_transition::beacon::helpers::{ + accessors::get_beacon_committee, + test_state::{sign_for, with_signing_validators_at}, + }; + use ethlambda_types::beacon::containers::shared::{AttestationData, Checkpoint}; + use http_body_util::BodyExt as _; + use tower::ServiceExt as _; + + struct Fixture { + store: Store, + state: BeaconState, + head_root: Root, + network: Arc, + pool: SharedAttestationPool, + } + + /// A fulu head state, stored in the current wall-clock epoch so the + /// submitted attestations are neither future nor stale. + fn fixture() -> Fixture { + let mut state = with_signing_validators_at(ForkName::Fulu, 64); + let BeaconState::Fulu(fulu) = &mut state else { + unreachable!("built as fulu") + }; + // At the first slot of the wall clock's epoch, so the head is its own + // epoch's checkpoint block and the attestation's target root. + let (probe, _) = beacon_store_at(BeaconState::Fulu(fulu.clone())); + let wall_epoch = compute_epoch_at_slot(crate::beacon::node::wall_slot(&probe)); + fulu.slot = compute_start_slot_at_epoch(wall_epoch); + let (store, head_root) = beacon_store_at(state.clone()); + Fixture { + store, + state, + head_root, + network: Arc::new(RecordingNetwork::default()), + pool: SharedAttestationPool::default(), + } + } + + /// A correctly signed attestation from `committee`'s member at `position`, + /// voting for the head at the head's own slot. + fn attestation(fixture: &Fixture, committee_index: u64, position: usize) -> SingleAttestation { + let slot = fixture.state.slot(); + let epoch = compute_epoch_at_slot(slot); + let data = AttestationData { + slot, + index: 0, + beacon_block_root: fixture.head_root, + source: fixture.state.current_justified_checkpoint(), + target: Checkpoint { + epoch, + root: fixture.head_root, + }, + }; + let committee = get_beacon_committee(&fixture.state, slot, committee_index).unwrap(); + let attester_index = committee[position]; + let domain = get_domain(&fixture.state, DOMAIN_BEACON_ATTESTER, Some(epoch)); + let signing_root = compute_signing_root(data.hash_tree_root(), domain); + SingleAttestation { + committee_index, + attester_index, + data, + signature: sign_for(attester_index as usize, signing_root), + } + } + + async fn submit( + fixture: &Fixture, + attestations: &[SingleAttestation], + ) -> (StatusCode, serde_json::Value) { + let network: RpcToP2PRef = fixture.network.clone(); + let app = routes() + .with_state(fixture.store.clone()) + .layer(Extension(network)) + .layer(Extension(fixture.pool.clone())); + let request = Request::post("/eth/v2/beacon/pool/attestations") + .header("content-type", "application/json") + .header("eth-consensus-version", "fulu") + .body(Body::from(serde_json::to_vec(attestations).unwrap())) + .unwrap(); + let response = app.oneshot(request).await.unwrap(); + let status = response.status(); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let json = if body.is_empty() { + serde_json::Value::Null + } else { + serde_json::from_slice(&body).unwrap() + }; + (status, json) + } + + #[tokio::test] + async fn a_valid_attestation_is_published_on_its_subnet() { + let fixture = fixture(); + let attestation = attestation(&fixture, 0, 0); + let (status, _) = submit(&fixture, std::slice::from_ref(&attestation)).await; + assert_eq!(status, StatusCode::OK); + + let published = fixture.network.published.lock().unwrap(); + assert_eq!(published.len(), 1); + let epoch = attestation.data.target.epoch; + let committees_per_slot = fixture + .store + .committee_cache() + .committees(&fixture.state, epoch) + .committees_per_slot(); + let expected_subnet = compute_subnet_for_attestation( + committees_per_slot, + attestation.data.slot, + 0, + &fixture.store.config(), + ); + assert_eq!(published[0], (expected_subnet, attestation)); + } + + #[tokio::test] + async fn a_bad_signature_is_refused_and_not_published() { + let fixture = fixture(); + let mut forged = attestation(&fixture, 0, 0); + forged.signature = attestation(&fixture, 0, 1).signature; + let (status, json) = submit(&fixture, &[forged]).await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(json["failures"][0]["index"], 0); + assert_eq!(json["failures"][0]["message"], "invalid signature"); + assert!(fixture.network.published.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn an_attester_outside_its_committee_is_refused() { + let fixture = fixture(); + let mut wrong = attestation(&fixture, 0, 0); + let outsider = (0..64u64) + .find(|index| { + !get_beacon_committee(&fixture.state, wrong.data.slot, 0) + .unwrap() + .contains(index) + }) + .expect("a 64-validator epoch spreads validators over several slots"); + wrong.attester_index = outsider; + let (status, json) = submit(&fixture, &[wrong]).await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!( + json["failures"][0]["message"], + "attester is not in the named committee" + ); + } + + #[tokio::test] + async fn a_mixed_batch_publishes_the_valid_and_reports_the_rest_by_position() { + let fixture = fixture(); + let good = attestation(&fixture, 0, 0); + let mut bad = attestation(&fixture, 0, 1); + bad.data.index = 1; + let (status, json) = submit(&fixture, &[bad, good.clone()]).await; + assert_eq!(status, StatusCode::BAD_REQUEST); + let failures = json["failures"].as_array().unwrap(); + assert_eq!(failures.len(), 1); + assert_eq!(failures[0]["index"], 0); + let published = fixture.network.published.lock().unwrap(); + assert_eq!(published.len(), 1); + assert_eq!(published[0].1, good); + } + + async fn get_aggregate( + fixture: &Fixture, + data_root: Root, + slot: u64, + committee: u64, + ) -> (StatusCode, serde_json::Value) { + let app = routes() + .with_state(fixture.store.clone()) + .layer(Extension(fixture.pool.clone())); + let uri = format!( + "/eth/v2/validator/aggregate_attestation?attestation_data_root={data_root}&slot={slot}&committee_index={committee}" + ); + let response = app + .oneshot(Request::get(uri).body(Body::empty()).unwrap()) + .await + .unwrap(); + let status = response.status(); + let body = response.into_body().collect().await.unwrap().to_bytes(); + ( + status, + serde_json::from_slice(&body).unwrap_or(serde_json::Value::Null), + ) + } + + /// The aggregator's round trip: its committee's submitted votes come back + /// combined, with a signature that verifies over the attesters' keys. + #[tokio::test] + async fn submitted_attestations_come_back_aggregated() { + let fixture = fixture(); + let committee = get_beacon_committee(&fixture.state, fixture.state.slot(), 0).unwrap(); + let votes: Vec = (0..committee.len()) + .map(|position| attestation(&fixture, 0, position)) + .collect(); + let (status, _) = submit(&fixture, &votes).await; + assert_eq!(status, StatusCode::OK); + + let data = votes[0].data; + let (status, json) = get_aggregate(&fixture, data.hash_tree_root(), data.slot, 0).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(json["version"], "fulu"); + + let bits_hex = json["data"]["aggregation_bits"].as_str().unwrap(); + let bits = hex::decode(bits_hex.trim_start_matches("0x")).unwrap(); + let set: u32 = bits.iter().map(|byte| byte.count_ones()).sum(); + // Every member's bit, plus the bitlist's length-marker bit. + assert_eq!(set as usize, committee.len() + 1); + + let pubkeys: Vec<_> = committee + .iter() + .map(|&index| fixture.state.validator(index).unwrap().pubkey) + .collect(); + let signature: ethlambda_types::beacon::primitives::BlsSignature = + serde_json::from_value(json["data"]["signature"].clone()).unwrap(); + let domain = get_domain( + &fixture.state, + DOMAIN_BEACON_ATTESTER, + Some(data.target.epoch), + ); + let signing_root = compute_signing_root(data.hash_tree_root(), domain); + assert!( + ethlambda_state_transition::beacon::bls::fast_aggregate_verify( + &pubkeys, + signing_root, + &signature + ) + ); + } + + #[tokio::test] + async fn an_aggregate_with_no_votes_is_a_404() { + let fixture = fixture(); + let (status, _) = + get_aggregate(&fixture, Root::repeat_byte(7), fixture.state.slot(), 0).await; + assert_eq!(status, StatusCode::NOT_FOUND); + } + + /// A `SignedAggregateAndProof` from `aggregator` over `aggregate`, signed + /// the way phase0's `validator.md` ("Construct aggregate") says. + fn signed_aggregate( + fixture: &Fixture, + aggregator: u64, + aggregate: electra::Attestation, + ) -> electra::SignedAggregateAndProof { + use ethlambda_types::beacon::constants::{ + DOMAIN_AGGREGATE_AND_PROOF, DOMAIN_SELECTION_PROOF, + }; + let slot = aggregate.data.slot; + let epoch = compute_epoch_at_slot(slot); + let selection_domain = get_domain(&fixture.state, DOMAIN_SELECTION_PROOF, Some(epoch)); + let selection_proof = sign_for( + aggregator as usize, + compute_signing_root(slot.hash_tree_root(), selection_domain), + ); + let message = electra::AggregateAndProof { + aggregator_index: aggregator, + aggregate, + selection_proof, + }; + let domain = get_domain(&fixture.state, DOMAIN_AGGREGATE_AND_PROOF, Some(epoch)); + let signature = sign_for( + aggregator as usize, + compute_signing_root(message.hash_tree_root(), domain), + ); + electra::SignedAggregateAndProof { message, signature } + } + + async fn submit_aggregates( + fixture: &Fixture, + aggregates: &[electra::SignedAggregateAndProof], + ) -> (StatusCode, serde_json::Value) { + let network: RpcToP2PRef = fixture.network.clone(); + let app = routes() + .with_state(fixture.store.clone()) + .layer(Extension(network)) + .layer(Extension(fixture.pool.clone())); + let request = Request::post("/eth/v2/validator/aggregate_and_proofs") + .header("content-type", "application/json") + .header("eth-consensus-version", "fulu") + .body(Body::from(serde_json::to_vec(aggregates).unwrap())) + .unwrap(); + let response = app.oneshot(request).await.unwrap(); + let status = response.status(); + let body = response.into_body().collect().await.unwrap().to_bytes(); + ( + status, + serde_json::from_slice(&body).unwrap_or(serde_json::Value::Null), + ) + } + + /// An aggregator's whole slot through this node: its committee's votes in, + /// the aggregate back out, and the signed aggregate published. + #[tokio::test] + async fn an_aggregator_can_publish_what_it_aggregated() { + let fixture = fixture(); + let slot = fixture.state.slot(); + let committee = get_beacon_committee(&fixture.state, slot, 0).unwrap(); + let votes: Vec = (0..committee.len()) + .map(|position| attestation(&fixture, 0, position)) + .collect(); + submit(&fixture, &votes).await; + let aggregate = fixture + .pool + .lock() + .unwrap() + .aggregate(votes[0].data.hash_tree_root(), slot, 0) + .unwrap(); + + // With 64 validators a committee has two members, fewer than + // TARGET_AGGREGATORS_PER_COMMITTEE, so every member is an aggregator. + let signed = signed_aggregate(&fixture, committee[0], aggregate); + let (status, json) = submit_aggregates(&fixture, std::slice::from_ref(&signed)).await; + assert_eq!(status, StatusCode::OK, "{json}"); + let published = fixture.network.aggregates.lock().unwrap(); + assert_eq!(published.len(), 1); + assert_eq!(published[0], SignedAggregateAndProof::Electra(signed)); + } + + #[tokio::test] + async fn an_aggregate_signed_by_someone_else_is_refused() { + let fixture = fixture(); + let slot = fixture.state.slot(); + let committee = get_beacon_committee(&fixture.state, slot, 0).unwrap(); + let votes: Vec = (0..committee.len()) + .map(|position| attestation(&fixture, 0, position)) + .collect(); + submit(&fixture, &votes).await; + let aggregate = fixture + .pool + .lock() + .unwrap() + .aggregate(votes[0].data.hash_tree_root(), slot, 0) + .unwrap(); + + let mut forged = signed_aggregate(&fixture, committee[0], aggregate.clone()); + forged.signature = signed_aggregate(&fixture, committee[1], aggregate).signature; + let (status, json) = submit_aggregates(&fixture, &[forged]).await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(json["failures"][0]["index"], 0); + assert!(fixture.network.aggregates.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn a_pre_electra_fork_header_is_refused() { + let fixture = fixture(); + let network: RpcToP2PRef = fixture.network.clone(); + let app = routes() + .with_state(fixture.store.clone()) + .layer(Extension(network)) + .layer(Extension(fixture.pool.clone())); + let request = Request::post("/eth/v2/beacon/pool/attestations") + .header("eth-consensus-version", "deneb") + .body(Body::from("[]")) + .unwrap(); + let response = app.oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } +} diff --git a/crates/net/rpc/src/beacon/proposal.rs b/crates/net/rpc/src/beacon/proposal.rs new file mode 100644 index 000000000..5d1844ad3 --- /dev/null +++ b/crates/net/rpc/src/beacon/proposal.rs @@ -0,0 +1,379 @@ +//! `GET /eth/v3/validator/blocks/{slot}` (`produceBlockV3`), a block for a +//! validator client to sign with its payload built by this node's own +//! execution client, and `POST /eth/v2/beacon/blocks` (`publishBlockV2`), the +//! signed block back to gossip and import. +//! +//! Only unblinded, locally built blocks: this node has no builder flow, so +//! `builder_boost_factor` is accepted and ignored, and every answer carries +//! `Eth-Execution-Payload-Blinded: false`. +//! +//! Payloads carrying blobs are refused for now, with a 503 the validator client +//! fails over on. Publishing such a block means computing and gossiping its +//! data column sidecars, which this node does not do yet, and a block its peers +//! cannot sample is a block they will not import. + +use axum::{ + Extension, Router, + body::Bytes, + extract::{Path, Query, State}, + http::{HeaderMap, HeaderValue, StatusCode, header}, + response::{IntoResponse, Response}, + routing::{get, post}, +}; +use ethlambda_engine::{ + EngineClient, ForkchoiceStateV1, + building::{BuiltPayload, PayloadAttributesV3}, + types::uint256, +}; +use ethlambda_network_api::RpcToP2PRef; +use ethlambda_state_transition::beacon::{ + attestation_pool::SharedAttestationPool, + block_production::{ + BlockInputs, advance_to_slot, assemble_block, pack_attestations, parse_execution_requests, + payload_inputs, + }, + stf::verify_block_signature, +}; +use ethlambda_storage::Store; +use ethlambda_types::{ + beacon::{ + containers::{ + self, BeaconState, + deneb::Blob, + electra::{BeaconBlock, SignedBeaconBlock}, + }, + fork::ForkName, + preset, + primitives::{BlsSignature, Bytes32, ExecutionAddress, KzgProof, Slot}, + }, + primitives::H256, +}; +use libssz::{SszDecode as _, SszEncode as _}; +use libssz_derive::{SszDecode, SszEncode}; +use libssz_types::SszList; +use serde::Deserialize; +use tracing::{info, warn}; + +use crate::beacon::{ApiError, validator::FeeRecipients, validator::head}; +use crate::shared::content::{Encoding, ssz_response, with_consensus_version}; + +/// One KZG proof per cell of every blob, fulu's `kzg_proofs` bound. +pub(crate) type CellKzgProofs = SszList< + KzgProof, + { preset::FIELD_ELEMENTS_PER_EXT_BLOB * preset::MAX_BLOB_COMMITMENTS_PER_BLOCK }, +>; +pub(crate) type Blobs = SszList; + +/// Fulu's `BlockContents`, the Beacon API's envelope for an unblinded block +/// and the blobs its proposer publishes with it. A beacon-APIs container, not +/// a consensus one, so it lives with the API. +#[derive(Debug, Clone, PartialEq, SszEncode, SszDecode, serde::Serialize)] +pub(crate) struct FuluBlockContents { + pub(crate) block: BeaconBlock, + #[serde(serialize_with = "ethlambda_types::beacon::serde_helpers::seq::serialize")] + pub(crate) kzg_proofs: CellKzgProofs, + #[serde(serialize_with = "ethlambda_types::beacon::serde_helpers::ssz_hex_seq::serialize")] + pub(crate) blobs: Blobs, +} + +/// Fulu's `SignedBlockContents`, what `publishBlockV2` receives. +#[derive(Debug, Clone, PartialEq, SszEncode, SszDecode)] +pub(crate) struct FuluSignedBlockContents { + pub(crate) signed_block: SignedBeaconBlock, + pub(crate) kzg_proofs: CellKzgProofs, + pub(crate) blobs: Blobs, +} + +pub(crate) fn routes() -> Router { + Router::new() + .route("/eth/v3/validator/blocks/{slot}", get(get_block)) + .route("/eth/v2/beacon/blocks", post(post_block)) +} + +/// `POST /eth/v2/beacon/blocks`, SSZ-encoded `SignedBlockContents`. +/// +/// Checked before it goes anywhere: the fork is fulu, it carries no blobs +/// (whose data columns this node cannot publish yet, see the module docs), it +/// builds on this node's head, and the proposer's signature verifies against +/// the head state advanced to its slot. Then handed to P2P, which gossips it +/// and gives it to the chain actor to import. The full import runs there, so +/// `200` here means validated and broadcast, the `gossip` level of +/// `broadcast_validation`, which is the endpoint's default. +async fn post_block( + State(store): State, + Extension(p2p): Extension, + headers: HeaderMap, + body: Bytes, +) -> Response { + let fork = headers + .get("eth-consensus-version") + .and_then(|value| value.to_str().ok()) + .and_then(ForkName::parse); + if fork != Some(ForkName::Fulu) { + return ApiError::BadRequest("Eth-Consensus-Version must be fulu").into_response(); + } + let is_ssz = headers + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.starts_with(crate::SSZ_CONTENT_TYPE)); + if !is_ssz { + return ( + StatusCode::UNSUPPORTED_MEDIA_TYPE, + "blocks are accepted as application/octet-stream only", + ) + .into_response(); + } + let Ok(contents) = FuluSignedBlockContents::from_ssz_bytes(&body) else { + return ApiError::BadRequest("the body is not fulu SignedBlockContents").into_response(); + }; + if !contents.blobs.is_empty() + || !contents.kzg_proofs.is_empty() + || !contents + .signed_block + .message + .body + .blob_kzg_commitments + .is_empty() + { + return ApiError::BadRequest( + "blocks with blobs are not accepted yet: their data columns cannot be published", + ) + .into_response(); + } + + let block = containers::SignedBeaconBlock::Fulu(contents.signed_block); + let (_, head_state) = match head(&store) { + Ok(found) => found, + Err(err) => return err.into_response(), + }; + if block.slot() <= head_state.slot() { + return ApiError::BadRequest("the block is not after this node's head").into_response(); + } + let Ok(state) = advance_to_slot(&head_state, block.slot(), &store.config()) else { + return ApiError::Internal("advancing the head state failed").into_response(); + }; + if !verify_block_signature(&state, &block) { + return ApiError::BadRequest("invalid block signature").into_response(); + } + if p2p.publish_beacon_block(block).is_err() { + return ApiError::Internal("the network actor is not running").into_response(); + } + StatusCode::OK.into_response() +} + +#[derive(Debug, Deserialize)] +struct ProduceQuery { + randao_reveal: BlsSignature, + #[serde(default)] + graffiti: Option, +} + +async fn get_block( + Path(slot): Path, + Query(query): Query, + State(store): State, + Extension(engine): Extension>, + Extension(pool): Extension, + Extension(fee_recipients): Extension, + headers: HeaderMap, +) -> Response { + let Ok(slot) = slot.parse::() else { + return ApiError::BadRequest("invalid slot").into_response(); + }; + let Some(engine) = engine else { + return ApiError::ServiceUnavailable( + "no execution client configured to build a payload with", + ) + .into_response(); + }; + let graffiti = query.graffiti.unwrap_or(Bytes32::ZERO); + let produced = produce( + &store, + &engine, + &pool, + &fee_recipients, + slot, + query.randao_reveal, + graffiti, + ) + .await; + let (block, payload_value, fork) = match produced { + Ok(produced) => produced, + Err(err) => return err.into_response(), + }; + + let contents = FuluBlockContents { + block, + kzg_proofs: Default::default(), + blobs: Default::default(), + }; + let accept = headers.get(header::ACCEPT).and_then(|v| v.to_str().ok()); + let mut response = match Encoding::from_accept(accept) { + Encoding::Ssz => ssz_response(contents.to_ssz()), + Encoding::Json => crate::json_response(serde_json::json!({ + "version": fork.as_str(), + "execution_payload_blinded": false, + "execution_payload_value": payload_value, + "consensus_block_value": "0", + "data": contents, + })), + }; + let headers = response.headers_mut(); + headers.insert( + "eth-execution-payload-blinded", + HeaderValue::from_static("false"), + ); + if let Ok(value) = HeaderValue::from_str(&payload_value) { + headers.insert("eth-execution-payload-value", value); + } + // Not computed: nothing here reads it, and the builder comparison it + // exists for does not happen on this node. + headers.insert("eth-consensus-block-value", HeaderValue::from_static("0")); + with_consensus_version(response, fork) +} + +/// The block for `slot`, the payload's value in wei as a decimal string, and +/// the block's fork. +async fn produce( + store: &Store, + engine: &EngineClient, + pool: &SharedAttestationPool, + fee_recipients: &FeeRecipients, + slot: Slot, + randao_reveal: BlsSignature, + graffiti: Bytes32, +) -> Result<(BeaconBlock, String, ForkName), ApiError> { + let config = store.config(); + let (head_root, head_state) = head(store)?; + if slot <= head_state.slot() { + return Err(ApiError::BadRequest("slot is not after the head block")); + } + let state = advance_to_slot(&head_state, slot, &config) + .map_err(|_| ApiError::Internal("advancing the head state failed"))?; + let fork = state.fork_name(); + if fork != ForkName::Fulu { + return Err(ApiError::BadRequest( + "block production is served for fulu only", + )); + } + let proposer = + ethlambda_state_transition::beacon::helpers::accessors::get_beacon_proposer_index(&state) + .map_err(|_| ApiError::Internal("no proposer for the slot"))?; + + let built = build_payload(store, engine, fee_recipients, &state, head_root, proposer).await?; + if !built.blobs_bundle.commitments.is_empty() { + warn!(%slot, blobs = built.blobs_bundle.commitments.len(), "Refusing a payload with blobs"); + return Err(ApiError::ServiceUnavailable( + "the payload carries blobs, whose data columns this node cannot publish yet", + )); + } + let execution_requests = parse_execution_requests(&built.execution_requests) + .map_err(|_| ApiError::Internal("the execution client's request list is malformed"))?; + let payload_value = decimal(&built.block_value); + + let candidates = pool + .lock() + .expect("attestation pool lock poisoned") + .block_candidates(); + let attestations = pack_attestations(&state, candidates); + let inputs = |attestations| BlockInputs { + randao_reveal, + graffiti, + attestations, + execution_payload: built.execution_payload.clone(), + blob_kzg_commitments: Vec::new(), + execution_requests: execution_requests.clone(), + }; + let attestation_count = attestations.len(); + let block = match assemble_block(&state, inputs(attestations), &config) { + Ok(block) => block, + // `pack_attestations` checks every attestation's signature against this + // state, so this should not happen; but a block without them still + // earns the proposal, and one that fails to build earns nothing. + Err(err) if attestation_count > 0 => { + warn!(%slot, %err, "Block with attestations failed to build; retrying without"); + assemble_block(&state, inputs(Vec::new()), &config) + .map_err(|_| ApiError::Internal("the block failed to build"))? + } + Err(_) => return Err(ApiError::Internal("the block failed to build")), + }; + info!( + %slot, + proposer, + attestations = block.body.attestations.len(), + transactions = block.body.execution_payload.transactions.len(), + "Produced block" + ); + Ok((block, payload_value, fork)) +} + +/// Ask the execution client to build on the head for `state`'s slot, then +/// collect what it built. +/// +/// Collected straight away rather than after waiting: the execution client +/// starts with a valid (possibly empty) payload and improves it, so an early +/// `getPayload` always answers, with less time for transactions to arrive. The +/// validator client asks at the start of the slot, which is when the block is +/// due. +async fn build_payload( + store: &Store, + engine: &EngineClient, + fee_recipients: &FeeRecipients, + state: &BeaconState, + head_root: H256, + proposer: u64, +) -> Result { + let config = store.config(); + let inputs = payload_inputs(state, &config) + .map_err(|_| ApiError::Internal("computing the payload attributes failed"))?; + let fee_recipient = fee_recipients + .lock() + .expect("fee recipient lock poisoned") + .get(&proposer) + .copied() + .unwrap_or_else(|| { + warn!( + proposer, + "No fee recipient prepared for the proposer; using the zero address" + ); + ExecutionAddress::ZERO + }); + let el_hash = |root: H256| store.beacon_el_block_hash(root).unwrap_or(H256::ZERO); + let forkchoice = ForkchoiceStateV1 { + head_block_hash: inputs.parent_hash, + safe_block_hash: el_hash(store.beacon_justified_checkpoint().root), + finalized_block_hash: el_hash(store.beacon_finalized_checkpoint().root), + }; + let attributes = PayloadAttributesV3 { + timestamp: inputs.timestamp, + prev_randao: inputs.prev_randao, + suggested_fee_recipient: fee_recipient, + withdrawals: inputs.withdrawals, + parent_beacon_block_root: head_root, + }; + let (status, payload_id) = engine + .forkchoice_updated_with_attributes(&forkchoice, &attributes) + .await + .map_err(|err| { + warn!(%err, "forkchoiceUpdated with payload attributes failed"); + ApiError::ServiceUnavailable("the execution client did not start building") + })?; + let Some(payload_id) = payload_id else { + warn!(status = ?status.status, "The execution client declined to build a payload"); + return Err(ApiError::ServiceUnavailable( + "the execution client declined to build a payload", + )); + }; + engine.get_payload(payload_id).await.map_err(|err| { + warn!(%err, "getPayload failed"); + ApiError::ServiceUnavailable("the execution client did not return a payload") + }) +} + +/// A wei amount as the decimal string the Beacon API's value fields carry. +fn decimal(value: ðlambda_types::beacon::primitives::Uint256) -> String { + let hex = uint256(value); + u128::from_str_radix(hex.trim_start_matches("0x"), 16) + .map(|value| value.to_string()) + .unwrap_or_else(|_| "0".to_string()) +} diff --git a/crates/net/rpc/src/beacon/states.rs b/crates/net/rpc/src/beacon/states.rs new file mode 100644 index 000000000..47b0e4493 --- /dev/null +++ b/crates/net/rpc/src/beacon/states.rs @@ -0,0 +1,578 @@ +//! `/eth/v2/debug/beacon/states/{state_id}`, +//! `/eth/v1/beacon/states/{state_id}/finality_checkpoints` and +//! `/eth/v1/beacon/states/{state_id}/validators`. +//! +//! Serving the first makes this client checkpoint-syncable from itself: +//! `bin/ethlambda/src/checkpoint_sync.rs` fetches exactly that path, as SSZ, +//! from whichever Beacon API server `--checkpoint-sync-url` names. + +use axum::{ + Router, + body::Bytes, + extract::{Path, Query, State}, + http::{HeaderMap, header}, + response::{IntoResponse, Response}, + routing::get, +}; +use ethlambda_storage::Store; +use ethlambda_types::{ + beacon::{ + constants::FAR_FUTURE_EPOCH, + containers::{BeaconState, shared::Validator}, + primitives::{BlsPubkey, Epoch, Gwei, ValidatorIndex}, + signing::compute_epoch_at_slot, + }, + primitives::H256, +}; +use serde::{Deserialize, Serialize}; + +use crate::{ + beacon::{ApiError, Envelope, blocks::is_finalized}, + shared::{ + block_id::BlockId, + content::{Encoding, ssz_response, with_consensus_version}, + }, +}; + +pub(crate) fn routes() -> Router { + Router::new() + .route("/eth/v2/debug/beacon/states/{state_id}", get(get_state)) + .route( + "/eth/v1/beacon/states/{state_id}/finality_checkpoints", + get(get_finality_checkpoints), + ) + .route( + "/eth/v1/beacon/states/{state_id}/validators", + get(get_validators).post(post_validators), + ) +} + +/// Resolve a `state_id` to the block root its state is stored under. +/// +/// A `0x…` id is a *state* root in this API, and states here are keyed by +/// block root with no reverse index. Rather than return the wrong state, or +/// quietly treat the id as a block root and be right only by coincidence, +/// refuse it and say which ids do work. +fn resolve_state_id(store: &Store, state_id: &str) -> Result { + match BlockId::parse(state_id)? { + BlockId::Root(_) => Err(ApiError::NotFound( + "lookup by state root is not indexed; use head, finalized, justified or a slot", + )), + id => Ok(id.resolve_beacon(store)?), + } +} + +/// Load the state a `state_id` names, or the response explaining why not. +fn load(store: &Store, state_id: &str) -> Result<(H256, std::sync::Arc), ApiError> { + let root = resolve_state_id(store, state_id)?; + let state = store + .get_state(&root) + .map_err(|_| ApiError::Internal("store read failed"))? + .ok_or(ApiError::NotFound("state not found"))?; + Ok((root, state)) +} + +async fn get_state( + Path(state_id): Path, + State(store): State, + headers: HeaderMap, +) -> Response { + let (root, state) = match load(&store, &state_id) { + Ok(found) => found, + Err(err) => return err.into_response(), + }; + let fork = state.fork_name(); + + let accept = headers.get(header::ACCEPT).and_then(|v| v.to_str().ok()); + let response = match Encoding::from_accept(accept) { + Encoding::Ssz => ssz_response(state.to_ssz()), + // `state.as_ref()` rather than a clone: a mainnet state runs to + // hundreds of megabytes, and serde serializes happily through the + // borrow. + Encoding::Json => crate::json_response(Envelope { + version: fork.as_str(), + execution_optimistic: store.is_beacon_optimistic(root), + finalized: is_finalized(&store, state.slot()), + data: state.as_ref(), + }), + }; + + with_consensus_version(response, fork) +} + +async fn get_finality_checkpoints( + Path(state_id): Path, + State(store): State, +) -> Response { + let (root, state) = match load(&store, &state_id) { + Ok(found) => found, + Err(err) => return err.into_response(), + }; + + // The state's own three checkpoints, not the store's fork-choice view. + // This endpoint is defined as a read of the state `state_id` names, and + // the state is the only place a *previous* justified checkpoint is kept + // at all: the store keeps one justified row and one finalized row. + crate::json_response(serde_json::json!({ + "execution_optimistic": store.is_beacon_optimistic(root), + "finalized": is_finalized(&store, state.slot()), + "data": { + "previous_justified": state.previous_justified_checkpoint(), + "current_justified": state.current_justified_checkpoint(), + "finalized": state.finalized_checkpoint(), + } + })) +} + +/// A validator's lifecycle status, as the Beacon API's `ValidatorStatus` names +/// it: the nine fine-grained statuses, each belonging to one of the four +/// coarse ones (`pending`, `active`, `exited`, `withdrawal`) a filter may also +/// name. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ValidatorStatus { + PendingInitialized, + PendingQueued, + ActiveOngoing, + ActiveExiting, + ActiveSlashed, + ExitedUnslashed, + ExitedSlashed, + WithdrawalPossible, + WithdrawalDone, +} + +impl ValidatorStatus { + /// The status of `validator` as of `epoch`, per the beacon-APIs + /// validator-status definitions. The cases are tested in epoch order, so + /// each arm can rely on every earlier one having failed. + fn of(validator: &Validator, balance: Gwei, epoch: Epoch) -> Self { + if validator.activation_epoch > epoch { + return if validator.activation_eligibility_epoch == FAR_FUTURE_EPOCH { + Self::PendingInitialized + } else { + Self::PendingQueued + }; + } + if epoch < validator.exit_epoch { + return if validator.exit_epoch == FAR_FUTURE_EPOCH { + Self::ActiveOngoing + } else if validator.slashed { + Self::ActiveSlashed + } else { + Self::ActiveExiting + }; + } + if epoch < validator.withdrawable_epoch { + return if validator.slashed { + Self::ExitedSlashed + } else { + Self::ExitedUnslashed + }; + } + if balance == 0 { + Self::WithdrawalDone + } else { + Self::WithdrawalPossible + } + } + + fn name(self) -> &'static str { + match self { + Self::PendingInitialized => "pending_initialized", + Self::PendingQueued => "pending_queued", + Self::ActiveOngoing => "active_ongoing", + Self::ActiveExiting => "active_exiting", + Self::ActiveSlashed => "active_slashed", + Self::ExitedUnslashed => "exited_unslashed", + Self::ExitedSlashed => "exited_slashed", + Self::WithdrawalPossible => "withdrawal_possible", + Self::WithdrawalDone => "withdrawal_done", + } + } + + /// Whether a `statuses` filter entry selects this status: its own name, or + /// the coarse status it belongs to. + fn matches(self, filter: &str) -> bool { + let name = self.name(); + name == filter || name.split('_').next() == Some(filter) + } +} + +/// A `validator_id`: an index into the registry, or a public key. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ValidatorId { + Index(ValidatorIndex), + Pubkey(BlsPubkey), +} + +impl ValidatorId { + fn parse(text: &str) -> Result { + if let Some(hex_digits) = text.strip_prefix("0x") { + let mut bytes = [0u8; 48]; + return hex::decode_to_slice(hex_digits, &mut bytes) + .map(|()| Self::Pubkey(BlsPubkey(bytes))) + .map_err(|_| ApiError::BadRequest("invalid validator id")); + } + text.parse() + .map(Self::Index) + .map_err(|_| ApiError::BadRequest("invalid validator id")) + } +} + +/// The body of `POST .../validators`. Both fields are optional, and an absent +/// or empty one does not filter. +#[derive(Debug, Default, Deserialize)] +struct ValidatorsRequest { + #[serde(default)] + ids: Vec, + #[serde(default)] + statuses: Vec, +} + +#[derive(Debug, Serialize)] +struct ValidatorEntry<'a> { + #[serde(with = "ethlambda_types::beacon::serde_helpers::quoted_or_bare")] + index: ValidatorIndex, + #[serde(with = "ethlambda_types::beacon::serde_helpers::quoted_or_bare")] + balance: Gwei, + status: &'static str, + validator: &'a Validator, +} + +/// `GET .../validators?id=…&status=…`. Each parameter may repeat, and each +/// value may itself be a comma-separated list. +async fn get_validators( + Path(state_id): Path, + State(store): State, + Query(pairs): Query>, +) -> Response { + let mut request = ValidatorsRequest::default(); + for (key, value) in pairs { + let values = value.split(',').map(str::to_owned); + match key.as_str() { + "id" => request.ids.extend(values), + "status" => request.statuses.extend(values), + _ => {} + } + } + validators_response(&store, &state_id, request) +} + +/// `POST .../validators`, the form a validator client uses: a long list of +/// public keys does not fit in a query string. +async fn post_validators( + Path(state_id): Path, + State(store): State, + body: Bytes, +) -> Response { + let request = if body.is_empty() { + ValidatorsRequest::default() + } else { + match serde_json::from_slice(&body) { + Ok(request) => request, + Err(_) => return ApiError::BadRequest("invalid request body").into_response(), + } + }; + validators_response(&store, &state_id, request) +} + +/// The registry entries of the state `state_id` names that match `request`, +/// in registry order. An id naming no validator is omitted rather than failing +/// the request, as the Beacon API specifies. +fn validators_response(store: &Store, state_id: &str, request: ValidatorsRequest) -> Response { + let ids = match request + .ids + .iter() + .map(|id| ValidatorId::parse(id)) + .collect::, _>>() + { + Ok(ids) => ids, + Err(err) => return err.into_response(), + }; + let (root, state) = match load(store, state_id) { + Ok(found) => found, + Err(err) => return err.into_response(), + }; + + let epoch = compute_epoch_at_slot(state.slot()); + let selected = |index: ValidatorIndex, validator: &Validator| { + ids.is_empty() + || ids.iter().any(|id| match id { + ValidatorId::Index(wanted) => *wanted == index, + ValidatorId::Pubkey(wanted) => *wanted == validator.pubkey, + }) + }; + let entries: Vec = state + .validators() + .iter() + .zip(state.balances().iter()) + .enumerate() + .filter(|(index, (validator, _))| selected(*index as ValidatorIndex, validator)) + .map(|(index, (validator, &balance))| { + let status = ValidatorStatus::of(validator, balance, epoch); + (index as ValidatorIndex, balance, status, validator) + }) + .filter(|(_, _, status, _)| { + request.statuses.is_empty() + || request.statuses.iter().any(|filter| status.matches(filter)) + }) + .map(|(index, balance, status, validator)| ValidatorEntry { + index, + balance, + status: status.name(), + validator, + }) + .collect(); + + crate::json_response(serde_json::json!({ + "execution_optimistic": store.is_beacon_optimistic(root), + "finalized": is_finalized(store, state.slot()), + "data": entries, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::beacon_fixture; + use axum::{ + body::Body, + http::{Request, StatusCode}, + }; + use http_body_util::BodyExt as _; + use tower::ServiceExt as _; + + const ANCHOR_SLOT: u64 = 64; + + async fn get(uri: &str, accept: Option<&str>) -> axum::response::Response { + let fixture = beacon_fixture(ANCHOR_SLOT); + let app = routes().with_state(fixture.store); + let mut request = Request::builder().uri(uri); + if let Some(accept) = accept { + request = request.header("accept", accept); + } + app.oneshot(request.body(Body::empty()).unwrap()) + .await + .unwrap() + } + + async fn body_json(response: axum::response::Response) -> serde_json::Value { + let body = response.into_body().collect().await.unwrap().to_bytes(); + serde_json::from_slice(&body).unwrap() + } + + /// Exactly what `bin/ethlambda/src/checkpoint_sync.rs` asks other clients + /// for, so serving it makes this client checkpoint-syncable from itself. + #[tokio::test] + async fn the_finalized_state_comes_back_as_ssz_for_checkpoint_sync() { + let response = get( + "/eth/v2/debug/beacon/states/finalized", + Some("application/octet-stream"), + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response + .headers() + .get(axum::http::header::CONTENT_TYPE) + .unwrap(), + crate::SSZ_CONTENT_TYPE + ); + assert_eq!( + response.headers().get("eth-consensus-version").unwrap(), + "phase0" + ); + + // The bytes have to decode back into the state they came from, since + // checkpoint sync's whole job is to do exactly that. + let body = response.into_body().collect().await.unwrap().to_bytes(); + let slot = ethlambda_types::beacon::containers::BeaconState::slot_from_ssz(&body) + .expect("the body is a beacon state"); + assert_eq!(slot, ANCHOR_SLOT); + } + + #[tokio::test] + async fn a_state_comes_back_as_json_by_default() { + let json = body_json(get("/eth/v2/debug/beacon/states/head", None).await).await; + assert_eq!(json["version"], "phase0"); + assert_eq!(json["data"]["slot"], "65", "integers are quoted"); + assert!( + json["data"]["genesis_validators_root"] + .as_str() + .unwrap() + .starts_with("0x") + ); + } + + #[tokio::test] + async fn a_state_root_id_is_refused_because_states_are_indexed_by_block_root() { + let id = format!("0x{}", "cd".repeat(32)); + let response = get(&format!("/eth/v2/debug/beacon/states/{id}"), None).await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + let json = body_json(response).await; + assert!( + json["message"].as_str().unwrap().contains("state root"), + "the refusal has to say why, got {}", + json["message"] + ); + } + + #[tokio::test] + async fn finality_checkpoints_report_all_three() { + let response = get("/eth/v1/beacon/states/head/finality_checkpoints", None).await; + assert_eq!(response.status(), StatusCode::OK); + let json = body_json(response).await; + + for field in ["previous_justified", "current_justified", "finalized"] { + assert!( + json["data"][field]["epoch"].is_string(), + "{field} epoch must be quoted, got {}", + json["data"][field]["epoch"] + ); + assert!( + json["data"][field]["root"] + .as_str() + .unwrap() + .starts_with("0x") + ); + } + } + + mod validators { + use super::*; + use crate::test_utils::beacon_store_at; + use ethlambda_state_transition::beacon::helpers::test_state::with_signing_validators_at; + use ethlambda_types::beacon::fork::ForkName; + + const COUNT: usize = 8; + + fn app() -> (Router, BeaconState) { + let state = with_signing_validators_at(ForkName::Fulu, COUNT); + let (store, _root) = beacon_store_at(state.clone()); + (routes().with_state(store), state) + } + + async fn post(body: serde_json::Value) -> axum::response::Response { + let (app, _) = app(); + let request = Request::post("/eth/v1/beacon/states/head/validators") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + app.oneshot(request).await.unwrap() + } + + fn pubkey_hex(state: &BeaconState, index: usize) -> String { + format!("0x{}", hex::encode(state.validators()[index].pubkey.0)) + } + + /// What `ethlambda validator` sends: its keys, to learn their indices. + #[tokio::test] + async fn a_pubkey_resolves_to_its_index() { + let (_, state) = app(); + let json = + body_json(post(serde_json::json!({ "ids": [pubkey_hex(&state, 5)] })).await).await; + let data = json["data"].as_array().unwrap(); + assert_eq!(data.len(), 1); + assert_eq!(data[0]["index"], "5"); + assert_eq!(data[0]["status"], "active_ongoing"); + assert_eq!(data[0]["validator"]["pubkey"], pubkey_hex(&state, 5)); + assert!(data[0]["balance"].is_string(), "integers are quoted"); + } + + #[tokio::test] + async fn an_unknown_id_is_omitted_not_an_error() { + let unknown = format!("0x{}", "ab".repeat(48)); + let json = + body_json(post(serde_json::json!({ "ids": ["2", unknown, "999"] })).await).await; + let data = json["data"].as_array().unwrap(); + assert_eq!(data.len(), 1); + assert_eq!(data[0]["index"], "2"); + } + + #[tokio::test] + async fn no_ids_means_every_validator() { + let json = body_json(post(serde_json::json!({})).await).await; + assert_eq!(json["data"].as_array().unwrap().len(), COUNT); + } + + #[tokio::test] + async fn a_malformed_id_is_a_400() { + let response = post(serde_json::json!({ "ids": ["0x1234"] })).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn a_coarse_status_filter_selects_its_fine_statuses() { + let active = body_json(post(serde_json::json!({ "statuses": ["active"] })).await).await; + assert_eq!(active["data"].as_array().unwrap().len(), COUNT); + let exited = body_json(post(serde_json::json!({ "statuses": ["exited"] })).await).await; + assert!(exited["data"].as_array().unwrap().is_empty()); + } + + #[tokio::test] + async fn get_takes_repeated_and_comma_separated_ids() { + let (app, _) = app(); + let request = Request::get("/eth/v1/beacon/states/head/validators?id=1,3&id=4") + .body(Body::empty()) + .unwrap(); + let json = body_json(app.oneshot(request).await.unwrap()).await; + let indices: Vec<&str> = json["data"] + .as_array() + .unwrap() + .iter() + .map(|entry| entry["index"].as_str().unwrap()) + .collect(); + assert_eq!(indices, ["1", "3", "4"]); + } + + #[test] + fn status_follows_the_lifecycle() { + let epoch = 10; + let validator = |eligibility, activation, exit, withdrawable, slashed| Validator { + activation_eligibility_epoch: eligibility, + activation_epoch: activation, + exit_epoch: exit, + withdrawable_epoch: withdrawable, + slashed, + ..Default::default() + }; + let far = FAR_FUTURE_EPOCH; + let cases = [ + ( + validator(far, far, far, far, false), + 0, + "pending_initialized", + ), + (validator(5, far, far, far, false), 0, "pending_queued"), + (validator(0, 1, far, far, false), 1, "active_ongoing"), + (validator(0, 1, 20, 30, false), 1, "active_exiting"), + (validator(0, 1, 20, 30, true), 1, "active_slashed"), + (validator(0, 1, 5, 30, false), 1, "exited_unslashed"), + (validator(0, 1, 5, 30, true), 1, "exited_slashed"), + (validator(0, 1, 5, 8, false), 1, "withdrawal_possible"), + (validator(0, 1, 5, 8, false), 0, "withdrawal_done"), + ]; + for (validator, balance, expected) in cases { + assert_eq!( + ValidatorStatus::of(&validator, balance, epoch).name(), + expected + ); + } + } + } + + #[tokio::test] + async fn a_malformed_id_is_a_400_and_an_absent_one_a_404() { + assert_eq!( + get("/eth/v2/debug/beacon/states/nope", None).await.status(), + StatusCode::BAD_REQUEST + ); + assert_eq!( + get("/eth/v2/debug/beacon/states/999999", None) + .await + .status(), + StatusCode::NOT_FOUND + ); + } +} diff --git a/crates/net/rpc/src/beacon/validator.rs b/crates/net/rpc/src/beacon/validator.rs new file mode 100644 index 000000000..abbbd95ee --- /dev/null +++ b/crates/net/rpc/src/beacon/validator.rs @@ -0,0 +1,813 @@ +//! The validator-facing endpoints under `/eth/v1/validator/`: what a validator +//! client asks a beacon node for in order to do its duties. +//! +//! Every answer is computed from the fork-choice head's post-state, read off +//! the shared `Store` the chain actor writes: the head row is refreshed on each +//! import and tick, so no message to the actor is needed. + +use std::collections::HashMap; +use std::sync::Arc; + +use axum::{ + Extension, Json, Router, + extract::{Path, Query, State}, + response::{IntoResponse, Response}, + routing::{get, post}, +}; +use ethlambda_storage::Store; +use ethlambda_types::{ + beacon::{ + containers::{ + BeaconState, + shared::{AttestationData, Checkpoint}, + }, + preset, + primitives::{BlsPubkey, CommitteeIndex, Epoch, ExecutionAddress, Slot, ValidatorIndex}, + signing::{compute_epoch_at_slot, compute_start_slot_at_epoch}, + }, + primitives::H256, +}; +use serde::{Deserialize, Serialize}; + +use ethlambda_network_api::RpcToP2PRef; +use ethlambda_state_transition::beacon::{ + fork_choice::checkpoint_state, + gossip::attestation::compute_subnet_for_attestation, + helpers::accessors::{CommitteeCacheExt as _, get_block_root_at_slot}, +}; + +use crate::beacon::ApiError; + +pub(crate) fn routes() -> Router { + Router::new() + .route( + "/eth/v1/validator/duties/proposer/{epoch}", + get(get_proposer_duties), + ) + .route( + "/eth/v1/validator/duties/attester/{epoch}", + post(post_attester_duties), + ) + .route( + "/eth/v1/validator/attestation_data", + get(get_attestation_data), + ) + .route( + "/eth/v1/validator/beacon_committee_subscriptions", + post(post_committee_subscriptions), + ) + .route( + "/eth/v1/validator/prepare_beacon_proposer", + post(post_prepare_beacon_proposer), + ) +} + +/// One entry of `beacon_committee_subscriptions`. Parsed so a malformed body +/// is refused, though `validator_index` is never read. +#[derive(Debug, Deserialize)] +struct CommitteeSubscription { + #[allow(dead_code)] + #[serde(with = "ethlambda_types::beacon::serde_helpers::quoted_or_bare")] + validator_index: ValidatorIndex, + #[serde(with = "ethlambda_types::beacon::serde_helpers::quoted_or_bare")] + committee_index: CommitteeIndex, + #[serde(with = "ethlambda_types::beacon::serde_helpers::quoted_or_bare")] + committees_at_slot: u64, + #[serde(with = "ethlambda_types::beacon::serde_helpers::quoted_or_bare")] + slot: Slot, + is_aggregator: bool, +} + +/// `POST /eth/v1/validator/beacon_committee_subscriptions`. +/// +/// Each aggregator's entry has the node join its committee's attestation +/// subnet until the end of that slot, so the committee's votes reach the pool +/// the aggregate endpoint answers from (phase0 `validator.md`, "Attestation +/// subnet subscription"). Non-aggregators' entries need nothing: publishing +/// reaches a subnet through gossipsub fanout without joining it. +async fn post_committee_subscriptions( + State(store): State, + Extension(p2p): Extension, + Json(subscriptions): Json>, +) -> Response { + let config = store.config(); + let subnets: Vec<(u64, Slot)> = subscriptions + .iter() + .filter(|entry| entry.is_aggregator) + .map(|entry| { + let subnet_id = compute_subnet_for_attestation( + entry.committees_at_slot, + entry.slot, + entry.committee_index, + &config, + ); + (subnet_id, entry.slot) + }) + .collect(); + if !subnets.is_empty() && p2p.subscribe_attestation_subnets(subnets).is_err() { + return ApiError::Internal("the network actor is not running").into_response(); + } + axum::http::StatusCode::OK.into_response() +} + +/// Each validator's execution-layer fee recipient, as its validator client +/// last named it. Read by block production, which puts the proposer's into +/// the payload attributes. In memory only: a validator client repeats the call +/// every epoch, so a restarted node relearns the map within one. +pub(crate) type FeeRecipients = Arc>>; + +/// One entry of `prepare_beacon_proposer`. +#[derive(Debug, Deserialize)] +struct ProposerPreparation { + #[serde(with = "ethlambda_types::beacon::serde_helpers::quoted_or_bare")] + validator_index: ValidatorIndex, + fee_recipient: ExecutionAddress, +} + +/// `POST /eth/v1/validator/prepare_beacon_proposer`: record where each +/// validator's block rewards should be paid. +async fn post_prepare_beacon_proposer( + Extension(fee_recipients): Extension, + Json(preparations): Json>, +) -> Response { + let mut map = fee_recipients.lock().expect("fee recipient lock poisoned"); + for preparation in preparations { + map.insert(preparation.validator_index, preparation.fee_recipient); + } + axum::http::StatusCode::OK.into_response() +} + +fn parse_epoch(epoch: &str) -> Result { + epoch + .parse() + .map_err(|_| ApiError::BadRequest("invalid epoch")) +} + +/// The fork-choice head's block root and post-state. +pub(crate) fn head(store: &Store) -> Result<(H256, Arc), ApiError> { + let (_slot, root) = store + .beacon_head() + .ok_or(ApiError::Internal("no head block"))?; + let state = store + .get_state(&root) + .map_err(|_| ApiError::Internal("store read failed"))? + .ok_or(ApiError::Internal("head state not found"))?; + Ok((root, state)) +} + +/// The root of the latest block at or before `slot`, on the chain ending in +/// `head_root`, whose post-state is `head_state`. +/// +/// The Beacon API defines each duty's `dependent_root` as +/// `get_block_root_at_slot(state, slot)`, which only answers for a slot before +/// the state's own. A slot at or past the head's is one no block has filled +/// since the head, so the head itself is the latest block at or before it. +/// +/// A slot older than the state's retained root window is an error rather than +/// a guess: a wrong `dependent_root` would let a validator client keep duties +/// a reorg has invalidated. A duty's dependent slot is at most two epochs back, +/// far inside the window, so this should never fire. +fn block_root_at_or_before( + head_state: &BeaconState, + head_root: H256, + slot: Slot, +) -> Result { + if slot >= head_state.slot() { + return Ok(head_root); + } + get_block_root_at_slot(head_state, slot) + .map_err(|_| ApiError::Internal("dependent slot is outside the state's root window")) +} + +#[derive(Debug, Serialize)] +struct ProposerDuty { + pubkey: BlsPubkey, + #[serde(with = "ethlambda_types::beacon::serde_helpers::quoted_or_bare")] + validator_index: ValidatorIndex, + #[serde(with = "ethlambda_types::beacon::serde_helpers::quoted_or_bare")] + slot: Slot, +} + +/// `GET /eth/v1/validator/duties/proposer/{epoch}`. +/// +/// Read from fulu's `proposer_lookahead`, which the state keeps for its own +/// epoch and the next `MIN_SEED_LOOKAHEAD` epochs, so any epoch in that window +/// is answered without advancing a state. Any other epoch is refused. +/// +/// `dependent_root` is v1's definition, the block root at +/// `compute_start_slot_at_epoch(epoch) - 1` (the genesis block's at epoch 0). +/// It is what `ethlambda validator` compares across fetches to notice a reorg. +async fn get_proposer_duties(Path(epoch): Path, State(store): State) -> Response { + match proposer_duties(&store, &epoch) { + Ok(body) => crate::json_response(body), + Err(err) => err.into_response(), + } +} + +fn proposer_duties(store: &Store, epoch: &str) -> Result { + let epoch = parse_epoch(epoch)?; + let (head_root, state) = head(store)?; + + let BeaconState::Fulu(fulu) = state.as_ref() else { + return Err(ApiError::BadRequest( + "proposer duties are served from fulu's proposer lookahead only", + )); + }; + let state_epoch = compute_epoch_at_slot(state.slot()); + let offset = epoch + .checked_sub(state_epoch) + .filter(|offset| *offset <= preset::MIN_SEED_LOOKAHEAD) + .ok_or(ApiError::BadRequest( + "epoch is outside the head state's proposer lookahead", + ))?; + + let first_slot = compute_start_slot_at_epoch(epoch); + let window_start = (offset * preset::SLOTS_PER_EPOCH) as usize; + let proposers = &fulu.proposer_lookahead[window_start..][..preset::SLOTS_PER_EPOCH as usize]; + let duties = proposers + .iter() + .zip(first_slot..) + .map(|(&validator_index, slot)| { + let validator = state + .validator(validator_index) + .map_err(|_| ApiError::Internal("lookahead names an unknown validator"))?; + Ok(ProposerDuty { + pubkey: validator.pubkey, + validator_index, + slot, + }) + }) + .collect::, ApiError>>()?; + + let dependent_root = block_root_at_or_before(&state, head_root, first_slot.saturating_sub(1))?; + Ok(serde_json::json!({ + "dependent_root": dependent_root, + "execution_optimistic": store.is_beacon_optimistic(head_root), + "data": duties, + })) +} + +#[derive(Debug, Serialize)] +struct AttesterDuty { + pubkey: BlsPubkey, + #[serde(with = "ethlambda_types::beacon::serde_helpers::quoted_or_bare")] + validator_index: ValidatorIndex, + #[serde(with = "ethlambda_types::beacon::serde_helpers::quoted_or_bare")] + committee_index: CommitteeIndex, + #[serde(with = "ethlambda_types::beacon::serde_helpers::quoted_or_bare")] + committee_length: u64, + #[serde(with = "ethlambda_types::beacon::serde_helpers::quoted_or_bare")] + committees_at_slot: u64, + #[serde(with = "ethlambda_types::beacon::serde_helpers::quoted_or_bare")] + validator_committee_index: u64, + #[serde(with = "ethlambda_types::beacon::serde_helpers::quoted_or_bare")] + slot: Slot, +} + +/// `POST /eth/v1/validator/duties/attester/{epoch}`, with the validator +/// indices to report on as a JSON array of decimal strings. +/// +/// The head state answers for its previous, current and next epoch as it is: +/// an epoch's committees depend only on its seed, whose RANDAO mix is fixed a +/// full epoch earlier, and on which validators are active in it, which the +/// registry records `MAX_SEED_LOOKAHEAD` epochs ahead. Any other epoch is +/// refused rather than computed from an advanced state. +/// +/// `dependent_root` is the block root at +/// `compute_start_slot_at_epoch(epoch - 1) - 1` (the genesis block's on +/// underflow), per the endpoint's definition: the last block that could still +/// change this epoch's shuffling. +/// +/// Every committee of the epoch is derived to find the requested validators, +/// which on a small registry is nothing but on mainnet is a full shuffle per +/// request. Caching the epoch's shuffle is a later optimization. +async fn post_attester_duties( + Path(epoch): Path, + State(store): State, + Json(indices): Json>, +) -> Response { + match attester_duties(&store, &epoch, &indices) { + Ok(body) => crate::json_response(body), + Err(err) => err.into_response(), + } +} + +fn attester_duties( + store: &Store, + epoch: &str, + indices: &[String], +) -> Result { + let epoch = parse_epoch(epoch)?; + let wanted = indices + .iter() + .map(|index| index.parse::()) + .collect::, _>>() + .map_err(|_| ApiError::BadRequest("invalid validator index"))?; + let (head_root, state) = head(store)?; + + let state_epoch = compute_epoch_at_slot(state.slot()); + if epoch + 1 < state_epoch || epoch > state_epoch + 1 { + return Err(ApiError::BadRequest( + "epoch is not within one epoch of the head state's", + )); + } + + let committees = store.committee_cache().committees(&state, epoch); + let committees_at_slot = committees.committees_per_slot(); + let first_slot = compute_start_slot_at_epoch(epoch); + let mut duties = Vec::new(); + for slot in first_slot..first_slot + preset::SLOTS_PER_EPOCH { + for committee_index in 0..committees_at_slot { + let committee = committees + .committee(slot, committee_index) + .map_err(|_| ApiError::Internal("committee computation failed"))?; + for (position, &validator_index) in committee.iter().enumerate() { + if !wanted.contains(&validator_index) { + continue; + } + let validator = state + .validator(validator_index) + .map_err(|_| ApiError::Internal("committee names an unknown validator"))?; + duties.push(AttesterDuty { + pubkey: validator.pubkey, + validator_index, + committee_index, + committee_length: committee.len() as u64, + committees_at_slot, + validator_committee_index: position as u64, + slot, + }); + } + } + } + + let dependent_slot = compute_start_slot_at_epoch(epoch.saturating_sub(1)).saturating_sub(1); + let dependent_root = block_root_at_or_before(&state, head_root, dependent_slot)?; + Ok(serde_json::json!({ + "dependent_root": dependent_root, + "execution_optimistic": store.is_beacon_optimistic(head_root), + "data": duties, + })) +} + +#[derive(Debug, Deserialize)] +struct AttestationDataQuery { + slot: Slot, + /// Required by the endpoint, and ignored: from electra on the committee + /// travels outside `AttestationData`, whose `index` is always zero, so + /// every committee of a slot attests to the same data. + #[allow(dead_code)] + committee_index: CommitteeIndex, +} + +/// `GET /eth/v1/validator/attestation_data?slot&committee_index`. +/// +/// Built as phase0's `validator.md` ("Attestation data") describes, with the +/// fork-choice head as `head_block` and `head_state` its post-state advanced +/// through empty slots to `slot`: +/// +/// - `beacon_block_root` is the head block's root. +/// - `source` is that advanced state's `current_justified_checkpoint`. It can +/// only change at an epoch boundary, so the state is advanced no further +/// than the start of `slot`'s epoch, through fork choice's cached +/// `checkpoint_state`, and only when the head sits in an earlier epoch. +/// - `target` is `slot`'s epoch and its boundary block: the head itself when +/// no block has filled the boundary slot since, else the root the state +/// recorded there. +/// - `index` is zero, as electra requires. +/// +/// A slot before the head's, or more than one slot past the wall clock, is +/// refused: neither is a slot a validator is asked to attest to. +async fn get_attestation_data( + Query(query): Query, + State(store): State, +) -> Response { + match attestation_data(&store, query.slot) { + Ok(data) => crate::json_response(serde_json::json!({ "data": data })), + Err(err) => err.into_response(), + } +} + +fn attestation_data(store: &Store, slot: Slot) -> Result { + let (head_root, state) = head(store)?; + if slot < state.slot() { + return Err(ApiError::BadRequest("slot is before the head block")); + } + if slot > crate::beacon::node::wall_slot(store) + 1 { + return Err(ApiError::BadRequest("slot is in the future")); + } + + let epoch = compute_epoch_at_slot(slot); + let epoch_start = compute_start_slot_at_epoch(epoch); + let source = if compute_epoch_at_slot(state.slot()) == epoch { + state.current_justified_checkpoint() + } else { + let boundary = Checkpoint { + epoch, + root: head_root, + }; + checkpoint_state(store, &boundary, &store.config()) + .map_err(|_| ApiError::Internal("advancing the head state failed"))? + .current_justified_checkpoint() + }; + let target = Checkpoint { + epoch, + root: block_root_at_or_before(&state, head_root, epoch_start)?, + }; + + Ok(AttestationData { + slot, + index: 0, + beacon_block_root: head_root, + source, + target, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::beacon_store_at; + use axum::{ + body::Body, + http::{Request, StatusCode}, + }; + use ethlambda_state_transition::beacon::helpers::{ + fulu::{get_beacon_proposer_indices, initialize_proposer_lookahead}, + test_state::with_signing_validators_at, + }; + use ethlambda_types::beacon::fork::ForkName; + use http_body_util::BodyExt as _; + use tower::ServiceExt as _; + + const COUNT: usize = 64; + + /// A fulu state one epoch past genesis with its lookahead filled in, as + /// every real fulu state has it, and distinct block roots per slot so a + /// `dependent_root` taken from the wrong slot shows up. + fn fulu_state() -> BeaconState { + let mut state = with_signing_validators_at(ForkName::Fulu, COUNT); + let lookahead = initialize_proposer_lookahead(&state).unwrap(); + let BeaconState::Fulu(fulu) = &mut state else { + unreachable!("built as fulu") + }; + fulu.proposer_lookahead = lookahead.try_into().unwrap(); + for (slot, root) in fulu.block_roots.iter_mut().enumerate() { + *root = H256::repeat_byte(slot as u8 + 1); + } + state + } + + async fn get(state: BeaconState, uri: &str) -> (StatusCode, serde_json::Value) { + let (store, _root) = beacon_store_at(state); + let request = Request::get(uri).body(Body::empty()).unwrap(); + let response = routes().with_state(store).oneshot(request).await.unwrap(); + let status = response.status(); + let body = response.into_body().collect().await.unwrap().to_bytes(); + (status, serde_json::from_slice(&body).unwrap()) + } + + #[tokio::test] + async fn proposers_match_the_spec_computation_for_both_lookahead_epochs() { + let state = fulu_state(); + let state_epoch = compute_epoch_at_slot(state.slot()); + for epoch in [state_epoch, state_epoch + 1] { + let expected = get_beacon_proposer_indices(&state, epoch).unwrap(); + let (status, json) = get( + state.clone(), + &format!("/eth/v1/validator/duties/proposer/{epoch}"), + ) + .await; + assert_eq!(status, StatusCode::OK); + + let duties = json["data"].as_array().unwrap(); + assert_eq!(duties.len(), preset::SLOTS_PER_EPOCH as usize); + for (offset, duty) in duties.iter().enumerate() { + let index = expected[offset]; + let slot = compute_start_slot_at_epoch(epoch) + offset as u64; + assert_eq!(duty["validator_index"], index.to_string()); + assert_eq!(duty["slot"], slot.to_string()); + let pubkey = state.validator(index).unwrap().pubkey; + assert_eq!(duty["pubkey"], format!("0x{}", hex::encode(pubkey.0))); + } + } + } + + #[tokio::test] + async fn the_dependent_root_is_the_block_before_the_epoch() { + let state = fulu_state(); + let state_epoch = compute_epoch_at_slot(state.slot()); + let (_, json) = get( + state.clone(), + &format!("/eth/v1/validator/duties/proposer/{state_epoch}"), + ) + .await; + // The state sits at the first slot of its epoch, so the slot before it + // is still in its root window. + let before = compute_start_slot_at_epoch(state_epoch) - 1; + let expected = get_block_root_at_slot(&state, before).unwrap(); + assert_eq!(json["dependent_root"], format!("{expected}")); + } + + #[tokio::test] + async fn the_next_epochs_dependent_block_is_the_head_itself() { + // The last slot of the head's epoch has not happened yet, so the latest + // block at or before it is the head. + let state = fulu_state(); + let next = compute_epoch_at_slot(state.slot()) + 1; + let (store, head_root) = beacon_store_at(state); + let request = Request::get(format!("/eth/v1/validator/duties/proposer/{next}")) + .body(Body::empty()) + .unwrap(); + let response = routes().with_state(store).oneshot(request).await.unwrap(); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["dependent_root"], format!("{head_root}")); + } + + async fn post( + state: BeaconState, + uri: &str, + body: serde_json::Value, + ) -> (StatusCode, serde_json::Value) { + let (store, _root) = beacon_store_at(state); + let request = Request::post(uri) + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + let response = routes().with_state(store).oneshot(request).await.unwrap(); + let status = response.status(); + let body = response.into_body().collect().await.unwrap().to_bytes(); + (status, serde_json::from_slice(&body).unwrap()) + } + + #[tokio::test] + async fn attester_duties_match_get_beacon_committee() { + use ethlambda_state_transition::beacon::helpers::accessors::get_beacon_committee; + + let state = fulu_state(); + let epoch = compute_epoch_at_slot(state.slot()); + let (status, json) = post( + state.clone(), + &format!("/eth/v1/validator/duties/attester/{epoch}"), + serde_json::json!(["3", "17"]), + ) + .await; + assert_eq!(status, StatusCode::OK); + + let duties = json["data"].as_array().unwrap(); + // Every active validator attests exactly once per epoch. + assert_eq!(duties.len(), 2); + for duty in duties { + let slot: u64 = duty["slot"].as_str().unwrap().parse().unwrap(); + let index: u64 = duty["committee_index"].as_str().unwrap().parse().unwrap(); + let position: usize = duty["validator_committee_index"] + .as_str() + .unwrap() + .parse() + .unwrap(); + let committee = get_beacon_committee(&state, slot, index).unwrap(); + assert_eq!( + committee[position].to_string(), + duty["validator_index"].as_str().unwrap() + ); + assert_eq!(duty["committee_length"], committee.len().to_string()); + assert_eq!(compute_epoch_at_slot(slot), epoch); + } + } + + #[tokio::test] + async fn an_unknown_validator_has_no_duty() { + let state = fulu_state(); + let epoch = compute_epoch_at_slot(state.slot()); + let (status, json) = post( + state, + &format!("/eth/v1/validator/duties/attester/{epoch}"), + serde_json::json!(["100000"]), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert!(json["data"].as_array().unwrap().is_empty()); + } + + #[tokio::test] + async fn the_attester_dependent_root_is_the_block_before_the_previous_epoch() { + // For the next epoch, the dependent slot is the one before the head's + // own epoch, which is inside the state's root window. + let state = fulu_state(); + let next = compute_epoch_at_slot(state.slot()) + 1; + let (_, json) = post( + state.clone(), + &format!("/eth/v1/validator/duties/attester/{next}"), + serde_json::json!(["0"]), + ) + .await; + let before = compute_start_slot_at_epoch(next - 1) - 1; + let expected = get_block_root_at_slot(&state, before).unwrap(); + assert_eq!(json["dependent_root"], format!("{expected}")); + } + + #[tokio::test] + async fn attester_duties_two_epochs_ahead_are_a_400() { + let state = fulu_state(); + let too_far = compute_epoch_at_slot(state.slot()) + 2; + let (status, _) = post( + state, + &format!("/eth/v1/validator/duties/attester/{too_far}"), + serde_json::json!(["0"]), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + } + + /// `fulu_state` moved `slots_past_boundary` slots into its epoch, with a + /// current justified checkpoint distinct from the default so a source read + /// from anywhere else shows up. + fn fulu_state_at(slots_past_boundary: u64) -> BeaconState { + let mut state = fulu_state(); + let BeaconState::Fulu(fulu) = &mut state else { + unreachable!("built as fulu") + }; + fulu.slot += slots_past_boundary; + fulu.current_justified_checkpoint = Checkpoint { + epoch: 1, + root: H256::repeat_byte(0xaa), + }; + state + } + + async fn attestation_data_for( + state: BeaconState, + slot: Slot, + ) -> (StatusCode, serde_json::Value, H256) { + let (store, head_root) = beacon_store_at(state); + let uri = format!("/eth/v1/validator/attestation_data?slot={slot}&committee_index=0"); + let request = Request::get(uri).body(Body::empty()).unwrap(); + let response = routes().with_state(store).oneshot(request).await.unwrap(); + let status = response.status(); + let body = response.into_body().collect().await.unwrap().to_bytes(); + (status, serde_json::from_slice(&body).unwrap(), head_root) + } + + #[tokio::test] + async fn at_the_boundary_the_head_is_the_target() { + let state = fulu_state_at(0); + let slot = state.slot(); + let (status, json, head_root) = attestation_data_for(state, slot).await; + assert_eq!(status, StatusCode::OK); + let data = &json["data"]; + assert_eq!(data["slot"], slot.to_string()); + assert_eq!(data["index"], "0"); + assert_eq!(data["beacon_block_root"], format!("{head_root}")); + assert_eq!( + data["target"]["epoch"], + compute_epoch_at_slot(slot).to_string() + ); + assert_eq!(data["target"]["root"], format!("{head_root}")); + assert_eq!(data["source"]["epoch"], "1"); + assert_eq!( + data["source"]["root"], + format!("{}", H256::repeat_byte(0xaa)) + ); + } + + #[tokio::test] + async fn past_the_boundary_the_target_is_the_recorded_boundary_block() { + let state = fulu_state_at(3); + let slot = state.slot() + 1; + let boundary = compute_start_slot_at_epoch(compute_epoch_at_slot(slot)); + let expected = get_block_root_at_slot(&state, boundary).unwrap(); + let (status, json, head_root) = attestation_data_for(state, slot).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(json["data"]["target"]["root"], format!("{expected}")); + assert_eq!(json["data"]["beacon_block_root"], format!("{head_root}")); + } + + #[tokio::test] + async fn a_head_in_the_previous_epoch_is_advanced_for_the_source() { + // The head sits late in its epoch and the attestation is for the next + // epoch's first slot, whose boundary no block has filled: the target is + // the head, and the source comes from the head state advanced across + // the boundary (unchanged here, since epoch processing leaves the + // current justified checkpoint alone without votes). + let state = fulu_state_at(preset::SLOTS_PER_EPOCH - 1); + let slot = state.slot() + 1; + let (status, json, head_root) = attestation_data_for(state, slot).await; + assert_eq!(status, StatusCode::OK, "{json}"); + assert_eq!( + json["data"]["target"]["epoch"], + compute_epoch_at_slot(slot).to_string() + ); + assert_eq!(json["data"]["target"]["root"], format!("{head_root}")); + assert_eq!( + json["data"]["source"]["root"], + format!("{}", H256::repeat_byte(0xaa)) + ); + } + + #[tokio::test] + async fn a_slot_before_the_head_is_a_400() { + let state = fulu_state_at(3); + let slot = state.slot() - 1; + let (status, _, _) = attestation_data_for(state, slot).await; + assert_eq!(status, StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn subscriptions_and_preparations_are_acknowledged() { + let subscription = serde_json::json!([{ + "validator_index": "1", "committee_index": "0", "committees_at_slot": "1", + "slot": "33", "is_aggregator": false + }]); + let (status, _) = post_raw( + "/eth/v1/validator/beacon_committee_subscriptions", + subscription, + ) + .await; + assert_eq!(status, StatusCode::OK); + + let preparation = serde_json::json!([{ + "validator_index": "1", + "fee_recipient": "0x000000000000000000000000000000000000dead" + }]); + let (status, _) = post_raw("/eth/v1/validator/prepare_beacon_proposer", preparation).await; + assert_eq!(status, StatusCode::OK); + } + + #[tokio::test] + async fn a_preparation_records_the_validators_fee_recipient() { + let (store, _) = beacon_store_at(fulu_state()); + let fee_recipients = FeeRecipients::default(); + let app = routes() + .with_state(store) + .layer(Extension(fee_recipients.clone())); + let body = serde_json::json!([ + { "validator_index": "7", "fee_recipient": format!("0x{}", "ab".repeat(20)) } + ]); + let request = Request::post("/eth/v1/validator/prepare_beacon_proposer") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + assert_eq!(app.oneshot(request).await.unwrap().status(), StatusCode::OK); + assert_eq!( + fee_recipients.lock().unwrap().get(&7), + Some(&ExecutionAddress::from_slice(&[0xab; 20])) + ); + } + + async fn post_raw(uri: &str, body: serde_json::Value) -> (StatusCode, axum::body::Bytes) { + let (store, _) = beacon_store_at(fulu_state()); + let request = Request::post(uri) + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + let network: RpcToP2PRef = Arc::new(crate::test_utils::RecordingNetwork::default()); + let app = routes() + .with_state(store) + .layer(Extension(network)) + .layer(Extension(FeeRecipients::default())); + let response = app.oneshot(request).await.unwrap(); + let status = response.status(); + ( + status, + response.into_body().collect().await.unwrap().to_bytes(), + ) + } + + /// An aggregator's entry joins its committee's subnet until its slot; a + /// plain attester's joins nothing. + #[tokio::test] + async fn only_aggregators_join_their_committee_subnet() { + let (store, _) = beacon_store_at(fulu_state()); + let network = Arc::new(crate::test_utils::RecordingNetwork::default()); + let p2p: RpcToP2PRef = network.clone(); + let app = routes().with_state(store).layer(Extension(p2p)); + let body = serde_json::json!([ + { "validator_index": "1", "committee_index": "3", "committees_at_slot": "4", + "slot": "34", "is_aggregator": true }, + { "validator_index": "2", "committee_index": "0", "committees_at_slot": "4", + "slot": "34", "is_aggregator": false }, + ]); + let request = Request::post("/eth/v1/validator/beacon_committee_subscriptions") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + let response = app.oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + // Slot 34 is offset 2 in its epoch: committee 3 of 4 per slot is the + // epoch's committee 11. + assert_eq!(*network.subscriptions.lock().unwrap(), vec![(11, 34)]); + } + + #[tokio::test] + async fn an_epoch_outside_the_lookahead_is_a_400() { + let state = fulu_state(); + let too_far = compute_epoch_at_slot(state.slot()) + 2; + let (status, _) = get( + state, + &format!("/eth/v1/validator/duties/proposer/{too_far}"), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + } +} diff --git a/crates/net/rpc/src/beacon/validator_client_tests.rs b/crates/net/rpc/src/beacon/validator_client_tests.rs new file mode 100644 index 000000000..16663d75c --- /dev/null +++ b/crates/net/rpc/src/beacon/validator_client_tests.rs @@ -0,0 +1,371 @@ +//! `ethlambda validator`'s own HTTP client, driven against this node's Beacon +//! API over a real socket. +//! +//! The endpoint tests elsewhere check each answer against the spec; this checks +//! the two ends agree on the wire. A field this node names differently from what +//! the client parses, a number left unquoted, or a status the client does not +//! expect would each pass every endpoint test here and still leave the client +//! unable to attest. + +use std::sync::Arc; + +use axum::Extension; +use ethlambda_blockchain::{SyncStatusController, metrics::SyncStatus}; +use ethlambda_network_api::RpcToP2PRef; +use ethlambda_state_transition::beacon::attestation_pool::SharedAttestationPool; +use ethlambda_state_transition::beacon::helpers::{ + accessors::get_domain, + fulu::initialize_proposer_lookahead, + test_state::{sign_for, with_signing_validators_at}, +}; +use ethlambda_types::{ + beacon::{ + constants::DOMAIN_BEACON_ATTESTER, + containers::BeaconState, + fork::ForkName, + preset, + primitives::BlsPubkey, + signing::{compute_epoch_at_slot, compute_signing_root, compute_start_slot_at_epoch}, + }, + primitives::HashTreeRoot as _, +}; +use ethlambda_validator::{ + Error, + beacon_node::{ + BeaconNodeApi, BlockRequest, + dto::{ + AttestationDataOutDto, CommitteeSubscriptionDto, ProposerPreparationDto, + SingleAttestationDto, encode_hex, + }, + http::HttpBeaconNode, + }, +}; + +use crate::test_utils::{RecordingNetwork, beacon_store_at}; + +const COUNT: usize = 64; + +/// A fulu head state at the first slot of the wall clock's current epoch, with +/// its proposer lookahead filled in, served over HTTP on an ephemeral port. +async fn serve() -> (HttpBeaconNode, BeaconState, Arc) { + serve_with_engine(None).await +} + +async fn serve_with_engine( + engine: Option, +) -> (HttpBeaconNode, BeaconState, Arc) { + let mut state = with_signing_validators_at(ForkName::Fulu, COUNT); + let (probe, _) = beacon_store_at(state.clone()); + let wall_epoch = compute_epoch_at_slot(crate::beacon::node::wall_slot(&probe)); + let lookahead = { + let BeaconState::Fulu(fulu) = &mut state else { + unreachable!("built as fulu") + }; + fulu.slot = compute_start_slot_at_epoch(wall_epoch); + initialize_proposer_lookahead(&state).unwrap() + }; + // The builder leaves the sync committee as a placeholder, which + // `process_sync_aggregate` rejects when a block is built on this state. + let sync_committee = + ethlambda_state_transition::beacon::helpers::altair::get_next_sync_committee(&state) + .unwrap(); + let BeaconState::Fulu(fulu) = &mut state else { + unreachable!("built as fulu") + }; + fulu.proposer_lookahead = lookahead.try_into().unwrap(); + fulu.current_sync_committee = sync_committee.clone(); + fulu.next_sync_committee = sync_committee; + + let (mut store, _root) = beacon_store_at(state.clone()); + // The store's clock, which the aggregate conditions read the current epoch + // from, as the chain actor's ticks would have set it. + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as u64; + store.set_time_ms(now_ms).unwrap(); + let network = Arc::new(RecordingNetwork::default()); + let p2p: RpcToP2PRef = network.clone(); + let router = crate::build_beacon_api_router(store, "ethlambda/test", "peer".into()) + .layer(Extension(SyncStatusController::new(SyncStatus::Synced))) + .layer(Extension(p2p)) + .layer(Extension(SharedAttestationPool::default())) + .layer(Extension(crate::beacon::validator::FeeRecipients::default())) + .layer(Extension(engine)); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, router).await }); + + let client = HttpBeaconNode::new(format!("http://{address}")).unwrap(); + (client, state, network) +} + +/// One slot of an attester's work, in the order `ethlambda validator` does it. +#[tokio::test] +async fn the_validator_client_can_attest_through_this_node() { + let (client, state, network) = serve().await; + let slot = state.slot(); + let epoch = compute_epoch_at_slot(slot); + + client.genesis().await.expect("genesis"); + client + .spec() + .await + .expect("the client reads this node's spec"); + assert!( + !client.is_optimistic_or_syncing().await.unwrap(), + "a synced node with no optimistic block must be signable against" + ); + + let pubkeys: Vec = (0..COUNT as u64) + .map(|index| state.validator(index).unwrap().pubkey) + .collect(); + let entries = client.validator_indices(&pubkeys).await.unwrap(); + assert_eq!(entries.len(), COUNT); + let indices: Vec = entries.iter().map(|entry| entry.index).collect(); + + let proposers = client.proposer_duties(epoch).await.unwrap(); + assert_eq!(proposers.duties.len(), preset::SLOTS_PER_EPOCH as usize); + + let attesters = client.attester_duties(epoch, &indices).await.unwrap(); + assert_eq!( + attesters.duties.len(), + COUNT, + "everyone attests once per epoch" + ); + + let subscriptions: Vec = attesters + .duties + .iter() + .map(|duty| CommitteeSubscriptionDto { + validator_index: duty.validator_index, + committee_index: duty.committee_index, + committees_at_slot: duty.committees_at_slot, + slot: duty.slot, + is_aggregator: false, + }) + .collect(); + client.subscribe_committees(&subscriptions).await.unwrap(); + let preparation = ProposerPreparationDto { + validator_index: 0, + fee_recipient: format!("0x{}", "de".repeat(20)), + }; + client + .prepare_beacon_proposer(&[preparation]) + .await + .unwrap(); + + // The attestation data is checked by the client itself before it signs. + let data = client.attestation_data(slot).await.unwrap(); + let domain = get_domain(&state, DOMAIN_BEACON_ATTESTER, Some(data.target.epoch)); + let signing_root = compute_signing_root(data.hash_tree_root(), domain); + let data_dto = AttestationDataOutDto::from(&data); + let attestations: Vec = attesters + .duties + .iter() + .filter(|duty| duty.slot == slot) + .map(|duty| SingleAttestationDto { + committee_index: duty.committee_index, + attester_index: duty.validator_index, + data: data_dto.clone(), + signature: encode_hex(&sign_for(duty.validator_index as usize, signing_root).0), + }) + .collect(); + assert!(!attestations.is_empty(), "someone attests at the head slot"); + + let accepted = client + .submit_attestations(&attestations, "fulu") + .await + .unwrap(); + assert_eq!(accepted, attestations.len()); + assert_eq!(network.published.lock().unwrap().len(), attestations.len()); + + // Aggregation, as an aggregator of the head slot's first committee: the + // aggregate of what was just submitted, then the signed aggregate back. + let duty = attesters + .duties + .iter() + .find(|duty| duty.slot == slot) + .expect("someone attests at the head slot"); + let aggregate = client + .aggregate_attestation(slot, data.hash_tree_root(), duty.committee_index) + .await + .unwrap(); + assert_eq!(aggregate.fork, ForkName::Fulu); + let voters = attestations + .iter() + .filter(|attestation| attestation.committee_index == duty.committee_index) + .count(); + let bits = (0..duty.committee_length as usize) + .filter(|&i| aggregate.attestation.aggregation_bits.get(i).unwrap()) + .count(); + assert_eq!(bits, voters); + + let signed = signed_aggregate(&state, duty.validator_index, aggregate.attestation); + client + .publish_aggregates(ForkName::Fulu, &[signed]) + .await + .unwrap(); + assert_eq!(network.aggregates.lock().unwrap().len(), 1); +} + +/// A signed aggregate from `aggregator`, as phase0's `validator.md` +/// ("Construct aggregate") builds it. Every member of these small committees +/// is an aggregator, so any member's selection proof selects it. +fn signed_aggregate( + state: &BeaconState, + aggregator: u64, + aggregate: ethlambda_types::beacon::containers::electra::Attestation, +) -> ethlambda_types::beacon::containers::electra::SignedAggregateAndProof { + use ethlambda_types::beacon::constants::{DOMAIN_AGGREGATE_AND_PROOF, DOMAIN_SELECTION_PROOF}; + use ethlambda_types::beacon::containers::electra::{ + AggregateAndProof, SignedAggregateAndProof, + }; + let slot = aggregate.data.slot; + let epoch = compute_epoch_at_slot(slot); + let selection_domain = get_domain(state, DOMAIN_SELECTION_PROOF, Some(epoch)); + let selection_proof = sign_for( + aggregator as usize, + compute_signing_root(slot.hash_tree_root(), selection_domain), + ); + let message = AggregateAndProof { + aggregator_index: aggregator, + aggregate, + selection_proof, + }; + let domain = get_domain(state, DOMAIN_AGGREGATE_AND_PROOF, Some(epoch)); + let signature = sign_for( + aggregator as usize, + compute_signing_root(message.hash_tree_root(), domain), + ); + SignedAggregateAndProof { message, signature } +} + +/// Without an execution client to build a payload with, block production +/// answers 503, which the client reads as a node it can fail over from rather +/// than a malformed answer. +#[tokio::test] +async fn block_production_without_an_execution_client_is_retryable() { + let (client, state, _) = serve().await; + let request = BlockRequest { + slot: state.slot() + 1, + proposer_index: 0, + randao_reveal: Default::default(), + graffiti: Default::default(), + }; + let err = client.produce_block(&request).await.unwrap_err(); + assert!(matches!(err, Error::BeaconNodeSyncing), "got {err:?}"); + assert!(err.is_retryable()); +} + +/// A stand-in execution client: `forkchoiceUpdated` with attributes answers a +/// payload id, and `getPayloadV5` a payload that extends the requested head +/// with exactly the requested attributes, which is all a real one's payload +/// has to agree with for the block to verify. +async fn fake_execution_client() -> ethlambda_engine::EngineClient { + use axum::{Json, routing::post}; + use std::sync::Mutex; + + let requested: Arc>> = Arc::default(); + let handler = move |Json(request): Json| { + let requested = requested.clone(); + async move { + let result = match request["method"].as_str() { + Some("engine_forkchoiceUpdatedV3") => { + let mut attributes = request["params"][1].clone(); + attributes["parentHash"] = request["params"][0]["headBlockHash"].clone(); + *requested.lock().unwrap() = Some(attributes); + serde_json::json!({ + "payloadStatus": { "status": "VALID", "latestValidHash": null }, + "payloadId": "0x0000000000000001", + }) + } + Some("engine_getPayloadV5") => { + let attributes = requested.lock().unwrap().clone().unwrap(); + serde_json::json!({ + "executionPayload": { + "parentHash": attributes["parentHash"], + "feeRecipient": attributes["suggestedFeeRecipient"], + "stateRoot": format!("0x{}", "00".repeat(32)), + "receiptsRoot": format!("0x{}", "00".repeat(32)), + "logsBloom": format!("0x{}", "00".repeat(256)), + "prevRandao": attributes["prevRandao"], + "blockNumber": "0x1", + "gasLimit": "0x1c9c380", + "gasUsed": "0x0", + "timestamp": attributes["timestamp"], + "extraData": "0x", + "baseFeePerGas": "0x7", + "blockHash": format!("0x{}", "ee".repeat(32)), + "transactions": [], + "withdrawals": attributes["withdrawals"], + "blobGasUsed": "0x0", + "excessBlobGas": "0x0", + }, + "blockValue": "0x2a", + "blobsBundle": { "commitments": [], "proofs": [], "blobs": [] }, + "shouldOverrideBuilder": false, + "executionRequests": [], + }) + } + _ => serde_json::Value::Null, + }; + Json(serde_json::json!({ "jsonrpc": "2.0", "id": request["id"], "result": result })) + } + }; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let router = axum::Router::new().route("/", post(handler)); + tokio::spawn(async move { axum::serve(listener, router).await }); + ethlambda_engine::EngineClient::new( + format!("http://{address}"), + ethlambda_engine::JwtSecret::new([0x0f; 32]), + ) + .unwrap() +} + +/// A proposer's slot through this node: the block produced from this node's +/// execution client, signed, and published back. +#[tokio::test] +async fn the_validator_client_can_propose_through_this_node() { + use ethlambda_state_transition::beacon::{ + block_production::advance_to_slot, helpers::accessors::get_beacon_proposer_index, + }; + use ethlambda_types::beacon::constants::{DOMAIN_BEACON_PROPOSER, DOMAIN_RANDAO}; + + let (client, state, network) = serve_with_engine(Some(fake_execution_client().await)).await; + let slot = state.slot() + 1; + let advanced = advance_to_slot( + &state, + slot, + ðlambda_types::beacon::config::Config::mainnet(), + ) + .unwrap(); + let proposer = get_beacon_proposer_index(&advanced).unwrap(); + let epoch = compute_epoch_at_slot(slot); + let randao_domain = get_domain(&advanced, DOMAIN_RANDAO, Some(epoch)); + let randao_reveal = sign_for( + proposer as usize, + compute_signing_root(epoch.hash_tree_root(), randao_domain), + ); + + let request = BlockRequest { + slot, + proposer_index: proposer, + randao_reveal, + graffiti: Default::default(), + }; + let produced = client.produce_block(&request).await.unwrap(); + assert_eq!(produced.block().slot, slot); + assert_eq!(produced.block().proposer_index, proposer); + + let block_domain = get_domain(&advanced, DOMAIN_BEACON_PROPOSER, Some(epoch)); + let signature = sign_for( + proposer as usize, + compute_signing_root(produced.block().hash_tree_root(), block_domain), + ); + let body = produced.into_signed_ssz(signature); + client.publish_block(ForkName::Fulu, &body).await.unwrap(); + assert_eq!(network.blocks.lock().unwrap().len(), 1); +} diff --git a/crates/net/rpc/src/lib.rs b/crates/net/rpc/src/lib.rs index 328584712..a2144f7c6 100644 --- a/crates/net/rpc/src/lib.rs +++ b/crates/net/rpc/src/lib.rs @@ -2,6 +2,8 @@ use std::net::{IpAddr, SocketAddr}; use axum::{Extension, Router}; use ethlambda_blockchain::{EventBus, SyncStatusController}; +use ethlambda_network_api::RpcToP2PRef; +use ethlambda_state_transition::beacon::attestation_pool::SharedAttestationPool; use ethlambda_storage::Store; use ethlambda_types::aggregator::AggregatorController; use tokio_util::sync::CancellationToken; @@ -11,6 +13,7 @@ pub(crate) const SSZ_CONTENT_TYPE: &str = "application/octet-stream"; mod admin; mod base; +mod beacon; mod blocks; mod events; mod fork_choice; @@ -18,6 +21,7 @@ mod genesis; mod heap_profiling; pub mod metrics; mod node; +mod shared; mod spec; pub mod test_driver; @@ -72,41 +76,78 @@ pub async fn start_rpc_server( .layer(Extension(aggregator)) .layer(Extension(sync_status)) .layer(Extension(events)); - let metrics_router = metrics::start_prometheus_metrics_api(); - let debug_router = build_debug_router(); + start_http_servers(config, Some(api_router), shutdown).await +} + +/// Bind and serve this process's HTTP surface, and return when it stops. +/// +/// The metrics and debug routers are always served: they need no state, and a +/// process that records Prometheus series without serving them leaves the +/// question of whether it is healthy unanswerable. `api_router` is what the +/// caller has to supply, and is what differs between the two sub-commands. +/// +/// `Some(router)` serves it alongside them: merged onto one listener when +/// `api_port == metrics_port`, otherwise on two independent servers, so +/// pointing both flags at one port is supported rather than a +/// misconfiguration. `None` serves only metrics and debug, on `metrics_port`; +/// `api_port` is then unused. That is `ethlambda beacon`, which has no lean +/// `Store`, `AggregatorController`, `SyncStatusController` or `EventBus` to +/// build the lean API from, and would be serving lean answers for a beacon +/// chain if it invented empty ones. +pub async fn start_http_servers( + config: RpcConfig, + api_router: Option, + shutdown: CancellationToken, +) -> Result<(), std::io::Error> { + let metrics_app = Router::new() + .merge(metrics::start_prometheus_metrics_api()) + .merge(build_debug_router()); + + let Some(api_router) = api_router else { + return serve( + config.http_address, + config.metrics_port, + metrics_app, + shutdown, + ) + .await; + }; if config.api_port == config.metrics_port { - let app = Router::new() - .merge(api_router) - .merge(metrics_router) - .merge(debug_router); - let addr = SocketAddr::new(config.http_address, config.api_port); - let listener = tokio::net::TcpListener::bind(addr).await?; - axum::serve(listener, app) - .with_graceful_shutdown(async move { - shutdown.cancelled().await; - }) - .await?; - } else { - let api_addr = SocketAddr::new(config.http_address, config.api_port); - let metrics_addr = SocketAddr::new(config.http_address, config.metrics_port); - let api_listener = tokio::net::TcpListener::bind(api_addr).await?; - let metrics_listener = tokio::net::TcpListener::bind(metrics_addr).await?; - let metrics_app = Router::new().merge(metrics_router).merge(debug_router); - let metrics_shutdown = shutdown.clone(); - tokio::try_join!( - axum::serve(api_listener, api_router).with_graceful_shutdown(async move { - shutdown.cancelled().await; - }), - axum::serve(metrics_listener, metrics_app).with_graceful_shutdown(async move { - metrics_shutdown.cancelled().await; - }), - )?; + let app = Router::new().merge(api_router).merge(metrics_app); + return serve(config.http_address, config.api_port, app, shutdown).await; } + let metrics_shutdown = shutdown.clone(); + tokio::try_join!( + serve(config.http_address, config.api_port, api_router, shutdown), + serve( + config.http_address, + config.metrics_port, + metrics_app, + metrics_shutdown + ), + )?; Ok(()) } +/// Bind one listener and serve `app` on it until `shutdown` is cancelled. +async fn serve( + address: IpAddr, + port: u16, + app: Router, + shutdown: CancellationToken, +) -> Result<(), std::io::Error> { + let addr = SocketAddr::new(address, port); + let listener = tokio::net::TcpListener::bind(addr).await?; + tracing::info!(%addr, "HTTP server listening"); + axum::serve(listener, app) + .with_graceful_shutdown(async move { + shutdown.cancelled().await; + }) + .await +} + /// Build the API router with the given store, client version, and peer ID. /// /// `version` (`RpcConfig::version`) and `peer_id` (the node's libp2p peer ID) @@ -127,6 +168,58 @@ fn build_api_router(store: Store, version: &'static str, peer_id: String) -> Rou .with_state(store) } +/// Build the Beacon API router. +/// +/// The mirror of [`build_api_router`], and deliberately not a superset of it: +/// the `/lean/v0` handlers read lean state variants and metadata keys a beacon +/// directory does not carry, so serving both off one store would answer lean +/// questions with beacon data, or panic trying. +/// +/// The metrics and debug routers are **not** merged here, for the same reason +/// [`build_api_router`] does not merge them: [`start_http_servers`] serves +/// them itself, and merging a path twice makes axum panic at startup. +pub fn build_beacon_api_router(store: Store, version: &'static str, peer_id: String) -> Router { + Router::new() + .merge(beacon::routes(version, peer_id)) + .with_state(store) +} + +/// What the Beacon API's validator endpoints reach beyond the store. +pub struct BeaconApiHandles { + /// Through which the pool, aggregate and block endpoints gossip what a + /// validator client hands them. + pub p2p: RpcToP2PRef, + /// Filled by the attestation pool endpoint and the aggregator subnets, + /// read by the aggregate endpoint and block production. + pub attestation_pool: SharedAttestationPool, + /// The execution client block production builds payloads with; `None` + /// makes it answer 503. + pub engine: Option, +} + +/// Start the HTTP servers for a beacon node. +/// +/// The beacon counterpart to [`start_rpc_server`]. It takes no +/// `AggregatorController` and no `EventBus`: a follower has no aggregator duty +/// to toggle, and the chain-events stream is part of the lean surface. It does +/// take the [`BeaconApiHandles`] the validator endpoints need. +pub async fn start_beacon_rpc_server( + config: RpcConfig, + store: Store, + sync_status: SyncStatusController, + handles: BeaconApiHandles, + peer_id: String, + shutdown: CancellationToken, +) -> Result<(), std::io::Error> { + let api_router = build_beacon_api_router(store, config.version, peer_id) + .layer(Extension(sync_status)) + .layer(Extension(handles.p2p)) + .layer(Extension(handles.attestation_pool)) + .layer(Extension(beacon::validator::FeeRecipients::default())) + .layer(Extension(handles.engine)); + start_http_servers(config, Some(api_router), shutdown).await +} + /// Build the debug router for profiling endpoints. fn build_debug_router() -> Router { use axum::routing::get; @@ -140,15 +233,25 @@ fn build_debug_router() -> Router { #[cfg(test)] pub(crate) mod test_utils { + use std::sync::Arc; + use axum::Router; - use ethlambda_storage::{StorageBackend, Store, Table}; + use ethlambda_storage::{ + ForkCheckpoints, StorageBackend, Store, Table, backend::InMemoryBackend, + }; use ethlambda_types::{ + beacon::{ + config::Config, + containers::{BeaconState, SignedBeaconBlock, phase0, shared::BeaconBlockHeader}, + preset, + }, block::{Block, BlockBody, BlockHeader}, checkpoint::Checkpoint, primitives::{H256, HashTreeRoot as _}, state::{JustificationValidators, JustifiedSlots, State, StateConfig}, }; use libssz::SszEncode; + use libssz_types::SszVector; /// Build the API router the way tests do, with placeholder client version /// and peer ID. Tests that assert on those identity values (e.g. the @@ -221,6 +324,226 @@ pub(crate) mod test_utils { root } + + /// A two-block beacon store, built for reuse by every beacon HTTP test. + pub(crate) struct BeaconFixture { + pub(crate) store: Store, + pub(crate) anchor_root: H256, + // `anchor_slot`, `head_root` and `head_slot` are unread by this + // task's own tests (which hardcode the fixture's slots since the + // fixture itself is built from a fixed `ANCHOR_SLOT`), but are part + // of what every later beacon-endpoint task reuses this fixture for. + #[allow(dead_code)] + pub(crate) anchor_slot: u64, + #[allow(dead_code)] + pub(crate) head_root: H256, + #[allow(dead_code)] + pub(crate) head_slot: u64, + } + + /// A minimal phase0 state at `slot`, linked to `parent_root`. + /// + /// Mirrors `beacon_test_state`/`beacon_test_state_with_parent` in + /// `ethlambda_storage::store`'s own tests: nothing here reads validators + /// or history, so every fixed-length vector is zero-filled rather than + /// populated with real content. + fn phase0_beacon_state(slot: u64, parent_root: H256) -> phase0::BeaconState { + phase0::BeaconState { + genesis_time: 1_606_824_023, + genesis_validators_root: H256::ZERO, + slot, + fork: Default::default(), + latest_block_header: BeaconBlockHeader { + slot, + proposer_index: 0, + parent_root, + state_root: H256::ZERO, + body_root: H256::ZERO, + }, + block_roots: SszVector::try_from(vec![H256::ZERO; preset::SLOTS_PER_HISTORICAL_ROOT]) + .expect("exactly N elements by construction"), + state_roots: SszVector::try_from(vec![H256::ZERO; preset::SLOTS_PER_HISTORICAL_ROOT]) + .expect("exactly N elements by construction"), + historical_roots: Default::default(), + eth1_data: Default::default(), + eth1_data_votes: Default::default(), + eth1_deposit_index: 0, + validators: Default::default(), + balances: Default::default(), + randao_mixes: SszVector::try_from(vec![ + H256::ZERO; + preset::EPOCHS_PER_HISTORICAL_VECTOR + ]) + .expect("exactly N elements by construction"), + slashings: SszVector::try_from(vec![0u64; preset::EPOCHS_PER_SLASHINGS_VECTOR]) + .expect("exactly N elements by construction"), + previous_epoch_attestations: Default::default(), + current_epoch_attestations: Default::default(), + justification_bits: Default::default(), + previous_justified_checkpoint: Default::default(), + current_justified_checkpoint: Default::default(), + finalized_checkpoint: Default::default(), + } + } + + /// A phase0 block at `slot`, with a trivial (default) body. + fn phase0_beacon_block(slot: u64, parent_root: H256) -> SignedBeaconBlock { + SignedBeaconBlock::Phase0(phase0::SignedBeaconBlock { + message: phase0::BeaconBlock { + slot, + proposer_index: 0, + parent_root, + state_root: H256::ZERO, + body: phase0::BeaconBlockBody::default(), + }, + signature: Default::default(), + }) + } + + /// Stands in for the P2P actor: records what the Beacon API would have + /// gossiped instead of gossiping it. + #[derive(Default)] + pub(crate) struct RecordingNetwork { + pub(crate) published: std::sync::Mutex< + Vec<( + u64, + ethlambda_types::beacon::containers::electra::SingleAttestation, + )>, + >, + pub(crate) aggregates: + std::sync::Mutex>, + pub(crate) subscriptions: std::sync::Mutex>, + pub(crate) blocks: + std::sync::Mutex>, + } + + impl ethlambda_network_api::RpcToP2P for RecordingNetwork { + fn publish_beacon_attestation( + &self, + subnet_id: u64, + attestation: ethlambda_types::beacon::containers::electra::SingleAttestation, + ) -> Result<(), spawned_concurrency::error::ActorError> { + self.published + .lock() + .unwrap() + .push((subnet_id, attestation)); + Ok(()) + } + + fn publish_beacon_aggregate( + &self, + aggregate: ethlambda_types::beacon::containers::SignedAggregateAndProof, + ) -> Result<(), spawned_concurrency::error::ActorError> { + self.aggregates.lock().unwrap().push(aggregate); + Ok(()) + } + + fn subscribe_attestation_subnets( + &self, + subnets: Vec<(u64, u64)>, + ) -> Result<(), spawned_concurrency::error::ActorError> { + self.subscriptions.lock().unwrap().extend(subnets); + Ok(()) + } + + fn publish_beacon_block( + &self, + block: ethlambda_types::beacon::containers::SignedBeaconBlock, + ) -> Result<(), spawned_concurrency::error::ActorError> { + self.blocks.lock().unwrap().push(block); + Ok(()) + } + } + + /// A beacon store whose anchor, and so head, is `state`, under a block at + /// the state's slot. Returns the store and that block's root. + /// + /// For endpoints that read the validator registry or committees, which the + /// empty phase0 state in [`beacon_fixture`] cannot exercise. The block is a + /// phase0 one whatever `state`'s fork: these endpoints read the state and + /// the block's root and slot, never the block's body. + pub(crate) fn beacon_store_at(state: BeaconState) -> (Store, H256) { + let slot = state.slot(); + let block = phase0_beacon_block(slot, H256::ZERO); + let root = block.message_hash_tree_root(); + let mut store = Store::init_beacon( + Arc::new(InMemoryBackend::default()), + 1_606_824_023, + Config::mainnet(), + root, + Checkpoint { root, slot }, + slot, + ); + store + .insert_signed_block(root, block) + .expect("insert anchor block"); + store + .insert_state(root, state) + .expect("insert anchor state"); + store + .update_checkpoints(ForkCheckpoints::head_only(root)) + .expect("make the anchor the head"); + (store, root) + } + + /// Build a beacon store anchored at `anchor_slot`, with a real child block + /// at `anchor_slot + 1` whose import moves the head for real. + /// + /// `Table::BlockRoots` is written only by `Store::update_checkpoints` + /// (see `BlockId::resolve_beacon`'s doc), so a fixture that wants that + /// table populated has to move the head through the real API rather than + /// poke the backend directly. That is what distinguishes this from + /// `beacon_test_state`/`beacon_test_block` in `ethlambda_storage::store`'s + /// own tests, which this otherwise mirrors. + pub(crate) fn beacon_fixture(anchor_slot: u64) -> BeaconFixture { + let anchor_block = phase0_beacon_block(anchor_slot, H256::ZERO); + let anchor_root = anchor_block.message_hash_tree_root(); + let anchor_state = phase0_beacon_state(anchor_slot, H256::ZERO); + + let mut store = Store::init_beacon( + Arc::new(InMemoryBackend::default()), + 1_606_824_023, + Config::mainnet(), + anchor_root, + Checkpoint { + root: anchor_root, + slot: anchor_slot, + }, + anchor_slot, + ); + store + .insert_signed_block(anchor_root, anchor_block) + .expect("insert anchor block"); + store + .insert_state(anchor_root, BeaconState::Phase0(anchor_state)) + .expect("insert anchor state"); + + let head_slot = anchor_slot + 1; + let head_block = phase0_beacon_block(head_slot, anchor_root); + let head_root = head_block.message_hash_tree_root(); + // `parent_root = anchor_root` so `insert_state`'s beacon arm diffs + // against the anchor's own state rather than snapshotting again. + let head_state = phase0_beacon_state(head_slot, anchor_root); + + store + .insert_signed_block(head_root, head_block) + .expect("insert head block"); + store + .insert_state(head_root, BeaconState::Phase0(head_state)) + .expect("insert head state"); + + store + .update_checkpoints(ForkCheckpoints::head_only(head_root)) + .expect("move head to the child block"); + + BeaconFixture { + store, + anchor_root, + anchor_slot, + head_root, + head_slot, + } + } } #[cfg(test)] @@ -284,10 +607,11 @@ mod tests { let finalized = store .latest_finalized() .expect("latest finalized checkpoint exists"); - let mut expected_state = store + let expected_state = store .get_state(&finalized.root) .expect("expected state") .unwrap(); + let mut expected_state = expected_state.expect_lean().clone(); expected_state.latest_block_header.state_root = H256::ZERO; let expected_ssz = expected_state.to_ssz(); @@ -462,6 +786,7 @@ mod tests { #[tokio::test] async fn test_get_latest_finalized_block() { use ethlambda_types::{ + beacon::containers::SignedBeaconBlock, block::{Block, BlockBody, MultiMessageAggregate, SignedBlock}, checkpoint::Checkpoint, primitives::{H256, HashTreeRoot as _}, @@ -491,7 +816,7 @@ mod tests { // Persist the signed block and mark it as the latest finalized checkpoint. store - .insert_signed_block(block_root, signed_block.clone()) + .insert_signed_block(block_root, SignedBeaconBlock::Lean(signed_block.clone())) .expect("insert_signed_block should succeed"); store .update_checkpoints(ForkCheckpoints::new( @@ -528,6 +853,80 @@ mod tests { assert_eq!(body.as_ref(), expected_ssz.as_slice()); } + /// The same block, as JSON, when the caller asks for it by name. + /// + /// The default above stays SSZ and that is load-bearing: + /// `bin/ethlambda/src/checkpoint_sync.rs` reads these bytes, and other + /// clients' lean checkpoint sync may send no `Accept` at all. JSON here is + /// opt-in, which is the opposite of the beacon surface's default. + #[tokio::test] + async fn the_lean_finalized_block_is_json_when_asked_for() { + use ethlambda_types::{ + beacon::containers::SignedBeaconBlock, + block::{Block, BlockBody, MultiMessageAggregate, SignedBlock}, + checkpoint::Checkpoint, + primitives::{H256, HashTreeRoot as _}, + }; + + let state = create_test_state(); + let backend = Arc::new(InMemoryBackend::new()); + let mut store = Store::from_anchor_state(backend, state, DEFAULT_MILLISECONDS_PER_SLOT); + + let block = Block { + slot: 1, + proposer_index: 0, + parent_root: store + .latest_finalized() + .expect("latest finalized checkpoint exists") + .root, + state_root: H256::ZERO, + body: BlockBody::default(), + }; + let block_root = block.header().hash_tree_root(); + let signed_block = SignedBlock { + message: block, + proof: MultiMessageAggregate::default(), + }; + store + .insert_signed_block(block_root, SignedBeaconBlock::Lean(signed_block)) + .expect("insert_signed_block should succeed"); + store + .update_checkpoints(ForkCheckpoints::new( + block_root, + None, + Some(Checkpoint { + root: block_root, + slot: 1, + }), + )) + .expect("update_checkpoints should succeed"); + + let app = test_utils::test_api_router(store); + let response = app + .oneshot( + Request::builder() + .uri("/lean/v0/blocks/finalized") + .header(header::ACCEPT, "application/json") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers().get(header::CONTENT_TYPE).unwrap(), + JSON_CONTENT_TYPE + ); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + // Lean encodes integers bare. This is not the beacon surface, whose + // every integer is a quoted decimal string. + assert_eq!(json["message"]["slot"], 1); + assert_eq!(json["message"]["proposer_index"], 0); + } + #[tokio::test] async fn test_get_latest_finalized_block_serves_genesis_with_placeholder_proof() { use ethlambda_types::block::{MultiMessageAggregate, SignedBlock}; @@ -553,6 +952,7 @@ mod tests { ) .expect("genesis served via get_signed_block") .unwrap(); + let genesis_block = genesis_block.expect_lean(); let expected = SignedBlock { message: genesis_block.message.clone(), proof: MultiMessageAggregate::default(), diff --git a/crates/net/rpc/src/node.rs b/crates/net/rpc/src/node.rs index ab0999cca..d15a51231 100644 --- a/crates/net/rpc/src/node.rs +++ b/crates/net/rpc/src/node.rs @@ -43,7 +43,7 @@ async fn get_syncing( .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis() as u64) .unwrap_or(genesis_ms); - let wall_slot = now_ms.saturating_sub(genesis_ms) / store.config().milliseconds_per_slot; + let wall_slot = now_ms.saturating_sub(genesis_ms) / store.config().slot_duration_ms; let head_slot = store.head_slot(); let sync_distance = wall_slot.saturating_sub(head_slot); let finalized_slot = store diff --git a/crates/net/rpc/src/shared/block_id.rs b/crates/net/rpc/src/shared/block_id.rs new file mode 100644 index 000000000..04f2ed420 --- /dev/null +++ b/crates/net/rpc/src/shared/block_id.rs @@ -0,0 +1,182 @@ +//! The `block_id` and `state_id` path parameters, parsed once for both surfaces. +//! +//! Parsing is chain-agnostic; resolution is not. Lean resolves a slot through +//! the head state's `historical_block_hashes` (see `crate::blocks`), beacon +//! through `Store::canonical_root_at_slot`, and the lean path stays where it +//! is: `Store::head_state` panics on a beacon store, and `BlockRoots` covers +//! the branch ending at the head rather than everything a lean store was +//! bootstrapped with, so repointing lean at it would change a working +//! endpoint's answers below the anchor. + +use ethlambda_storage::Store; +use ethlambda_types::primitives::H256; + +/// The block at `slot` among the roots this store can name, or `None`. +/// +/// `Table::BlockRoots` is the slot index, and `Store::update_checkpoints` is +/// its only writer. That writer diffs the old head against the new one and +/// writes nothing when they are the same root, which is the situation at +/// bootstrap: `Store::init_beacon` seeds `KEY_HEAD` with the anchor. So the +/// anchor's own slot is missing from the index even though its block is on +/// disk, and `canonical_root_at_slot` alone cannot find it. +/// +/// That slot is not a curiosity. `bin/ethlambda/src/checkpoint_sync.rs` reads +/// a peer's finalized state, takes the anchor slot from it, and asks that peer +/// for `/eth/v2/beacon/blocks/{anchor_slot}`. Answering 404 there makes this +/// node unusable as a checkpoint-sync source for any client, ethlambda +/// included. +/// +/// Each candidate is **checked** rather than assumed: `block_entry` gives the +/// root's real slot, and a candidate is accepted only when it equals the slot +/// asked for. A store that holds nothing at `slot` therefore still answers +/// `None`, rather than the nearest checkpoint. At most three index reads, and +/// only on a miss. +fn anchored_root_at_slot(store: &Store, slot: u64) -> Option { + let named = [ + store + .latest_finalized() + .ok() + .map(|checkpoint| checkpoint.root), + store + .latest_justified() + .ok() + .map(|checkpoint| checkpoint.root), + store.beacon_head().map(|(_slot, root)| root), + ]; + + named + .into_iter() + .flatten() + .find(|root| store.block_entry(root).is_some_and(|(at, _)| at == slot)) +} + +/// A `block_id` path parameter. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum BlockId { + Head, + Genesis, + Finalized, + Justified, + Slot(u64), + Root(H256), +} + +/// Why an id could not be used. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum IdError { + /// The id is not one this API defines. 400. + Malformed, + /// Well-formed, but nothing is stored under it. 404. + NotFound, +} + +impl BlockId { + /// Parse without touching the store. + pub(crate) fn parse(raw: &str) -> Result { + match raw { + "head" => return Ok(BlockId::Head), + "genesis" => return Ok(BlockId::Genesis), + "finalized" => return Ok(BlockId::Finalized), + "justified" => return Ok(BlockId::Justified), + _ => {} + } + + if let Some(hex_body) = raw.strip_prefix("0x") { + let bytes = hex::decode(hex_body).map_err(|_| IdError::Malformed)?; + let arr: [u8; 32] = bytes.try_into().map_err(|_| IdError::Malformed)?; + return Ok(BlockId::Root(H256(arr))); + } + + if !raw.is_empty() && raw.chars().all(|c| c.is_ascii_digit()) { + return raw + .parse() + .map(BlockId::Slot) + .map_err(|_| IdError::Malformed); + } + + Err(IdError::Malformed) + } + + /// Resolve to a block root against a **beacon** store. + /// + /// `genesis` is always refused; see the `BlockId::Genesis` arm below for + /// why. + pub(crate) fn resolve_beacon(&self, store: &Store) -> Result { + match self { + BlockId::Root(root) => Ok(*root), + BlockId::Head => store + .beacon_head() + .map(|(_slot, root)| root) + .ok_or(IdError::NotFound), + BlockId::Finalized => Ok(store + .latest_finalized() + .map_err(|_| IdError::NotFound)? + .root), + BlockId::Justified => Ok(store + .latest_justified() + .map_err(|_| IdError::NotFound)? + .root), + // A beacon store cannot answer `genesis`. `BlockRoots` indexes the + // canonical branch above the store's anchor, and the anchor's own + // slot is never written to it: `update_checkpoints` is that + // index's only writer and it walks from the old head to the new + // one, which at bootstrap are the same root. A checkpoint-synced + // directory has no genesis block to return either way. Refusing + // is better than resolving it to the anchor and calling that + // genesis. + BlockId::Genesis => Err(IdError::NotFound), + BlockId::Slot(slot) => { + if let Some(root) = store + .canonical_root_at_slot(*slot) + .map_err(|_| IdError::NotFound)? + { + return Ok(root); + } + anchored_root_at_slot(store, *slot).ok_or(IdError::NotFound) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn named_ids_parse() { + assert_eq!(BlockId::parse("head").unwrap(), BlockId::Head); + assert_eq!(BlockId::parse("genesis").unwrap(), BlockId::Genesis); + assert_eq!(BlockId::parse("finalized").unwrap(), BlockId::Finalized); + assert_eq!(BlockId::parse("justified").unwrap(), BlockId::Justified); + } + + #[test] + fn a_slot_and_a_root_parse() { + assert_eq!(BlockId::parse("4096").unwrap(), BlockId::Slot(4096)); + let root = format!("0x{}", "ab".repeat(32)); + assert_eq!( + BlockId::parse(&root).unwrap(), + BlockId::Root(H256([0xab; 32])) + ); + } + + #[test] + fn malformed_ids_are_rejected() { + assert!(BlockId::parse("not-an-id").is_err()); + assert!(BlockId::parse("0xdeadbeef").is_err(), "wrong length"); + assert!(BlockId::parse("0xzz").is_err(), "not hex"); + assert!(BlockId::parse("").is_err()); + } + + #[test] + fn genesis_is_refused_on_a_beacon_store() { + // Deliberate, not incidental: see the `BlockId::Genesis` arm of + // `resolve_beacon` for why the anchor slot can never be reached + // through `BlockRoots`. + let fixture = crate::test_utils::beacon_fixture(64); + assert_eq!( + BlockId::Genesis.resolve_beacon(&fixture.store), + Err(IdError::NotFound) + ); + } +} diff --git a/crates/net/rpc/src/shared/content.rs b/crates/net/rpc/src/shared/content.rs new file mode 100644 index 000000000..5110bb0c8 --- /dev/null +++ b/crates/net/rpc/src/shared/content.rs @@ -0,0 +1,114 @@ +//! What a response is encoded as, and how it says so. +//! +//! The Beacon API negotiates on `Accept`: JSON unless the caller asks for +//! `application/octet-stream`, which is the same order lighthouse serves +//! (`beacon_node/http_api/src/lib.rs`, the `get_beacon_block` and +//! `get_debug_beacon_states` handlers). The lean surface predates this and +//! defaults the other way on its two SSZ endpoints, so the default is the +//! caller's to pass rather than a constant here. + +use axum::{ + http::{HeaderValue, header}, + response::{IntoResponse, Response}, +}; +use ethlambda_types::beacon::fork::ForkName; + +/// Which encoding a response body carries. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Encoding { + Json, + Ssz, +} + +impl Encoding { + /// Read an `Accept` header, defaulting to JSON. + /// + /// Ranked by the `q` weight the header gives each type, since a client that + /// accepts both states its preference that way and ignoring it would serve + /// SSZ to a caller who merely tolerates it. An absent, wildcard or unknown + /// type is JSON: it is the encoding every consumer can read. + pub(crate) fn from_accept(accept: Option<&str>) -> Self { + let Some(accept) = accept else { + return Encoding::Json; + }; + + let mut best = (Encoding::Json, -1.0f32); + for entry in accept.split(',') { + let mut parts = entry.split(';'); + let media = parts.next().unwrap_or("").trim(); + let encoding = match media { + "application/octet-stream" => Encoding::Ssz, + "application/json" => Encoding::Json, + _ => continue, + }; + let weight = parts + .find_map(|p| p.trim().strip_prefix("q=")?.parse::().ok()) + .unwrap_or(1.0); + if weight > best.1 { + best = (encoding, weight); + } + } + + best.0 + } +} + +/// Tag a response with the fork its body was encoded under. +/// +/// Required on every response carrying a fork-versioned container, in both +/// encodings, so an SSZ caller can tell which container it just received. +pub(crate) fn with_consensus_version(mut response: Response, fork: ForkName) -> Response { + if let Ok(value) = HeaderValue::from_str(fork.as_str()) { + response + .headers_mut() + .insert("eth-consensus-version", value); + } + response +} + +/// An SSZ body, with the content type the specification names for it. +pub(crate) fn ssz_response(bytes: Vec) -> Response { + let mut response = bytes.into_response(); + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static(crate::SSZ_CONTENT_TYPE), + ); + response +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn no_accept_header_means_json() { + assert_eq!(Encoding::from_accept(None), Encoding::Json); + } + + #[test] + fn octet_stream_means_ssz() { + assert_eq!( + Encoding::from_accept(Some("application/octet-stream")), + Encoding::Ssz + ); + } + + #[test] + fn a_q_weighted_header_picks_the_higher_weight() { + // Lighthouse and the curl default both send lists; the spec orders by q. + assert_eq!( + Encoding::from_accept(Some("application/json;q=0.9,application/octet-stream")), + Encoding::Ssz + ); + assert_eq!( + Encoding::from_accept(Some("application/octet-stream;q=0.1,application/json")), + Encoding::Json + ); + } + + #[test] + fn a_wildcard_or_unknown_type_falls_back_to_json() { + assert_eq!(Encoding::from_accept(Some("*/*")), Encoding::Json); + assert_eq!(Encoding::from_accept(Some("text/html")), Encoding::Json); + } +} diff --git a/crates/net/rpc/src/shared/mod.rs b/crates/net/rpc/src/shared/mod.rs new file mode 100644 index 000000000..b74db81db --- /dev/null +++ b/crates/net/rpc/src/shared/mod.rs @@ -0,0 +1,4 @@ +//! The parts the lean and beacon HTTP surfaces both use. + +pub(crate) mod block_id; +pub(crate) mod content; diff --git a/crates/net/rpc/src/spec.rs b/crates/net/rpc/src/spec.rs index ca3136d06..7ab01aa72 100644 --- a/crates/net/rpc/src/spec.rs +++ b/crates/net/rpc/src/spec.rs @@ -28,7 +28,7 @@ struct SpecResponse { async fn get_spec(State(store): State) -> impl IntoResponse { let config = store.config(); json_response(SpecResponse { - ms_per_slot: config.milliseconds_per_slot, + ms_per_slot: config.slot_duration_ms, intervals_per_slot: INTERVALS_PER_SLOT, ms_per_interval: config.milliseconds_per_interval(), historical_roots_limit: HISTORICAL_ROOTS_LIMIT as u64, diff --git a/crates/net/rpc/src/test_driver.rs b/crates/net/rpc/src/test_driver.rs index ffb76d59f..6a81484e5 100644 --- a/crates/net/rpc/src/test_driver.rs +++ b/crates/net/rpc/src/test_driver.rs @@ -320,7 +320,7 @@ async fn run_verify_signatures( }; let response = match verify_block_signatures(&state, &signed_block) { - Ok(()) => VerifySignaturesResponse { + Ok(_timings) => VerifySignaturesResponse { succeeded: true, error: None, }, @@ -352,7 +352,7 @@ fn snapshot_store(store: &Store) -> DriverSnapshot { DriverSnapshot { head_slot: store.head_slot(), head_root: store.head().expect("head exists"), - time: store.time().expect("store time exists"), + time: store.intervals_since_genesis(), justified_checkpoint: store .latest_justified() .expect("latest justified checkpoint exists"), @@ -383,7 +383,7 @@ mod tests { // Head, time, checkpoints all read without panicking; that's the // contract `init_fork_choice` relies on before the first reset. let _ = store.head(); - assert_eq!(store.time().unwrap(), 0); + assert_eq!(store.intervals_since_genesis(), 0); assert_eq!(store.latest_justified().unwrap().slot, 0); assert_eq!(store.latest_finalized().unwrap().slot, 0); } diff --git a/crates/net/rpc/tests/http_servers.rs b/crates/net/rpc/tests/http_servers.rs new file mode 100644 index 000000000..6171b1730 --- /dev/null +++ b/crates/net/rpc/tests/http_servers.rs @@ -0,0 +1,214 @@ +//! What [`ethlambda_rpc::start_http_servers`] serves when it is given no API +//! router. +//! +//! The difference from the `Some(api_router)` case has to be exactly "the lean +//! API is absent", with the metrics and debug routers still up: a chain with no +//! lean `Store` to answer from would otherwise have a lean route reachable on +//! its listener, answering for the wrong chain. `ethlambda beacon` used to be +//! that caller and reaches `start_rpc_server` today, off placeholder state, so +//! that one HTTP call site serves both chains; this is the shape it goes back +//! to once the beacon follower has a surface of its own. These tests bind a +//! real socket, because binding is the part that differs and a router-level +//! `oneshot` would not exercise it. + +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::time::Duration; + +use ethlambda_rpc::{RpcConfig, start_http_servers}; +use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; +use tokio::net::{TcpListener, TcpStream}; +use tokio_util::sync::CancellationToken; + +const LOCALHOST: IpAddr = IpAddr::V4(Ipv4Addr::LOCALHOST); + +/// A port nothing is listening on, from an ephemeral bind that is then +/// released. Racy in principle; the window is one test's worth of microseconds +/// and the alternative is threading the bound address back out of +/// `start_http_servers` for the tests' sake alone. +async fn free_port() -> u16 { + let probe = TcpListener::bind(SocketAddr::new(LOCALHOST, 0)) + .await + .expect("an ephemeral port is available"); + probe + .local_addr() + .expect("the probe socket has an addr") + .port() +} + +/// GET `path`, retrying the connect until the server is accepting. +/// +/// `start_http_servers` binds inside the spawned task, so there is no moment +/// the caller can await before the socket exists. +async fn get(port: u16, path: &str) -> String { + for _ in 0..100 { + let Ok(mut stream) = TcpStream::connect(SocketAddr::new(LOCALHOST, port)).await else { + tokio::time::sleep(Duration::from_millis(20)).await; + continue; + }; + let request = + format!("GET {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"); + stream + .write_all(request.as_bytes()) + .await + .expect("the request writes"); + let mut response = Vec::new(); + stream + .read_to_end(&mut response) + .await + .expect("the response reads"); + return String::from_utf8_lossy(&response).to_string(); + } + panic!("the server never accepted a connection on port {port}"); +} + +/// The status line of an HTTP response, e.g. `HTTP/1.1 200 OK`. +fn status_line(response: &str) -> &str { + response.lines().next().unwrap_or("").trim_end() +} + +#[tokio::test] +async fn with_no_api_router_metrics_and_debug_are_served_on_the_metrics_port() { + let port = free_port().await; + let shutdown = CancellationToken::new(); + let config = RpcConfig { + http_address: LOCALHOST, + // Deliberately different from `metrics_port`: with no API router this + // must not be bound at all, which the assertion below pins. + api_port: free_port().await, + metrics_port: port, + version: "ethlambda/test", + }; + + let served = tokio::spawn(start_http_servers(config.clone(), None, shutdown.clone())); + + // Metrics: the whole reason `beacon` serves anything at all. + assert!( + status_line(&get(port, "/metrics").await).contains("200"), + "/metrics must be served" + ); + // Debug: `beacon` gains these by going through the shared entry point. + // Heap profiling was previously node-only. + assert!( + !status_line(&get(port, "/debug/pprof/allocs").await).contains("404"), + "the debug router must be mounted" + ); + // The lean API must not be reachable: there is no `Store` behind it here. + assert!( + status_line(&get(port, "/lean/v0/health").await).contains("404"), + "no lean route may be served without an API router" + ); + + // `api_port` is not merely unused, it is never bound: a fresh listener on + // it must succeed. + TcpListener::bind(SocketAddr::new(LOCALHOST, config.api_port)) + .await + .expect("api_port must be free when no API router is supplied"); + + shutdown.cancel(); + served + .await + .expect("the server task joins") + .expect("the server exits cleanly on shutdown"); +} + +#[tokio::test] +async fn a_cancelled_token_stops_the_server() { + let port = free_port().await; + let shutdown = CancellationToken::new(); + let config = RpcConfig { + http_address: LOCALHOST, + api_port: port, + metrics_port: port, + version: "ethlambda/test", + }; + + let served = tokio::spawn(start_http_servers(config, None, shutdown.clone())); + assert!(status_line(&get(port, "/metrics").await).contains("200")); + + shutdown.cancel(); + // Without graceful shutdown wired up this hangs rather than failing, so + // bound it: `run_beacon` parking on `pending()` is what this guards. + tokio::time::timeout(Duration::from_secs(5), served) + .await + .expect("the server stops within five seconds of cancellation") + .expect("the server task joins") + .expect("the server exits cleanly"); +} + +/// The beacon router answers where the lean one used to, and does **not** +/// serve `/lean/v0`. +/// +/// That absence is the point of the whole surface: the lean handlers read +/// metadata keys and state variants a beacon directory never carries, so +/// before this existed a beacon node's `--api-port` answered lean questions +/// by panicking the request. A 404 is the honest answer for a chain that is +/// not running. +#[tokio::test] +async fn the_beacon_router_replaces_the_lean_one() { + use axum::{body::Body, http::Request}; + use ethlambda_types::beacon::{ + config::Config, + containers::{SignedBeaconBlock, phase0}, + primitives::Root, + }; + use tower::ServiceExt as _; + + const GENESIS_TIME: u64 = 1_606_824_023; + let slot = 64; + + let block = SignedBeaconBlock::Phase0(phase0::SignedBeaconBlock { + message: phase0::BeaconBlock { + slot, + proposer_index: 0, + parent_root: Root::ZERO, + state_root: Root::ZERO, + body: phase0::BeaconBlockBody::default(), + }, + signature: Default::default(), + }); + let root = block.message_hash_tree_root(); + + let mut store = ethlambda_storage::Store::init_beacon( + std::sync::Arc::new(ethlambda_storage::backend::InMemoryBackend::default()), + GENESIS_TIME, + Config::mainnet(), + root, + ethlambda_types::checkpoint::Checkpoint { root, slot }, + slot, + ); + store + .insert_signed_block(root, block) + .expect("insert anchor block"); + + let router = ethlambda_rpc::build_beacon_api_router(store, "ethlambda/test", "peer".into()) + .layer(axum::Extension( + ethlambda_blockchain::SyncStatusController::default(), + )); + + let beacon = router + .clone() + .oneshot( + Request::builder() + .uri("/eth/v1/node/version") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(beacon.status(), axum::http::StatusCode::OK); + + let lean = router + .oneshot( + Request::builder() + .uri("/lean/v0/node/syncing") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + lean.status(), + axum::http::StatusCode::NOT_FOUND, + "a beacon node must not serve the lean surface off a beacon store" + ); +} diff --git a/crates/storage/Cargo.toml b/crates/storage/Cargo.toml index f230862d5..8fe5cc786 100644 --- a/crates/storage/Cargo.toml +++ b/crates/storage/Cargo.toml @@ -11,6 +11,7 @@ version.workspace = true [dependencies] ethlambda-crypto.workspace = true +ethlambda-metrics.workspace = true ethlambda-types.workspace = true tracing.workspace = true @@ -22,6 +23,8 @@ libssz-derive.workspace = true libssz-types.workspace = true lru.workspace = true +xdelta3.workspace = true [dev-dependencies] tempfile = "3" +proptest = "1" diff --git a/crates/storage/src/api/mod.rs b/crates/storage/src/api/mod.rs index 00755269c..703ddfc53 100644 --- a/crates/storage/src/api/mod.rs +++ b/crates/storage/src/api/mod.rs @@ -17,4 +17,6 @@ mod tables; mod traits; pub use tables::{ALL_TABLES, Table}; -pub use traits::{Error, PrefixResult, StorageBackend, StorageReadView, StorageWriteBatch}; +pub use traits::{ + Error, PrefixResult, StorageBackend, StorageReadView, StorageReadViewExt, StorageWriteBatch, +}; diff --git a/crates/storage/src/api/tables.rs b/crates/storage/src/api/tables.rs index 4cb798aa2..f2d2f0db6 100644 --- a/crates/storage/src/api/tables.rs +++ b/crates/storage/src/api/tables.rs @@ -34,10 +34,37 @@ pub enum Table { /// Includes finalized blocks (anchor) and all non-finalized blocks. /// Pruned when slots become finalized (keeps finalized block itself). LiveChain, + /// Data column sidecars: (slot || block_root || column_index) -> DataColumnSidecar + /// + /// Written on arrival rather than at block import, so the availability + /// check can read them before the block they belong to is imported, and so + /// a restart keeps what this node already paid to verify. Keyed slot-first + /// for the same reason `BlockProof` is: the by-range handler scans a slot + /// window, and a future pruner scans in slot order and stops early. + /// + /// The only table with no pruning rule. Growth is bounded by nothing yet; + /// `MIN_EPOCHS_FOR_DATA_COLUMN_SIDECARS_REQUESTS` is where a pruner lands. + DataColumns, + /// Data column sidecars parked until their parent block has a post-state: + /// (slot || block_root || column_index) -> DataColumnSidecar + /// + /// Deliberately not `DataColumns`. A sidecar lands here before its + /// inclusion proof, its KZG batch and its proposer signature have been + /// checked, because the proposer check needs a post-state the parent does + /// not have yet, and the other two are held back so a replay pays for them + /// once rather than once per attempt. `DataColumns` is what + /// [`Store::data_column_indices_for`] reads and so what the data + /// availability gate believes; an unverified row there would let a peer + /// satisfy the gate with a column nothing ever judged. + /// + /// Same key as `DataColumns`, so a row moves between the two without + /// re-deriving anything. Emptied by the replay that verifies a row and by + /// the finality eviction that gives up on one. + PendingDataColumns, } /// All table variants. -pub const ALL_TABLES: [Table; 8] = [ +pub const ALL_TABLES: [Table; 10] = [ Table::BlockHeaders, Table::BlockBodies, Table::BlockProof, @@ -46,6 +73,8 @@ pub const ALL_TABLES: [Table; 8] = [ Table::StateDiffs, Table::Metadata, Table::LiveChain, + Table::DataColumns, + Table::PendingDataColumns, ]; impl Table { @@ -60,6 +89,8 @@ impl Table { Table::StateDiffs => "state_diffs", Table::Metadata => "metadata", Table::LiveChain => "live_chain", + Table::DataColumns => "data_columns", + Table::PendingDataColumns => "pending_data_columns", } } } diff --git a/crates/storage/src/api/traits.rs b/crates/storage/src/api/traits.rs index 5b654bfcd..35747be3c 100644 --- a/crates/storage/src/api/traits.rs +++ b/crates/storage/src/api/traits.rs @@ -24,8 +24,38 @@ pub trait StorageBackend: Send + Sync { /// A read-only view of the storage. pub trait StorageReadView { - /// Get a value by key from a table. - fn get(&self, table: Table, key: &[u8]) -> Result>, Error>; + /// Calls `read_fn` with a borrow of the value stored under `key`, if any, + /// and returns whether the key was present. + /// + /// The value is never copied: `read_fn` sees the backend's own buffer, so + /// a caller that only decodes or inspects the bytes avoids allocating a + /// value-sized `Vec` (full state snapshots are 100+ MB on mainnet-sized + /// beacon chains). `read_fn` runs at most once, and its error is returned + /// as-is. A `&mut dyn FnMut` rather than a generic closure, so the trait + /// stays usable as `dyn StorageReadView`. + fn read( + &self, + table: Table, + key: &[u8], + read_fn: &mut dyn FnMut(&[u8]) -> Result<(), Error>, + ) -> Result; + + /// Get a value by key from a table, copied into an owned `Vec`. + /// + /// Prefer [`StorageReadViewExt::read_with`] when the bytes are only + /// decoded: it decodes from the backend's buffer without the copy. + fn get(&self, table: Table, key: &[u8]) -> Result>, Error> { + self.read_with(table, key, <[u8]>::to_vec) + } + + /// Whether `key` is present in a table. + /// + /// Same answer as `get(..)?.is_some()` but never materializes the value, so + /// the cost does not scale with its size. Prefer it for pure existence + /// checks on large values. + fn contains(&self, table: Table, key: &[u8]) -> Result { + self.read(table, key, &mut |_| Ok(())) + } /// Iterate over all entries with a given key prefix. fn prefix_iterator( @@ -35,6 +65,33 @@ pub trait StorageReadView { ) -> Result + '_>, Error>; } +/// Generic conveniences over [`StorageReadView::read`]. +/// +/// A separate trait because its methods are generic, which would make +/// `StorageReadView` itself unusable as a trait object. The blanket impl +/// covers every view, `dyn StorageReadView` included. +pub trait StorageReadViewExt: StorageReadView { + /// Decodes the value stored under `key` straight from the backend's + /// buffer, without copying it first. `None` when the key is absent. + fn read_with( + &self, + table: Table, + key: &[u8], + decode: impl FnOnce(&[u8]) -> T, + ) -> Result, Error> { + let mut decode = Some(decode); + let mut value = None; + self.read(table, key, &mut |bytes| { + let decode = decode.take().expect("read calls read_fn at most once"); + value = Some(decode(bytes)); + Ok(()) + })?; + Ok(value) + } +} + +impl StorageReadViewExt for V {} + /// A write batch that can be committed atomically. pub trait StorageWriteBatch: Send { /// Put multiple key-value pairs into a table. diff --git a/crates/storage/src/backend/in_memory.rs b/crates/storage/src/backend/in_memory.rs index ec3fcd8f5..6c4d303f6 100644 --- a/crates/storage/src/backend/in_memory.rs +++ b/crates/storage/src/backend/in_memory.rs @@ -66,13 +66,17 @@ struct InMemoryReadView<'a> { } impl StorageReadView for InMemoryReadView<'_> { - fn get(&self, table: Table, key: &[u8]) -> Result>, Error> { - Ok(self - .guard - .get(&table) - .expect("table exists") - .get(key) - .cloned()) + fn read( + &self, + table: Table, + key: &[u8], + read_fn: &mut dyn FnMut(&[u8]) -> Result<(), Error>, + ) -> Result { + let Some(value) = self.guard.get(&table).expect("table exists").get(key) else { + return Ok(false); + }; + read_fn(value)?; + Ok(true) } fn prefix_iterator( diff --git a/crates/storage/src/backend/rocksdb.rs b/crates/storage/src/backend/rocksdb.rs index 3440f94ce..cccdb39af 100644 --- a/crates/storage/src/backend/rocksdb.rs +++ b/crates/storage/src/backend/rocksdb.rs @@ -4,8 +4,8 @@ use crate::api::{ ALL_TABLES, Error, PrefixResult, StorageBackend, StorageReadView, StorageWriteBatch, Table, }; use rocksdb::{ - BlockBasedOptions, Cache, ColumnFamilyDescriptor, DBWithThreadMode, MultiThreaded, Options, - WriteBatch, WriteOptions, + BlockBasedOptions, Cache, ColumnFamilyDescriptor, DBCompressionType, DBWithThreadMode, + MultiThreaded, Options, WriteBatch, WriteOptions, }; use std::path::Path; use std::sync::Arc; @@ -18,6 +18,43 @@ fn cf_name(table: Table) -> &'static str { table.name() } +/// The smallest value a blob-file table stores out of line. +/// +/// RocksDB's default data block size: a larger value would get an oversized +/// block of its own anyway, so it gains nothing from staying inline. +const MIN_BLOB_SIZE: u64 = 4 * 1024; + +/// Whether a table keeps its values in blob files rather than inline in SSTs. +/// +/// `States` holds full state snapshots (100+ MB on mainnet-sized beacon +/// chains) and `StateDiffs` the deltas between them (hundreds of KB). Inline, +/// every compaction that touches a file rewrites those values, and a lookup +/// that misses still reads the data block around the key, which here can be +/// a whole snapshot. In a blob file a value is written once, and the SSTs +/// hold only small references to it. +fn stores_values_in_blob_files(table: Table) -> bool { + matches!(table, Table::States | Table::StateDiffs) +} + +/// Moves a column family's large values into blob files. +/// +/// RocksDB applies the change to an existing database as it goes: new +/// writes land in blob files, and inline values move out as compaction +/// rewrites their SSTs. So a data directory written without it opens +/// unchanged, and no `DB_VERSION` bump is needed. +fn enable_blob_files(cf_opts: &mut Options) { + cf_opts.set_enable_blob_files(true); + cf_opts.set_min_blob_size(MIN_BLOB_SIZE); + // SST blocks get RocksDB's default Snappy, but blob files default to no + // compression, so moving the values out would otherwise grow the tables + // on disk. The values are raw SSZ. + cf_opts.set_blob_compression_type(DBCompressionType::Lz4); + // No blob garbage collection: nothing deletes or overwrites a state, so it + // would only relocate live blobs during compaction. Revisit if states are + // ever pruned. No blob cache either: the store caches decoded states + // itself, and a snapshot-sized entry would evict the whole block cache. +} + /// RocksDB storage backend. #[derive(Clone)] pub struct RocksDBBackend { @@ -52,6 +89,9 @@ impl RocksDBBackend { .map(|t| { let mut cf_opts = Options::default(); cf_opts.set_block_based_table_factory(&block_opts); + if stores_values_in_blob_files(*t) { + enable_blob_files(&mut cf_opts); + } ColumnFamilyDescriptor::new(cf_name(*t), cf_opts) }) .collect(); @@ -93,7 +133,15 @@ impl StorageBackend for RocksDBBackend { .ok() .flatten() .unwrap_or(0); - sst_bytes + memtable_bytes + // `estimate-live-data-size` counts SST files only, so a blob-file + // table's values would otherwise vanish from the estimate. + let blob_bytes = self + .db + .property_int_value_cf(&cf, "rocksdb.live-blob-file-size") + .ok() + .flatten() + .unwrap_or(0); + sst_bytes + memtable_bytes + blob_bytes } } @@ -103,13 +151,24 @@ struct RocksDBReadView { } impl StorageReadView for RocksDBReadView { - fn get(&self, table: Table, key: &[u8]) -> Result>, Error> { + fn read( + &self, + table: Table, + key: &[u8], + read_fn: &mut dyn FnMut(&[u8]) -> Result<(), Error>, + ) -> Result { let cf = self .db .cf_handle(cf_name(table)) .ok_or_else(|| format!("Column family {} not found", cf_name(table)))?; - Ok(self.db.get_cf(&cf, key)?) + // Pinned: references RocksDB's own buffer instead of copying the + // value into a `Vec`. + let Some(value) = self.db.get_pinned_cf(&cf, key)? else { + return Ok(false); + }; + read_fn(&value)?; + Ok(true) } fn prefix_iterator( @@ -200,6 +259,82 @@ mod tests { run_backend_tests(&backend); } + /// A value big enough for a blob file, and the property counting them. + const BLOB_VALUE_LEN: usize = 64 * 1024; + const NUM_BLOB_FILES: &str = "rocksdb.num-blob-files"; + + fn num_blob_files(backend: &RocksDBBackend, table: Table) -> u64 { + let cf = backend.db.cf_handle(cf_name(table)).unwrap(); + backend + .db + .property_int_value_cf(&cf, NUM_BLOB_FILES) + .unwrap() + .unwrap() + } + + fn put_and_flush(backend: &RocksDBBackend, table: Table, key: &[u8], value: Vec) { + let mut batch = backend.begin_write().unwrap(); + batch.put_batch(table, vec![(key.to_vec(), value)]).unwrap(); + batch.commit().unwrap(); + let cf = backend.db.cf_handle(cf_name(table)).unwrap(); + backend.db.flush_cf(&cf).unwrap(); + } + + #[test] + fn large_state_values_live_in_blob_files() { + let dir = tempdir().unwrap(); + let backend = RocksDBBackend::open(dir.path()).unwrap(); + let value: Vec = (0..BLOB_VALUE_LEN).map(|i| (i % 251) as u8).collect(); + + for table in [Table::States, Table::StateDiffs] { + put_and_flush(&backend, table, b"big", value.clone()); + assert_eq!(num_blob_files(&backend, table), 1, "{table:?}"); + + let view = backend.begin_read().unwrap(); + assert_eq!(view.get(table, b"big").unwrap(), Some(value.clone())); + assert!(view.contains(table, b"big").unwrap()); + assert!(!view.contains(table, b"absent").unwrap()); + } + assert!(backend.estimate_table_bytes(Table::States) > 0); + + // Small values, and every other table, stay inline. + put_and_flush(&backend, Table::States, b"small", vec![7; 16]); + assert_eq!(num_blob_files(&backend, Table::States), 1); + put_and_flush(&backend, Table::BlockHeaders, b"big", value); + assert_eq!(num_blob_files(&backend, Table::BlockHeaders), 0); + } + + #[test] + fn a_directory_written_without_blob_files_still_reads() { + let dir = tempdir().unwrap(); + let value: Vec = (0..BLOB_VALUE_LEN).map(|i| (i % 251) as u8).collect(); + + // The layout a data directory had before blob files: every table + // with plain options. + { + let mut opts = Options::default(); + opts.create_if_missing(true); + opts.create_missing_column_families(true); + let cfs = ALL_TABLES.iter().map(|t| cf_name(*t)); + let db = DBWithThreadMode::::open_cf(&opts, dir.path(), cfs).unwrap(); + let cf = db.cf_handle(cf_name(Table::States)).unwrap(); + db.put_cf(&cf, b"old", &value).unwrap(); + db.flush_cf(&cf).unwrap(); + } + + let backend = RocksDBBackend::open(dir.path()).unwrap(); + assert_eq!(num_blob_files(&backend, Table::States), 0); + put_and_flush(&backend, Table::States, b"new", value.clone()); + assert_eq!(num_blob_files(&backend, Table::States), 1); + + let view = backend.begin_read().unwrap(); + assert_eq!( + view.get(Table::States, b"old").unwrap(), + Some(value.clone()) + ); + assert_eq!(view.get(Table::States, b"new").unwrap(), Some(value)); + } + #[test] fn test_persistence() { let dir = tempdir().unwrap(); diff --git a/crates/storage/src/backend/tests.rs b/crates/storage/src/backend/tests.rs index 46c908777..0f9cd962c 100644 --- a/crates/storage/src/backend/tests.rs +++ b/crates/storage/src/backend/tests.rs @@ -17,6 +17,8 @@ pub fn run_backend_tests(backend: &dyn StorageBackend) { test_delete(backend); test_prefix_iterator(backend); test_nonexistent_key(backend); + test_contains(backend); + test_read(backend); test_delete_then_put(backend); test_put_then_delete(backend); test_delete_range(backend); @@ -45,6 +47,72 @@ fn test_put_and_get(backend: &dyn StorageBackend) { } } +fn test_contains(backend: &dyn StorageBackend) { + { + let mut batch = backend.begin_write().unwrap(); + batch + .put_batch( + Table::BlockHeaders, + vec![(b"test_contains_key".to_vec(), b"value1".to_vec())], + ) + .unwrap(); + batch.commit().unwrap(); + } + let view = backend.begin_read().unwrap(); + assert!( + view.contains(Table::BlockHeaders, b"test_contains_key") + .unwrap() + ); + assert!( + !view + .contains(Table::BlockHeaders, b"test_contains_missing") + .unwrap() + ); +} + +fn test_read(backend: &dyn StorageBackend) { + { + let mut batch = backend.begin_write().unwrap(); + batch + .put_batch( + Table::BlockHeaders, + vec![(b"test_read_key".to_vec(), b"value1".to_vec())], + ) + .unwrap(); + batch.commit().unwrap(); + } + let view = backend.begin_read().unwrap(); + + let mut seen = Vec::new(); + let found = view + .read(Table::BlockHeaders, b"test_read_key", &mut |bytes| { + seen.push(bytes.to_vec()); + Ok(()) + }) + .unwrap(); + assert!(found); + assert_eq!(seen, vec![b"value1".to_vec()]); + + // A missing key never reaches the callback. + let mut calls = 0; + let found = view + .read(Table::BlockHeaders, b"test_read_missing", &mut |_| { + calls += 1; + Ok(()) + }) + .unwrap(); + assert!(!found); + assert_eq!(calls, 0); + + // The callback's own error is what `read` returns. + let err = view + .read(Table::BlockHeaders, b"test_read_key", &mut |_| { + Err("decode failed".into()) + }) + .unwrap_err(); + assert_eq!(err.to_string(), "decode failed"); +} + fn test_delete(backend: &dyn StorageBackend) { // Write data { diff --git a/crates/storage/src/beacon_state_delta.rs b/crates/storage/src/beacon_state_delta.rs new file mode 100644 index 000000000..11e7ec37a --- /dev/null +++ b/crates/storage/src/beacon_state_delta.rs @@ -0,0 +1,506 @@ +//! Byte-domain state deltas for the beacon chain. +//! +//! `state_diff.rs` beside this module is lean's own diff algorithm and stays +//! untouched: it is field-shaped, storing each `State` field verbatim or +//! omitting it (`validators` is dropped on the documented assumption that it +//! never changes; `historical_block_hashes` is regenerated from the slot gap +//! rather than stored). Lean states are small enough that a byte-delta's +//! machinery would be pure overhead there. +//! +//! A beacon validator registry breaks the `validators`-never-changes +//! assumption every epoch, so lean's [`crate::state_diff::StateDiff`] cannot +//! be reused as-is here. Rather than growing it with beacon-only, +//! length-aware handling for `validators`/`balances`/`inactivity_scores`, +//! this module works in the byte domain instead of the field domain: VCDIFF +//! (via `xdelta3`) diffs the SSZ encoding of the whole state against a base +//! state's encoding. When a variable-length SSZ list grows, every offset +//! after it shifts; VCDIFF emits a COPY at the new offset rather than the +//! byte-for-byte mismatch an xor delta would produce, so the three big +//! arrays need no special handling of their own. +//! +//! `insert_state`/`get_state` (in `store.rs`) call into this module for the +//! beacon arm: `insert_state` writes a snapshot at anchors and a +//! [`frame`]d [`encode`] otherwise, and `get_state` walks the resulting +//! chain and [`decode`]s it back, via [`unframe`]. + +use ethlambda_types::primitives::H256; + +/// Headroom over the target's length for VCDIFF's own framing, for the case +/// where a delta is nearly as large as the target it produces (an epoch +/// boundary, where most of the state genuinely changed). +const DELTA_FRAMING_MARGIN: usize = 4096; + +/// The delta taking `base` to `target`. +/// +/// Uses `encode_with_output_len` rather than the crate's `encode`, which sizes +/// its output as `(input.len() + src.len()) * 2`: at beacon scale that is a +/// ~1.4 GB transient allocation per call, and the `u32` cast wraps above a +/// ~1.07 GB source. A delta is never larger than the target plus framing, so +/// the target's own length plus a margin is the real bound. +/// +/// An empty target has no bytes to diff; `xd3_encode_memory` is not exercised +/// for it, so the empty delta stands in directly rather than round-tripping +/// through the C API for a case it need not see. A beacon state is never +/// empty, but a codec that panics on one is a sharp edge worth avoiding. +pub(crate) fn encode(target: &[u8], base: &[u8]) -> Vec { + if target.is_empty() { + return Vec::new(); + } + let output_buffer_len = u32::try_from(target.len() + DELTA_FRAMING_MARGIN) + .expect("target length plus margin fits a u32 at beacon scale"); + xdelta3::encode_with_output_len(target, base, output_buffer_len).unwrap_or_else(|err| { + panic!( + "xdelta3 encode failed against a {output_buffer_len}-byte output bound \ + (target {} bytes, margin {DELTA_FRAMING_MARGIN}): {err:?}", + target.len() + ) + }) +} + +/// The inverse of [`encode`]. +/// +/// `target_len` comes from the frame, so the output buffer is sized exactly +/// rather than at `(delta + base) * 2`. Panics on a delta this build cannot +/// apply: `from_db_state` has already rejected a directory of the wrong +/// format version, so anything reaching here is corruption rather than an old +/// database, which matches how every other read in this crate treats a bad +/// value. +/// +/// A `target_len` of zero is [`encode`]'s empty-target case; the empty +/// output stands in directly for the same reason encode short-circuits it. +pub(crate) fn decode(delta: &[u8], base: &[u8], target_len: usize) -> Vec { + if target_len == 0 { + return Vec::new(); + } + let output_buffer_len = + u32::try_from(target_len).expect("target length fits a u32 at beacon scale"); + xdelta3::decode_with_output_len(delta, base, output_buffer_len).unwrap_or_else(|err| { + panic!("xdelta3 decode failed against exact target length {target_len}: {err:?}") + }) +} + +/// A `StateDiffs` value on the beacon arm: +/// `base_root (32) || slot (8, big-endian) || target_len (8, big-endian) || delta`. +/// +/// Raw rather than SSZ, like `Metadata["chain"]` and the `States` fork tag: an +/// SSZ `ByteList` would need a bound, and an epoch-boundary delta runs to +/// megabytes, well past the `ByteList512KiB` every existing block-level byte +/// field uses. `target_len` rides along so `decode` can size its output +/// exactly. +pub(crate) fn frame(base_root: H256, slot: u64, target_len: u64, delta: &[u8]) -> Vec { + let mut bytes = Vec::with_capacity(32 + 8 + 8 + delta.len()); + bytes.extend_from_slice(base_root.as_slice()); + bytes.extend_from_slice(&slot.to_be_bytes()); + bytes.extend_from_slice(&target_len.to_be_bytes()); + bytes.extend_from_slice(delta); + bytes +} + +/// The inverse of [`frame`]. +pub(crate) fn unframe(bytes: &[u8]) -> (H256, u64, u64, &[u8]) { + let base_root = H256::from_slice(&bytes[0..32]); + let slot = u64::from_be_bytes( + bytes[32..40] + .try_into() + .expect("frame carries an 8-byte slot"), + ); + let target_len = u64::from_be_bytes( + bytes[40..48] + .try_into() + .expect("frame carries an 8-byte target_len"), + ); + (base_root, slot, target_len, &bytes[48..]) +} + +#[cfg(test)] +mod tests { + use super::*; + use proptest::prelude::*; + + #[test] + fn a_delta_round_trips() { + let base = vec![7u8; 4096]; + let mut target = base.clone(); + target[100] = 9; + target.extend_from_slice(&[1u8; 64]); + + let delta = encode(&target, &base); + assert_eq!(decode(&delta, &base, target.len()), target); + } + + #[test] + fn a_delta_survives_an_insertion_that_shifts_everything_after_it() { + // The case an xor delta cannot handle and VCDIFF can: a + // variable-length SSZ list grows, so every later offset moves. + let base: Vec = (0..8192u32).map(|i| i as u8).collect(); + let mut target = base.clone(); + target.splice(10..10, [0xffu8; 128]); + + let delta = encode(&target, &base); + assert_eq!(decode(&delta, &base, target.len()), target); + assert!( + delta.len() < target.len() / 4, + "a shift defeated the delta: {} bytes for a {} byte target", + delta.len(), + target.len() + ); + } + + #[test] + fn an_identical_target_is_nearly_all_copy() { + // Pins that the output bound comes from the target rather than from + // (input + src) * 2, which is what the crate's convenience wrappers + // would ask for. + let base = vec![1u8; 4096]; + let delta = encode(&base, &base); + assert!(delta.len() < 512, "got {} bytes", delta.len()); + assert_eq!(decode(&delta, &base, base.len()), base); + } + + #[test] + fn a_frame_round_trips() { + let base_root = H256::from([4u8; 32]); + let framed = frame(base_root, 77, 4096, &[1, 2, 3]); + let (root, slot, target_len, delta) = unframe(&framed); + assert_eq!( + (root, slot, target_len, delta), + (base_root, 77, 4096, &[1, 2, 3][..]) + ); + } + + proptest! { + #[test] + fn any_delta_round_trips( + base in prop::collection::vec(any::(), 0..8192), + target in prop::collection::vec(any::(), 0..8192), + ) { + let delta = encode(&target, &base); + prop_assert_eq!(decode(&delta, &base, target.len()), target); + } + } + + // ------------------------------------------------------------------- + // Mainnet-scale measurement. + // + // The synthetic 64 MiB probe behind the design doc's "encode cost is + // largely retired by measurement" claim extrapolates linearly from a + // change pattern far more structured than a real reward distribution, + // which flatters delta *size* even though timings are less + // shape-sensitive. This measures the real thing: an electra state at + // mainnet's validator count, diffed across the two shapes the codec + // actually sees (an ordinary slot, and the epoch boundary the design's + // snapshot-interval choice hinges on). + // + // This repo has no `benches/` directory or bench harness; slow + // measurements are ordinary `#[ignore]`d tests here (see + // `crates/common/crypto/src/signature.rs`), not a criterion target. + // + // `ethlambda-storage` cannot take `ethlambda-state-transition` as a + // dev-dependency to reuse its `test_state` helper: state-transition + // already depends on storage, so the reverse would be a cycle. The + // container types both crates build from live in `ethlambda_types` + // regardless (state-transition's `beacon` module re-exports them), so + // the state below is built directly from there instead. + // ------------------------------------------------------------------- + + use ethlambda_types::beacon::constants; + use ethlambda_types::beacon::containers::{ + BeaconBlockHeader, BeaconState, Checkpoint, Validator, altair, deneb, electra, + }; + use ethlambda_types::beacon::preset; + use ethlambda_types::beacon::primitives::{ExecutionAddress, Uint256}; + + use crate::state_codec::encode_state_value; + + /// Validators in the synthesized state. Mainnet's active set is this order of + /// magnitude, and the codec's cost is a function of the encoded length, which + /// this drives. + const VALIDATOR_COUNT: usize = 2_000_000; + + /// A sync committee with every seat at its all-default (invalid-as-a-curve- + /// point) pubkey. `ethlambda-state-transition`'s own state builder derives + /// real BLS keys instead, because its tests exercise sync committee + /// aggregation; this benchmark only round-trips the byte-domain codec, so a + /// default key is enough to get the field's length right. + fn empty_sync_committee() -> altair::SyncCommittee { + altair::SyncCommittee { + pubkeys: vec![Default::default(); preset::SYNC_COMMITTEE_SIZE] + .try_into() + .expect("built at exactly SYNC_COMMITTEE_SIZE"), + aggregate_pubkey: Default::default(), + } + } + + /// A root with `n` embedded in its low bytes, so roots built from + /// different `n` never collide and, unlike a repeated-byte fixture root, + /// never hand xdelta3 a long run of identical bytes to exploit. + fn root_for_index(n: u64) -> H256 { + let mut bytes = [0u8; 32]; + bytes[..8].copy_from_slice(&n.to_le_bytes()); + H256(bytes) + } + + /// An execution payload header whose every field is derived from `slot`, + /// matching how a real payload header is replaced wholesale every block. + fn execution_payload_header_for_slot(slot: u64) -> deneb::ExecutionPayloadHeader { + deneb::ExecutionPayloadHeader { + parent_hash: root_for_index(slot), + fee_recipient: ExecutionAddress::ZERO, + state_root: H256::ZERO, + receipts_root: H256::ZERO, + logs_bloom: vec![0u8; preset::BYTES_PER_LOGS_BLOOM] + .try_into() + .expect("built at exactly BYTES_PER_LOGS_BLOOM"), + prev_randao: root_for_index(slot), + block_number: slot, + gas_limit: 30_000_000, + gas_used: 15_000_000, + timestamp: slot, + extra_data: Default::default(), + base_fee_per_gas: Uint256::ZERO, + block_hash: root_for_index(slot + 1), + transactions_root: H256::ZERO, + withdrawals_root: H256::ZERO, + blob_gas_used: 0, + excess_blob_gas: 0, + } + } + + /// A balance that grows with `index` rather than sitting at one constant. + /// A constant fill compresses far better than a real reward distribution + /// would and would flatter the measurement, which is the specific thing + /// this benchmark exists to stop doing. + fn balance_for_index(index: usize) -> u64 { + preset::MIN_ACTIVATION_BALANCE + index as u64 + } + + /// An inactivity score that varies with `index`, for the same reason + /// [`balance_for_index`] does. + fn inactivity_score_for_index(index: usize) -> u64 { + index as u64 + } + + /// An electra state at mainnet scale: `VALIDATOR_COUNT` validators, + /// balances, participation entries and inactivity scores, and every ring + /// buffer at its full preset length. + /// + /// Allocates on the order of a gigabyte once its SSZ encoding and the two + /// mutated states built from it (see [`advance_one_slot`] and + /// [`advance_across_epoch_boundary`]) are counted too, which is why every + /// test that calls this is `#[ignore]`d. + fn mainnet_scale_electra_state() -> electra::BeaconState { + let validators: Vec = (0..VALIDATOR_COUNT) + .map(|_| Validator { + effective_balance: preset::MIN_ACTIVATION_BALANCE, + activation_eligibility_epoch: 0, + activation_epoch: 0, + exit_epoch: constants::FAR_FUTURE_EPOCH, + withdrawable_epoch: constants::FAR_FUTURE_EPOCH, + ..Default::default() + }) + .collect(); + + electra::BeaconState { + genesis_time: 0, + genesis_validators_root: H256::ZERO, + // An interior slot of its epoch, well clear of a boundary, since + // `advance_one_slot` needs a `+= 1` that does not itself cross one. + slot: preset::SLOTS_PER_EPOCH * 2, + fork: Default::default(), + latest_block_header: Default::default(), + block_roots: vec![H256::ZERO; preset::SLOTS_PER_HISTORICAL_ROOT] + .try_into() + .expect("the vector is built at its exact length"), + state_roots: vec![H256::ZERO; preset::SLOTS_PER_HISTORICAL_ROOT] + .try_into() + .expect("the vector is built at its exact length"), + historical_roots: Default::default(), + eth1_data: Default::default(), + eth1_data_votes: Default::default(), + eth1_deposit_index: 0, + validators: validators + .try_into() + .expect("VALIDATOR_COUNT is far below VALIDATOR_REGISTRY_LIMIT"), + balances: vec![preset::MIN_ACTIVATION_BALANCE; VALIDATOR_COUNT] + .try_into() + .expect("VALIDATOR_COUNT is far below VALIDATOR_REGISTRY_LIMIT"), + randao_mixes: vec![H256::ZERO; preset::EPOCHS_PER_HISTORICAL_VECTOR] + .try_into() + .expect("the vector is built at its exact length"), + slashings: vec![0; preset::EPOCHS_PER_SLASHINGS_VECTOR] + .try_into() + .expect("the vector is built at its exact length"), + previous_epoch_participation: vec![0u8; VALIDATOR_COUNT] + .try_into() + .expect("VALIDATOR_COUNT is far below VALIDATOR_REGISTRY_LIMIT"), + // Uniform and non-zero, standing in for "everyone was timely last + // epoch": what makes advance_across_epoch_boundary's shift of this + // into `previous_epoch_participation` a real change rather than a + // zero-to-zero no-op. + current_epoch_participation: vec![0b0000_0111u8; VALIDATOR_COUNT] + .try_into() + .expect("VALIDATOR_COUNT is far below VALIDATOR_REGISTRY_LIMIT"), + justification_bits: Default::default(), + previous_justified_checkpoint: Default::default(), + current_justified_checkpoint: Default::default(), + finalized_checkpoint: Default::default(), + inactivity_scores: vec![0u64; VALIDATOR_COUNT] + .try_into() + .expect("VALIDATOR_COUNT is far below VALIDATOR_REGISTRY_LIMIT"), + current_sync_committee: empty_sync_committee(), + next_sync_committee: empty_sync_committee(), + latest_execution_payload_header: execution_payload_header_for_slot(0), + next_withdrawal_index: 0, + next_withdrawal_validator_index: 0, + historical_summaries: Default::default(), + deposit_requests_start_index: constants::UNSET_DEPOSIT_REQUESTS_START_INDEX, + deposit_balance_to_consume: 0, + exit_balance_to_consume: 0, + earliest_exit_epoch: 0, + consolidation_balance_to_consume: 0, + earliest_consolidation_epoch: 0, + pending_deposits: Default::default(), + pending_partial_withdrawals: Default::default(), + pending_consolidations: Default::default(), + } + } + + /// What one ordinary block's transition touches: the header, one ring + /// buffer entry each in `block_roots`/`state_roots`, a handful of + /// balances, one slot's worth of attesters in `current_epoch_participation`, + /// and the execution payload header. Everything else is untouched, which is + /// the shape a per-slot delta should stay cheap against. + fn advance_one_slot(base: &electra::BeaconState) -> electra::BeaconState { + let mut state = base.clone(); + state.slot += 1; + + state.latest_block_header = BeaconBlockHeader { + slot: state.slot, + proposer_index: state.slot % VALIDATOR_COUNT as u64, + parent_root: root_for_index(base.slot), + state_root: H256::ZERO, + body_root: root_for_index(state.slot), + }; + + let ring_index = (state.slot as usize) % preset::SLOTS_PER_HISTORICAL_ROOT; + state.block_roots[ring_index] = root_for_index(state.slot); + state.state_roots[ring_index] = root_for_index(state.slot + 1); + + // A proposer reward plus one block's worth of attesters. + for i in 0..5 { + let index = (i * VALIDATOR_COUNT) / 5; + state.balances[index] = balance_for_index(index); + } + + // One slot's attesters: one epoch's worth of committees split across + // SLOTS_PER_EPOCH slots. + let attesters = VALIDATOR_COUNT / preset::SLOTS_PER_EPOCH as usize; + for participation in state.current_epoch_participation[..attesters].iter_mut() { + *participation = 0b0000_0111; + } + + state.latest_execution_payload_header = execution_payload_header_for_slot(state.slot); + state + } + + /// What a slot transition touches when it also crosses an epoch boundary: + /// every balance and inactivity score, most validators' effective balance, + /// both participation lists, one `randao_mixes`/`slashings` ring entry + /// each, and all three checkpoints. This is the shape an epoch-sized + /// snapshot interval has to survive. + fn advance_across_epoch_boundary(base: &electra::BeaconState) -> electra::BeaconState { + let mut state = base.clone(); + // Repositioned to the last slot of its own epoch, so the `+= 1` below + // is a genuine epoch crossing regardless of where `base` itself sits. + state.slot = (base.slot / preset::SLOTS_PER_EPOCH + 1) * preset::SLOTS_PER_EPOCH - 1; + state.slot += 1; + let epoch = state.slot / preset::SLOTS_PER_EPOCH; + + for index in 0..state.balances.len() { + state.balances[index] = balance_for_index(index); + } + for (index, score) in state.inactivity_scores.iter_mut().enumerate() { + *score = inactivity_score_for_index(index); + } + + // Most validators' effective balance moves; hysteresis means a + // validator only updates once its real balance crosses a threshold, + // which a minority miss in any given epoch. + for index in 0..state.validators.len() { + if index % 997 != 0 { + state.validators[index].effective_balance = balance_for_index(index); + } + } + state.balances.apply_updates(); + state.validators.apply_updates(); + + let zeroed_participation = vec![0u8; VALIDATOR_COUNT] + .try_into() + .expect("VALIDATOR_COUNT is far below VALIDATOR_REGISTRY_LIMIT"); + state.previous_epoch_participation = + std::mem::replace(&mut state.current_epoch_participation, zeroed_participation); + + let randao_index = (epoch as usize) % preset::EPOCHS_PER_HISTORICAL_VECTOR; + state.randao_mixes[randao_index] = root_for_index(epoch); + let slashings_index = (epoch as usize) % preset::EPOCHS_PER_SLASHINGS_VECTOR; + state.slashings[slashings_index] = balance_for_index(slashings_index); + + state.previous_justified_checkpoint = state.current_justified_checkpoint; + state.current_justified_checkpoint = Checkpoint { + epoch, + root: root_for_index(epoch), + }; + state.finalized_checkpoint = Checkpoint { + epoch: epoch.saturating_sub(1), + root: root_for_index(epoch.saturating_sub(1)), + }; + + state + } + + /// Encodes `base` (once, by the caller) and `target`, times the delta + /// round trip, prints the numbers under the `delta_bench` prefix so they + /// can be pulled out of a log with `grep delta_bench`, and asserts the + /// round trip: a benchmark that silently produced a wrong delta would be + /// worse than none. + fn measure_shape(shape: &str, base_bytes: &[u8], target: electra::BeaconState) { + let target_bytes = encode_state_value(&BeaconState::Electra(target)); + + let encode_start = std::time::Instant::now(); + let delta = encode(&target_bytes, base_bytes); + let encode_elapsed = encode_start.elapsed(); + + let decode_start = std::time::Instant::now(); + let round_tripped = decode(&delta, base_bytes, target_bytes.len()); + let decode_elapsed = decode_start.elapsed(); + + println!( + "delta_bench shape={shape} base_len={base_len} target_len={target_len} \ + delta_len={delta_len} encode_ms={encode_ms:.3} decode_ms={decode_ms:.3}", + base_len = base_bytes.len(), + target_len = target_bytes.len(), + delta_len = delta.len(), + encode_ms = encode_elapsed.as_secs_f64() * 1000.0, + decode_ms = decode_elapsed.as_secs_f64() * 1000.0, + ); + + assert_eq!( + round_tripped, target_bytes, + "{shape}: decoded delta did not reproduce the target's encoding" + ); + } + + #[test] + #[ignore = "slow: synthesizes two mainnet-scale beacon states (~1 GB, minutes)"] + fn measure_delta_cost_at_mainnet_scale() { + let base = mainnet_scale_electra_state(); + let base_bytes = encode_state_value(&BeaconState::Electra(base.clone())); + + measure_shape("one_slot", &base_bytes, advance_one_slot(&base)); + measure_shape( + "epoch_boundary", + &base_bytes, + advance_across_epoch_boundary(&base), + ); + } +} diff --git a/crates/storage/src/committee_cache.rs b/crates/storage/src/committee_cache.rs new file mode 100644 index 000000000..763ee235b --- /dev/null +++ b/crates/storage/src/committee_cache.rs @@ -0,0 +1,446 @@ +//! The committee-shuffling cache: [`EpochCommittees`] shared across every +//! caller asking the same epoch's committees of a state that agrees on the +//! shuffling's deciding block. +//! +//! Held by the `Store`, shared by both actors this node runs the beacon half +//! on: the chain actor's state transition and fork choice, and p2p's gossip +//! validation tasks (blocking threads that call `Store::committee_cache` +//! concurrently with the chain actor). Held there rather than in a global or +//! rebuilt inside each helper, for the same reason [`crate::store::Store`]'s +//! `state_cache` is: which shufflings are worth keeping resident, and how +//! much memory that may cost, is the store's decision and not something a +//! leaf helper can answer. A caller with no `Store` of its own (a spec +//! runner, a one-off lookup, a unit test) holds a fresh +//! [`CommitteeCache::default`] for as long as that work lasts instead. +//! +//! This module knows nothing about a `BeaconState`: it is handed a +//! [`ShufflingKey`] and, on a miss, runs a builder closure that hands back +//! the finished [`EpochCommittees`]. The consensus logic that derives both +//! from a state (the active-set scan, the shuffle seed, the shuffle itself, +//! and the deciding-block lookup that makes a key sound to share across +//! states) lives in `ethlambda-state-transition`'s +//! `beacon::helpers::accessors`, in the `CommitteeCacheExt` trait this type +//! implements there: that crate depends on this one, not the other way +//! around, so the derivation cannot live here. + +use std::sync::{Arc, Mutex, OnceLock}; + +use ethlambda_types::beacon::committees::EpochCommittees; +use ethlambda_types::beacon::primitives::{Epoch, Root}; + +/// What pins the committees a state names for an epoch: the epoch itself, and +/// the last block root that could still have changed them. +/// +/// The same key lighthouse calls an `AttestationShufflingId`, and for the same +/// reason. An epoch `E`'s committees are fixed by two values and nothing else: +/// the active validator set at `E`, and the shuffle seed at `E`. The seed is +/// the RANDAO mix from epoch `E - MIN_SEED_LOOKAHEAD - 1`, complete once that +/// epoch ends. The active set moves only through `activation_epoch` and +/// `exit_epoch`, and every assignment to either goes through +/// `compute_activation_exit_epoch`, which lands at least `MAX_SEED_LOOKAHEAD` +/// epochs ahead of the epoch making the change. So no block after the end of +/// `E - 2` can alter either input. +/// +/// The block root at the last slot of `E - 2` therefore identifies the +/// history that determines `E`'s committees: two states agreeing on it agree +/// on the committees of `E`, however much they disagree about everything +/// since. Epoch and decision root together are what makes a cross-state +/// cache sound where `epoch` or `seed` alone would not be. +/// +/// Opaque to this module: `epoch` and `decision_root` are read only for +/// equality and (for eviction) ordering by `epoch`. `ethlambda-state-transition`'s +/// `beacon::helpers::accessors::shuffling_key` is what derives one from a +/// state and knows what the two fields mean; see its own documentation, +/// including for why epochs 0 and 1 are keyed on the genesis block. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ShufflingKey { + pub epoch: Epoch, + pub decision_root: Root, +} + +/// How many distinct shufflings stay resident in a [`CommitteeCache`]. +/// +/// One block needs a pair: every fork's `process_attestation` accepts an +/// attestation whose target is the current or the previous epoch and no +/// other, and fork choice replays that same block's attestations against the +/// same pair. The rest of the room is for forks. Two branches that disagree +/// on an epoch's deciding block have different shufflings for it, so +/// importing their blocks in turn needs both branches' pairs resident at +/// once: with room for only one, each import would evict the entry the other +/// branch's next import asks for, and rebuild its own. A split that outlives +/// an epoch, or one between more than two branches, needs room for more pairs +/// still. +/// +/// Tuned rather than derived. An entry is one `u64` per active validator, +/// about 19 MB at mainnet's ~2.4M, so this trades memory for how many +/// concurrent branches import without rebuilding; lighthouse's own default +/// (`DEFAULT_CACHE_SIZE` in its `shuffling_cache.rs`) is larger. It must +/// exceed [`HEAD_SHUFFLINGS`], which eviction never drops, so that a miss +/// always has an entry it may evict; that is checked at compile time below. +const COMMITTEE_CACHE_CAPACITY: usize = 8; + +/// How many of the canonical head's shufflings [`CommitteeCache::pin_head`] +/// pins: its previous, current, and next epochs'. +const HEAD_SHUFFLINGS: usize = 3; + +const _: () = assert!( + COMMITTEE_CACHE_CAPACITY > HEAD_SHUFFLINGS, + "the cache must hold at least one shuffling the head does not pin" +); + +/// One [`CommitteeCache::get_or_init`] call's verdict on the key it was +/// given: whether an already-finished [`EpochCommittees`] was resident +/// (`Hit`), or the call had to wait on a shuffle instead, whether it ran +/// that shuffle itself or a concurrent call racing it did (`Miss`). +/// +/// Recorded by the caller: `ethlambda-state-transition`'s +/// `CommitteeCacheExt::committees` is what turns this into +/// `lean_beacon_committee_cache_lookups_total`'s `hit`/`miss` labels; this +/// module never touches metrics; it does not know they exist. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Lookup { + Hit, + Miss, +} + +/// One resident shuffling's slot: empty until some call's builder fills it. +/// +/// An `Arc` around the `OnceLock` (rather than the `OnceLock` sitting +/// directly in the entry list) is what lets [`CommitteeCache::evict_one`] +/// drop the entry list's own reference to a slot while a waiter elsewhere +/// still holds a clone of the same `Arc`, taken before eviction ran: the +/// waiter's `get_or_init` call keeps running against a slot that is no +/// longer reachable from the cache at all, exactly as safely as if it had +/// never been evicted. +type Slot = Arc>>; + +/// The state one [`Mutex`] in [`CommitteeCache`] guards: the resident entries +/// and the current head's pins. One lock over both, rather than two, because +/// eviction needs to check the pins while deciding which entry to drop, and +/// the lock is held only for that bookkeeping, never while a shuffle runs; +/// see [`CommitteeCache::get_or_init`]. +#[derive(Debug, Default)] +struct CacheState { + /// Resident shufflings in insertion order, which breaks ties between + /// entries of the same epoch. Linear-scanned rather than hash-indexed + /// because [`COMMITTEE_CACHE_CAPACITY`] is small enough that a `HashMap` + /// would be more machinery than the comparisons it replaces. + entries: Vec<(ShufflingKey, Slot)>, + /// The block [`CommitteeCache::pin_head`] was last given, and the + /// shufflings it pinned for it. `None` until the owner reports a head, + /// and for a cache nobody reports one to, which then evicts on epoch + /// alone. + head: Option<(Root, [Option; HEAD_SHUFFLINGS])>, +} + +impl CacheState { + /// Drops the entry for the oldest epoch the head does not pin, the + /// earliest inserted among entries of the same epoch. + fn evict_one(&mut self) { + let pinned = |key: &ShufflingKey| { + self.head + .as_ref() + .is_some_and(|(_, keys)| keys.contains(&Some(*key))) + }; + let victim = self + .entries + .iter() + .enumerate() + .filter(|(_, (key, _))| !pinned(key)) + .min_by_key(|(_, (key, _))| key.epoch) + .map(|(position, _)| position); + // Always `Some` when the cache is full: the head pins at most + // `HEAD_SHUFFLINGS` entries, and the capacity is asserted to exceed + // it. + if let Some(position) = victim { + self.entries.remove(position); + } + } +} + +/// [`EpochCommittees`] shared across the calls asking for the same epoch's +/// committees, so a block's attestations derive each epoch's shuffling once +/// between all of them instead of once apiece. +/// +/// Internally synchronized, so every method takes `&self`: see the module +/// documentation for why (both the chain actor and p2p's gossip validation +/// tasks share one of these, held by the `Store`). +/// +/// # Eviction +/// +/// Lighthouse's rule (`ShufflingCache::prune_cache`): a miss on a full cache +/// drops the entry for the oldest epoch, but never one of the shufflings the +/// canonical head pins through [`Self::pin_head`]. Oldest-first rather +/// than least-recently-used because an older epoch's shuffling is less +/// likely to be asked for again than a newer one's, whichever branch either +/// belongs to, while the head's are the ones its next block is certain to +/// ask for. Every lookup is counted in +/// `lean_beacon_committee_cache_lookups_total`, whose misses are what show +/// whether the capacity is holding up. +#[derive(Debug, Default)] +pub struct CommitteeCache { + state: Mutex, +} + +impl CommitteeCache { + /// `key`'s committees, running `build` on a miss and sharing the result + /// with every other call naming the same `key`, including ones already + /// waiting when this call arrives. + /// + /// The entry-list lock is held only long enough to find or insert `key`'s + /// slot, never while `build` runs: `build` is a whole-epoch shuffle, and + /// holding the lock across it would make every other lookup, of any key, + /// wait on this one's shuffle rather than just the callers who share it. + /// Concurrent misses on the same key instead race + /// [`OnceLock::get_or_init`] on the slot they all found (having each, in + /// turn, taken the lock and seen it already there): exactly one of them + /// runs `build`, and the rest block on its result. A `build` that panics + /// leaves the slot empty rather than poisoning anything, since nothing + /// here wraps it in a `Mutex`; the next caller for this key retries it. + pub fn get_or_init( + &self, + key: ShufflingKey, + build: impl FnOnce() -> EpochCommittees, + ) -> (Arc, Lookup) { + let slot = { + let mut state = self.state.lock().unwrap(); + if let Some((_, slot)) = state + .entries + .iter() + .find(|(entry_key, _)| *entry_key == key) + { + Arc::clone(slot) + } else { + let slot: Slot = Arc::new(OnceLock::new()); + if state.entries.len() >= COMMITTEE_CACHE_CAPACITY { + state.evict_one(); + } + state.entries.push((key, Arc::clone(&slot))); + slot + } + }; + + // Checked outside the lock, and before the `get_or_init` call below + // that may itself fill it: a slot already filled by an earlier, now + // finished call is the only case this labels `Hit`. A slot this call + // just inserted, or one a concurrent call inserted a moment ago and + // has not finished building yet, is a `Miss` regardless of which of + // the racing calls ends up actually running `build`. + let lookup = if slot.get().is_some() { + Lookup::Hit + } else { + Lookup::Miss + }; + let committees = Arc::clone(slot.get_or_init(|| Arc::new(build()))); + (committees, lookup) + } + + /// The block root last passed to [`Self::pin_head`], so the owner can + /// skip re-pinning a head that has not moved. + pub fn head_root(&self) -> Option { + self.state + .lock() + .unwrap() + .head + .as_ref() + .map(|(root, _)| *root) + } + + /// Pins the shufflings `keys` names against eviction, and remembers + /// `head_root` for [`Self::head_root`]. + /// + /// Named distinctly from `ethlambda-state-transition`'s + /// `CommitteeCacheExt::update_head`, rather than reusing that name here + /// too, because inherent methods shadow trait methods of the same name on + /// the same receiver: a caller with the extension trait in scope who + /// meant to call it would silently reach this one instead, and the two + /// take different arguments (a state there, already-derived keys here). + /// [`CommitteeCacheExt::update_head`] is what derives `keys` from a head + /// state's previous, current, and next epochs, and is every real + /// caller's entry point; this method is the state-agnostic half it calls + /// into; see its own documentation for why those three epochs are the + /// right ones to pin. + /// + /// Only the pinning is replaced here: whatever the previous head pinned + /// stays resident until an insertion evicts it on epoch like any other + /// entry. + pub fn pin_head(&self, head_root: Root, keys: [Option; HEAD_SHUFFLINGS]) { + self.state.lock().unwrap().head = Some((head_root, keys)); + } +} + +#[cfg(test)] +mod tests { + use std::sync::Barrier; + use std::thread; + + use super::*; + + /// A trivial builder result: these tests exercise the cache's own + /// bookkeeping (keying, eviction, pinning, single-flight, sharing), never + /// the shuffle itself, so an empty `EpochCommittees` is as good as a real + /// one for every assertion here. + fn dummy_committees(epoch: Epoch) -> EpochCommittees { + EpochCommittees::new(epoch, Vec::new(), 1) + } + + fn key(epoch: Epoch, decision_root: u8) -> ShufflingKey { + ShufflingKey { + epoch, + decision_root: Root::repeat_byte(decision_root), + } + } + + /// A second lookup of the same key must be served the first lookup's + /// `EpochCommittees` rather than rebuilding it. + #[test] + fn a_repeat_lookup_is_served_from_the_cache() { + let cache = CommitteeCache::default(); + let k = key(1, 0); + + let (first, first_lookup) = cache.get_or_init(k, || dummy_committees(1)); + let (second, second_lookup) = cache.get_or_init(k, || panic!("should not rebuild")); + + assert_eq!(first_lookup, Lookup::Miss); + assert_eq!(second_lookup, Lookup::Hit); + assert!(Arc::ptr_eq(&first, &second)); + } + + /// The cache must not grow past its capacity as distinct keys accumulate. + #[test] + fn the_cache_stays_within_its_capacity_bound() { + let cache = CommitteeCache::default(); + for n in 0..COMMITTEE_CACHE_CAPACITY + 5 { + let k = key(n as Epoch, n as u8); + cache.get_or_init(k, move || dummy_committees(n as Epoch)); + assert!(cache.state.lock().unwrap().entries.len() <= COMMITTEE_CACHE_CAPACITY); + } + } + + /// A miss on a full cache drops the entry for the oldest epoch, not the + /// first one inserted. + #[test] + fn a_miss_evicts_the_oldest_epoch_first() { + let cache = CommitteeCache::default(); + // Insert newest epoch first, so eviction-by-epoch and + // eviction-by-insertion-order disagree about which entry goes. + let epochs: Vec = (0..COMMITTEE_CACHE_CAPACITY as Epoch).rev().collect(); + for (n, epoch) in epochs.iter().enumerate() { + cache.get_or_init(key(*epoch, n as u8), move || dummy_committees(*epoch)); + } + + cache.get_or_init(key(1000, 0xAA), || dummy_committees(1000)); + + let resident: Vec = cache + .state + .lock() + .unwrap() + .entries + .iter() + .map(|(k, _)| *k) + .collect(); + let oldest_epoch = *epochs.iter().min().unwrap(); + assert!(!resident.iter().any(|k| k.epoch == oldest_epoch)); + for epoch in &epochs { + if *epoch != oldest_epoch { + assert!(resident.iter().any(|k| k.epoch == *epoch)); + } + } + } + + /// The head's pinned shufflings survive any number of misses, even when + /// they are the oldest entries resident, which is exactly what the epoch + /// rule would otherwise drop first. + #[test] + fn the_heads_shufflings_are_never_evicted() { + let cache = CommitteeCache::default(); + let pinned_keys = [Some(key(1, 1)), Some(key(2, 2)), Some(key(3, 3))]; + for k in pinned_keys.into_iter().flatten() { + cache.get_or_init(k, move || dummy_committees(k.epoch)); + } + cache.pin_head(Root::repeat_byte(0xAA), pinned_keys); + assert_eq!(cache.head_root(), Some(Root::repeat_byte(0xAA))); + + for n in 0..COMMITTEE_CACHE_CAPACITY as u8 * 3 { + let k = key(100 + n as Epoch, n); + cache.get_or_init(k, move || dummy_committees(k.epoch)); + } + + let resident: Vec = cache + .state + .lock() + .unwrap() + .entries + .iter() + .map(|(k, _)| *k) + .collect(); + for k in pinned_keys.into_iter().flatten() { + assert!(resident.contains(&k), "pinned {k:?} was evicted"); + } + assert!(cache.state.lock().unwrap().entries.len() <= COMMITTEE_CACHE_CAPACITY); + } + + /// Concurrent misses on the same key must run `build` exactly once: the + /// rest wait on the first call's result rather than each deriving their + /// own. This is the property the module documentation calls load-bearing + /// at an epoch boundary, where dozens of validation tasks miss the same + /// key at once. + #[test] + fn concurrent_misses_on_one_key_run_the_builder_once() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + let cache = Arc::new(CommitteeCache::default()); + let k = key(7, 7); + let build_calls = Arc::new(AtomicUsize::new(0)); + let threads = 16; + // Every thread reaches `get_or_init` before any of them may proceed + // into it, so the race is real rather than accidentally serialized + // by however fast each thread happens to spawn. + let barrier = Arc::new(Barrier::new(threads)); + + let handles: Vec<_> = (0..threads) + .map(|_| { + let cache = Arc::clone(&cache); + let build_calls = Arc::clone(&build_calls); + let barrier = Arc::clone(&barrier); + thread::spawn(move || { + barrier.wait(); + let (committees, _) = cache.get_or_init(k, || { + build_calls.fetch_add(1, Ordering::SeqCst); + // A little work, so a thread that would have run its + // own independent shuffle has time to, if the + // single-flight guarantee did not hold. + thread::yield_now(); + dummy_committees(k.epoch) + }); + committees + }) + }) + .collect(); + + let results: Vec> = + handles.into_iter().map(|h| h.join().unwrap()).collect(); + + assert_eq!(build_calls.load(Ordering::SeqCst), 1); + for committees in &results[1..] { + assert!(Arc::ptr_eq(&results[0], committees)); + } + } + + /// Every clone of a `CommitteeCache` shares one underlying cache: a + /// lookup through one clone is a hit through another. `CommitteeCache` + /// does not itself derive `Clone` (its only owner, `Store`, holds it + /// behind an `Arc` and clones that instead), so this wraps it the same + /// way and checks the sharing the `Arc` is there to provide. + #[test] + fn clones_of_the_cache_share_one_underlying_cache() { + let cache = Arc::new(CommitteeCache::default()); + let clone = Arc::clone(&cache); + + let (first, _) = cache.get_or_init(key(1, 1), || dummy_committees(1)); + let (second, lookup) = clone.get_or_init(key(1, 1), || panic!("should not rebuild")); + + assert_eq!(lookup, Lookup::Hit); + assert!(Arc::ptr_eq(&first, &second)); + } +} diff --git a/crates/storage/src/error.rs b/crates/storage/src/error.rs index 7837cc149..432c1582f 100644 --- a/crates/storage/src/error.rs +++ b/crates/storage/src/error.rs @@ -1,4 +1,5 @@ -use ethlambda_types::{genesis::GenesisMismatch, primitives::H256}; +use ethlambda_types::checkpoint::Checkpoint; +use ethlambda_types::primitives::H256; #[derive(Debug, thiserror::Error)] pub enum Error { @@ -8,10 +9,76 @@ pub enum Error { UnexpectedMissingBlockHeader(H256), #[error("unexpected missing state for root {0}")] UnexpectedMissingState(H256), - /// The data directory holds a chain from a different network. Refusing to - /// touch it is deliberate: re-initializing on top would leave the foreign - /// blocks in place, and they are reachable through the slot-indexed reads - /// that serve `BlocksByRange`. - #[error("persisted state does not match the configured genesis: {0}")] - GenesisMismatch(#[from] GenesisMismatch), + /// The data directory was written by a build with a different on-disk + /// format. There is no migration: the `States` value layout changed, so + /// every state already written would decode as the wrong shape. + /// + /// `found` is `0` for a directory written before versioning existed. + #[error( + "data directory has database version {found}, this build requires {expected}; \ + wipe the data directory and resync" + )] + DbVersionMismatch { found: u64, expected: u64 }, + /// The data directory was written by a build compiled against the other + /// SSZ preset. Every container bound is a compile-time constant, so the + /// states already written have a different shape than this build would + /// give them, and their hash tree roots differ too. There is no migration + /// for the same reason there is none for [`Error::DbVersionMismatch`]. + /// + /// `found` is `None` for a directory written before the preset was + /// recorded, or carrying a selector byte this build does not know. + #[error( + "data directory was written against the {} preset, this build is {expected}; \ + wipe the data directory or rebuild against that preset", + .found.unwrap_or("unknown") + )] + PresetMismatch { + found: Option<&'static str>, + expected: &'static str, + }, + /// A directory's finalized checkpoint names no root. This is a defensive + /// guard, not a state either bootstrap path can reach: `init_beacon` + /// writes the anchor in the same atomic batch as the rest of the + /// metadata, and `init_store` always anchors at a real block root, so a + /// crash either leaves no metadata at all (later reads panic in + /// `get_metadata` rather than returning this) or a fully anchored + /// directory. Reaching this variant means either a store was built + /// directly with a zero checkpoint, or the metadata value was corrupted + /// at rest. + #[error("data directory has no anchor; wipe it and resync")] + UnanchoredDirectory, + /// [`Store::repair_head`](crate::store::Store::repair_head)'s walk, looking + /// for the newest ancestor of a stale head with a persisted state, went + /// further back than the writer's queue could ever explain. + /// + /// The walk is bounded at `STATE_WRITE_QUEUE_CAPACITY + 1` blocks: the + /// queue plus the one write the worker thread can be holding. Past that, + /// this is not an unclean shutdown racing the writer, it is a corrupt + /// directory. + #[error( + "head {start} has no persisted state {hops} blocks back (stalled at {stalled_at}), \ + more than the state writer's queue can explain; the data directory is corrupt: \ + wipe it and resync" + )] + HeadRepairExceededWindow { + start: H256, + stalled_at: H256, + hops: usize, + }, + /// A checkpoint (justified or finalized) names a root this directory has + /// no state for. + /// + /// Unlike a stale head, [`Store::repair_head`](crate::store::Store::repair_head) + /// never repairs this: justified and finalized are consensus statements, + /// and inventing an earlier one to paper over a missing state is not + /// something a storage-layer repair may do. [`Store::verify_anchor_states`](crate::store::Store::verify_anchor_states) + /// reports it instead, for a resuming caller to treat the same way it + /// already treats a stale directory: fall back to checkpoint sync if a + /// URL is configured, or fail naming the remedy below if not. + #[error( + "the state for the checkpoint at slot {} (root {}) is missing; wipe the data \ + directory, or configure a checkpoint-sync URL to re-anchor", + .checkpoint.slot, .checkpoint.root + )] + AnchorStateLost { checkpoint: Checkpoint }, } diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index ec5dcfbf7..bc33414fc 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -1,14 +1,26 @@ mod api; pub mod backend; +mod beacon_state_delta; +mod committee_cache; mod error; +mod metrics; +mod state_codec; mod state_diff; +mod state_writer; mod store; -pub use api::{ALL_TABLES, StorageBackend, StorageReadView, StorageWriteBatch, Table}; +pub use api::{ + ALL_TABLES, StorageBackend, StorageReadView, StorageReadViewExt, StorageWriteBatch, Table, +}; +pub use committee_cache::{CommitteeCache, Lookup, ShufflingKey}; /// Error type returned by the fallible [`Store`] operations, exported so -/// callers can match on it (e.g. to distinguish [`Error::GenesisMismatch`]). +/// callers can match on it (e.g. to distinguish [`Error::DbVersionMismatch`]). pub use error::Error; +// `CacheKey` lives in `state_writer` (beside the `StateCache` it keys), not +// `store`; re-exported here so the public path (`ethlambda_storage::CacheKey`) +// is unaffected by which module owns it. +pub use state_writer::CacheKey; pub use store::{ - ForkCheckpoints, GetForkchoiceStoreError, HeadVoteWindow, MAX_RESUMABLE_DB_STATE_AGE, - NEW_PAYLOAD_CAP, Store, + Chain, DB_VERSION, ForkCheckpoints, GetForkchoiceStoreError, HeadVoteWindow, + MAX_RESUMABLE_DB_STATE_AGE, NEW_PAYLOAD_CAP, Store, }; diff --git a/crates/storage/src/metrics.rs b/crates/storage/src/metrics.rs new file mode 100644 index 000000000..3270eeb92 --- /dev/null +++ b/crates/storage/src/metrics.rs @@ -0,0 +1,47 @@ +//! Prometheus metrics for the storage layer. + +use std::sync::LazyLock; + +use ethlambda_metrics::*; + +static LEAN_STATE_WRITE_QUEUE_DEPTH: LazyLock = LazyLock::new(|| { + register_int_gauge!( + "lean_state_write_queue_depth", + "States handed to the background writer but not yet committed" + ) + .unwrap() +}); + +static LEAN_STATE_WRITE_SECONDS: LazyLock = LazyLock::new(|| { + register_histogram!( + "lean_state_write_seconds", + "Time the background writer spends encoding, diffing and committing one state", + vec![0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.0] + ) + .unwrap() +}); + +/// One state was handed to the writer. Call from `insert_state`, and pair +/// with [`dec_state_write_queue_depth`]. +/// +/// `inc`/`dec` rather than reading `PendingStates::len()` and `set`ting it: +/// the two sides of the handoff run on different threads, so a read-then-set +/// there races the other side's own read-then-set and can latch the gauge one +/// too high, silently, for as long as the node stays quiet afterward. +/// `IntGauge::inc`/`dec` are the atomic increment/decrement themselves, so +/// there is nothing between the read and the write for the other side to +/// land in. +pub(crate) fn inc_state_write_queue_depth() { + LEAN_STATE_WRITE_QUEUE_DEPTH.inc(); +} + +/// One state left the writer's queue, its commit having returned. Call after +/// [`PendingStates::remove`](crate::state_writer::PendingStates::remove). +pub(crate) fn dec_state_write_queue_depth() { + LEAN_STATE_WRITE_QUEUE_DEPTH.dec(); +} + +/// Time one state write; the guard records on drop. +pub(crate) fn time_state_write() -> TimingGuard { + TimingGuard::new(&LEAN_STATE_WRITE_SECONDS) +} diff --git a/crates/storage/src/state_codec.rs b/crates/storage/src/state_codec.rs new file mode 100644 index 000000000..3e0255d89 --- /dev/null +++ b/crates/storage/src/state_codec.rs @@ -0,0 +1,81 @@ +//! The tagged state codecs shared by [`crate::store`] and [`crate::state_writer`]. +//! +//! `store.rs` and `state_writer.rs` had begun importing from each other, and +//! the read path landing next would have added several more edges in one +//! direction. These three functions are what both sides actually share, and +//! they depend on nothing in either: [`BeaconState`], [`ForkName`], and SSZ. +//! +//! The equivalent tagged codec for signed beacon blocks, +//! `encode_beacon_block_value`/`decode_beacon_block_value`, stayed in +//! `store.rs`: it was the import cycle that forced this split, and nothing +//! outside `store.rs` reads or writes a block value, so there was no cycle to +//! break there. + +use ethlambda_types::{ + beacon::{containers::BeaconState, fork::ForkName}, + state::State, +}; + +/// Encodes a `States` value: the state's fork selector, then the variant's own +/// SSZ. +/// +/// The tag is what lets one table hold both a lean `State` and a beacon +/// `BeaconState` without the reader having to already know which it is. Note +/// [`ForkName::Lean`]'s selector is not a variant index, so the byte must go +/// back through [`ForkName::from_selector`] rather than being cast. +pub(crate) fn encode_state_value(state: &BeaconState) -> Vec { + let mut bytes = Vec::new(); + bytes.push(state.fork_name().selector()); + bytes.extend_from_slice(&state.to_ssz()); + bytes +} + +/// The inverse of [`encode_state_value`]. +/// +/// Panics on a value this build cannot tag-decode, matching every other state +/// and block read in the crate: +/// [`Store::from_db_state`](crate::store::Store::from_db_state) has already +/// rejected a directory of the wrong format version, so anything reaching +/// here is corruption rather than an old database. +pub(crate) fn decode_state_value(bytes: &[u8]) -> BeaconState { + let (tag, ssz) = bytes.split_first().expect("value is never empty"); + let fork = ForkName::from_selector(*tag).expect("value carries a known fork selector"); + BeaconState::from_ssz(fork, ssz).expect("valid state value") +} + +/// [`decode_state_value`] for the lean reader, which has no beacon shape to do +/// anything with. +pub(crate) fn decode_lean_state_value(bytes: &[u8]) -> State { + match decode_state_value(bytes) { + BeaconState::Lean(state) => state, + beacon => panic!( + "lean read a {} state out of the States table; a data directory holds one chain", + beacon.fork_name() + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_tagged_state_value_round_trips() { + let state = BeaconState::Lean(State::from_genesis(7, vec![])); + let bytes = encode_state_value(&state); + assert_eq!(decode_state_value(&bytes), state); + } + + #[test] + fn the_selector_is_not_a_dense_index() { + // ForkName::Lean is 255 so that beacon forks after fulu keep taking the + // next free value. A reader that treated the tag as a variant index + // would decode a lean state as phase0-shaped, so this pins the round + // trip through from_selector rather than the raw byte. + assert_eq!(ForkName::Lean.selector(), 255); + assert_eq!( + ForkName::from_selector(ForkName::Lean.selector()), + Some(ForkName::Lean) + ); + } +} diff --git a/crates/storage/src/state_writer.rs b/crates/storage/src/state_writer.rs new file mode 100644 index 000000000..d1103d0a7 --- /dev/null +++ b/crates/storage/src/state_writer.rs @@ -0,0 +1,849 @@ +//! How a post-state is represented in storage, in both directions, and the +//! background thread that commits a write. +//! +//! Owns the write plan ([`StateWrite`], [`plan_lean_state_write`], +//! [`plan_beacon_state_write`]), the single read path ([`read_state`]) that +//! [`Store::get_state`](crate::store::Store::get_state) calls directly, the +//! cache type both sides share ([`StateCache`], keyed by [`CacheKey`]), and +//! the handoff buffer ([`PendingStates`]) that keeps a state readable between +//! the moment [`Store::insert_state`](crate::store::Store::insert_state) +//! hands it off and the moment its write lands. +//! +//! [`Store::insert_state`](crate::store::Store::insert_state) does not +//! execute the plan itself: it builds a [`StateWriteRequest`] and hands it to +//! [`StateWriterHandle`], returning once the state is cached and buffered. +//! [`StateWriter`] is what actually runs the plan and commits it, on a thread +//! of its own, and the contract a caller (or a future reader of this module) +//! needs to hold in mind is: +//! +//! - **One worker, never a pool.** A beacon state's delta is computed against +//! its parent's *encoded bytes*, so the backend walks in [`read_state`] are +//! only safe because states are inserted parent-before-child *and committed +//! in that same order*. A single thread draining one FIFO channel is what +//! gives the second half of that for free; a pool would not. +//! - **The channel blocks when full**, at [`STATE_WRITE_QUEUE_CAPACITY`] +//! entries, which is exactly what the write already did before it moved off +//! the importer's thread: this can only ever degrade to that, never past it. +//! - **The handle joins the thread when the last `Store` clone drops** (see +//! [`StateWriterHandle`]'s doc), which is what makes "dropped" and +//! "settled" the same event for every test in this crate, and what makes a +//! drop on an async executor's worker thread a blocking call elsewhere. +//! - **A write that panics is not retried, hidden, or cleaned up after.** The +//! entry stays in [`PendingStates`] (see its doc), and the next hand-off +//! observes the closed channel and fails loudly, rather than silently +//! losing states behind a worker that kept going. +//! +//! Reading and writing live in one module because they are one contract, not +//! two: a change to what a write produces, such as the diff format or the +//! snapshot boundary, is a change to what a reader must be able to find. +//! Splitting them apart would let the two drift until a reader silently +//! failed to reconstruct what a writer had actually written. + +use std::borrow::Cow; +use std::collections::HashMap; +use std::sync::mpsc::{Receiver, SyncSender, sync_channel}; +use std::sync::{Arc, Mutex}; +use std::thread::JoinHandle; + +use ethlambda_types::{ + beacon::{containers::BeaconState, fork::ForkName}, + block::BlockHeader, + primitives::H256, + state::State, +}; +use libssz::{SszDecode, SszEncode}; +use lru::LruCache; +use tracing::error; + +use crate::api::{StorageBackend, StorageReadViewExt, Table}; +use crate::beacon_state_delta; +use crate::error::Error; +use crate::state_codec::{decode_lean_state_value, decode_state_value, encode_state_value}; +use crate::state_diff::StateDiff; +use crate::store::Chain; +use crate::store::beacon_block_slot; + +/// What a state insert will write, decided but not yet committed. +/// +/// At least one of `snapshot` and `diff` is always `Some`: a write that +/// persists nothing would silently drop the state. +pub(crate) struct StateWrite { + /// `Table::States` value. `Some` at a snapshot anchor. + pub snapshot: Option>, + /// `Table::StateDiffs` value. `Some` for every lean state, and for a + /// beacon state that is not an anchor. + pub diff: Option>, + /// Beacon only: the target's [`encode_state_value`] bytes, for the + /// writer's parent memo. `None` on the lean arm, which diffs in the field + /// domain and never reads a parent's bytes. + pub encoded: Option>, +} + +/// States handed to the writer but not yet committed to the backend. +/// +/// The handoff buffer between [`Store::insert_state`](crate::store::Store::insert_state) +/// and the code that executes the write. Consulted by [`read_state`] after the +/// LRU and before the backend, so a state is readable from the instant +/// `insert_state` returns rather than from whenever the write lands. +/// +/// An entry is removed only *after* its `commit()` has returned, so there is +/// no instant at which neither this buffer nor the backend can answer for a +/// root. That is the whole point of it: the LRU alone cannot serve this role, +/// because it may evict an entry whose write has not happened yet. +/// +/// An entry that outlives a failed write is deliberate, not a leak. Removing +/// it on unwind would turn a durability failure into a correctness one: a +/// reader would be told `None` for a root the importer was already told was +/// persisted. Retaining it means the buffer goes on answering truthfully for +/// a state the backend never received. +#[derive(Default)] +pub(crate) struct PendingStates(Mutex>>); + +impl PendingStates { + pub(crate) fn insert(&self, root: H256, state: Arc) { + self.0.lock().unwrap().insert(root, state); + } + + pub(crate) fn get(&self, root: &H256) -> Option> { + self.0.lock().unwrap().get(root).cloned() + } + + pub(crate) fn remove(&self, root: &H256) { + self.0.lock().unwrap().remove(root); + } +} + +/// What a cached state is keyed by. +/// +/// One cache rather than two, so a single capacity bounds the total rather +/// than each kind separately overshooting it. A checkpoint state is keyed by +/// its epoch as well as its root because a checkpoint's root is the last block +/// at or before its boundary slot, so the same root can serve different epochs. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum CacheKey { + /// A block's post-state, keyed by that block's root. + BlockState(H256), + /// The state advanced to a checkpoint's epoch boundary. + CheckpointState { epoch: u64, root: H256 }, +} + +/// The shared state cache, memoizing post-states by block root. +pub(crate) type StateCache = Mutex>>; + +/// Reads the post-state for `root`, from wherever it currently lives. +/// +/// The single state read path, called both by +/// [`Store::get_state`](crate::store::Store::get_state) and by the writer +/// thread's own parent lookup, which is what lets the lean arm's parent fetch +/// run off the importer's thread while still hitting the shared LRU. +/// +/// Lookup order is cache, then `pending`, then the backend. The cache and +/// `pending` hold the same `Arc` for a state in flight, so the order is not a +/// correctness question, only a cost one: every read consults the LRU anyway, +/// so putting it first means `pending` is reached only on a miss and stays a +/// safety net rather than a hot path. +/// +/// The backend walks never have to consult `pending`, because states are +/// inserted parent before child *and committed in that same order*: any +/// descendant of a pending state is itself pending and is answered above, so +/// a diff chain can never run *through* a root the backend does not yet have. +/// The second half is what a single writer thread gives and a pool would not: +/// a grandchild committed while its ancestor is still pending would make this +/// walk return `Ok(None)` for a state that exists. +pub(crate) fn read_state( + backend: &dyn StorageBackend, + chain: Chain, + cache: &StateCache, + pending: &PendingStates, + root: &H256, +) -> Result>, Error> { + let key = CacheKey::BlockState(*root); + if let Some(state) = cache.lock().unwrap().get(&key).cloned() { + return Ok(Some(state)); + } + if let Some(state) = pending.get(root) { + return Ok(Some(state)); + } + + let state = match chain { + Chain::Lean => { + // Anchor snapshot in `States`, otherwise reconstruct from the diff chain. + let snapshot = { + let view = backend.begin_read().expect("read view"); + view.read_with(Table::States, &root.to_ssz(), decode_lean_state_value) + .expect("read") + }; + let state = match snapshot { + Some(state) => state, + None => match reconstruct_state(backend, root)? { + Some(state) => state, + None => return Ok(None), + }, + }; + BeaconState::Lean(state) + } + Chain::Beacon => { + // Decoded exactly once, after every delta in the chain has already + // been folded in the byte domain; see + // `reconstruct_beacon_state_bytes`'s doc comment for why an SSZ + // decode per hop instead would be the whole cost that delta layer + // exists to avoid. A snapshot root decodes straight from the + // backend's buffer. + let Some(mut state) = + reconstruct_beacon_state_bytes(backend, root, |bytes| decode_state_value(&bytes))? + else { + return Ok(None); + }; + rebase_onto_resident(cache, &mut state); + state + } + }; + + // A cached `Arc` cannot be flushed later, so every root taken through it + // would pay the slow, uncached hashing path. + let mut state = state; + state.apply_pending_mutations(); + let state = Arc::new(state); + cache.lock().unwrap().put(key, state.clone()); + Ok(Some(state)) +} + +/// Makes a state just decoded from storage share memory with a resident one. +/// +/// A decoded state's tree-backed fields (see `BeaconState::rebase_on`) are +/// fresh allocations, so without this every cache miss would hold a full +/// private copy of the registry next to cached states it is nearly identical +/// to. The parent's state is the closest relative when it is resident; +/// otherwise the most recently used state still shares nearly all of the +/// registry. The rebased state is equal to the decoded one: only which +/// allocations back it change. +/// +/// The cache lock is released before rebasing, which walks the whole registry. +fn rebase_onto_resident(cache: &StateCache, state: &mut BeaconState) { + let parent = CacheKey::BlockState(state.latest_block_header().parent_root); + let base = { + let cache = cache.lock().unwrap(); + cache + .peek(&parent) + .or_else(|| cache.iter().next().map(|(_, state)| state)) + .cloned() + }; + if let Some(base) = base { + state.rebase_on(&base); + } +} + +/// Reconstructs a beacon state's raw *encoded* bytes (see +/// [`encode_state_value`]) by walking `StateDiffs` back to the nearest +/// `States` snapshot and folding deltas forward, byte domain only. +/// +/// Mirrors [`reconstruct_state`]'s walk-then-replay shape for lean, but +/// cannot share its body: a beacon `StateDiffs` record is a +/// [`beacon_state_delta::frame`]d byte delta, not a [`StateDiff`], so the +/// base root read off each hop comes from +/// [`beacon_state_delta::unframe`] instead of a `StateDiff`'s own field. +/// +/// Hands the encoded bytes to `consume` rather than returning a decoded +/// [`BeaconState`], so that a caller that only needs the bytes (the writer +/// thread's `StateWriter::encoded_parent_bytes`, for its diff base) is never +/// made to pay for a decode it will not use. +/// [`Store::get_state`](crate::store::Store::get_state)'s beacon arm is the +/// one caller that decodes, and it does so exactly once, after every delta +/// has already been folded. +/// +/// `consume` gets [`Cow::Borrowed`] when `root` is itself a snapshot: the +/// backend's own buffer, never copied. Otherwise the first delta reads the +/// borrowed snapshot as its base, and `consume` gets the folded result as +/// [`Cow::Owned`], so taking ownership costs no further copy either way +/// beyond the one a borrowed snapshot needs. +/// +/// `Ok(None)` when `root` is unknown, or the chain runs off the retained +/// window before reaching a snapshot: a missing `StateDiffs` record below +/// the pruned boundary, matching how the lean walk in +/// [`reconstruct_state`] handles both cases. +pub(crate) fn reconstruct_beacon_state_bytes( + backend: &dyn StorageBackend, + root: &H256, + consume: impl FnOnce(Cow<'_, [u8]>) -> T, +) -> Result, Error> { + let view = backend.begin_read().expect("read view"); + let mut records: Vec> = Vec::new(); + let mut consume = Some(consume); + let mut cursor = *root; + loop { + let key = cursor.to_ssz(); + // The walk ends at the first snapshot, so the deltas collected so + // far are folded onto it while it is still borrowed. + let folded = view + .read_with(Table::States, &key, |snapshot| { + let consume = consume.take().expect("the walk ends at its first snapshot"); + // `records` runs target -> snapshot child; reverse to snapshot + // child -> target, the order the chain was written in, so + // folding forward replays it correctly. + records.reverse(); + fold_beacon_state_deltas(snapshot, &records, consume) + }) + .expect("read"); + if folded.is_some() { + return Ok(folded); + } + let Some(diff_bytes) = view.get(Table::StateDiffs, &key).expect("get") else { + return Ok(None); + }; + let (base_root, _, _, _) = beacon_state_delta::unframe(&diff_bytes); + cursor = base_root; + records.push(diff_bytes); + } +} + +/// Applies `records` (snapshot child first) to a borrowed `snapshot` and +/// hands the result to `consume`: the snapshot itself when there is nothing +/// to apply, otherwise the owned output of the last delta. +fn fold_beacon_state_deltas( + snapshot: &[u8], + records: &[Vec], + consume: impl FnOnce(Cow<'_, [u8]>) -> T, +) -> T { + let mut records = records.iter(); + let Some(first) = records.next() else { + return consume(Cow::Borrowed(snapshot)); + }; + let (_, _, target_len, delta) = beacon_state_delta::unframe(first); + let mut bytes = beacon_state_delta::decode(delta, snapshot, target_len as usize); + for record in records { + let (_, _, target_len, delta) = beacon_state_delta::unframe(record); + bytes = beacon_state_delta::decode(delta, &bytes, target_len as usize); + } + consume(Cow::Owned(bytes)) +} + +/// Reconstruct a state from diffs and the nearest ancestor snapshot. +/// +/// Walks `base_root` pointers back until a snapshot is found, fetches the +/// target's block header, and delegates the assembly to +/// [`state_diff::reconstruct`](crate::state_diff::reconstruct). +/// +/// Lean directories only: the inlined `BlockHeaders` read below assumes a +/// bare [`BlockHeader`], which is what a lean directory stores there. A +/// beacon directory tags that table with a fork selector ahead of a +/// `SignedBeaconBlock`, so calling this on one would decode the wrong shape +/// and hit the `expect("valid header")` below instead of an explicit panic. +/// Nothing does today: [`read_state`]'s `Chain::Lean` arm is this function's +/// only caller, so that invariant is enforced by having exactly one caller +/// rather than by a runtime check, unlike +/// [`Store::get_block_header`](crate::store::Store::get_block_header)'s +/// `lean_only` guard. +/// +/// Returns `Ok(None)` when the root is unknown or the diff chain is broken. +pub(crate) fn reconstruct_state( + backend: &dyn StorageBackend, + root: &H256, +) -> Result, Error> { + // Walk back collecting diffs until we reach a snapshot. + let view = backend.begin_read().expect("read view"); + let mut diffs: Vec = Vec::new(); + let mut cursor = *root; + let snapshot = loop { + let key = cursor.to_ssz(); + if let Some(snapshot) = view + .read_with(Table::States, &key, decode_lean_state_value) + .expect("read") + { + break snapshot; + } + let Some(diff) = view + .read_with(Table::StateDiffs, &key, |bytes| { + StateDiff::from_ssz_bytes(bytes).expect("valid state diff") + }) + .expect("read") + else { + return Ok(None); + }; + cursor = diff.base_root; + diffs.push(diff); + }; + drop(view); + + // `diffs` runs target -> snapshot child; reverse to snapshot child -> target. + diffs.reverse(); + + // The latest block header lives in BlockHeaders; the stored state caches + // the real state_root there, so it equals the header byte-for-byte. + let view = backend.begin_read().expect("read view"); + let header = view + .read_with(Table::BlockHeaders, &root.to_ssz(), |bytes| { + BlockHeader::from_ssz_bytes(bytes).expect("valid header") + }) + .expect("read"); + drop(view); + let Some(latest_block_header) = header else { + return Ok(None); + }; + + Ok(Some(crate::state_diff::reconstruct( + snapshot, + &diffs, + latest_block_header, + ))) +} + +/// Whether a state at `slot` crosses an `interval` snapshot boundary relative +/// to its parent. +/// +/// `parent_slot` is `None` for the store's first-ever beacon state, which has +/// no parent block on record and is therefore always a snapshot: there is no +/// base to diff against. +/// +/// Split out from the planning functions because the beacon arm has to know +/// the answer *before* deciding whether to read the parent's encoded bytes at +/// all: at an anchor it never diffs, and fetching a base it will not use would +/// turn an avoided read into a mandatory one on every anchor. +pub(crate) fn is_anchor(slot: u64, parent_slot: Option, interval: u64) -> bool { + match parent_slot { + Some(parent_slot) => slot / interval > parent_slot / interval, + None => true, + } +} + +/// The bytes a lean post-state writes, given its parent state. +/// +/// Every lean state records a `StateDiffs` entry; a snapshot is added only +/// when the block crosses a [`ForkName::snapshot_interval`] boundary. Takes +/// the post-state by value because [`StateDiff::from_states`] consumes it, and +/// the parent by reference because the diff only reads it. +pub(crate) fn plan_lean_state_write(state: State, parent: &State) -> StateWrite { + let interval = ForkName::Lean.snapshot_interval(); + // Serialize before `state` is consumed by the diff below. + let snapshot = is_anchor(state.slot, Some(parent.slot), interval) + .then(|| encode_state_value(&BeaconState::Lean(state.clone()))); + let diff = StateDiff::from_states(parent, state) + .expect("state transition produced a non-append historical_block_hashes") + .to_ssz(); + StateWrite { + snapshot, + diff: Some(diff), + encoded: None, + } +} + +/// The bytes a beacon post-state writes. +/// +/// `parent` is `Some((parent_root, parent_encoded_bytes))` for a state that +/// diffs against its parent, and `None` for an anchor. Carrying the anchor +/// decision in this argument rather than in a separate flag is what keeps a +/// caller from fetching a base it will not use; see [`is_anchor`]. +/// +/// `slot` is passed rather than read off `state` because the caller has +/// already computed it for [`is_anchor`], and because +/// [`BeaconState::slot`] panics on the lean arm: a signature that cannot +/// reach that panic is worth more than one that merely never does. +pub(crate) fn plan_beacon_state_write( + state: &BeaconState, + slot: u64, + parent: Option<(H256, &[u8])>, +) -> StateWrite { + let target = encode_state_value(state); + match parent { + None => StateWrite { + snapshot: Some(target.clone()), + diff: None, + encoded: Some(target), + }, + Some((parent_root, base)) => { + let delta = beacon_state_delta::encode(&target, base); + // Deliberately not weakened to a plain `assert`: this repo's + // release-fast profile keeps debug assertions on in tests while + // stripping them from shipped binaries, so this round trip is + // exercised on every test run without costing anything in + // production. + debug_assert_eq!( + beacon_state_delta::decode(&delta, base, target.len()), + target, + "a beacon state delta must decode back to its target" + ); + let target_len = target.len() as u64; + let framed = beacon_state_delta::frame(parent_root, slot, target_len, &delta); + StateWrite { + snapshot: None, + diff: Some(framed), + encoded: Some(target), + } + } + } +} + +/// How many handed-off states may wait in the channel. +/// +/// Two, so one import overlaps the previous write without letting the queue +/// become a memory sink: a mainnet `BeaconState` is large enough that an +/// unbounded queue would turn a slow disk into an out-of-memory kill. With a +/// full channel the send blocks, which is exactly what the write did before it +/// moved off the importer's thread, so this can only ever degrade to today and +/// never past it. +/// +/// This bounds the channel, not every state alive at once: up to three are +/// live at a time in the worst case, two queued plus one the worker is +/// currently writing, and each of those three is also held by `PendingStates` +/// and the LRU. +pub(crate) const STATE_WRITE_QUEUE_CAPACITY: usize = 2; + +/// One state handed to the writer. +pub(crate) struct StateWriteRequest { + pub root: H256, + pub state: Arc, +} + +/// The writer thread's own state. +/// +/// Holds the individual `Arc`s it needs rather than a `Store`, which would be +/// a reference cycle through the [`StateWriterHandle`] that is supposed to +/// join it. +struct StateWriter { + backend: Arc, + chain: Chain, + cache: Arc, + pending: Arc, + /// The most recently encoded beacon state, so the beacon write path does + /// not re-encode a parent's whole SSZ on every import. + /// + /// Thread-local and a plain `Option`, not a shared `Mutex`: requests are + /// drained in order by this one thread, so the parent of the state being + /// written is whatever this thread wrote last. + encoded_memo: Option<(H256, Vec)>, +} + +impl StateWriter { + fn run(mut self, rx: Receiver) { + for request in rx { + self.write(&request); + // Only now, after the commit returned: until this line both the + // buffer and the backend can answer for this root, and after it + // the backend alone can. There is no instant where neither does. + self.pending.remove(&request.root); + crate::metrics::dec_state_write_queue_depth(); + } + } + + fn write(&mut self, request: &StateWriteRequest) { + let _timing = crate::metrics::time_state_write(); + let root = request.root; + let write = match &*request.state { + BeaconState::Lean(lean) => { + let parent_root = lean.latest_block_header.parent_root; + let parent = read_state( + self.backend.as_ref(), + self.chain, + &self.cache, + &self.pending, + &parent_root, + ) + .expect("read parent state") + .expect("parent state must exist to diff against"); + plan_lean_state_write(lean.clone(), parent.expect_lean()) + } + beacon_state => { + let slot = beacon_state.slot(); + let parent_root = beacon_state.latest_block_header().parent_root; + let interval = beacon_state.fork_name().snapshot_interval(); + let parent_slot = beacon_block_slot(self.backend.as_ref(), &parent_root); + let base = (!is_anchor(slot, parent_slot, interval)) + .then(|| self.encoded_parent_bytes(parent_root)); + plan_beacon_state_write( + beacon_state, + slot, + base.as_deref().map(|base| (parent_root, base)), + ) + } + }; + + debug_assert!( + write.snapshot.is_some() || write.diff.is_some(), + "a state write that persists nothing would silently drop the state" + ); + let key = root.to_ssz(); + let mut batch = self.backend.begin_write().expect("write batch"); + if let Some(diff) = write.diff { + batch + .put_batch(Table::StateDiffs, vec![(key.clone(), diff)]) + .expect("put state diff"); + } + if let Some(snapshot) = write.snapshot { + batch + .put_batch(Table::States, vec![(key, snapshot)]) + .expect("put state snapshot"); + } + batch.commit().expect("commit"); + + if let Some(encoded) = write.encoded { + self.encoded_memo = Some((root, encoded)); + } + } + + /// The parent's [`encode_state_value`] bytes, for the beacon delta. + /// + /// Checks the memo first: requests are drained in order, so the parent is + /// almost always the state this thread wrote last, making this free. A + /// miss folds the diff chain, which produces these exact bytes as a + /// by-product, so nothing is decoded and then re-encoded to satisfy it. + /// + /// # Panics + /// + /// If `parent_root` has no state. This is only reached once + /// [`beacon_block_slot`] has found a parent block, which is only ever true + /// once that parent's own state has already been written: this thread + /// committed it before pulling the request being served now. + fn encoded_parent_bytes(&self, parent_root: H256) -> Vec { + self.encoded_memo + .as_ref() + .filter(|(root, _)| *root == parent_root) + .map(|(_, bytes)| bytes.clone()) + .unwrap_or_else(|| { + reconstruct_beacon_state_bytes(self.backend.as_ref(), &parent_root, |bytes| { + bytes.into_owned() + }) + .expect("read parent state") + .expect("parent state must exist to diff against") + }) + } +} + +/// Owns the writer thread and joins it on drop. +/// +/// Lives behind an `Arc` inside [`Store`](crate::store::Store), which is +/// `Clone`: a `Drop` on `Store` itself would fire on every clone, so the join +/// hangs off this instead and runs once, when the last clone releases it. +/// +/// # Blocking +/// +/// Dropping the last `Store` clone joins the writer, which blocks the +/// dropping thread until the queue drains and the in-flight write commits. +/// A drop on an async executor's worker thread (e.g. replacing a `Store` +/// behind a `tokio::sync::RwLock`) therefore blocks that worker for the same +/// span, not just the caller. +pub(crate) struct StateWriterHandle { + /// `Option` so [`Drop`] can take it. Dropping the sender is what ends the + /// thread's `recv` loop, and it has to happen before the join or the join + /// waits forever. + tx: Option>, + join: Option>, +} + +impl StateWriterHandle { + pub(crate) fn spawn( + backend: Arc, + chain: Chain, + cache: Arc, + pending: Arc, + ) -> Self { + let (tx, rx) = sync_channel(STATE_WRITE_QUEUE_CAPACITY); + let writer = StateWriter { + backend, + chain, + cache, + pending, + encoded_memo: None, + }; + let join = std::thread::Builder::new() + .name("state-writer".into()) + .spawn(move || writer.run(rx)) + .expect("spawn the state writer thread"); + Self { + tx: Some(tx), + join: Some(join), + } + } + + /// Hands a state to the writer, blocking while the queue is full. + /// + /// # Panics + /// + /// If the writer thread is gone, which only happens after it panicked. Its + /// own panic message is already on the default hook; this is the importer + /// learning about it, one state later. + pub(crate) fn send(&self, request: StateWriteRequest) { + self.tx + .as_ref() + .expect("the sender is taken only in Drop") + .send(request) + .expect("the state writer thread died; see the logged panic above"); + } +} + +impl Drop for StateWriterHandle { + fn drop(&mut self) { + // Ends the thread's `recv` loop. Must precede the join. + drop(self.tx.take()); + let Some(join) = self.join.take() else { + return; + }; + if join.join().is_err() { + error!("the state writer thread panicked; queued state writes were lost"); + // Panicking during an unwind aborts the process, so the writer's + // panic is re-raised here only when this drop is not itself + // unwinding. Either way the thread's own panic message already + // reached the default hook and the line above. + assert!( + std::thread::panicking(), + "the state writer thread panicked; see the logged panic above" + ); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ethlambda_types::block::BlockHeader; + use ethlambda_types::primitives::HashTreeRoot as _; + use ethlambda_types::state::{PUBLIC_KEY_SIZE, Validator}; + + fn base_state() -> State { + let validators = vec![Validator { + attestation_pubkey: [7u8; PUBLIC_KEY_SIZE], + proposal_pubkey: [9u8; PUBLIC_KEY_SIZE], + index: 0, + }]; + State::from_genesis(1_000, validators) + } + + /// A valid direct child of `parent` at `slot`, shaped the way the state + /// transition leaves a post-state: the parent's block root appended to + /// `historical_block_hashes`, zero-filled for any skipped slots, and + /// `latest_block_header` set to this block's own header. + fn child_state(parent: &State, slot: u64) -> State { + let parent_root = parent.latest_block_header.hash_tree_root(); + let empty_slots = (slot - parent.slot - 1) as usize; + + let mut hbh = parent.historical_block_hashes.to_vec(); + hbh.push(parent_root); + hbh.extend(std::iter::repeat_n(H256::ZERO, empty_slots)); + + let mut child = parent.clone(); + child.slot = slot; + child.historical_block_hashes = hbh.try_into().expect("within limit"); + child.latest_block_header = BlockHeader { + slot, + proposer_index: 0, + parent_root, + state_root: H256::ZERO, + body_root: H256::ZERO, + }; + child + } + + #[test] + fn a_first_state_with_no_parent_is_always_an_anchor() { + assert!(is_anchor(5, None, 1_024)); + assert!(is_anchor(0, None, 1_024)); + } + + #[test] + fn an_anchor_is_a_crossing_of_the_interval_boundary() { + assert!(is_anchor(1_024, Some(1_023), 1_024)); + assert!(!is_anchor(1_023, Some(1_022), 1_024)); + assert!(!is_anchor(1_025, Some(1_024), 1_024)); + assert!( + is_anchor(3_072, Some(1_000), 1_024), + "a jump across several intervals is still one anchor" + ); + } + + #[test] + fn a_lean_plan_always_writes_a_diff_and_snapshots_only_at_an_anchor() { + let interval = ForkName::Lean.snapshot_interval(); + + let mut parent = base_state(); + parent.slot = interval - 1; + parent.latest_block_header.slot = interval - 1; + + let crossing = child_state(&parent, interval); + let expected = encode_state_value(&BeaconState::Lean(crossing.clone())); + let plan = plan_lean_state_write(crossing, &parent); + assert!(plan.diff.is_some(), "every lean state records a diff"); + assert_eq!( + plan.snapshot.as_deref(), + Some(expected.as_slice()), + "the snapshot holds the anchored state's own bytes, not its parent's" + ); + assert!(plan.encoded.is_none(), "the lean arm keeps no encoded memo"); + + let mut inside = base_state(); + inside.slot = interval; + inside.latest_block_header.slot = interval; + let non_crossing = child_state(&inside, interval + 1); + let plan = plan_lean_state_write(non_crossing, &inside); + assert!(plan.diff.is_some()); + assert!( + plan.snapshot.is_none(), + "staying inside the interval does not" + ); + } + + #[test] + fn a_beacon_plan_with_no_parent_writes_a_snapshot_carrying_the_memo() { + let state = BeaconState::Lean(base_state()); + let slot = state.expect_lean().slot; + let plan = plan_beacon_state_write(&state, slot, None); + assert!( + plan.snapshot.is_some(), + "no parent means no base to diff against" + ); + assert!(plan.diff.is_none()); + assert_eq!( + plan.encoded.as_deref(), + plan.snapshot.as_deref(), + "the memo is the very bytes that were snapshotted" + ); + } + + /// A lean state stands in for a mainnet one here on purpose: the beacon + /// write path diffs in the byte domain, so what it is fed is a `Vec` + /// and nothing below `encode_state_value` knows or cares which fork + /// produced it. That keeps this test free of a multi-megabyte fixture. + #[test] + fn a_beacon_delta_decodes_back_to_its_target() { + let parent = base_state(); + let child = child_state(&parent, parent.slot + 1); + let parent_root = parent.latest_block_header.hash_tree_root(); + + let base = encode_state_value(&BeaconState::Lean(parent)); + let slot = child.slot; + let state = BeaconState::Lean(child); + let plan = plan_beacon_state_write(&state, slot, Some((parent_root, &base))); + + assert!( + plan.snapshot.is_none(), + "a parent means a diff, not a snapshot" + ); + let framed = plan.diff.expect("a non-anchor records a diff"); + let (read_base_root, framed_slot, target_len, delta) = beacon_state_delta::unframe(&framed); + assert_eq!(read_base_root, parent_root); + assert_eq!( + framed_slot, slot, + "the frame carries the target state's slot" + ); + assert_eq!( + beacon_state_delta::decode(delta, &base, target_len as usize), + plan.encoded + .expect("the beacon arm always memoizes its target"), + ); + } + + #[test] + fn a_pending_state_is_readable_and_stops_being_pending_when_removed() { + let pending = PendingStates::default(); + let state = Arc::new(BeaconState::Lean(base_state())); + let root = H256([3u8; 32]); + + assert!(pending.get(&root).is_none()); + + pending.insert(root, state.clone()); + // The hit is the same `Arc`, not a copy: this is the property + // `read_state`'s doc leans on to say the buffer costs nothing beyond + // an LRU miss even for a mainnet-sized state. + assert!(Arc::ptr_eq(&pending.get(&root).unwrap(), &state)); + + pending.remove(&root); + assert!(pending.get(&root).is_none()); + } +} diff --git a/crates/storage/src/store.rs b/crates/storage/src/store.rs index 9d12dea87..28b147de7 100644 --- a/crates/storage/src/store.rs +++ b/crates/storage/src/store.rs @@ -4,7 +4,8 @@ use std::sync::{Arc, LazyLock, Mutex}; use lru::LruCache; -use crate::api::{StorageBackend, StorageReadView, StorageWriteBatch, Table}; +use crate::api::{StorageBackend, StorageReadView, StorageReadViewExt, StorageWriteBatch, Table}; +use crate::committee_cache::CommitteeCache; use crate::error::Error; use ethlambda_crypto::signature::ValidatorSignature; @@ -14,21 +15,30 @@ use ethlambda_types::{ AggregatedAttestation, AggregationBits, AttestationData, HashedAttestationData, bits_is_subset, validator_indices, }, + beacon::{ + config::Config, + containers::{BeaconState, Checkpoint as BeaconCheckpoint, SignedBeaconBlock}, + fork::ForkName, + fork_choice::{LatestMessage, PayloadStatusV1, PowBlock}, + preset::{Preset, SLOTS_PER_EPOCH}, + primitives::ExecutionBlockHash, + }, block::{ Block, BlockBody, BlockHeader, MultiMessageAggregate, SignedBlock, SingleMessageAggregate, }, - chain_config::ChainConfig, checkpoint::Checkpoint, - constants::INTERVALS_PER_SLOT, - genesis::GenesisConfig, primitives::{H256, HashTreeRoot as _}, state::{State, anchor_pair_is_consistent}, }; use libssz::{SszDecode, SszEncode}; -use crate::state_diff::StateDiff; +use crate::state_codec::encode_state_value; +use crate::state_writer::{ + CacheKey, PendingStates, STATE_WRITE_QUEUE_CAPACITY, StateCache, StateWriteRequest, + StateWriterHandle, read_state, +}; use thiserror::Error; -use tracing::{error, info, warn}; +use tracing::{info, warn}; /// Errors returned by [`Store::get_forkchoice_store`]. #[derive(Debug, Error)] @@ -83,9 +93,22 @@ impl ForkCheckpoints { // ============ Metadata Keys ============ -/// Key for "time" field of the Store. Its value has type [`u64`] and it's SSZ-encoded. +/// Key for "time" field of the Store: a UNIX timestamp in **milliseconds**, on +/// both chains. Its value has type [`u64`] and it's SSZ-encoded. +/// +/// The single clock. Milliseconds because it has to be fine enough for the +/// finest grid either chain schedules on, which is lean's interval: with +/// `INTERVALS_PER_SLOT` intervals to a slot, most interval boundaries fall +/// strictly between two whole seconds, and a second-resolution row could not +/// name them. Everything coarser is derived, exactly and in one direction: +/// [`Store::current_slot`] for either chain, [`Store::intervals_since_genesis`] +/// for lean's tick pipeline, and a plain division by a thousand for the beacon +/// specification's second-denominated `Store.time`. +/// +/// Absolute rather than an offset from genesis, so that a reader holding no +/// configuration can still compare it against a wall clock. const KEY_TIME: &[u8] = b"time"; -/// Key for "config" field of the Store. Its value has type [`ChainConfig`] and it's SSZ-encoded. +/// Key for "config" field of the Store. Its value has type [`Config`] and it's SSZ-encoded. const KEY_CONFIG: &[u8] = b"config"; /// Key for "head" field of the Store. Its value has type [`H256`] and it's SSZ-encoded. const KEY_HEAD: &[u8] = b"head"; @@ -95,16 +118,94 @@ const KEY_SAFE_TARGET: &[u8] = b"safe_target"; const KEY_LATEST_JUSTIFIED: &[u8] = b"latest_justified"; /// Key for "latest_finalized" field of the Store. Its value has type [`Checkpoint`] and it's SSZ-encoded. const KEY_LATEST_FINALIZED: &[u8] = b"latest_finalized"; +/// Key for the on-disk format version. Its value has type [`u64`] and it's SSZ-encoded. +const KEY_DB_VERSION: &[u8] = b"db_version"; +/// Key for which chain this directory holds. Its value is a single +/// [`Chain::selector`] byte, not SSZ: it predates being able to decode +/// anything else in the directory. +const KEY_CHAIN: &[u8] = b"chain"; +/// Key for which SSZ preset the build that wrote this directory used. Its +/// value is a single [`Preset::selector`] byte, raw for the same reason +/// [`KEY_CHAIN`] is, and more sharply: the preset is what *decides* the shape +/// of the containers in `States`, so it has to be readable before anything in +/// the directory is decoded, including by a build that would decode them into +/// the wrong shape. +/// +/// Written beside [`KEY_CONFIG`] by both bootstrap paths and checked by +/// [`Store::from_db_state`]. Unlike [`KEY_DB_VERSION`], a mismatch here is not +/// something this build could fix by migrating: the other preset's states are +/// a different protocol's states (see this crate's `preset` module), so the +/// only answer is to refuse. +const KEY_PRESET: &[u8] = b"preset"; +/// Key for the beacon store's unrealized justified checkpoint. +/// +/// The *realized* pair has no beacon-specific key: both chains record theirs +/// under [`KEY_LATEST_JUSTIFIED`]/[`KEY_LATEST_FINALIZED`] as a slot-denominated +/// [`Checkpoint`], so one `update_checkpoints` advances either chain. A beacon +/// epoch converts to that shape losslessly, since an epoch names its own start +/// slot; see [`Store::beacon_justified_checkpoint`]. +const KEY_BEACON_UNREALIZED_JUSTIFIED: &[u8] = b"beacon_unrealized_justified"; +/// Key for the beacon store's unrealized finalized checkpoint. +const KEY_BEACON_UNREALIZED_FINALIZED: &[u8] = b"beacon_unrealized_finalized"; +/// The slot this directory's chain begins at: the anchor block's own slot, +/// whether that anchor is genesis or a checkpoint. Its value has type [`u64`] +/// and it's SSZ-encoded. +/// +/// Written once by each bootstrap path and never rewritten, like [`KEY_CONFIG`] +/// and [`KEY_CHAIN`]. It is the store's only record of where it started: +/// [`KEY_LATEST_FINALIZED`] is seeded to the anchor too, but moves with the +/// chain, so after the first finalization nothing else on disk can answer this. +const KEY_ANCHOR_SLOT: &[u8] = b"anchor_slot"; +/// The on-disk format this build reads and writes. +/// +/// Bumped whenever a table's key or value layout changes. `from_db_state` +/// refuses any other value rather than migrating: a lean devnet resyncs in +/// minutes, and a wrong guess about an old layout corrupts silently. +/// +/// 2 added [`KEY_ANCHOR_SLOT`], which [`Store::from_db_state`] requires and a +/// version 1 directory does not carry. +/// +/// 3 widened `Config` with the runtime keys a `config.yaml` carries: the struct +/// is SSZ-encoded under [`KEY_CONFIG`], so a directory written by the previous +/// version decodes into the wrong fields. There is no migration, by the same +/// policy every previous change followed. +/// +/// 4 added `PRESET_BASE` and `CONFIG_NAME` to `Config`, at the front of its +/// encoding, for the same reason and with the same consequence as 3. +pub const DB_VERSION: u64 = 4; -/// Persist a full-state snapshot whenever a block's slot crosses a multiple of -/// this value (relative to its parent's slot). +/// The consensus protocol a data directory holds. /// -/// Snapshots are the only entries written to `States` (plus the bootstrap -/// anchor); they are never pruned and bound state-reconstruction diff walks to -/// at most this many steps. A slot count, not a duration: the walk cost is -/// per-slot, so it does not follow the configured cadence. ~68 minutes at the -/// default 4-second slots. -const SNAPSHOT_ANCHOR_INTERVAL: u64 = 1_024; +/// Written once at bootstrap and never rewritten, like `Metadata["config"]`. A +/// directory is one chain or the other for its whole life: the two use +/// different state shapes, different checkpoint types and different clock +/// units, and nothing migrates between them. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Chain { + Lean, + Beacon, +} + +impl Chain { + /// The byte this chain is stored under. Spelled out rather than derived + /// from the variant order, because it is a storage format and not a + /// discriminant: reordering the variants must not reinterpret a directory. + pub const fn selector(self) -> u8 { + match self { + Chain::Lean => 0, + Chain::Beacon => 1, + } + } + + /// The inverse of [`Chain::selector`]. + pub const fn from_selector(byte: u8) -> Option { + match byte { + 0 => Some(Chain::Lean), + 1 => Some(Chain::Beacon), + _ => None, + } + } +} /// Number of reconstructed/imported states memoized in memory. /// @@ -535,6 +636,69 @@ impl GossipSignatureBuffer { } } +/// Beacon fork-choice state that is per-slot or per-epoch scratch rather than +/// chain history: nothing here survives a restart, and nothing here is worth +/// the write amplification of persisting. +/// +/// `proposer_boost_root` resets every slot, `block_timeliness` is read only by +/// the same-slot reorg helpers, `equivocating_indices` is rebuilt by replaying +/// attester slashings on sync, `latest_messages` is rebuilt by the first epoch +/// of attestations, `pow_blocks` stands in for a call to an execution client +/// that a restarted node would simply make again, and +/// `unrealized_justifications` is recomputed by replaying epoch processing on a +/// copy of a block's post-state, which a node resuming from an anchor does +/// anyway as it re-imports the unfinalized window. `optimistic_roots` and +/// `payload_statuses` are likewise answers an execution client can be asked +/// for again, and `el_block_hashes` is a cache over data already decodable +/// from the block itself. +/// +/// Most of this is uncapped: the per-validator maps are bounded by the +/// validator set, and the per-block ones (`block_timeliness`, +/// `unrealized_justifications`) grow with the blocks this process has +/// imported. Two are the exception, `el_block_hashes` and `optimistic_roots`, +/// each pruned to the unfinalized window by its own `prune_*` method. Both +/// fill on a path that runs for the whole life of the process and has no other +/// way of emptying them: `forkchoiceUpdated` reads the first once per head +/// move, and an execution client doing a long state sync answers +/// `NOT_VALIDATED` to every block, which writes the second once per import. +#[derive(Default)] +pub(crate) struct BeaconScratch { + pub(crate) proposer_boost_root: H256, + pub(crate) block_timeliness: HashMap, + pub(crate) equivocating_indices: HashSet, + pub(crate) latest_messages: HashMap, + pub(crate) pow_blocks: HashMap, + pub(crate) unrealized_justifications: HashMap, + /// Beacon roots imported on an execution client's `NOT_VALIDATED` answer, + /// against the slot the unfinalized-window bound prunes them by. + /// + /// An entry leaves on a later `VALID` or `INVALIDATED` verdict, and, for + /// the ones that get neither, on finality. Bounded like `el_block_hashes` + /// and for the same kind of reason: an execution client stuck on `SYNCING` + /// answers `NOT_VALIDATED` to every block, and without + /// `prune_beacon_optimistic_roots` nothing would ever take those entries + /// back out. + /// + /// Nothing outside `fork_choice::mark_validated`'s own ancestor walk reads + /// [`Store::is_beacon_optimistic`] yet, so outside that walk this is + /// write-only. The readers it is waiting for are the ones that need to + /// answer "is my head optimistic?": the Beacon API's `execution_optimistic` + /// response field, and a sync status that distinguishes a head this node + /// has vouched for from one it has merely imported. + pub(crate) optimistic_roots: HashMap, + /// Payload statuses keyed by execution block hash, standing in for a call + /// to an execution client exactly as `pow_blocks` does. Written only by + /// the `sync/optimistic` fixture runner's `on_payload_info` step; the + /// production path carries its verdict as an `on_block` parameter instead. + pub(crate) payload_statuses: HashMap, + /// Beacon root to `(slot, execution block hash)`. A cache, not a source of + /// truth: every entry is recoverable by decoding the block. Unlike its + /// neighbours it *is* bounded, by `prune_beacon_el_block_hashes`, because + /// forkchoiceUpdated reads it once per head move for the whole life of the + /// process. + pub(crate) el_block_hashes: HashMap, +} + /// Encode a LiveChain key (slot, root) to bytes. /// Layout: slot (8 bytes big-endian) || root (32 bytes) /// Big-endian ensures lexicographic ordering matches numeric ordering. @@ -555,6 +719,72 @@ fn encode_block_root_key(slot: u64) -> Vec { slot.to_be_bytes().to_vec() } +/// The length of every [`data_column_key`]: slot, root, column index. +const DATA_COLUMN_KEY_LEN: usize = 8 + 32 + 8; + +/// The key one sidecar is stored under: slot, then block root, then column. +/// +/// Extends [`encode_slot_root_key`]'s slot||root pair with the column index, +/// so a block's sidecars share the same slot-major prefix `LiveChain` and +/// `BlockProof` already use, and a prefix scan over just that pair (see +/// [`data_column_block_prefix`]) recovers every column of one block. +fn data_column_key(slot: u64, block_root: &H256, column_index: u64) -> Vec { + let mut key = encode_slot_root_key(slot, block_root); + key.extend_from_slice(&column_index.to_be_bytes()); + key +} + +/// The prefix every sidecar of one block shares: its slot||root pair. +fn data_column_block_prefix(slot: u64, block_root: &H256) -> Vec { + encode_slot_root_key(slot, block_root) +} + +/// Encodes a beacon `BlockHeaders` value: the block's fork selector, then the +/// variant's own SSZ. +/// +/// The same tag-then-payload shape as [`encode_state_value`], and for the same +/// reason: a beacon block's shape varies by fork, and SSZ carries no type tag +/// of its own. +fn encode_beacon_block_value(block: &SignedBeaconBlock) -> Vec { + let mut bytes = Vec::new(); + bytes.push(block.fork_name().selector()); + bytes.extend_from_slice(&block.to_ssz()); + bytes +} + +/// The inverse of [`encode_beacon_block_value`]. +/// +/// Panics on a value this build cannot tag-decode, matching every other read +/// in this file: `from_db_state` has already rejected a directory of the wrong +/// format version, so anything reaching here is corruption rather than an old +/// database. +fn decode_beacon_block_value(bytes: &[u8]) -> SignedBeaconBlock { + let (tag, ssz) = bytes.split_first().expect("value is never empty"); + let fork = ForkName::from_selector(*tag).expect("value carries a known fork selector"); + SignedBeaconBlock::from_ssz(fork, ssz).expect("valid signed block") +} + +/// `root`'s slot on a beacon chain, read without a `Store`, for the writer +/// thread's anchor decision. +/// +/// `None` when no block is on record for `root`, which is how the store's +/// first-ever beacon state is recognised: it has no parent block, so there is +/// no base to diff against and it is always a snapshot. +/// +/// Beacon directories only: it decodes the row as a tagged +/// [`SignedBeaconBlock`], which is the wrong shape for a lean directory's bare +/// [`BlockHeader`] (unlike [`block_fields`](Store::block_fields), which +/// dispatches on `self.chain`). Nothing does today: [`StateWriter::write`](crate::state_writer)'s +/// non-`Lean` match arm is this function's only caller, so that invariant is +/// enforced by having exactly one caller rather than by a runtime check. +pub(crate) fn beacon_block_slot(backend: &dyn StorageBackend, root: &H256) -> Option { + let view = backend.begin_read().expect("read view"); + view.read_with(Table::BlockHeaders, &root.to_ssz(), |bytes| { + decode_beacon_block_value(bytes).slot() + }) + .expect("read") +} + /// Fork choice store backed by a pluggable storage backend. /// /// The Store maintains all state required for fork choice and block processing: @@ -574,15 +804,35 @@ fn encode_block_root_key(slot: u64) -> Vec { #[derive(Clone)] pub struct Store { backend: Arc, - /// Cached copy of the persisted [`ChainConfig`]. - /// - /// The config is written once at bootstrap and has no setter, so a plain copy - /// per `Store` cannot go stale: sharing it behind an `Arc` would buy nothing. - /// It stays in `Table::Metadata` under `KEY_CONFIG` because `from_db_state` - /// reads it back to reject a DB whose genesis time or slot duration disagrees - /// with the config file; this field only spares every caller a backend round - /// trip and a `Result` it could never act on. - config: ChainConfig, + /// The node's runtime configuration: genesis time and slot duration for + /// both chains, plus the beacon fork schedule when this is a beacon + /// directory. + /// + /// Behind an `Arc` rather than a plain copy: every beacon fork-choice call + /// takes `&mut Store` alongside the config, so a caller has to hold it + /// across a mutable borrow of the store it came from. Cloning the `Arc` is + /// one atomic increment, not a copy of the fork schedule. + /// + /// Written once at bootstrap and never rewritten, so a per-`Store` copy + /// cannot go stale. It stays in `Table::Metadata` under `KEY_CONFIG` + /// because `from_db_state` reads it back to reject a DB whose genesis time + /// or slot duration disagrees with the config file; this field only spares + /// every caller a backend round trip and a `Result` it could never act on. + config: Arc, + /// Which chain this directory holds. Cached for the same reason + /// [`Store::config`] is: written once at bootstrap, so a per-`Store` copy + /// cannot go stale. + pub(crate) chain: Chain, + /// The slot this store's chain begins at, from [`KEY_ANCHOR_SLOT`]. Cached + /// for the same reason [`Store::chain`] is, and it is read on the + /// `data_column_sidecars_by_range` path, where a backend round trip per + /// request would buy nothing: the value cannot change while the process + /// runs. + /// + /// A node that bootstrapped from genesis has zero here; one that + /// checkpoint-synced has the checkpoint's slot. Nothing below it is + /// servable, because nothing below it was ever written. + anchor_slot: u64, new_payloads: Arc>, known_payloads: Arc>, /// Fork-choice votes, independent from bounded proof/signature buffers. @@ -590,12 +840,56 @@ pub struct Store { /// In-memory gossip signatures, consumed at interval 2 aggregation. gossip_signatures: Arc>, /// LRU memoization of states by block root, shared across `Store` clones. - /// Avoids reconstructing recent states from diffs on every read. - state_cache: Arc>>, + /// + /// Holds the same fork-ladder enum the `States` table stores, so a lean + /// entry is a `BeaconState::Lean`. This is the only state cache: it is what + /// bounds the beacon fork choice, which previously held whole states in + /// unbounded maps. + /// + /// Behind an `Arc` because a hit must not copy: a mainnet `BeaconState` is + /// large enough that returning an owned one would give back much of what + /// the cache saves. Lean callers that need an owned `State` clone through + /// the `Arc`, which is cheap at lean's sizes. + /// + /// A miss is never an error. Every caller derives the value by + /// reconstructing from the nearest snapshot, which is what makes this a + /// cache rather than the store's record of anything, and why the capacity + /// is a pure speed and memory trade with no correctness stake. Nothing + /// here may become a consensus input: a decision that changed with cache + /// residency would be a bug, not a tuning choice. + state_cache: Arc, + /// States handed to the writer but not yet committed; see + /// [`PendingStates`]. + pending_states: Arc, + /// Committee shufflings shared across everything that asks a beacon state + /// which validators attest at a slot: the state transition as it + /// processes a block's attestations, fork choice as it replays those same + /// attestations into the latest-message store, and p2p's gossip + /// validation tasks, on their own blocking threads, doing the same for a + /// gossiped aggregate or subnet attestation. + /// + /// Held here, by the `Store` both actors already share (rather than by + /// the chain actor alone, as it used to be), because it is exactly what + /// makes the sharing above possible: a `Store` clone is cheap and every + /// caller already has one, where a second field threaded down from the + /// chain actor alone would not reach p2p's tasks at all. Internally + /// synchronized like [`Self::state_cache`], so every method takes + /// `&self`; see [`CommitteeCache`]'s own documentation for the cache + /// itself, and `ethlambda-state-transition`'s + /// `beacon::helpers::accessors::CommitteeCacheExt` for the state-aware + /// half built on top of it. + /// + /// Always empty on lean, which has no beacon committees. + committee_cache: Arc, + /// Beacon fork-choice scratch. Empty and untouched on a lean chain. + pub(crate) beacon: Arc>, + /// The background writer, joined when the last clone of this `Store` + /// drops. See [`StateWriterHandle`]. + state_writer: Arc, } /// Build an empty state cache sized to [`STATE_CACHE_CAPACITY`]. -fn new_state_cache() -> Arc>> { +fn new_state_cache() -> Arc { let capacity = NonZeroUsize::new(STATE_CACHE_CAPACITY).expect("cache capacity is non-zero"); Arc::new(Mutex::new(LruCache::new(capacity))) } @@ -650,91 +944,343 @@ impl Store { .expect("store initialization should succeed in get_forkchoice_store")) } - /// Build a Store from the state already persisted in the storage backend. + /// Load the chain a data directory holds, without judging whether it is + /// ours. + /// + /// Returns `None` when the backend has never held a chain of either kind, + /// leaving the caller to initialize one from genesis or a checkpoint. + /// + /// **The caller must check [`Store::chain`] and verify the finalized + /// state's genesis against the network it was configured for before + /// writing anything.** This returns a usable `Store` for a foreign chain + /// as readily as for our own, and initializing a new anchor on top of a + /// foreign one would leave that chain's rows in place, reachable through + /// the slot-indexed reads that serve `BlocksByRange`, so peers would be + /// served another network's blocks. + /// + /// Loading and judging are separate because the judgement needs the + /// configured network and this crate does not know it. `main`'s + /// `fetch_initial_state` is the caller, and it discharges the obligation + /// immediately; the beacon path grows its own alongside it. /// - /// Returns `None` when the backend holds no chain state yet, leaving the - /// caller to initialize one from genesis or a checkpoint. + /// **The caller must also call [`Store::repair_head`], after its own + /// checks, before trusting this store for anything else.** This does not + /// do it itself: repairing the head is a mutation, and this function's own + /// contract (like every other read here) is to load without writing. + /// `repair_head` also wants the caller's checks to have already run: it + /// assumes justified and finalized both have persisted states (see + /// [`Store::verify_anchor_states`]), which is what lets it bound its walk + /// against finalized rather than potentially rewinding past it. /// /// # Errors /// - /// Returns [`Error::GenesisMismatch`] when the persisted chain was started - /// from a different genesis than `genesis`. This is fatal rather than a - /// fall back to "treat the DB as empty": writing a new anchor on top would - /// leave the foreign chain's rows in place, and slot-indexed reads such as - /// [`Self::get_signed_blocks_by_slot_range`] would then serve them to - /// peers. - pub fn from_db_state( - backend: Arc, - genesis: &GenesisConfig, - ) -> Result, Error> { - let persisted_config = { - // Both keys are written by `init_store`, so a backend missing - // either has never held a chain. + /// [`Error::DbVersionMismatch`] when the directory was written by a build + /// with a different on-disk format. There is no migration. + pub fn from_db_state(backend: Arc) -> Result, Error> { + let (config, chain, anchor_slot) = { + // Written by both `init_store` and `init_beacon`, so a backend + // missing this has never held a chain of either kind. let view = backend.begin_read().expect("read view"); + // Kept as raw bytes: it may only be decoded once the version and + // preset checks below have passed. let Some(bytes) = view.get(Table::Metadata, KEY_CONFIG).expect("get config") else { return Ok(None); }; - if view - .get(Table::Metadata, KEY_LATEST_FINALIZED) - .expect("get latest finalized") - .is_none() - { - return Ok(None); + + let found = view + .read_with(Table::Metadata, KEY_DB_VERSION, |bytes| { + u64::from_ssz_bytes(bytes).expect("valid db version") + }) + .expect("read db version") + .unwrap_or(0); + if found != DB_VERSION { + return Err(Error::DbVersionMismatch { + found, + expected: DB_VERSION, + }); } - ChainConfig::from_persisted_ssz_bytes(&bytes).expect("valid config") - }; - // The slot duration is absent from the state, so `verify_state` below - // cannot see it: compare the persisted config directly. A data - // directory built at another cadence indexes its blocks against a - // different time grid, which makes it as foreign as another genesis. - genesis - .verify_time_config(&persisted_config) - .inspect_err(|err| { - error!( - %err, - db_genesis_time = persisted_config.genesis_time, - db_milliseconds_per_slot = persisted_config.milliseconds_per_slot, - expected_genesis_time = genesis.genesis_time, - expected_milliseconds_per_slot = genesis.milliseconds_per_slot, - "Persisted DB was built on a different time grid; refusing to reuse this data directory" - ) - })?; + // Before the config decode below and well before any state read: + // the preset fixes every SSZ container bound in the directory, so + // a build holding the other one would not be reading the same + // shapes back. The version check above cannot stand in for this, + // since both presets write the same *layout* at the same version. + let found_preset = view + .read_with(Table::Metadata, KEY_PRESET, |bytes| bytes.first().copied()) + .expect("read preset") + .flatten() + .and_then(Preset::from_selector); + if found_preset != Some(Preset::ACTIVE) { + return Err(Error::PresetMismatch { + found: found_preset.map(Preset::name), + expected: Preset::ACTIVE.name(), + }); + } + + let chain = view + .read_with(Table::Metadata, KEY_CHAIN, |bytes| bytes.first().copied()) + .expect("read chain") + .flatten() + .and_then(Chain::from_selector) + .expect("a versioned directory always carries a chain tag"); + + // Both bootstrap paths write this, and the version check above + // already turned away every directory written before they did, so + // an absent key here is not an old directory but a corrupt one. + let anchor_slot = view + .read_with(Table::Metadata, KEY_ANCHOR_SLOT, |bytes| { + u64::from_ssz_bytes(bytes).expect("valid anchor slot") + }) + .expect("read anchor slot") + .expect("a versioned directory always carries an anchor slot"); + + ( + Config::from_ssz_bytes(&bytes).expect("valid config"), + chain, + anchor_slot, + ) + }; - let store = Self { + info!(?chain, anchor_slot, "Loaded store from persisted DB state"); + Ok(Some(Self::from_parts( backend, - config: persisted_config, - new_payloads: Arc::new(Mutex::new(PayloadBuffer::new(NEW_PAYLOAD_CAP))), - known_payloads: Arc::new(Mutex::new(PayloadBuffer::new(AGGREGATED_PAYLOAD_CAP))), - fork_choice: Default::default(), - gossip_signatures: Arc::new(Mutex::new(GossipSignatureBuffer::new( - GOSSIP_SIGNATURE_CAP, - ))), - state_cache: new_state_cache(), + Arc::new(config), + chain, + anchor_slot, + ))) + } + + /// Checks that both the justified and finalized checkpoints have a + /// persisted state, returning the finalized state (which a resuming + /// caller needs anyway, to verify genesis) or [`Error::AnchorStateLost`] + /// naming whichever checkpoint does not. + /// + /// Call this, and act on its error, before [`Store::repair_head`]: + /// unlike the head, justified and finalized are consensus statements. A + /// repair may roll the head back to a recent ancestor because fork choice + /// reprocesses forward from there on its own (see `repair_head`'s doc), + /// but there is no equivalent recovery for a checkpoint a repair cannot + /// simply invent an earlier version of. So this reports the loss instead, + /// for the caller's own retry logic to treat exactly like a stale + /// directory: fall back to checkpoint sync if a URL is configured, or + /// fail naming the remedy in [`Error::AnchorStateLost`] if not. + /// + /// This is also what lets `repair_head` bound its own walk against + /// finalized: once this has passed, finalized's state is known good, so a + /// walk that reaches it (rather than running past it) is a normal + /// termination, not a case `repair_head` has to guard against on its own. + pub fn verify_anchor_states(&self) -> Result, Error> { + let justified = self.latest_justified()?; + if !self.has_state(&justified.root)? { + return Err(Error::AnchorStateLost { + checkpoint: justified, + }); + } + let finalized = self.latest_finalized()?; + self.get_state(&finalized.root)? + .ok_or(Error::AnchorStateLost { + checkpoint: finalized, + }) + } + + /// Rewinds the head to the newest ancestor with a persisted state, if the + /// recorded head has none. + /// + /// A resuming caller (see [`Store::from_db_state`]'s doc) calls this + /// after [`Store::verify_anchor_states`] has already confirmed justified + /// and finalized both have one; this method assumes that and does not + /// re-check it. + /// + /// # Why the head can outrun its own state + /// + /// The writer thread (see [`crate::state_writer::StateWriterHandle`]) + /// commits a block's post-state asynchronously; the caller that hands it + /// off gets control back once the state is cached and buffered, not once + /// it is on disk. The importer's `update_head` writes `KEY_HEAD` (and the + /// canonical `BlockRoots` index) synchronously, right after that hand-off, + /// so an unclean shutdown can catch the head pointer on disk before the + /// state it names is. Before the writer thread existed the state write + /// was synchronous and always preceded the head write, so a persisted + /// head implied a persisted state; this restores that invariant on the + /// way back up, once per resume, rather than requiring every future + /// reader of the head to re-check it. + /// + /// Only the head pointer and the canonical `BlockRoots` index are + /// rewritten (the latter by the same [`Store::update_checkpoints`] every + /// head move already goes through). Justified and finalized are never + /// touched here; see [`Store::verify_anchor_states`] for why. + /// + /// `KEY_SAFE_TARGET` is left exactly as stale as it already was: it can + /// still name a hopped block whose `LiveChain` row this method just + /// deleted. Harmless, deliberately not fixed up here: its only reader + /// (the lean tick pipeline's block-building guard) takes the block's + /// header, never its state, and the next interval-3 tick recomputes it + /// from `get_live_chain()`, which already reflects the deletion. + /// + /// # This also removes the hopped blocks from fork choice + /// + /// Each block walked past keeps its `BlockHeaders`/`BlockBodies`/ + /// `BlockProof` rows; only its `LiveChain` row is deleted. That is + /// already this codebase's encoding of "invisible to fork choice" (see + /// [`Store::insert_pending_block`]'s doc), and it is what makes the + /// rewind stick: leaving the row behind would keep the stateless tip + /// visible to `compute_lmd_ghost_head`, which does not consult + /// `has_state`, so the very next fork-choice run would walk right back to + /// it. Deleting it composes with machinery that already exists to bring a + /// stateless block back: `on_block_core` keys its duplicate check on + /// `has_state`, not on the block being on record, so re-processing it is + /// not treated as a no-op; range sync asks peers for blocks starting at + /// `head_slot + 1`, which is now this block's slot again; and the + /// pending-block walk that runs when a new block's parent has no state + /// pulls a stateless ancestor back out of storage with + /// [`Store::get_signed_block`] and re-imports it. Nothing here has to + /// reach across into the blockchain crate to trigger any of that; it + /// falls out of a block just being stateless again, which is the + /// ordinary case those three already handle. + /// + /// # Termination + /// + /// On the lean arm, guaranteed: [`Store::init_store`] writes the anchor's + /// snapshot synchronously, in the same atomic batch as the rest of the + /// metadata, and a lean head always names a block (`init_store` writes it + /// in that same batch), so the walk always reaches a persisted state + /// before it could reach a root with no block entry at all. + /// + /// On the beacon arm this is not guaranteed the same way: the anchor + /// insertion that follows [`Store::init_beacon`] now enqueues its state + /// like any other (`insert_state` no longer writes synchronously on + /// either arm), so the same unclean-shutdown window that motivates this + /// method can also catch the anchor itself without a state. Two + /// independent bounds cover this instead of trusting synchronous writes + /// that no longer happen, checked together since + /// [`Store::anchor_slot`] `<=` [`Store::latest_finalized`]'s slot always + /// holds and the two therefore coincide on a fresh checkpoint-sync + /// anchor with no finalization progress yet: + /// + /// - Below or at [`Store::latest_finalized`]'s slot, + /// [`Store::prune_live_chain`] has already deleted `LiveChain` rows for + /// every slot down there, so a head rewound that far would be invisible + /// to fork choice regardless — worse than the state it is missing. + /// Reported as [`Error::AnchorStateLost`], the same error + /// `verify_anchor_states` reports; reaching it here means that check's + /// invariant did not hold, which this treats as an error rather than + /// trusting. This is what the tie above resolves to, since it names the + /// checkpoint and a remedy. + /// - Strictly below [`Store::anchor_slot`] *and* strictly below + /// finalized's slot (so distinguishable from the tie), this directory + /// could never have held a state to fall back to at all. Reported as + /// [`Error::UnexpectedMissingState`] naming the original head, not an + /// unfamiliar ancestor the operator never saw the walk step onto. + /// + /// Independently of both, at most [`STATE_WRITE_QUEUE_CAPACITY`] + 1 + /// blocks can have an unwritten state behind the head at once (the queue + /// plus the one write the worker thread can be holding), so a walk + /// longer than that means something other than this window caused it. + /// That case is reported as [`Error::HeadRepairExceededWindow`] rather + /// than walked past. A root with no block entry at all, reached partway + /// through the walk (not at the start; see below), is a broken parent + /// chain and is reported as [`Error::UnexpectedMissingBlockHeader`]. + /// + /// # A head with no block at all + /// + /// On a lean directory this is corruption: `init_store` writes the head's + /// own block in the same batch as `KEY_HEAD`, so nothing legitimate + /// leaves that row pointing at an absent header. Reported as + /// [`Error::UnexpectedMissingBlockHeader`]. + /// + /// On a beacon directory this can be legitimate: [`Store::init_beacon`] + /// alone seeds `KEY_HEAD` at the checkpoint root before the anchor block + /// and state that pair with it have been inserted (a separate step, + /// taken by the caller once checkpoint sync or the genesis path completes + /// it), and `from_db_state`'s contract is to load that directory anyway. + /// The writer-outran-the-head race this method repairs cannot produce + /// that shape on its own: it requires the head's own block to already be + /// on disk, only its state to be missing. So this bails out untouched, + /// rather than reporting corruption for a directory `from_db_state` has + /// always accepted. + pub fn repair_head(&mut self) -> Result<(), Error> { + let start = self.head()?; + // `has_state` checks `pending_states` before the backend, but that + // buffer is always empty here: this runs once per resume, right after + // `from_parts` built a fresh one, before anything has been inserted. + // So this is a pure backend check, not a reason to reach for a raw + // table read instead. Checked before `block_entry` so the common, + // already-healthy case pays for exactly one read, not two. + if self.has_state(&start)? { + return Ok(()); + } + + // See "A head with no block at all" above. + let Some((mut slot, mut parent_root)) = self.block_entry(&start) else { + return match self.chain { + Chain::Lean => Err(Error::UnexpectedMissingBlockHeader(start)), + Chain::Beacon => Ok(()), + }; }; - // Also compare against the finalized state: the persisted config - // carries no validator registry, so the check above cannot catch a - // chain that shares our genesis time and cadence but not our validator - // set. Finalized is chosen over head because it is the state the - // anchor is rebuilt from and it never gets pruned. - let finalized = store.latest_finalized()?.root; - let state = store - .get_state(&finalized)? - .ok_or(Error::UnexpectedMissingState(finalized))?; - genesis.verify_state(&state).inspect_err(|err| { - error!( - %err, - db_genesis_time = state.config.genesis_time, - db_validators = state.validators.len(), - expected_genesis_time = genesis.genesis_time, - expected_validators = genesis.genesis_validators.len(), - "Persisted DB belongs to a different network; refusing to reuse this data directory" - ) - })?; + let finalized = self.latest_finalized()?; + let mut cursor = start; + let mut hops = 0usize; + // `(slot, root)` pairs to delete from `LiveChain`; see "This also + // removes the hopped blocks from fork choice" above. + let mut hopped = Vec::new(); + + loop { + // `anchor_slot <= finalized.slot` always holds, so the two bounds + // coincide on a fresh checkpoint-sync anchor with no finalization + // progress yet. The tie, and everything at or below finalized in + // general, is reported as `AnchorStateLost`: it names the + // checkpoint and a remedy, which `UnexpectedMissingState` does + // not. Only where the anchor sits strictly *above* finalized + // does a stop at or below it get the more specific error: there, + // "this directory never held that block" is the more accurate + // statement than a reader would get from the checkpoint's own + // (later) slot. + if slot <= finalized.slot { + return Err( + if slot <= self.anchor_slot && self.anchor_slot < finalized.slot { + Error::UnexpectedMissingState(start) + } else { + Error::AnchorStateLost { + checkpoint: finalized, + } + }, + ); + } + + hopped.push((slot, cursor)); + hops += 1; + if hops > STATE_WRITE_QUEUE_CAPACITY + 1 { + return Err(Error::HeadRepairExceededWindow { + start, + stalled_at: cursor, + hops, + }); + } + + cursor = parent_root; + if self.has_state(&cursor)? { + break; + } + let Some(next) = self.block_entry(&cursor) else { + return Err(Error::UnexpectedMissingBlockHeader(cursor)); + }; + slot = next.0; + parent_root = next.1; + } - info!("Loaded store from persisted DB state"); - Ok(Some(store)) + // Reaching here means the loop hopped at least once, so `cursor` is + // strictly an ancestor of `start`: nothing below removes a row for a + // head that was never touched. + self.delete_live_chain_entries(&hopped); + warn!( + from = %start, + to = %cursor, + hops, + "head outran the state writer; rewound to the newest ancestor with a persisted state" + ); + self.update_checkpoints(ForkCheckpoints::head_only(cursor))?; + Ok(()) } /// Internal helper to initialize the store with anchor data. @@ -746,7 +1292,6 @@ impl Store { anchor_body: Option, milliseconds_per_slot: u64, ) -> Result { - let config = ChainConfig::new(anchor_state.config.genesis_time, milliseconds_per_slot); // Save original state_root for validation let original_state_root = anchor_state.latest_block_header.state_root; @@ -767,23 +1312,39 @@ impl Store { let anchor_block_root = anchor_state.latest_block_header.hash_tree_root(); + let anchor_slot = anchor_state.latest_block_header.slot; let anchor_checkpoint = Checkpoint { root: anchor_block_root, - slot: anchor_state.latest_block_header.slot, + slot: anchor_slot, }; + // The runtime config a lean directory bootstraps with: built once here + // so the same value backs both the persisted row and the in-memory + // `Store`, rather than reconstructing it twice. + let runtime_config = Arc::new(Config::lean( + anchor_state.config.genesis_time, + milliseconds_per_slot, + )); + // Insert initial data { let mut batch = backend.begin_write().expect("write batch"); // Metadata let metadata_entries = vec![ - (KEY_TIME.to_vec(), 0u64.to_ssz()), - (KEY_CONFIG.to_vec(), config.to_ssz()), + (KEY_DB_VERSION.to_vec(), DB_VERSION.to_ssz()), + (KEY_CHAIN.to_vec(), vec![Chain::Lean.selector()]), + (KEY_PRESET.to_vec(), vec![Preset::ACTIVE.selector()]), + // Genesis, not zero: `KEY_TIME` is an absolute UNIX + // millisecond, so the value that means "the clock has not + // advanced past genesis" is genesis itself. + (KEY_TIME.to_vec(), runtime_config.genesis_time_ms().to_ssz()), + (KEY_CONFIG.to_vec(), runtime_config.to_ssz()), (KEY_HEAD.to_vec(), anchor_block_root.to_ssz()), (KEY_SAFE_TARGET.to_vec(), anchor_block_root.to_ssz()), (KEY_LATEST_JUSTIFIED.to_vec(), anchor_checkpoint.to_ssz()), (KEY_LATEST_FINALIZED.to_vec(), anchor_checkpoint.to_ssz()), + (KEY_ANCHOR_SLOT.to_vec(), anchor_slot.to_ssz()), ]; batch .put_batch(Table::Metadata, metadata_entries) @@ -819,7 +1380,10 @@ impl Store { // State snapshot. The anchor has no parent in the store, so it is // the base of every diff chain: store it as a full snapshot in // `States` (never pruned) so reconstruction always terminates here. - let state_entries = vec![(anchor_block_root.to_ssz(), anchor_state.to_ssz())]; + let state_entries = vec![( + anchor_block_root.to_ssz(), + encode_state_value(&BeaconState::Lean(anchor_state.clone())), + )]; batch .put_batch(Table::States, state_entries) .expect("put state"); @@ -836,101 +1400,342 @@ impl Store { batch.commit().expect("commit"); } - info!(%anchor_state_root, %anchor_block_root, "Initialized store"); + info!(%anchor_state_root, %anchor_block_root, anchor_slot, "Initialized store"); + + Ok(Self::from_parts( + backend, + runtime_config, + Chain::Lean, + anchor_slot, + )) + } + + /// Initialize an empty beacon-chain store. + /// + /// Writes only what every later read assumes exists: the format version, + /// the chain tag, the config, a zero clock and zeroed checkpoints. The + /// anchor block and state are written by the beacon fork choice's own + /// `get_forkchoice_store`, which is where the specification's construction + /// rules live and which needs beacon helpers this crate cannot call. + /// + /// `anchor_slot` is that caller's `anchor_state.slot()`, taken as its own + /// argument rather than read off `anchor_checkpoint`: the stored checkpoint + /// is epoch-denominated, so its slot is the epoch's *start*, which sits + /// below the anchor's own slot whenever the anchor is not itself a boundary + /// block. + pub fn init_beacon( + backend: Arc, + genesis_time: u64, + config: Config, + anchor_block_root: H256, + anchor_checkpoint: Checkpoint, + anchor_slot: u64, + ) -> Self { + let runtime_config = Arc::new(Config { + genesis_time, + ..config + }); + + let zero_checkpoint = BeaconCheckpoint::default(); + // `KEY_HEAD`, `KEY_LATEST_JUSTIFIED` and `KEY_LATEST_FINALIZED` are + // seeded here for the same reason `init_store` seeds them on a lean + // directory: `update_checkpoints` reads the head it is moving *from* + // and the finalized slot it is advancing *past*, so both chains have + // to start with those rows present rather than have that one writer + // grow an absent-key branch. + let metadata_entries = vec![ + (KEY_DB_VERSION.to_vec(), DB_VERSION.to_ssz()), + (KEY_CHAIN.to_vec(), vec![Chain::Beacon.selector()]), + (KEY_PRESET.to_vec(), vec![Preset::ACTIVE.selector()]), + // Genesis rather than zero, for the reason `init_store` gives. + // `get_forkchoice_store` overwrites this immediately with the + // anchor's own time; the seed matters only for the window before + // it does. + (KEY_TIME.to_vec(), runtime_config.genesis_time_ms().to_ssz()), + (KEY_CONFIG.to_vec(), runtime_config.to_ssz()), + (KEY_HEAD.to_vec(), anchor_block_root.to_ssz()), + (KEY_LATEST_JUSTIFIED.to_vec(), anchor_checkpoint.to_ssz()), + (KEY_LATEST_FINALIZED.to_vec(), anchor_checkpoint.to_ssz()), + ( + KEY_BEACON_UNREALIZED_JUSTIFIED.to_vec(), + zero_checkpoint.to_ssz(), + ), + ( + KEY_BEACON_UNREALIZED_FINALIZED.to_vec(), + zero_checkpoint.to_ssz(), + ), + (KEY_ANCHOR_SLOT.to_vec(), anchor_slot.to_ssz()), + ]; - Ok(Self { + let mut batch = backend.begin_write().expect("write batch"); + batch + .put_batch(Table::Metadata, metadata_entries) + .expect("put metadata"); + batch.commit().expect("commit"); + + info!(genesis_time, anchor_slot, "Initialized beacon store"); + + Self::from_parts(backend, runtime_config, Chain::Beacon, anchor_slot) + } + + /// Assembles a `Store` from the fields that vary across constructors, + /// filling in the rest with fresh, empty buffers shared by every bootstrap + /// path: [`Store::init_store`] (used by both [`Store::from_anchor_state`] + /// and [`Store::get_forkchoice_store`]), [`Store::init_beacon`] and + /// [`Store::from_db_state`]. + /// + /// `anchor_slot` is the one field the resume path cannot derive, which is + /// why the two `init_*` paths persist it under [`KEY_ANCHOR_SLOT`] for + /// [`Store::from_db_state`] to read back. + fn from_parts( + backend: Arc, + config: Arc, + chain: Chain, + anchor_slot: u64, + ) -> Self { + let state_cache = new_state_cache(); + let pending_states = Arc::new(PendingStates::default()); + let state_writer = Arc::new(StateWriterHandle::spawn( + backend.clone(), + chain, + state_cache.clone(), + pending_states.clone(), + )); + Self { backend, config, + chain, + anchor_slot, new_payloads: Arc::new(Mutex::new(PayloadBuffer::new(NEW_PAYLOAD_CAP))), known_payloads: Arc::new(Mutex::new(PayloadBuffer::new(AGGREGATED_PAYLOAD_CAP))), fork_choice: Default::default(), gossip_signatures: Arc::new(Mutex::new(GossipSignatureBuffer::new( GOSSIP_SIGNATURE_CAP, ))), - state_cache: new_state_cache(), - }) + state_cache, + pending_states, + committee_cache: Arc::new(CommitteeCache::default()), + beacon: Default::default(), + state_writer, + } } // ============ Metadata Helpers ============ - fn get_metadata(&self, key: &[u8]) -> Result { + /// Reads an SSZ metadata value that the store's bootstrap path guarantees + /// exists. + /// + /// Names the key on the way out: the lean and beacon paths seed different + /// key sets, so an absent key means the wrong chain's accessor was reached + /// on this store, and the key is what says which one. + pub(crate) fn get_metadata(&self, key: &[u8]) -> T { let view = self.backend.begin_read().expect("read view"); - let bytes = view - .get(Table::Metadata, key) - .expect("get") - .expect("metadata key exists"); - Ok(T::from_ssz_bytes(&bytes).expect("valid encoding")) + view.read_with(Table::Metadata, key, |bytes| { + T::from_ssz_bytes(bytes).expect("valid encoding") + }) + .expect("read") + .unwrap_or_else(|| { + panic!( + "metadata key {:?} is absent on a {:?} store", + String::from_utf8_lossy(key), + self.chain + ) + }) + } + + pub(crate) fn set_metadata(&self, key: &[u8], value: &T) { + self.set_metadata_batch(&[(key, value)]); } - fn set_metadata(&self, key: &[u8], value: &T) -> Result<(), Error> { + /// Writes several SSZ metadata values under one commit. + /// + /// [`Store::set_metadata`] opens and commits a batch per call, so a caller + /// advancing a set of related keys through it would pay a commit each and + /// leave a window in which only some of them had landed. An empty slice + /// writes nothing rather than committing an empty batch, so a caller can + /// pass only the values that actually changed. + pub(crate) fn set_metadata_batch(&self, values: &[(&[u8], &T)]) { + if values.is_empty() { + return; + } let mut batch = self.backend.begin_write().expect("write batch"); + let entries = values + .iter() + .map(|(key, value)| (key.to_vec(), value.to_ssz())) + .collect(); batch - .put_batch(Table::Metadata, vec![(key.to_vec(), value.to_ssz())]) + .put_batch(Table::Metadata, entries) .expect("put metadata"); batch.commit().expect("commit"); - Ok(()) } // ============ Time ============ - /// Returns the current store time in interval counts since genesis. + /// The store clock, as a UNIX timestamp in milliseconds. One row, one unit, + /// both chains. /// - /// Each increment represents one interval, a fifth of the configured slot. - /// Use [`Self::current_slot`] - /// for the slot; the interval within it is `time() % INTERVALS_PER_SLOT`. - pub fn time(&self) -> Result { - self.get_metadata(KEY_TIME) + /// Named for its unit because the beacon specification's `Store.time` is + /// the same quantity in seconds, and the two would otherwise be one + /// unmarked factor of a thousand apart at every call site. A caller that + /// wants the specification's number divides; see + /// [`Self::ms_since_genesis`] for the callers that want an offset instead. + pub fn time_ms(&self) -> Result { + Ok(self.get_metadata(KEY_TIME)) + } + + /// Sets the store clock. See [`Self::time_ms`] for the unit. + pub fn set_time_ms(&mut self, time_ms: u64) -> Result<(), Error> { + self.set_metadata(KEY_TIME, &time_ms); + Ok(()) } - /// Sets the current store time. - pub fn set_time(&mut self, time: u64) -> Result<(), Error> { - self.set_metadata(KEY_TIME, &time) + /// How far past genesis the store clock reads, in milliseconds. + /// + /// The base of both derived clocks below, and the one place the genesis + /// subtraction and its saturation are written. Saturates rather than + /// wrapping: a store seeded at genesis never reads earlier, but an + /// externally supplied anchor time can. + /// + /// Public because it is also what a caller placing a moment *within* the + /// current slot wants: `ms_since_genesis() % slot_duration_ms` is how far + /// into its slot the clock reads, which the beacon reorg and + /// block-timeliness rules compare against their basis-point deadlines. + pub fn ms_since_genesis(&self) -> u64 { + self.time_ms() + .expect("store time exists") + .saturating_sub(self.config.genesis_time_ms()) + } + + /// How many intervals have elapsed since genesis, each a fifth of the + /// configured slot. + /// + /// Derived from [`Self::time_ms`], not stored: it is the same clock read on + /// a finer grid, and a second row would be a second thing to keep in step. + /// This is the grid lean's `on_tick` steps through, running a duty per + /// step, and the one lean's fork-choice fixtures report. + /// + /// The slot is `intervals_since_genesis() / INTERVALS_PER_SLOT` and the + /// interval within it is the remainder; the former agrees with + /// [`Self::current_slot`] by construction, since both divide the same + /// millisecond offset. + /// + /// Meaningful on lean only, but not gated: a beacon directory shares the + /// row this reads, so the answer is well-defined there and simply names a + /// grid that chain does not schedule on. + pub fn intervals_since_genesis(&self) -> u64 { + self.ms_since_genesis() / self.config.milliseconds_per_interval() } - /// The current slot, derived from the store clock. + /// The slot the store clock falls in, on either chain. + /// + /// Divides by [`Config::slot_duration_ms`] rather than + /// [`Config::seconds_per_slot`] so a cadence that is not a whole number of + /// seconds still lands on the right slot: `Config::lean` derives + /// `seconds_per_slot` by truncating the millisecond value, so for lean the + /// millisecond field is the authoritative one. The two agree wherever + /// `slot_duration_ms == seconds_per_slot * 1000`, which every beacon + /// configuration holds to. pub fn current_slot(&self) -> u64 { - self.time().expect("store time exists") / INTERVALS_PER_SLOT + self.ms_since_genesis() / self.config.slot_duration_ms } // ============ Config ============ - /// Returns the chain configuration. + /// The node's runtime configuration. + /// + /// Returns an owned handle rather than a reference so the caller can hold + /// it across the `&mut Store` that every beacon fork-choice entry point + /// takes alongside it; cloning the `Arc` is one atomic increment, not a + /// copy of the fork schedule. + /// + /// Infallible: fixed at bootstrap and cached, so this never reads the + /// backend. + pub fn config(&self) -> Arc { + Arc::clone(&self.config) + } + + /// Which consensus protocol this data directory holds. + /// + /// Infallible for the same reason [`Store::config`] is: fixed at bootstrap + /// and cached, so this never reads the backend. + pub fn chain(&self) -> Chain { + self.chain + } + + /// Refuse a lean-only accessor on a beacon store, naming the accessor. /// - /// Infallible: the config is fixed at bootstrap and cached in the `Store`, - /// so this never reads the backend. - pub fn config(&self) -> &ChainConfig { - &self.config + /// `Table::BlockHeaders` holds a different shape per chain: a lean + /// directory a [`BlockHeader`], a beacon one the whole signed block. An + /// accessor that decodes lean's own types out of it therefore answers + /// nothing on a beacon directory, and left unchecked it fails inside SSZ + /// with a length mismatch that names neither the accessor nor the caller. + /// + /// A P2P handler reached one through a peer's request on 2026-09-11 and + /// took the whole swarm actor down with exactly that error, so the check + /// is here rather than left to every call site to remember. Callers that + /// need these fields on either chain have + /// [`block_entry`](Self::block_entry) and + /// [`block_slot_and_state_root`](Self::block_slot_and_state_root), which + /// decode per chain. + #[cold] + #[track_caller] + fn lean_only(accessor: &str) -> ! { + panic!("{accessor} is lean-only and was called on a beacon store"); } // ============ Head ============ /// Returns the current head block root. pub fn head(&self) -> Result { - self.get_metadata(KEY_HEAD) + Ok(self.get_metadata(KEY_HEAD)) } // ============ Safe Target ============ /// Returns the safe target block root for attestations. pub fn safe_target(&self) -> Result { - self.get_metadata(KEY_SAFE_TARGET) + Ok(self.get_metadata(KEY_SAFE_TARGET)) } /// Sets the safe target block root. pub fn set_safe_target(&mut self, safe_target: H256) -> Result<(), Error> { - self.set_metadata(KEY_SAFE_TARGET, &safe_target) + self.set_metadata(KEY_SAFE_TARGET, &safe_target); + Ok(()) } // ============ Checkpoints ============ /// Returns the latest justified checkpoint. pub fn latest_justified(&self) -> Result { - self.get_metadata(KEY_LATEST_JUSTIFIED) + Ok(self.get_metadata(KEY_LATEST_JUSTIFIED)) } /// Returns the latest finalized checkpoint. pub fn latest_finalized(&self) -> Result { - self.get_metadata(KEY_LATEST_FINALIZED) + Ok(self.get_metadata(KEY_LATEST_FINALIZED)) + } + + /// The root of the finalized state, whichever chain this store holds. + /// + /// Reads [`KEY_LATEST_FINALIZED`] without asking which chain it is on: + /// both keep their finalized checkpoint there, and the epoch-to-slot + /// conversion the beacon accessors apply + /// ([`Store::beacon_finalized_checkpoint`]) touches only the slot, never + /// the root. So a caller that wants just the anchor, such as a resume + /// path's genesis check, needs no chain-specific branch at all. + /// + /// Every initialized directory has one: `init_store` anchors at the + /// genesis or checkpoint block, and `init_beacon` takes its anchor as an + /// argument and writes it in the same atomic batch as the rest of the + /// metadata. A zero root is therefore not a state either bootstrap path + /// can produce; see [`Error::UnanchoredDirectory`] for what it takes to + /// reach one. + pub fn finalized_state_root(&self) -> Result { + let root = self.latest_finalized()?.root; + if root.is_zero() { + return Err(Error::UnanchoredDirectory); + } + Ok(root) } // ============ Checkpoint Updates ============ @@ -973,7 +1778,20 @@ impl Store { // live chain index, signatures, and attestation data. These are cheap and // affect fork choice correctness (live chain) or attestation processing. // Heavy state/block pruning is deferred to prune_old_data(). - if let Some(finalized) = checkpoints.finalized + // + // Lean only, and deliberately so. The gossip-signature and aggregated + // payload buffers hold lean attestations, which a beacon directory + // never has, so those two would be no-ops. `prune_live_chain` would + // not be: it drops every row below the finalized slot, but the beacon + // fork choice walks *past* that boundary. `filter_block_tree` asks + // `get_checkpoint_block` for the ancestor at the finalized epoch's + // start slot, and `get_ancestor` keeps walking parents while their + // slot exceeds the one asked for, so an empty start slot sends it to a + // block strictly below the horizon. A missing row there is a hard + // `SpecAssert`, not a degraded read, so beacon needs its own horizon + // before it can prune at all. + if self.chain == Chain::Lean + && let Some(finalized) = checkpoints.finalized && finalized.slot > old_finalized_slot { let pruned_chain = self @@ -1037,26 +1855,39 @@ impl Store { let mut deletes = Vec::new(); let mut entries = Vec::new(); - let mut old_header = self - .get_block_header(&old_root)? + // The head did not move, so the canonical index cannot have changed. + // Answered before the two reads below because a checkpoint-only + // advance passes the head through unchanged, and on the beacon arm + // that is the common case rather than an edge one. + if old_root == new_root { + return Ok((deletes, entries)); + } + + // Through `block_entry` rather than `get_block_header`: the walk wants + // only a slot and a parent root, which both chains' header rows carry, + // so this diff is the same computation on either. + let mut old_entry = self + .block_entry(&old_root) .ok_or(Error::UnexpectedMissingBlockHeader(old_root))?; - let mut new_header = self - .get_block_header(&new_root)? + let mut new_entry = self + .block_entry(&new_root) .ok_or(Error::UnexpectedMissingBlockHeader(new_root))?; // Walk both branches back toward their common ancestor, until we find the common ancestor. while old_root != new_root { - if old_header.slot < new_header.slot { - entries.push((encode_block_root_key(new_header.slot), new_root.to_ssz())); - new_root = new_header.parent_root; - new_header = self - .get_block_header(&new_root)? + let (old_slot, old_parent) = old_entry; + let (new_slot, new_parent) = new_entry; + if old_slot < new_slot { + entries.push((encode_block_root_key(new_slot), new_root.to_ssz())); + new_root = new_parent; + new_entry = self + .block_entry(&new_root) .ok_or(Error::UnexpectedMissingBlockHeader(new_root))?; } else { - deletes.push(encode_block_root_key(old_header.slot)); - old_root = old_header.parent_root; - old_header = self - .get_block_header(&old_root)? + deletes.push(encode_block_root_key(old_slot)); + old_root = old_parent; + old_entry = self + .block_entry(&old_root) .ok_or(Error::UnexpectedMissingBlockHeader(old_root))?; } } @@ -1145,6 +1976,52 @@ impl Store { Ok(count) } + /// Writes one live-chain index row. + /// + /// `insert_signed_block` writes these as part of a block's own batch; this + /// is the standalone form, for tests and for any caller that needs to put a + /// row back. + pub fn insert_live_chain_entry(&mut self, slot: u64, root: H256, parent_root: H256) { + let entries = vec![(encode_slot_root_key(slot, &root), parent_root.to_ssz())]; + let mut batch = self.backend.begin_write().expect("write batch"); + batch + .put_batch(Table::LiveChain, entries) + .expect("put live chain entry"); + batch.commit().expect("commit"); + } + + /// Deletes the named live-chain index rows. + /// + /// Unlike [`prune_live_chain`](Self::prune_live_chain), which drops a whole + /// slot range below a horizon and is lean-only, this removes exactly the + /// `(slot, root)` pairs given. That is what invalidating an execution + /// payload needs: the roots to drop are a subtree, not a slot window, and + /// the blocks either side of them at the same slots must survive. + /// + /// Dropping the row is the whole of "remove this block from fork choice": + /// [`block_index`](Self::block_index) is the only source + /// `filter_block_tree`, `compute_weights` and `get_head` read, so a root + /// with no row contributes no weight to any ancestor and can never be + /// walked to. + /// + /// The block and its state stay in their own tables. Nothing reads them + /// once the index row is gone, and keeping them means an operator can still + /// inspect what was rejected. + pub fn delete_live_chain_entries(&mut self, entries: &[(u64, H256)]) { + if entries.is_empty() { + return; + } + let keys: Vec> = entries + .iter() + .map(|(slot, root)| encode_slot_root_key(*slot, root)) + .collect(); + let mut batch = self.backend.begin_write().expect("write batch"); + batch + .delete_batch(Table::LiveChain, keys) + .expect("delete live chain entries"); + batch.commit().expect("commit"); + } + /// Prune gossip signatures for slots <= finalized_slot. /// /// Returns the number of entries pruned. @@ -1215,31 +2092,60 @@ impl Store { /// Get the block header by root. pub fn get_block_header(&self, root: &H256) -> Result, Error> { + if self.chain != Chain::Lean { + Self::lean_only("Store::get_block_header"); + } let view = self.backend.begin_read().expect("read view"); Ok(view - .get(Table::BlockHeaders, &root.to_ssz()) - .expect("get") - .map(|bytes| BlockHeader::from_ssz_bytes(&bytes).expect("valid header"))) + .read_with(Table::BlockHeaders, &root.to_ssz(), |bytes| { + BlockHeader::from_ssz_bytes(bytes).expect("valid header") + }) + .expect("read")) } // ============ Signed Blocks ============ /// Insert a block as pending (parent state not yet available). /// - /// Stores block data in `BlockHeaders`/`BlockBodies`/`BlockProof` - /// **without** writing to `LiveChain`. This persists the heavy proof - /// data (~3KB+ per block) to disk while keeping the block invisible to - /// fork choice. + /// One method for both chains, mirroring [`insert_signed_block`](Self::insert_signed_block): + /// the lean arm stores block data in `BlockHeaders`/`BlockBodies`/`BlockProof`, + /// the beacon arm stores the whole signed block in a single `BlockHeaders` + /// row. Neither arm writes to `LiveChain`, which is the one and only + /// difference from `insert_signed_block`: that omission is exactly what + /// keeps a pending block invisible to fork choice until its parent + /// arrives and it is re-inserted as admitted. Lean's proof data + /// (~3KB+ per block) is persisted to disk in the meantime rather than + /// held in memory. /// /// When the block is later processed via [`insert_signed_block`](Self::insert_signed_block), /// the same keys are overwritten (idempotent) and a `LiveChain` entry is added. + /// + /// Unlike `insert_signed_block`, this never calls + /// `record_known_attestation_votes`: that call records votes carried by + /// blocks fork choice has actually admitted, and a pending block is not + /// admitted. pub fn insert_pending_block( &mut self, root: H256, - signed_block: SignedBlock, + block: SignedBeaconBlock, ) -> Result<(), Error> { let mut batch = self.backend.begin_write().expect("write batch"); - write_signed_block(batch.as_mut(), &root, signed_block); + + match block { + SignedBeaconBlock::Lean(signed_block) => { + write_signed_block(batch.as_mut(), &root, signed_block); + } + beacon_block => { + // The whole signed block, in one row, through the same + // `write_beacon_block` `insert_signed_block`'s beacon arm + // uses: no `BlockBodies` row (a beacon block has no + // header/body split) and no `BlockProof` row (its signature + // lives inside the block, not in a separate proof blob). + write_beacon_block(batch.as_mut(), &root, &beacon_block); + } + } + + // One commit for both arms, so the write stays atomic. batch.commit().expect("commit"); Ok(()) } @@ -1251,89 +2157,377 @@ impl Store { /// only storing signatures for non-genesis blocks. /// /// Takes ownership to avoid cloning large signature data. + /// + /// One method for both chains, split inline rather than behind a per-chain + /// helper: the two arms write different tables, and that difference is + /// exactly what a reader of this function needs to see. Because a beacon + /// [`Root`](ethlambda_types::beacon::primitives::Root) is already [`H256`], + /// there is one key type shared by both arms and nothing to bridge. pub fn insert_signed_block( &mut self, root: H256, - signed_block: SignedBlock, + block: SignedBeaconBlock, ) -> Result<(), Error> { let mut batch = self.backend.begin_write().expect("write batch"); - let block = write_signed_block(batch.as_mut(), &root, signed_block); - let index_entries = vec![( - encode_slot_root_key(block.slot, &root), - block.parent_root.to_ssz(), - )]; - batch - .put_batch(Table::LiveChain, index_entries) - .expect("put non-finalized chain index"); + // The lean arm's post-commit attestation-vote recording has nothing to + // do for a beacon block, which carries no lean attestations, so the + // decision is handed back out of the match rather than run + // unconditionally after the commit. + let lean_block = match block { + SignedBeaconBlock::Lean(signed_block) => { + let block = write_signed_block(batch.as_mut(), &root, signed_block); + + let index_entries = vec![( + encode_slot_root_key(block.slot, &root), + block.parent_root.to_ssz(), + )]; + batch + .put_batch(Table::LiveChain, index_entries) + .expect("put non-finalized chain index"); + + Some(block) + } + beacon_block => { + let slot = beacon_block.slot(); + let parent_root = beacon_block.parent_root(); + + // The whole signed block, in one row. `BlockHeaders` holds a + // full lean `BlockHeader` on a lean directory and a whole + // beacon block here; the two shapes never coexist in one + // table, since a data directory holds one chain for its whole + // life (see `Chain`). + // + // `BlockBodies` is not written at all on this arm. Lean splits + // header from body so a header-only query need not pay for the + // body, and so an empty body can be left out entirely; a + // beacon block has no such empty case and nothing reads a + // beacon header without its block, so a second row would only + // add a write and a way for the two to disagree. + write_beacon_block(batch.as_mut(), &root, &beacon_block); + + // `BlockRoots` is not written here, on either chain: it + // indexes the canonical branch, which import order does not + // determine, so `update_checkpoints` maintains it as the head + // moves. `BlockProof` is lean's alone, since a beacon block + // carries its signature inside the block rather than in a + // separate proof blob. + let index_entries = vec![(encode_slot_root_key(slot, &root), parent_root.to_ssz())]; + batch + .put_batch(Table::LiveChain, index_entries) + .expect("put non-finalized chain index"); + + None + } + }; + // One commit for both arms, so the write stays atomic: a half-written + // block would be visible to the LiveChain scan without being + // decodable from BlockHeaders/BlockBodies. batch.commit().expect("commit"); - self.record_known_attestation_votes(&block.body.attestations); + + if let Some(block) = lean_block { + self.record_known_attestation_votes(&block.body.attestations); + } Ok(()) } - /// Get a block (header + body, no signatures) by root. - /// - /// Unlike [`get_signed_block`](Self::get_signed_block), this works for the - /// genesis block, which has no signature entry. - pub fn get_block(&self, root: &H256) -> Result, Error> { - let view = self.backend.begin_read().expect("read view"); - let key = root.to_ssz(); + // ============ Beacon Checkpoints ============ - let Some(header_bytes) = view.get(Table::BlockHeaders, &key).expect("get") else { - return Ok(None); - }; - let header = BlockHeader::from_ssz_bytes(&header_bytes).expect("valid header"); + /// Returns the beacon store's justified checkpoint. + /// + /// Reads the same slot-denominated [`KEY_LATEST_JUSTIFIED`] row lean uses + /// and converts back. The conversion is exact in both directions: a + /// checkpoint's epoch is stored as that epoch's own start slot, so + /// dividing recovers it, and this pair of helpers is the only place either + /// chain's checkpoint changes units. + pub fn beacon_justified_checkpoint(&self) -> BeaconCheckpoint { + Self::as_beacon_checkpoint( + self.latest_justified() + .expect("justified checkpoint exists"), + ) + } - let body = if header.body_root == *EMPTY_BODY_ROOT { - BlockBody::default() - } else { - let Some(body_bytes) = view.get(Table::BlockBodies, &key).expect("get") else { - return Ok(None); - }; - BlockBody::from_ssz_bytes(&body_bytes).expect("valid body") - }; + /// Returns the beacon store's finalized checkpoint. See + /// [`Store::beacon_justified_checkpoint`] for the unit conversion. + pub fn beacon_finalized_checkpoint(&self) -> BeaconCheckpoint { + Self::as_beacon_checkpoint( + self.latest_finalized() + .expect("finalized checkpoint exists"), + ) + } - Ok(Some(Block::from_header_and_body(header, body))) + /// A stored, slot-denominated [`Checkpoint`] read as a beacon one. + fn as_beacon_checkpoint(checkpoint: Checkpoint) -> BeaconCheckpoint { + BeaconCheckpoint { + epoch: checkpoint.slot / SLOTS_PER_EPOCH, + root: checkpoint.root, + } } - /// Get a signed block by combining header, body, and the merged proof. - /// - /// Returns None if the header or body (for non-empty bodies) is missing, - /// or if the proof row is missing for any block other than the - /// slot-0 anchor. + /// A beacon checkpoint in the slot-denominated form both chains store. /// - /// Proofs are absent in two cases: genesis-style anchor blocks (no - /// proposer ever signed them), and finalized blocks whose proofs were - /// pruned by [`prune_old_block_proofs`](Self::prune_old_block_proofs). - /// To keep BlocksByRoot symmetric with the fork-choice view for peers, - /// synthesize an empty proof for the slot-0 anchor only; for any other slot - /// a missing proof surfaces as `None` (a pruned finalized block can no - /// longer be served with its proof) rather than as a fabricated block. - pub fn get_signed_block(&self, root: &H256) -> Result, Error> { - let view = self.backend.begin_read().expect("read view"); - Ok(Self::signed_block_from_view(view.as_ref(), root)) + /// An epoch is stored as its own start slot, which is what makes + /// [`Store::as_beacon_checkpoint`] recover it exactly, and what lets the + /// shared finalization-advance comparison read a beacon checkpoint + /// without a second rule for it. + pub fn beacon_checkpoint_as_stored(checkpoint: BeaconCheckpoint) -> Checkpoint { + Checkpoint { + root: checkpoint.root, + slot: checkpoint.epoch * SLOTS_PER_EPOCH, + } } - fn signed_block_from_view(view: &dyn StorageReadView, root: &H256) -> Option { - let key = root.to_ssz(); + /// Returns the beacon store's unrealized justified checkpoint. + pub fn beacon_unrealized_justified_checkpoint(&self) -> BeaconCheckpoint { + self.get_metadata(KEY_BEACON_UNREALIZED_JUSTIFIED) + } - let header_bytes = view.get(Table::BlockHeaders, &key).expect("get")?; - let header = BlockHeader::from_ssz_bytes(&header_bytes).expect("valid header"); + /// Sets the beacon store's unrealized justified checkpoint. + /// + /// No monotonicity check: the specification's own + /// `update_unrealized_checkpoints` owns that rule, and this is a plain + /// write underneath it. + pub fn set_beacon_unrealized_justified_checkpoint(&mut self, checkpoint: BeaconCheckpoint) { + self.set_metadata(KEY_BEACON_UNREALIZED_JUSTIFIED, &checkpoint); + } + + /// Returns the beacon store's unrealized finalized checkpoint. + pub fn beacon_unrealized_finalized_checkpoint(&self) -> BeaconCheckpoint { + self.get_metadata(KEY_BEACON_UNREALIZED_FINALIZED) + } + + /// Sets the beacon store's unrealized finalized checkpoint. See + /// [`Store::set_beacon_unrealized_justified_checkpoint`] for why there is + /// no monotonicity check here either. + pub fn set_beacon_unrealized_finalized_checkpoint(&mut self, checkpoint: BeaconCheckpoint) { + self.set_metadata(KEY_BEACON_UNREALIZED_FINALIZED, &checkpoint); + } + + /// Advances whichever of the unrealized justified and finalized + /// checkpoints is `Some`, under one commit. + /// + /// The fork choice moves the two as a pair, so they are written as one: + /// separate commits leave a window in which one has advanced and the + /// other has not. Passing `None` for one leaves that key alone, and + /// `None` for both writes nothing at all. + /// + /// The *realized* pair has no counterpart here: it goes through + /// [`Store::update_checkpoints`], the writer both chains share. + pub fn set_beacon_unrealized_checkpoints( + &mut self, + justified: Option, + finalized: Option, + ) { + let mut values: Vec<(&[u8], &BeaconCheckpoint)> = Vec::with_capacity(2); + if let Some(justified) = justified.as_ref() { + values.push((KEY_BEACON_UNREALIZED_JUSTIFIED, justified)); + } + if let Some(finalized) = finalized.as_ref() { + values.push((KEY_BEACON_UNREALIZED_FINALIZED, finalized)); + } + self.set_metadata_batch(&values); + } + + // ============ Beacon Head ============ + + /// The beacon fork-choice head as `(slot, root)`, or `None` if the head + /// row names a block this store has no header for. + /// + /// Derived rather than stored: the head itself is [`KEY_HEAD`], the row + /// both chains keep and [`Store::update_checkpoints`] is the single writer + /// of, and the slot comes from the head's own + /// [`block_entry`](Self::block_entry). A second head row denominated in + /// `slot || root` would be a value that could drift from the first. + pub fn beacon_head(&self) -> Option<(u64, H256)> { + let root = self.head().expect("head block exists"); + let (slot, _) = self.block_entry(&root)?; + Some((slot, root)) + } + + /// `root`'s slot and parent root, without decoding its body. + /// + /// The two chains keep different shapes in `Table::BlockHeaders`: a lean + /// directory a full [`BlockHeader`], a beacon one the whole signed block. + /// Both answer this question, so the decode is what varies and the caller + /// does not have to care which chain it is on. That is what lets the + /// fork-choice tree walk and the `BlockRoots` index diff be written once + /// for both. + /// + /// On the beacon arm this decodes the whole block to reach two fields. + /// Callers walking a chain of them should build [`Store::block_index`] + /// once instead, which reads the same links out of `LiveChain`. + pub fn block_entry(&self, root: &H256) -> Option<(u64, H256)> { + self.block_fields(root) + .map(|(slot, parent_root, _)| (slot, parent_root)) + } + + /// `root`'s slot and state root, without decoding its body on lean. + /// + /// The sibling of [`block_entry`](Self::block_entry), which answers the + /// parent-root question instead, and chain-generic for the same reason: + /// a lean directory keeps a [`BlockHeader`] in `Table::BlockHeaders` and a + /// beacon one the whole signed block, so the decode is what varies and the + /// caller does not have to know which chain it is on. On the beacon arm + /// this decodes the whole block to reach two fields. + /// + /// Both fields come out of one read, so a caller emitting them together + /// cannot pair a slot with a state root from a different block. That is + /// what the chain-event emission needs, and why it does not read them + /// through two accessors. + pub fn block_slot_and_state_root(&self, root: &H256) -> Option<(u64, H256)> { + self.block_fields(root) + .map(|(slot, _, state_root)| (slot, state_root)) + } + + /// `root`'s slot, parent root and state root: the one read and the one + /// per-chain decode that [`block_entry`](Self::block_entry) and + /// [`block_slot_and_state_root`](Self::block_slot_and_state_root) project + /// out of. + /// + /// One decode rather than one per accessor, so the `Table::BlockHeaders` + /// row shape is stated once and the two public accessors cannot disagree + /// about which block a row is. Returning all three costs nothing: every + /// field is already in hand once the row is decoded. + fn block_fields(&self, root: &H256) -> Option<(u64, H256, H256)> { + let view = self.backend.begin_read().expect("read view"); + view.read_with(Table::BlockHeaders, &root.to_ssz(), |bytes| { + match self.chain { + Chain::Lean => { + let header = BlockHeader::from_ssz_bytes(bytes).expect("valid header"); + (header.slot, header.parent_root, header.state_root) + } + Chain::Beacon => { + let block = decode_beacon_block_value(bytes); + (block.slot(), block.parent_root(), block.state_root()) + } + } + }) + .expect("read") + } + + /// Whether a block is stored under `root`. + pub fn has_block(&self, root: &H256) -> bool { + let view = self.backend.begin_read().expect("read view"); + view.contains(Table::BlockHeaders, &root.to_ssz()) + .expect("contains") + } + + /// Every stored beacon block as `root -> (slot, parent_root)`. + /// + /// Built once per tree walk and passed down rather than re-read per hop: + /// `get_weight` calls `get_ancestor` once per active validator, so a point + /// lookup per hop would multiply a scan the specification already writes + /// as naive by a backend round trip. + /// + /// The same `LiveChain` scan lean's fork choice reads through + /// [`Store::get_live_chain`], under the name the beacon specification uses + /// and without the `Result`: the beacon fork choice's error type lives in + /// `ethlambda-types` and cannot name a storage error, like the rest of the + /// scratch accessors below. + pub fn block_index(&self) -> HashMap { + self.get_live_chain().expect("live chain scan") + } + + /// Get a block (header + body, no signatures) by root. + /// + /// Unlike [`get_signed_block`](Self::get_signed_block), this works for the + /// genesis block, which has no signature entry. + pub fn get_block(&self, root: &H256) -> Result, Error> { + if self.chain != Chain::Lean { + Self::lean_only("Store::get_block"); + } + let view = self.backend.begin_read().expect("read view"); + let key = root.to_ssz(); + + let Some(header) = view + .read_with(Table::BlockHeaders, &key, |bytes| { + BlockHeader::from_ssz_bytes(bytes).expect("valid header") + }) + .expect("read") + else { + return Ok(None); + }; + + let body = if header.body_root == *EMPTY_BODY_ROOT { + BlockBody::default() + } else { + let Some(body) = view + .read_with(Table::BlockBodies, &key, |bytes| { + BlockBody::from_ssz_bytes(bytes).expect("valid body") + }) + .expect("read") + else { + return Ok(None); + }; + body + }; + + Ok(Some(Block::from_header_and_body(header, body))) + } + + /// Get a signed block by root, as the fork it was written under. + /// + /// One method for both chains, split inline the same way + /// [`insert_signed_block`](Self::insert_signed_block) is: dispatched on + /// `self.chain` rather than by trial-decoding, since a data directory + /// holds one chain for its whole life and the tag is authoritative. + /// + /// The lean arm returns None if the header or body (for non-empty + /// bodies) is missing, or if the proof row is missing for any block + /// other than the slot-0 anchor. + /// + /// Proofs are absent in two cases: genesis-style anchor blocks (no + /// proposer ever signed them), and finalized blocks whose proofs were + /// pruned by [`prune_old_block_proofs`](Self::prune_old_block_proofs). + /// To keep BlocksByRoot symmetric with the fork-choice view for peers, + /// synthesize an empty proof for the slot-0 anchor only; for any other slot + /// a missing proof surfaces as `None` (a pruned finalized block can no + /// longer be served with its proof) rather than as a fabricated block. + pub fn get_signed_block(&self, root: &H256) -> Result, Error> { + let view = self.backend.begin_read().expect("read view"); + + match self.chain { + Chain::Lean => { + Ok(Self::signed_block_from_view(view.as_ref(), root).map(SignedBeaconBlock::Lean)) + } + Chain::Beacon => Ok(view + .read_with( + Table::BlockHeaders, + &root.to_ssz(), + decode_beacon_block_value, + ) + .expect("read")), + } + } + + fn signed_block_from_view(view: &dyn StorageReadView, root: &H256) -> Option { + let key = root.to_ssz(); + + let header = view + .read_with(Table::BlockHeaders, &key, |bytes| { + BlockHeader::from_ssz_bytes(bytes).expect("valid header") + }) + .expect("read")?; // Use empty body if header indicates empty, otherwise fetch from DB let body = if header.body_root == *EMPTY_BODY_ROOT { BlockBody::default() } else { - let body_bytes = view.get(Table::BlockBodies, &key).expect("get")?; - BlockBody::from_ssz_bytes(&body_bytes).expect("valid body") + view.read_with(Table::BlockBodies, &key, |bytes| { + BlockBody::from_ssz_bytes(bytes).expect("valid body") + }) + .expect("read")? }; let sig_key = encode_slot_root_key(header.slot, root); - let proof = match view.get(Table::BlockProof, &sig_key).expect("get") { - Some(proof_bytes) => { - MultiMessageAggregate::from_ssz_bytes(&proof_bytes).expect("valid block proof") - } + let proof = view + .read_with(Table::BlockProof, &sig_key, |bytes| { + MultiMessageAggregate::from_ssz_bytes(bytes).expect("valid block proof") + }) + .expect("read"); + let proof = match proof { + Some(proof) => proof, // Synthesis only covers the genesis-style anchor (slot 0). For any // other slot a missing proof (pruned finalized block, or genuine // corruption) surfaces as `None` rather than a fabricated block. @@ -1364,21 +2558,33 @@ impl Store { pub fn canonical_root_at_slot(&self, slot: u64) -> Result, Error> { let view = self.backend.begin_read().expect("read view"); Ok(view - .get(Table::BlockRoots, &encode_block_root_key(slot)) - .expect("get block root") - .map(|bytes| H256::from_ssz_bytes(&bytes).expect("valid block root"))) + .read_with(Table::BlockRoots, &encode_block_root_key(slot), |bytes| { + H256::from_ssz_bytes(bytes).expect("valid block root") + }) + .expect("read block root")) } /// Return canonical signed blocks for the slot range `[start_slot, end_slot]`. /// - /// Missing slots or blocks are skipped. This keeps the current request - /// behavior while centralizing the slot-index lookup so the storage backend - /// can optimize range reads later. + /// Missing slots or blocks are skipped, which is what both chains' + /// `BlocksByRange` wants: "In cases where a slot is empty for a given slot + /// number, no block is returned." + /// + /// Canonical by construction rather than by filtering: `BlockRoots` holds + /// one root per slot on the branch ending at the current head, maintained by + /// [`update_checkpoints`](Self::update_checkpoints), so a sibling block at a + /// slot the head does not descend from is never read. Blocks come back in + /// ascending slot order because the loop walks the range in it. + /// + /// One method for both chains, split inline the same way + /// [`get_signed_block`](Self::get_signed_block) is. The index walk above the + /// split is identical: `BlockRoots` is written for either chain and keyed by + /// slot alone, so only the read of the row it points at differs. pub fn get_signed_blocks_by_slot_range( &self, start_slot: u64, end_slot: u64, - ) -> Result, Error> { + ) -> Result, Error> { let view = self.backend.begin_read().expect("read view"); let mut blocks = Vec::new(); for slot in start_slot..=end_slot { @@ -1386,15 +2592,35 @@ impl Store { // `canonical_root_at_slot`, which opens a fresh one per call: a // range must be served from a single snapshot so a head change // partway through cannot splice two branches into one response. - let Some(root_bytes) = view - .get(Table::BlockRoots, &encode_block_root_key(slot)) - .expect("get block root") + let Some(root) = view + .read_with(Table::BlockRoots, &encode_block_root_key(slot), |bytes| { + H256::from_ssz_bytes(bytes).expect("valid block root") + }) + .expect("read block root") else { continue; }; - let root = H256::from_ssz_bytes(&root_bytes).expect("valid block root"); - if let Some(block) = Self::signed_block_from_view(view.as_ref(), &root) { - blocks.push(block); + match self.chain { + Chain::Lean => { + if let Some(block) = Self::signed_block_from_view(view.as_ref(), &root) { + blocks.push(SignedBeaconBlock::Lean(block)); + } + } + // A beacon block is one row, so there is no equivalent of the + // lean arm's "header found but proof pruned" `None`: the row is + // there or the slot is skipped. + Chain::Beacon => { + if let Some(block) = view + .read_with( + Table::BlockHeaders, + &root.to_ssz(), + decode_beacon_block_value, + ) + .expect("read") + { + blocks.push(block); + } + } } } Ok(blocks) @@ -1404,138 +2630,158 @@ impl Store { /// Returns the state for the given block root. /// - /// Fast path: a full snapshot in `States`. Otherwise the state is - /// reconstructed by walking parent-linked `StateDiffs` back to the nearest - /// ancestor snapshot and replaying forward. Returns `None` if the diff chain - /// is broken or the target block header is unavailable. - pub fn get_state(&self, root: &H256) -> Result, Error> { - // Memoized hot states first (states are immutable per root). - if let Some(state) = self.state_cache.lock().unwrap().get(root) { - return Ok(Some(state.clone())); - } - // Anchor snapshot in `States`, otherwise reconstruct from the diff chain. - let snapshot = { - let view = self.backend.begin_read().expect("read view"); - view.get(Table::States, &root.to_ssz()) - .expect("get") - .map(|bytes| State::from_ssz_bytes(&bytes).expect("valid state")) - }; - let state = if let Some(s) = snapshot { - s - } else { - let Some(s) = self.reconstruct_state(root)? else { - return Ok(None); - }; - s - }; - self.state_cache.lock().unwrap().put(*root, state.clone()); - Ok(Some(state)) - } - - /// Reconstruct a state from diffs and the nearest ancestor snapshot. - /// - /// Walks `base_root` pointers back until a snapshot is found, fetches the - /// target's block header, and delegates the assembly to - /// [`state_diff::reconstruct`](crate::state_diff::reconstruct). + /// One method for both chains, split inline the same way + /// [`get_signed_block`](Self::get_signed_block) is: dispatched on + /// `self.chain` rather than on the returned value's own shape, since a + /// data directory holds one chain for its whole life and the tag is + /// authoritative. /// - /// Returns `Ok(None)` when the root is unknown or the diff chain is broken. - fn reconstruct_state(&self, root: &H256) -> Result, Error> { - // Walk back collecting diffs until we reach a snapshot. - let view = self.backend.begin_read().expect("read view"); - let mut diffs: Vec = Vec::new(); - let mut cursor = *root; - let snapshot = loop { - if let Some(bytes) = view.get(Table::States, &cursor.to_ssz()).expect("get") { - break State::from_ssz_bytes(&bytes).expect("valid state"); - } - let Some(diff_bytes) = view.get(Table::StateDiffs, &cursor.to_ssz()).expect("get") - else { - return Ok(None); - }; - let diff = StateDiff::from_ssz_bytes(&diff_bytes).expect("valid state diff"); - cursor = diff.base_root; - diffs.push(diff); - }; - drop(view); + /// The lookup order (cache, then the `pending_states` write-buffer, then + /// the backend) and the per-chain reconstruction live on + /// [`read_state`](crate::state_writer::read_state), which this calls + /// directly rather than restating: one copy of the algorithm is one copy + /// that can go stale. + pub fn get_state(&self, root: &H256) -> Result>, Error> { + read_state( + self.backend.as_ref(), + self.chain, + &self.state_cache, + &self.pending_states, + root, + ) + } - // `diffs` runs target -> snapshot child; reverse to snapshot child -> target. - diffs.reverse(); + /// The memoized state for `key`, if it is still resident. + pub fn cached_state(&self, key: CacheKey) -> Option> { + self.state_cache.lock().unwrap().get(&key).cloned() + } - // The latest block header lives in BlockHeaders; the stored state caches - // the real state_root there, so it equals the header byte-for-byte. - let Some(latest_block_header) = self.get_block_header(root)? else { - return Ok(None); - }; + /// Memoizes `state` under `key`. + /// + /// Takes `&self`, not `&mut self`: the read-only fork-choice helpers derive + /// on a miss and must be able to record the result. The interior mutex is + /// what makes that sound, and it is why `checkpoint_state` and the helpers + /// that reach it can stay `&Store`. + pub fn cache_state(&self, key: CacheKey, state: Arc) { + debug_assert!( + !state.has_pending_mutations(), + "a cached Arc cannot be flushed later; flush before wrapping the state in one" + ); + self.state_cache.lock().unwrap().put(key, state); + } - Ok(Some(crate::state_diff::reconstruct( - snapshot, - &diffs, - latest_block_header, - ))) + /// The committee-shuffling cache shared by every clone of this `Store`. + /// + /// Returns a cloned `Arc` (an atomic increment, like [`Self::config`]) + /// rather than a borrow of `&self`: a caller that also needs `&mut self` + /// in the same call, such as `fork_choice::on_block`'s `store` and + /// `committees` parameters, cannot borrow `self` both ways at once, and + /// an owned handle sidesteps that rather than forcing every such caller + /// to split its call in two. The clone is cheap, and every method on the + /// returned cache takes `&self` regardless of which handle reaches it. + pub fn committee_cache(&self) -> Arc { + Arc::clone(&self.committee_cache) } /// Returns whether a state is available for the given block root. /// - /// True if a snapshot exists or the state can be reconstructed from a diff. + /// True if `pending_states` or the state cache holds the state, a snapshot + /// exists, or the state can be reconstructed from a diff. Never reads a + /// value: existence checks only. pub fn has_state(&self, root: &H256) -> Result { + // Same pending-before-backend order as `read_state`; see its doc for + // why the backend never has to consult `pending_states` on its own. + if self.pending_states.get(root).is_some() { + return Ok(true); + } + // A cached block state is always one that was handed to the writer + // (`insert_state`, so in `pending_states` until committed) or read back + // from the backend (`read_state`); nothing deletes a persisted state. + // `peek`, not `get`: an existence check must not reorder the LRU. + // Without this, a state stored as a snapshot only (a beacon epoch + // anchor) costs a full-value read from RocksDB just to be found. + if self + .state_cache + .lock() + .unwrap() + .peek(&CacheKey::BlockState(*root)) + .is_some() + { + return Ok(true); + } let view = self.backend.begin_read().expect("read view"); let key = root.to_ssz(); - let states = view.get(Table::States, &key).expect("get"); - let diffs = view.get(Table::StateDiffs, &key).expect("get"); - Ok(states.is_some() || diffs.is_some()) + // Diffs first: every non-anchor state has one, so most roots never + // touch `States`, where even a miss is costly (no bloom filter). + Ok(view.contains(Table::StateDiffs, &key).expect("contains") + || view.contains(Table::States, &key).expect("contains")) } - /// Persist a post-block state as a parent-linked diff, snapshotting at anchors. + /// Persist a post-block state. + /// + /// One method for both chains, split inline: dispatched on `state`'s own + /// variant rather than on `self.chain`, since the write already has a + /// concrete value in hand (mirrors [`insert_signed_block`](Self::insert_signed_block), + /// which dispatches its write the same way while its `get_signed_block` + /// counterpart dispatches reads on `self.chain`). + /// + /// The byte-producing half of each arm, and the backend reads, the + /// commit and the parent-bytes memo, all live on the background writer in + /// [`crate::state_writer`]; this method only builds the request and hands + /// it off. + /// + /// Lean: a parent-linked diff, snapshotting at anchors. Every non-genesis + /// state gets a `StateDiffs` entry (never pruned, so the full state + /// history is preserved). A full snapshot is written to `States` only + /// when the block crosses a [`ForkName::snapshot_interval`] boundary; + /// these anchors are never pruned and bound the reconstruction walk. The + /// state is also inserted into the in-memory cache so the immediate next + /// read (e.g. as a child block's parent state) is hot without + /// reconstruction. The diff is built against the parent state, identified + /// by the post-state's own `latest_block_header.parent_root` (the state + /// transition sets it to the block's parent) and fetched via + /// [`get_state`](Self::get_state). The parent was persisted when its own + /// block was imported, so this read is normally a cache hit; a cold cache + /// falls back to a snapshot read or a diff-chain reconstruction. /// - /// Every non-genesis state gets a `StateDiffs` entry (never pruned, so the - /// full state history is preserved). A full snapshot is written to `States` - /// only when the block crosses a [`SNAPSHOT_ANCHOR_INTERVAL`] boundary; these - /// anchors are never pruned and bound the reconstruction walk. The state is - /// also inserted into the in-memory cache so the immediate next read (e.g. as - /// a child block's parent state) is hot without reconstruction. + /// Beacon: a byte-domain [`beacon_state_delta`](crate::beacon_state_delta) + /// diff, snapshotting at anchors, the same shape as lean's but working in + /// the SSZ byte domain rather than the field domain: a beacon validator + /// registry breaks lean's [`StateDiff`](crate::state_diff::StateDiff)'s "`validators` never changes" + /// assumption every epoch, so lean's diff cannot be reused as-is. The + /// parent is identified the same way lean's is, off the post-state's own + /// `latest_block_header.parent_root`, but its *encoded* bytes are what the + /// delta is computed against; the writer thread's parent-bytes lookup + /// (`StateWriter::encoded_parent_bytes`) explains how those are obtained + /// without an extra encode round trip. The store's first-ever beacon + /// state (a bootstrap or checkpoint-sync anchor) has no parent block on + /// record, so it is always a snapshot regardless of the interval math: + /// there is no base to diff against. /// - /// The diff is built against the parent state, identified by the post-state's - /// own `latest_block_header.parent_root` (the state transition sets it to the - /// block's parent) and fetched via [`get_state`](Self::get_state). The parent - /// was persisted when its own block was imported, so this read is normally a - /// cache hit; a cold cache falls back to a snapshot read or a diff-chain - /// reconstruction. + /// The work itself happens on the writer thread; this call returns once + /// the state is in the cache and the handoff buffer, which is what makes + /// it readable before it is written. A full queue blocks here. /// /// # Panics /// - /// Panics if no state exists for the parent root: a child state can only be - /// inserted after its parent's state has been persisted. - pub fn insert_state(&mut self, root: H256, state: State) -> Result<(), Error> { - // The post-state's latest_block_header is the block's own header, so its - // parent_root identifies the parent (base) state to diff against. - let parent_root = state.latest_block_header.parent_root; - let parent_state = self - .get_state(&parent_root) - .expect("parent state must exist to diff against") - .unwrap(); - let is_anchor = - state.slot / SNAPSHOT_ANCHOR_INTERVAL > parent_state.slot / SNAPSHOT_ANCHOR_INTERVAL; - - // Snapshot only at anchors; serialize before `state` is consumed. - let snapshot_bytes = is_anchor.then(|| state.to_ssz()); - // Memoize the post-state for fast reads, then move it into the diff so - // its multi-MB justification fields are not cloned again. - self.state_cache.lock().unwrap().put(root, state.clone()); - let diff_bytes = StateDiff::from_states(&parent_state, state) - .expect("state transition produced a non-append historical_block_hashes") - .to_ssz(); - - let key = root.to_ssz(); - let mut batch = self.backend.begin_write().expect("write batch"); - batch - .put_batch(Table::StateDiffs, vec![(key.clone(), diff_bytes)]) - .expect("put state diff"); - if let Some(snapshot_bytes) = snapshot_bytes { - batch - .put_batch(Table::States, vec![(key, snapshot_bytes)]) - .expect("put state snapshot"); - } - batch.commit().expect("commit"); + /// If the writer thread has already died from a previous write's panic; + /// see [`StateWriterHandle::send`](crate::state_writer::StateWriterHandle::send). + /// The invariant that a child state's parent must already be persisted is + /// still enforced, and still panics on violation, but on the writer + /// thread now rather than in this call. + pub fn insert_state(&mut self, root: H256, mut state: BeaconState) -> Result<(), Error> { + // A cached `Arc` cannot be flushed later, so every root taken + // through it would pay the slow, uncached hashing path. + state.apply_pending_mutations(); + let state = Arc::new(state); + // Both the cache and the buffer take a handle to the same state. The + // cache is the hot path for the immediate next read; the buffer is + // what keeps the state readable if the cache evicts it before the + // writer has committed. See `PendingStates`. + self.cache_state(CacheKey::BlockState(root), state.clone()); + self.pending_states.insert(root, state.clone()); + crate::metrics::inc_state_write_queue_depth(); + self.state_writer.send(StateWriteRequest { root, state }); Ok(()) } @@ -1882,6 +3128,9 @@ impl Store { /// Returns the slot of the current head block. pub fn head_slot(&self) -> u64 { + if self.chain != Chain::Lean { + Self::lean_only("Store::head_slot"); + } self.get_block_header(&self.head().expect("head block exists")) .expect("head block exists") .unwrap() @@ -1890,6 +3139,9 @@ impl Store { /// Returns the slot of the current safe target block. pub fn safe_target_slot(&self) -> u64 { + if self.chain != Chain::Lean { + Self::lean_only("Store::safe_target_slot"); + } self.get_block_header(&self.safe_target().expect("safe target exists")) .expect("safe target exists") .unwrap() @@ -1897,225 +3149,1285 @@ impl Store { } /// Returns a clone of the head state. + /// + /// Lean-only: every caller of this accessor wants the concrete lean + /// `State`, so the `BeaconState::Lean` wrapper is peeled off here rather + /// than at each call site. pub fn head_state(&self) -> State { - self.get_state(&self.head().expect("head block exists")) + let state = self + .get_state(&self.head().expect("head block exists")) .expect("head state is always available") - .unwrap() + .unwrap(); + state.expect_lean().clone() } -} - -/// Write block header, body, and the merged proof blob onto an existing batch. -/// -/// Returns the deserialized [`Block`] so callers can access fields like -/// `slot` and `parent_root` without re-deserializing. -fn write_signed_block( - batch: &mut dyn StorageWriteBatch, - root: &H256, - signed_block: SignedBlock, -) -> Block { - let SignedBlock { - message: block, - proof, - } = signed_block; - let header = block.header(); - let root_bytes = root.to_ssz(); + // ============ Beacon Fork-Choice Scratch ============ + // + // None of these carry a `Result`: the beacon fork choice's error type + // lives in `ethlambda-types` and cannot name a storage error, so these + // accessors take and return plain values like the rest of this scratch. - let header_entries = vec![(root_bytes.clone(), header.to_ssz())]; - batch - .put_batch(Table::BlockHeaders, header_entries) - .expect("put block header"); + /// The block root proposer boost currently applies to. Resets every slot. + pub fn proposer_boost_root(&self) -> H256 { + self.beacon.lock().unwrap().proposer_boost_root + } - // Skip storing empty bodies - they can be reconstructed from the header's body_root - if header.body_root != *EMPTY_BODY_ROOT { - let body_entries = vec![(root_bytes.clone(), block.body.to_ssz())]; - batch - .put_batch(Table::BlockBodies, body_entries) - .expect("put block body"); + /// Sets the block root proposer boost currently applies to. + pub fn set_proposer_boost_root(&mut self, root: H256) { + self.beacon.lock().unwrap().proposer_boost_root = root; } - // Store the merged multi-message aggregate proof blob, keyed by slot||root - // so proof pruning can scan in slot order and stop early. - let proof_entries = vec![(encode_slot_root_key(header.slot, root), proof.to_ssz())]; - batch - .put_batch(Table::BlockProof, proof_entries) - .expect("put block proof"); + /// Whether `root` arrived within the same-slot reorg window. `None` when + /// no timeliness has been recorded for the block yet. + pub fn block_timeliness(&self, root: &H256) -> Option { + self.beacon + .lock() + .unwrap() + .block_timeliness + .get(root) + .copied() + } - block -} + /// Records whether `root` arrived within the same-slot reorg window. + pub fn set_block_timeliness(&mut self, root: H256, timely: bool) { + self.beacon + .lock() + .unwrap() + .block_timeliness + .insert(root, timely); + } -#[cfg(test)] -mod tests { - use super::*; - use crate::backend::InMemoryBackend; - use ethlambda_types::constants::DEFAULT_MILLISECONDS_PER_SLOT; - use ethlambda_types::genesis::{GenesisMismatch, GenesisValidatorEntry}; - use ethlambda_types::state::PUBLIC_KEY_SIZE; + /// Whether `index` has been observed equivocating (via a processed + /// attester slashing). + pub fn is_equivocating(&self, index: u64) -> bool { + self.beacon + .lock() + .unwrap() + .equivocating_indices + .contains(&index) + } - /// Validator at `index` whose two pubkeys are filled with `seed`, so - /// changing the seed changes the registry without changing its size. - fn validator(index: u64, seed: u8) -> Validator { - Validator { - attestation_pubkey: [seed; PUBLIC_KEY_SIZE], - proposal_pubkey: [seed.wrapping_add(1); PUBLIC_KEY_SIZE], - index, - } + /// Marks `index` as equivocating. + pub fn insert_equivocating_index(&mut self, index: u64) { + self.beacon + .lock() + .unwrap() + .equivocating_indices + .insert(index); } - /// Genesis config describing a chain started at `genesis_time` with - /// `validators`, for the `from_db_state` identity check. - fn genesis_config(genesis_time: u64, validators: &[Validator]) -> GenesisConfig { - GenesisConfig { - genesis_time, - milliseconds_per_slot: DEFAULT_MILLISECONDS_PER_SLOT, - genesis_validators: validators - .iter() - .map(|v| GenesisValidatorEntry { - attestation_pubkey: v.attestation_pubkey, - proposal_pubkey: v.proposal_pubkey, - }) - .collect(), - } + /// The latest attestation recorded for validator `index`, if any. + pub fn latest_message(&self, index: u64) -> Option { + self.beacon + .lock() + .unwrap() + .latest_messages + .get(&index) + .copied() } - /// Insert a block header (and dummy body + proof) for a given root, slot, - /// and parent. The stored header equals `header_at(slot, parent_root)`, so a - /// state built from the same `(slot, parent_root)` reconstructs byte-identically. - fn insert_header(backend: &dyn StorageBackend, root: H256, slot: u64, parent_root: H256) { - let header = header_at(slot, parent_root); - let mut batch = backend.begin_write().expect("write batch"); - let key = root.to_ssz(); - batch - .put_batch(Table::BlockHeaders, vec![(key.clone(), header.to_ssz())]) - .expect("put header"); - batch - .put_batch(Table::BlockBodies, vec![(key.clone(), vec![0u8; 4])]) - .expect("put body"); - batch - .put_batch( - Table::BlockProof, - vec![(encode_slot_root_key(slot, &root), vec![0u8; 4])], - ) - .expect("put proof"); - batch - .put_batch( - Table::BlockRoots, - vec![(encode_block_root_key(slot), root.to_ssz())], - ) - .expect("put block root"); - batch.commit().expect("commit"); + /// Records the latest attestation for validator `index`. + pub fn set_latest_message(&mut self, index: u64, message: LatestMessage) { + self.beacon + .lock() + .unwrap() + .latest_messages + .insert(index, message); } - /// Insert a real full-state snapshot for a given root (seeds a diff-chain base). - fn insert_snapshot(backend: &dyn StorageBackend, root: H256, state: &State) { - let mut batch = backend.begin_write().expect("write batch"); - batch - .put_batch(Table::States, vec![(root.to_ssz(), state.to_ssz())]) - .expect("put snapshot"); - batch.commit().expect("commit"); + /// Calls `f` with `(validator_index, latest_message)` for every latest + /// message whose validator has not been observed equivocating. + /// + /// Takes a closure rather than returning an iterator or a cloned map: + /// the data lives behind a mutex, so a borrow of it cannot escape the + /// lock. The equivocator filter lives here, at the read, because + /// `get_weight` must exclude an equivocator's vote entirely rather than + /// let it count for either side of the fork it created. + pub fn for_each_non_equivocating_latest_message(&self, mut f: impl FnMut(u64, LatestMessage)) { + let beacon = self.beacon.lock().unwrap(); + for (&index, &message) in &beacon.latest_messages { + if !beacon.equivocating_indices.contains(&index) { + f(index, message); + } + } } - /// Count entries in a table. - fn count_entries(backend: &dyn StorageBackend, table: Table) -> usize { - let view = backend.begin_read().expect("read view"); - view.prefix_iterator(table, &[]) - .expect("iterator") - .filter_map(|r| r.ok()) - .count() + /// Looks up a PoW block by its own hash, standing in for the + /// specification's `get_pow_block(hash)`. + pub fn beacon_pow_block(&self, hash: H256) -> Option { + self.beacon.lock().unwrap().pow_blocks.get(&hash).copied() } - /// Check if a key exists in a table. - fn has_key(backend: &dyn StorageBackend, table: Table, root: &H256) -> bool { - let view = backend.begin_read().expect("read view"); - view.get(table, &root.to_ssz()).expect("get").is_some() + /// Records a PoW block, keyed by its own `block_hash` rather than a + /// caller-supplied key, matching the specification's lookup by that same + /// hash. + pub fn insert_beacon_pow_block(&mut self, block: PowBlock) { + self.beacon + .lock() + .unwrap() + .pow_blocks + .insert(block.block_hash, block); } - /// Check whether a block proof exists for a (slot, root) pair. - fn has_block_proof(backend: &dyn StorageBackend, slot: u64, root: &H256) -> bool { - let view = backend.begin_read().expect("read view"); - view.get(Table::BlockProof, &encode_slot_root_key(slot, root)) - .expect("get") - .is_some() + /// Looks up an execution client's answer for a payload, by that payload's + /// own execution block hash. + pub fn beacon_payload_status(&self, block_hash: ExecutionBlockHash) -> Option { + self.beacon + .lock() + .unwrap() + .payload_statuses + .get(&block_hash) + .cloned() } - /// Canonical block root at `slot`, for storage-index assertions. + /// Records an execution client's answer for a payload. The fixture format + /// allows the same payload's status to be updated several times over a + /// case, so this overwrites rather than preserving a first answer. + pub fn insert_beacon_payload_status( + &mut self, + block_hash: ExecutionBlockHash, + status: PayloadStatusV1, + ) { + self.beacon + .lock() + .unwrap() + .payload_statuses + .insert(block_hash, status); + } + + /// Whether `root` was imported on a `NOT_VALIDATED` answer and has not + /// since been resolved. + pub fn is_beacon_optimistic(&self, root: H256) -> bool { + self.beacon + .lock() + .unwrap() + .optimistic_roots + .contains_key(&root) + } + + /// Whether this store holds any optimistic root at all. + /// + /// The cheap half of [`Store::is_beacon_optimistic`], for callers that + /// would otherwise pay for a `block_index` scan only to walk a set that is + /// empty. With a healthy execution client it always is. + pub fn has_beacon_optimistic_roots(&self) -> bool { + !self.beacon.lock().unwrap().optimistic_roots.is_empty() + } + + /// Marks `root` as imported on a payload the execution layer has not + /// vouched for yet, against the slot the unfinalized-window bound prunes + /// it by. + pub fn insert_beacon_optimistic_root(&mut self, root: H256, slot: u64) { + self.beacon + .lock() + .unwrap() + .optimistic_roots + .insert(root, slot); + } + + /// Clears `root`'s optimistic marker, once its payload has been resolved + /// either way. + pub fn remove_beacon_optimistic_root(&mut self, root: H256) { + self.beacon.lock().unwrap().optimistic_roots.remove(&root); + } + + /// Drops optimistic roots strictly below `finalized_slot`. + /// + /// A root below finality can no longer be validated or invalidated in any + /// way this node acts on, so holding it only costs memory. Unlike + /// `el_block_hashes` this needs no exemption for the finalized checkpoint's + /// own root: nothing reads a finalized block's optimistic status, and + /// `mark_validated`'s walk stopping one block earlier is the same answer. + pub fn prune_beacon_optimistic_roots(&mut self, finalized_slot: u64) { + self.beacon + .lock() + .unwrap() + .optimistic_roots + .retain(|_root, slot| *slot >= finalized_slot); + } + + /// The execution block hash cached for a beacon root at import. + pub fn beacon_el_block_hash(&self, root: H256) -> Option { + self.beacon + .lock() + .unwrap() + .el_block_hashes + .get(&root) + .map(|(_slot, hash)| *hash) + } + + /// Caches the execution block hash a beacon block carries, against the slot + /// the unfinalized-window bound prunes it by. + pub fn insert_beacon_el_block_hash( + &mut self, + root: H256, + slot: u64, + block_hash: ExecutionBlockHash, + ) { + self.beacon + .lock() + .unwrap() + .el_block_hashes + .insert(root, (slot, block_hash)); + } + + /// Drops cached hashes strictly below `finalized_slot`, always keeping + /// `keep`. + /// + /// Strictly below, not at or below: the justified and head blocks + /// `forkchoiceUpdated` reads are at or above that slot, so this bound keeps + /// every root the call reads but one. + /// + /// That one is `keep`, the finalized checkpoint's own root, whose hash the + /// same call sends as `finalized_block_hash`. The slot bound alone does not + /// reach it: `finalized_slot` comes from a checkpoint, and + /// [`Store::beacon_checkpoint_as_stored`] stores an epoch as its own start + /// slot, while the checkpoint root is the last block at *or before* that + /// boundary. A missed proposal at an epoch boundary therefore leaves the + /// finalized block below the bound, and dropping its hash makes every later + /// `forkchoiceUpdated` carry `finalized_block_hash = 0x00..0`, which stops + /// the execution client advancing its own finalized block for as long as + /// the process runs. + pub fn prune_beacon_el_block_hashes(&mut self, finalized_slot: u64, keep: H256) { + self.beacon + .lock() + .unwrap() + .el_block_hashes + .retain(|root, (slot, _hash)| *slot >= finalized_slot || *root == keep); + } + + /// Returns `root`'s unrealized justification, if this store has computed + /// one. + /// + /// `get_voting_source` reads this for every block from a prior epoch, so + /// it is the hottest map in the scratch; recomputing a missing entry means + /// replaying epoch processing on a copy of that block's post-state. That + /// still does not make it chain history: a restarted node re-imports the + /// unfinalized window from its anchor and refills the map as it goes. + pub fn unrealized_justification(&self, root: &H256) -> Option { + self.beacon + .lock() + .unwrap() + .unrealized_justifications + .get(root) + .copied() + } + + /// Records `root`'s unrealized justification. + pub fn set_unrealized_justification(&mut self, root: H256, checkpoint: BeaconCheckpoint) { + self.beacon + .lock() + .unwrap() + .unrealized_justifications + .insert(root, checkpoint); + } + + // ============ Data Columns ============ + // + // Written on arrival (after verification), not at block import: the + // availability check needs a block's columns before that block imports, + // and a restart should keep what this node already paid to verify. See + // `Table::DataColumns`. + + /// Store one verified sidecar. + /// + /// Takes the encoded bytes rather than the container: the caller has just + /// decoded them off the wire, and re-encoding a sidecar to store it would + /// pay a second SSZ pass on the hot gossip path for nothing. + /// + /// No earliest-slot bookkeeping rides along. The floor the by-range handler + /// refuses below is [`Store::anchor_slot`], which is where this directory's + /// chain begins and so is where custody could have begun; deriving it from + /// the sidecars actually written would make a hot-path read-decide-write + /// span out of a value that is fixed for the life of the directory. + /// + /// `&self` rather than `&mut self`, unlike most other writers in this + /// file, matching `set_metadata`: nothing here mutates a `Store` field + /// itself, only the shared backend behind it, so a shared reference + /// suffices no matter how many read-only clones exist elsewhere. + pub fn put_data_column_sidecar( + &self, + slot: u64, + block_root: &H256, + column_index: u64, + encoded: Vec, + ) -> Result<(), Error> { + self.put_column_row(Table::DataColumns, slot, block_root, column_index, encoded) + } + + /// Park a sidecar whose parent block has no post-state to check it against. + /// + /// The same key and the same encoded bytes as + /// [`Self::put_data_column_sidecar`], in `Table::PendingDataColumns` + /// instead. Nothing that decides data availability reads that table, which + /// is the whole point: this row has passed only the cheap structural + /// checks, and the expensive ones run when + /// [`Self::take_pending_data_column_sidecar`] hands it back. + pub fn put_pending_data_column_sidecar( + &self, + slot: u64, + block_root: &H256, + column_index: u64, + encoded: Vec, + ) -> Result<(), Error> { + self.put_column_row( + Table::PendingDataColumns, + slot, + block_root, + column_index, + encoded, + ) + } + + /// Commit one sidecar row, under the same key in whichever of the two + /// column tables the caller named. + /// + /// Which table a sidecar belongs in is what separates the two writers + /// above; how a row is committed is not, and the availability gate's whole + /// safety rests on a parked row and a verified one being the same bytes + /// under the same key in different tables. + fn put_column_row( + &self, + table: Table, + slot: u64, + block_root: &H256, + column_index: u64, + encoded: Vec, + ) -> Result<(), Error> { + let mut batch = self.backend.begin_write().expect("write batch"); + let entries = vec![(data_column_key(slot, block_root, column_index), encoded)]; + batch + .put_batch(table, entries) + .expect("put data column sidecar"); + batch.commit().expect("commit"); + Ok(()) + } + + /// Drop every parked row this directory holds. + /// + /// Called once at startup. The only index into `PendingDataColumns` is the + /// chain actor's in-memory `sidecars_awaiting_parent`, which does not + /// survive a restart, so every row written before one is unreachable by + /// construction — kept, unverified, and never read again. Nothing is lost + /// by dropping them: a parked sidecar had passed no check worth + /// preserving, and the block it belongs to will ask for its columns again. + pub fn clear_pending_data_column_sidecars(&self) -> Result<(), Error> { + // `delete_range` is half-open, and every key here is 48 bytes, so the + // upper bound is one byte longer and all ones: a shorter key sorts + // before its own extension, which is what makes this a strict bound on + // even an all-ones key rather than one that spares it. + let mut batch = self.backend.begin_write().expect("write batch"); + batch + .delete_range( + Table::PendingDataColumns, + &[0u8; DATA_COLUMN_KEY_LEN], + &[u8::MAX; DATA_COLUMN_KEY_LEN + 1], + ) + .expect("clear parked data column sidecars"); + batch.commit().expect("commit"); + Ok(()) + } + + /// Read a parked sidecar back and drop its row in one step. + /// + /// Take rather than get: every caller is either about to verify the + /// sidecar, after which it belongs in `DataColumns` and not here, or about + /// to give up on it. Leaving the row behind for the caller to delete is + /// the shape that leaks one on every path that returns early. + pub fn take_pending_data_column_sidecar( + &self, + slot: u64, + block_root: &H256, + column_index: u64, + ) -> Result>, Error> { + let key = data_column_key(slot, block_root, column_index); + let view = self.backend.begin_read().expect("read view"); + let encoded = view.get(Table::PendingDataColumns, &key).expect("get"); + drop(view); + if encoded.is_some() { + let mut batch = self.backend.begin_write().expect("write batch"); + batch + .delete_batch(Table::PendingDataColumns, vec![key]) + .expect("delete pending data column sidecar"); + batch.commit().expect("commit"); + } + Ok(encoded) + } + + /// Drop parked rows without reading them, for sidecars being given up on. + pub fn delete_pending_data_column_sidecars( + &self, + keys: impl IntoIterator, + ) -> Result<(), Error> { + let keys: Vec> = keys + .into_iter() + .map(|(slot, block_root, column_index)| { + data_column_key(slot, &block_root, column_index) + }) + .collect(); + if keys.is_empty() { + return Ok(()); + } + let mut batch = self.backend.begin_write().expect("write batch"); + batch + .delete_batch(Table::PendingDataColumns, keys) + .expect("delete pending data column sidecars"); + batch.commit().expect("commit"); + Ok(()) + } + + /// One sidecar, or `None` if this node never custodied it. + pub fn get_data_column_sidecar( + &self, + slot: u64, + block_root: &H256, + column_index: u64, + ) -> Result>, Error> { + let view = self.backend.begin_read().expect("read view"); + Ok(view + .get( + Table::DataColumns, + &data_column_key(slot, block_root, column_index), + ) + .expect("get")) + } + + /// Whether this node already holds a specific column of one block. + /// + /// A point `get` on the same key [`Self::put_data_column_sidecar`] writes, + /// unlike [`Self::data_column_indices_for`]'s prefix scan: a RocksDB + /// iterator loads every value it walks past, so scanning a whole block's + /// columns just to ask about one of them reads every sibling sidecar this + /// node custodies, which is too costly to pay inline for every incoming + /// gossip column. + pub fn has_data_column(&self, slot: u64, root: &H256, index: u64) -> bool { + let view = self.backend.begin_read().expect("read view"); + view.contains(Table::DataColumns, &data_column_key(slot, root, index)) + .expect("contains") + } + + /// Which columns of one block this node holds, ascending. + /// + /// What the availability check asks: it compares this against the columns + /// the node owes rather than fetching the sidecars themselves, so a block + /// missing one column costs no decoding at all. + /// + /// Sorted explicitly rather than trusted from the backend: both current + /// backends already return a prefix scan in lexicographic key order (see + /// `InMemoryBackend::prefix_iterator`), which for a fixed slot||root + /// prefix and a big-endian index is already ascending, but a future + /// backend need not repeat that guarantee. + pub fn data_column_indices_for(&self, slot: u64, block_root: &H256) -> Result, Error> { + let view = self.backend.begin_read().expect("read view"); + let prefix = data_column_block_prefix(slot, block_root); + let mut indices: Vec = view + .prefix_iterator(Table::DataColumns, &prefix) + .expect("iterator") + .filter_map(|res| res.ok()) + .map(|(key, _)| { + let index_bytes: [u8; 8] = key[key.len() - 8..] + .try_into() + .expect("a column key ends in an eight-byte index"); + u64::from_be_bytes(index_bytes) + }) + .collect(); + indices.sort_unstable(); + Ok(indices) + } + + /// Every sidecar in `[start_slot, end_slot)` whose column is in `columns`, + /// restricted to each slot's canonical block, in slot then column order. + /// + /// What the by-range handler serves from. The specification asks a + /// response to be "consistent from a single chain within the context of + /// the request", but gossip import only requires a sidecar's block to + /// name a known, finalized-descendant parent, not a canonical one: a live + /// fork can leave both siblings' columns stored at one slot, and + /// `Table::DataColumns` is never pruned, so an orphaned sidecar would + /// otherwise sit there forever and leak into every future range answer + /// covering that slot. `Table::BlockRoots` is the canonical slot-to-root + /// index [`Self::get_signed_blocks_by_slot_range`] and the block-range + /// handler already key off, kept current by + /// [`Self::update_checkpoints`] on both chains; a slot with no entry + /// there has no canonical block; per the same "no block is returned for + /// an empty slot" rule the block-range handler applies, it contributes no + /// sidecars either. + /// + /// One [`StorageReadView::prefix_iterator`] call per slot rather than a + /// single range read, because [`StorageReadView`] offers only + /// exact-prefix iteration, with no range-iterator counterpart to + /// [`StorageWriteBatch::delete_range`]; a per-slot prefix is the closest + /// match this interface can express. The per-slot shape is right on its + /// own terms regardless, and should not change even if a range iterator + /// existed: this table is never pruned, so a whole-table scan would + /// degrade forever, while these indexed per-slot seeks stay bounded by + /// the requested range. + pub fn data_column_sidecars_in_range( + &self, + start_slot: u64, + end_slot: u64, + columns: &[u64], + ) -> Result>, Error> { + let view = self.backend.begin_read().expect("read view"); + let mut found = Vec::new(); + for slot in start_slot..end_slot { + let Some(root) = view + .read_with(Table::BlockRoots, &encode_block_root_key(slot), |bytes| { + H256::from_ssz_bytes(bytes).expect("valid block root") + }) + .expect("read block root") + else { + continue; + }; + let prefix = data_column_block_prefix(slot, &root); + let entries = view + .prefix_iterator(Table::DataColumns, &prefix) + .expect("iterator") + .filter_map(|res| res.ok()); + for (key, value) in entries { + let index_bytes: [u8; 8] = key[key.len() - 8..] + .try_into() + .expect("a column key ends in an eight-byte index"); + if columns.contains(&u64::from_be_bytes(index_bytes)) { + found.push(value.to_vec()); + } + } + } + Ok(found) + } + + /// The slot this store's chain begins at. + /// + /// Zero for a directory bootstrapped from genesis, the checkpoint's slot + /// for one bootstrapped from a checkpoint. Fixed for the life of the + /// directory, which is what lets this be a field read rather than a + /// backend round trip; see [`KEY_ANCHOR_SLOT`]. + /// + /// This is the honest floor for both the `Status` message's + /// `earliest_available_slot` and the `by_range` handlers: nothing below it + /// was ever written, so nothing below it can be served. + pub fn anchor_slot(&self) -> u64 { + self.anchor_slot + } +} + +/// Write a whole beacon signed block onto an existing batch, as one +/// `BlockHeaders` row. +/// +/// The beacon counterpart of [`write_signed_block`], and a function for the +/// same reason: both `insert_signed_block` and `insert_pending_block` write +/// this row, so the key encoding, the value encoding and the table are stated +/// once. No `BlockBodies` row (a beacon block has no header/body split) and no +/// `BlockProof` row (its signature lives inside the block). +fn write_beacon_block(batch: &mut dyn StorageWriteBatch, root: &H256, block: &SignedBeaconBlock) { + let header_entries = vec![(root.to_ssz(), encode_beacon_block_value(block))]; + batch + .put_batch(Table::BlockHeaders, header_entries) + .expect("put beacon block"); +} + +/// Write block header, body, and the merged proof blob onto an existing batch. +/// +/// Returns the deserialized [`Block`] so callers can access fields like +/// `slot` and `parent_root` without re-deserializing. +fn write_signed_block( + batch: &mut dyn StorageWriteBatch, + root: &H256, + signed_block: SignedBlock, +) -> Block { + let SignedBlock { + message: block, + proof, + } = signed_block; + + let header = block.header(); + let root_bytes = root.to_ssz(); + + let header_entries = vec![(root_bytes.clone(), header.to_ssz())]; + batch + .put_batch(Table::BlockHeaders, header_entries) + .expect("put block header"); + + // Skip storing empty bodies - they can be reconstructed from the header's body_root + if header.body_root != *EMPTY_BODY_ROOT { + let body_entries = vec![(root_bytes.clone(), block.body.to_ssz())]; + batch + .put_batch(Table::BlockBodies, body_entries) + .expect("put block body"); + } + + // Store the merged multi-message aggregate proof blob, keyed by slot||root + // so proof pruning can scan in slot order and stop early. + let proof_entries = vec![(encode_slot_root_key(header.slot, root), proof.to_ssz())]; + batch + .put_batch(Table::BlockProof, proof_entries) + .expect("put block proof"); + + block +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::backend::InMemoryBackend; + use crate::committee_cache::{Lookup, ShufflingKey}; + use ethlambda_types::beacon::committees::EpochCommittees; + use ethlambda_types::beacon::containers::Checkpoint as BeaconCheckpoint; + // Only the tests name a status variant: the store itself stores and hands + // back whole `PayloadStatusV1` values without ever reading the tag. + use ethlambda_types::beacon::fork_choice::PayloadStatusEnum; + use ethlambda_types::beacon::primitives::Uint256; + use ethlambda_types::constants::{DEFAULT_MILLISECONDS_PER_SLOT, INTERVALS_PER_SLOT}; + + /// Insert a block header (and dummy body + proof) for a given root, slot, + /// and parent. The stored header equals `header_at(slot, parent_root)`, so a + /// state built from the same `(slot, parent_root)` reconstructs byte-identically. + fn insert_header(backend: &dyn StorageBackend, root: H256, slot: u64, parent_root: H256) { + let header = header_at(slot, parent_root); + let mut batch = backend.begin_write().expect("write batch"); + let key = root.to_ssz(); + batch + .put_batch(Table::BlockHeaders, vec![(key.clone(), header.to_ssz())]) + .expect("put header"); + batch + .put_batch(Table::BlockBodies, vec![(key.clone(), vec![0u8; 4])]) + .expect("put body"); + batch + .put_batch( + Table::BlockProof, + vec![(encode_slot_root_key(slot, &root), vec![0u8; 4])], + ) + .expect("put proof"); + batch + .put_batch( + Table::BlockRoots, + vec![(encode_block_root_key(slot), root.to_ssz())], + ) + .expect("put block root"); + batch.commit().expect("commit"); + } + + /// Insert a real full-state snapshot for a given root (seeds a diff-chain base). + fn insert_snapshot(backend: &dyn StorageBackend, root: H256, state: &State) { + let mut batch = backend.begin_write().expect("write batch"); + batch + .put_batch( + Table::States, + vec![( + root.to_ssz(), + encode_state_value(&BeaconState::Lean(state.clone())), + )], + ) + .expect("put snapshot"); + batch.commit().expect("commit"); + } + + /// Count entries in a table. + fn count_entries(backend: &dyn StorageBackend, table: Table) -> usize { + let view = backend.begin_read().expect("read view"); + view.prefix_iterator(table, &[]) + .expect("iterator") + .filter_map(|r| r.ok()) + .count() + } + + /// Check if a key exists in a table. + fn has_key(backend: &dyn StorageBackend, table: Table, root: &H256) -> bool { + let view = backend.begin_read().expect("read view"); + view.contains(table, &root.to_ssz()).expect("contains") + } + + /// Check whether a block proof exists for a (slot, root) pair. + fn has_block_proof(backend: &dyn StorageBackend, slot: u64, root: &H256) -> bool { + let view = backend.begin_read().expect("read view"); + view.contains(Table::BlockProof, &encode_slot_root_key(slot, root)) + .expect("contains") + } + + /// Canonical block root at `slot`, for storage-index assertions. fn canonical_root(store: &Store, slot: u64) -> Option { store .canonical_root_at_slot(slot) .expect("canonical block root") } - /// Generate a deterministic H256 root from an index. - fn root(index: u64) -> H256 { - let mut bytes = [0u8; 32]; - bytes[..8].copy_from_slice(&index.to_be_bytes()); - H256::from(bytes) + /// Generate a deterministic H256 root from an index. + fn root(index: u64) -> H256 { + let mut bytes = [0u8; 32]; + bytes[..8].copy_from_slice(&index.to_be_bytes()); + H256::from(bytes) + } + + fn signed_block(slot: u64, parent_root: H256) -> SignedBlock { + SignedBlock { + message: Block { + slot, + proposer_index: 0, + parent_root, + state_root: H256::ZERO, + body: BlockBody::default(), + }, + proof: MultiMessageAggregate::default(), + } + } + + fn signed_block_with_attestations( + slot: u64, + parent_root: H256, + attestations: Vec, + ) -> SignedBlock { + SignedBlock { + message: Block { + slot, + proposer_index: 0, + parent_root, + state_root: H256::ZERO, + body: BlockBody { + attestations: attestations.try_into().unwrap(), + }, + }, + proof: MultiMessageAggregate::default(), + } + } + + /// A signed beacon block with an empty body and a zero signature, for + /// tests that only care about `slot` and `parent_root`. Phase0-shaped + /// since nothing under test here reads anything fork-specific, mirroring + /// the `block` helper in `state_transition`'s beacon fork-choice tests. + fn beacon_test_block(slot: u64, parent_root: H256) -> SignedBeaconBlock { + use ethlambda_types::beacon::containers::phase0; + + SignedBeaconBlock::Phase0(phase0::SignedBeaconBlock { + message: phase0::BeaconBlock { + slot, + proposer_index: 0, + parent_root, + state_root: H256::ZERO, + body: phase0::BeaconBlockBody { + randao_reveal: Default::default(), + eth1_data: Default::default(), + graffiti: H256::ZERO, + proposer_slashings: Default::default(), + attester_slashings: Default::default(), + attestations: Default::default(), + deposits: Default::default(), + voluntary_exits: Default::default(), + }, + }, + signature: Default::default(), + }) + } + + #[test] + fn a_beacon_block_round_trips_through_the_store() { + // A beacon directory, not the lean `test_store`: `block_entry` decodes + // the header row through the store's own chain tag, so a beacon row + // read from a lean-tagged store is the one thing that cannot work. + let mut store = beacon_test_store(Arc::new(InMemoryBackend::new())); + let block = beacon_test_block(5, H256::from([1u8; 32])); + let root = block.message_hash_tree_root(); + + store + .insert_signed_block(root, block.clone()) + .expect("insert beacon block"); + + assert_eq!(store.block_entry(&root), Some((5, H256::from([1u8; 32])))); + assert!(store.has_block(&root)); + } + + #[test] + fn a_lean_block_still_records_its_attestation_votes() { + // The lean arm's post-commit side effect must survive the split: a + // beacon block has no lean attestations, so the decision has to be + // made per arm rather than unconditionally. + let mut store = Store::test_store(); + let data = make_att_data_for_target(8, root(8)); + let signed = signed_block_with_attestations( + 1, + H256::ZERO, + vec![AggregatedAttestation { + aggregation_bits: make_proof_for_validators(&[1, 3]).participants, + data: data.clone(), + }], + ); + let block_root = signed.message.hash_tree_root(); + + store + .insert_signed_block(block_root, SignedBeaconBlock::Lean(signed)) + .expect("insert lean block"); + + let votes = store.extract_latest_known_attestations(); + assert_eq!(votes[&1], data); + assert_eq!(votes[&3], data); + } + + #[test] + fn an_unknown_root_has_no_block() { + let store = Store::test_store(); + assert!(!store.has_block(&H256::from([9u8; 32]))); + assert_eq!(store.block_entry(&H256::from([9u8; 32])), None); + } + + /// A beacon store anchored at the zero root, for the tests that only need + /// the chain tag rather than a real anchor block. + fn beacon_test_store(backend: Arc) -> Store { + Store::init_beacon( + backend, + 0, + Config::mainnet(), + H256::ZERO, + Checkpoint::default(), + 0, + ) + } + + #[test] + fn a_beacon_block_reads_back_as_the_fork_it_was_written_as() { + // `get_signed_block` dispatches on the store's own chain tag, so this + // needs a real beacon store rather than the lean `test_store` helper: + // on a lean store the tag would send a beacon-shaped body row through + // the lean decode path. + let backend = Arc::new(InMemoryBackend::new()); + let mut store = beacon_test_store(backend); + let block = beacon_test_block(5, H256::from([1u8; 32])); + let root = block.message_hash_tree_root(); + + store + .insert_signed_block(root, block.clone()) + .expect("insert beacon block"); + + // The body row carries a fork selector, so the reader recovers the + // shape without the caller having to know which chain it opened. + let read = store + .get_signed_block(&root) + .expect("get") + .expect("present"); + assert_eq!(read.fork_name(), block.fork_name()); + assert_eq!(read.slot(), 5); + assert_eq!(read.parent_root(), H256::from([1u8; 32])); + } + + #[test] + fn a_lean_block_still_reads_back_through_the_same_method() { + let mut store = Store::test_store(); + let signed = signed_block_with_attestations(1, H256::ZERO, Vec::new()); + let root = signed.message.hash_tree_root(); + + store + .insert_signed_block(root, SignedBeaconBlock::Lean(signed.clone())) + .expect("insert lean block"); + + let read = store + .get_signed_block(&root) + .expect("get") + .expect("present"); + match read { + SignedBeaconBlock::Lean(lean) => assert_eq!(lean.message.slot, signed.message.slot), + other => panic!("expected a lean block, got {}", other.fork_name()), + } + } + + #[test] + fn block_slot_and_state_root_returns_a_lean_blocks_own_slot_and_state_root() { + let backend = Arc::new(InMemoryBackend::new()); + let mut store = Store::from_anchor_state( + backend, + State::from_genesis(0, vec![]), + DEFAULT_MILLISECONDS_PER_SLOT, + ); + let anchor_root = store.head().expect("head root"); + + let block = signed_block(1, anchor_root); + let state_root = block.message.state_root; + let block_root = block.message.hash_tree_root(); + store + .insert_signed_block(block_root, SignedBeaconBlock::Lean(block)) + .expect("insert lean block"); + + assert_eq!( + store.block_slot_and_state_root(&block_root), + Some((1, state_root)) + ); + } + + #[test] + fn block_slot_and_state_root_returns_a_beacon_blocks_own_slot_and_state_root() { + // Distinctive slot and state_root (not the zeroed defaults + // `beacon_test_block` uses), so a decode that silently returned the + // wrong field, or the wrong block, would not pass by coincidence. + use ethlambda_types::beacon::containers::phase0; + + let mut store = beacon_test_store(Arc::new(InMemoryBackend::new())); + let state_root = H256::from([7u8; 32]); + let block = SignedBeaconBlock::Phase0(phase0::SignedBeaconBlock { + message: phase0::BeaconBlock { + slot: 42, + proposer_index: 0, + parent_root: H256::from([1u8; 32]), + state_root, + body: phase0::BeaconBlockBody { + randao_reveal: Default::default(), + eth1_data: Default::default(), + graffiti: H256::ZERO, + proposer_slashings: Default::default(), + attester_slashings: Default::default(), + attestations: Default::default(), + deposits: Default::default(), + voluntary_exits: Default::default(), + }, + }, + signature: Default::default(), + }); + let block_root = block.message_hash_tree_root(); + + store + .insert_signed_block(block_root, block) + .expect("insert beacon block"); + + assert_eq!( + store.block_slot_and_state_root(&block_root), + Some((42, state_root)) + ); + } + + #[test] + fn block_slot_and_state_root_returns_none_for_an_absent_root() { + let store = Store::test_store(); + assert_eq!( + store.block_slot_and_state_root(&H256::from([9u8; 32])), + None + ); + } + + #[test] + fn block_slot_and_state_root_agrees_with_block_entry_on_slot_for_a_lean_block() { + let backend = Arc::new(InMemoryBackend::new()); + let mut store = Store::from_anchor_state( + backend, + State::from_genesis(0, vec![]), + DEFAULT_MILLISECONDS_PER_SLOT, + ); + let anchor_root = store.head().expect("head root"); + + let block = signed_block(1, anchor_root); + let block_root = block.message.hash_tree_root(); + store + .insert_signed_block(block_root, SignedBeaconBlock::Lean(block)) + .expect("insert lean block"); + + let (entry_slot, _) = store.block_entry(&block_root).expect("block entry"); + let (read_slot, _) = store + .block_slot_and_state_root(&block_root) + .expect("block slot and state root"); + // Both accessors read the same BlockHeaders row, so they must not + // disagree about which block it is. + assert_eq!(entry_slot, read_slot); + } + + #[test] + fn block_slot_and_state_root_agrees_with_block_entry_on_slot_for_a_beacon_block() { + let mut store = beacon_test_store(Arc::new(InMemoryBackend::new())); + let block = beacon_test_block(5, H256::from([1u8; 32])); + let block_root = block.message_hash_tree_root(); + store + .insert_signed_block(block_root, block) + .expect("insert beacon block"); + + let (entry_slot, _) = store.block_entry(&block_root).expect("block entry"); + let (read_slot, _) = store + .block_slot_and_state_root(&block_root) + .expect("block slot and state root"); + assert_eq!(entry_slot, read_slot); + } + + impl Store { + /// Create a Store with an in-memory backend for tests. + fn test_store() -> Self { + let backend = Arc::new(InMemoryBackend::new()); + Self::from_parts( + backend, + Arc::new(Config::lean(0, DEFAULT_MILLISECONDS_PER_SLOT)), + Chain::Lean, + 0, + ) + } + + /// Create a Store with a shared in-memory backend for tests that need + /// direct backend access. + fn test_store_with_backend(backend: Arc) -> Self { + Self::from_parts( + backend, + Arc::new(Config::lean(0, DEFAULT_MILLISECONDS_PER_SLOT)), + Chain::Lean, + 0, + ) + } + } + + // ============ Chain / DB Version Tests ============ + + #[test] + fn a_fresh_lean_store_records_its_chain_and_db_version() { + let backend = Arc::new(InMemoryBackend::new()); + let store = Store::from_anchor_state( + backend.clone(), + State::from_genesis(7, vec![]), + DEFAULT_MILLISECONDS_PER_SLOT, + ); + assert_eq!(store.chain(), Chain::Lean); + + let view = backend.begin_read().expect("read view"); + let version = view + .get(Table::Metadata, KEY_DB_VERSION) + .expect("get") + .expect("db version written at bootstrap"); + assert_eq!( + u64::from_ssz_bytes(&version).expect("valid version"), + DB_VERSION + ); + } + + #[test] + fn a_fresh_lean_store_records_the_preset_it_was_built_against() { + let backend = Arc::new(InMemoryBackend::new()); + let _ = Store::from_anchor_state( + backend.clone(), + State::from_genesis(7, vec![]), + DEFAULT_MILLISECONDS_PER_SLOT, + ); + + let view = backend.begin_read().expect("read view"); + let preset = view + .get(Table::Metadata, KEY_PRESET) + .expect("get") + .expect("preset written at bootstrap"); + assert_eq!( + preset.first().copied().and_then(Preset::from_selector), + Some(Preset::ACTIVE) + ); + } + + #[test] + fn from_db_state_refuses_a_directory_written_against_another_preset() { + let backend = Arc::new(InMemoryBackend::new()); + let _ = Store::from_anchor_state( + backend.clone(), + State::from_genesis(7, vec![]), + DEFAULT_MILLISECONDS_PER_SLOT, + ); + + // Rewrite only the preset byte, to whichever this build is not. Which + // one that is depends on the feature this crate was compiled with, and + // the check must hold either way round, so the value is derived rather + // than written as a literal. + let other = match Preset::ACTIVE { + Preset::Mainnet => Preset::Minimal, + Preset::Minimal => Preset::Mainnet, + }; + let mut batch = backend.begin_write().expect("write batch"); + let entries = vec![(KEY_PRESET.to_vec(), vec![other.selector()])]; + batch + .put_batch(Table::Metadata, entries) + .expect("put preset"); + batch.commit().expect("commit"); + + let Err(err) = Store::from_db_state(backend) else { + panic!("a directory built against another preset must not be reused"); + }; + let Error::PresetMismatch { found, expected } = err else { + panic!("expected a preset mismatch, got {err}"); + }; + assert_eq!(found, Some(other.name())); + assert_eq!(expected, Preset::ACTIVE.name()); + } + + #[test] + fn from_db_state_refuses_a_directory_with_no_preset_recorded() { + let backend = Arc::new(InMemoryBackend::new()); + let _ = Store::from_anchor_state( + backend.clone(), + State::from_genesis(7, vec![]), + DEFAULT_MILLISECONDS_PER_SLOT, + ); + + // A directory from before the preset was recorded. It cannot be + // assumed to be this build's: the whole point of the row is that + // nothing else in the directory says which shapes it holds. + let mut batch = backend.begin_write().expect("write batch"); + batch + .delete_batch(Table::Metadata, vec![KEY_PRESET.to_vec()]) + .expect("delete preset"); + batch.commit().expect("commit"); + + let Err(err) = Store::from_db_state(backend) else { + panic!("a directory with no preset recorded must not be reused"); + }; + assert!(matches!(err, Error::PresetMismatch { found: None, .. })); } - fn signed_block(slot: u64, parent_root: H256) -> SignedBlock { - SignedBlock { - message: Block { - slot, - proposer_index: 0, - parent_root, - state_root: H256::ZERO, - body: BlockBody::default(), - }, - proof: MultiMessageAggregate::default(), - } + #[test] + fn a_fresh_lean_store_starts_its_clock_at_genesis() { + const GENESIS_TIME: u64 = 1_770_407_233; + let backend = Arc::new(InMemoryBackend::new()); + let store = Store::from_anchor_state( + backend, + State::from_genesis(GENESIS_TIME, vec![]), + DEFAULT_MILLISECONDS_PER_SLOT, + ); + + // The row is an absolute Unix millisecond, so "not moved yet" is + // genesis itself; both derived clocks read zero off it. + assert_eq!(store.time_ms().expect("time"), GENESIS_TIME * 1_000); + assert_eq!(store.ms_since_genesis(), 0); + assert_eq!(store.intervals_since_genesis(), 0); + assert_eq!(store.current_slot(), 0); } - fn signed_block_with_attestations( - slot: u64, - parent_root: H256, - attestations: Vec, - ) -> SignedBlock { - SignedBlock { - message: Block { - slot, - proposer_index: 0, - parent_root, - state_root: H256::ZERO, - body: BlockBody { - attestations: attestations.try_into().unwrap(), - }, - }, - proof: MultiMessageAggregate::default(), + #[test] + fn the_derived_clocks_agree_at_every_interval_boundary() { + // One row, three readings. The interval grid is the finest, so it is + // the one that can disagree with the slot: it must not. + const GENESIS_TIME: u64 = 1_770_407_233; + const MILLISECONDS_PER_SLOT: u64 = 4_000; + let backend = Arc::new(InMemoryBackend::new()); + let mut store = Store::from_anchor_state( + backend, + State::from_genesis(GENESIS_TIME, vec![]), + MILLISECONDS_PER_SLOT, + ); + let genesis_ms = GENESIS_TIME * 1_000; + let ms_per_interval = MILLISECONDS_PER_SLOT / INTERVALS_PER_SLOT; + + for intervals in 0..4 * INTERVALS_PER_SLOT { + store + .set_time_ms(genesis_ms + intervals * ms_per_interval) + .expect("set time"); + assert_eq!(store.intervals_since_genesis(), intervals); + assert_eq!( + store.current_slot(), + intervals / INTERVALS_PER_SLOT, + "interval {intervals}" + ); } + + // And a reading between two boundaries names the interval it is inside, + // which is the whole reason the row is finer than a second: four of + // every five of these boundaries are not on a whole second. + store + .set_time_ms(genesis_ms + ms_per_interval + 1) + .expect("set time"); + assert_eq!(store.intervals_since_genesis(), 1); } - impl Store { - /// Create a Store with an in-memory backend for tests. - fn test_store() -> Self { - let backend = Arc::new(InMemoryBackend::new()); - Self { - backend, - config: ChainConfig::new(0, DEFAULT_MILLISECONDS_PER_SLOT), - new_payloads: Arc::new(Mutex::new(PayloadBuffer::new(NEW_PAYLOAD_CAP))), - known_payloads: Arc::new(Mutex::new(PayloadBuffer::new(AGGREGATED_PAYLOAD_CAP))), - fork_choice: Default::default(), - gossip_signatures: Arc::new(Mutex::new(GossipSignatureBuffer::new( - GOSSIP_SIGNATURE_CAP, - ))), - state_cache: new_state_cache(), - } + #[test] + fn current_slot_follows_the_slot_duration_not_the_truncated_second() { + // A cadence that is not a whole number of seconds: `Config::lean` + // truncates `seconds_per_slot`, so dividing by that would put this + // store a slot ahead of itself within a few slots. `current_slot` + // divides by the millisecond duration instead. + const GENESIS_TIME: u64 = 1_000; + const MILLISECONDS_PER_SLOT: u64 = 6_500; + let backend = Arc::new(InMemoryBackend::new()); + let mut store = Store::from_anchor_state( + backend, + State::from_genesis(GENESIS_TIME, vec![]), + MILLISECONDS_PER_SLOT, + ); + let genesis_ms = GENESIS_TIME * 1_000; + + for (elapsed_ms, expected_slot) in + [(0, 0), (6_499, 0), (6_500, 1), (13_000, 2), (26_000, 4)] + { + store + .set_time_ms(genesis_ms + elapsed_ms) + .expect("set time"); + assert_eq!( + store.current_slot(), + expected_slot, + "{elapsed_ms}ms after genesis at a {MILLISECONDS_PER_SLOT}ms cadence" + ); } + } - /// Create a Store with a shared in-memory backend for tests that need - /// direct backend access. - fn test_store_with_backend(backend: Arc) -> Self { - Self { - backend, - config: ChainConfig::new(0, DEFAULT_MILLISECONDS_PER_SLOT), - new_payloads: Arc::new(Mutex::new(PayloadBuffer::new(NEW_PAYLOAD_CAP))), - known_payloads: Arc::new(Mutex::new(PayloadBuffer::new(AGGREGATED_PAYLOAD_CAP))), - fork_choice: Default::default(), - gossip_signatures: Arc::new(Mutex::new(GossipSignatureBuffer::new( - GOSSIP_SIGNATURE_CAP, - ))), - state_cache: new_state_cache(), + #[test] + fn the_store_clock_never_reads_before_genesis() { + let backend = Arc::new(InMemoryBackend::new()); + let mut store = Store::from_anchor_state( + backend, + State::from_genesis(1_770_407_233, vec![]), + DEFAULT_MILLISECONDS_PER_SLOT, + ); + + // An externally supplied anchor time is the one way this row lands + // below genesis; saturating is what keeps every derived clock at the + // first slot of the chain rather than the last of a u64. + store.set_time_ms(1).expect("set time"); + assert_eq!(store.ms_since_genesis(), 0); + assert_eq!(store.intervals_since_genesis(), 0); + assert_eq!(store.current_slot(), 0); + } + + #[test] + fn from_db_state_rejects_an_unversioned_database() { + let backend = Arc::new(InMemoryBackend::new()); + let _ = Store::from_anchor_state( + backend.clone(), + State::from_genesis(7, vec![]), + DEFAULT_MILLISECONDS_PER_SLOT, + ); + + // A pre-versioning database is exactly one with no version key, so + // deleting it reproduces the format this build must refuse. + let mut batch = backend.begin_write().expect("write batch"); + batch + .delete_batch(Table::Metadata, vec![KEY_DB_VERSION.to_vec()]) + .expect("delete db version"); + batch.commit().expect("commit"); + + // Matched rather than `expect_err`: that would need `Store: Debug`, and + // the store holds a `dyn StorageBackend` and buffers with no `Debug`. + let Err(err) = Store::from_db_state(backend) else { + panic!("an unversioned database must not be reused"); + }; + assert!(matches!( + err, + Error::DbVersionMismatch { + found: 0, + expected: DB_VERSION } - } + )); + } + + #[test] + fn a_directory_written_by_the_previous_format_is_refused() { + let backend = Arc::new(InMemoryBackend::new()); + + // A directory from the format one version back: only `KEY_CONFIG` and + // `KEY_DB_VERSION` need to be present to reach the check, since it + // runs before the preset and chain reads. + let mut batch = backend.begin_write().expect("write batch"); + let entries = vec![ + (KEY_CONFIG.to_vec(), Config::mainnet().to_ssz()), + (KEY_DB_VERSION.to_vec(), (DB_VERSION - 1).to_ssz()), + ]; + batch + .put_batch(Table::Metadata, entries) + .expect("put metadata"); + batch.commit().expect("commit"); + + // Matched rather than `expect_err`: that would need `Store: Debug`, and + // the store holds a `dyn StorageBackend` and buffers with no `Debug`. + let Err(err) = Store::from_db_state(backend) else { + panic!("a directory written by the previous format must not be reused"); + }; + assert!( + matches!( + err, + Error::DbVersionMismatch { found, expected } + if found == DB_VERSION - 1 && expected == DB_VERSION + ), + "got {err:?}" + ); } // ============ Block Signature Pruning Tests ============ @@ -2133,13 +4445,13 @@ mod tests { let block_1 = signed_block(1, anchor_root); let root_1 = block_1.message.hash_tree_root(); store - .insert_signed_block(root_1, block_1) + .insert_signed_block(root_1, SignedBeaconBlock::Lean(block_1)) .expect("insert block 1"); let block_3 = signed_block(3, root_1); let root_3 = block_3.message.hash_tree_root(); store - .insert_signed_block(root_3, block_3) + .insert_signed_block(root_3, SignedBeaconBlock::Lean(block_3)) .expect("insert block 3"); store .update_checkpoints(ForkCheckpoints::head_only(root_3)) @@ -2153,51 +4465,383 @@ mod tests { let side_block_2 = signed_block(2, anchor_root); let side_root_2 = side_block_2.message.hash_tree_root(); store - .insert_signed_block(side_root_2, side_block_2) - .expect("insert side block 2"); - - let side_block_4 = signed_block(4, side_root_2); - let side_root_4 = side_block_4.message.hash_tree_root(); + .insert_signed_block(side_root_2, SignedBeaconBlock::Lean(side_block_2)) + .expect("insert side block 2"); + + let side_block_4 = signed_block(4, side_root_2); + let side_root_4 = side_block_4.message.hash_tree_root(); + store + .insert_signed_block(side_root_4, SignedBeaconBlock::Lean(side_block_4)) + .expect("insert side block 4"); + store + .update_checkpoints(ForkCheckpoints::head_only(side_root_4)) + .expect("update head to side block 4"); + + assert_eq!(canonical_root(&store, 0), Some(anchor_root)); + assert_eq!(canonical_root(&store, 1), None); + assert_eq!(canonical_root(&store, 2), Some(side_root_2)); + assert_eq!(canonical_root(&store, 3), None); + assert_eq!(canonical_root(&store, 4), Some(side_root_4)); + } + + #[test] + fn from_db_state_preserves_block_root_index() { + // No state is ever inserted for the block below, and none needs to + // be: `from_db_state` only loads (see its doc); `repair_head` is a + // separate, explicit step a resuming caller takes afterward (see + // `main.rs`'s `fetch_initial_state`), so nothing here mutates the + // head this test is checking the index survives around. + let backend = Arc::new(InMemoryBackend::new()); + let mut store = Store::from_anchor_state( + backend.clone(), + State::from_genesis(12345, vec![]), + DEFAULT_MILLISECONDS_PER_SLOT, + ); + + let block = signed_block(1, store.head().expect("head root")); + let block_root = block.message.hash_tree_root(); + store + .insert_signed_block(block_root, SignedBeaconBlock::Lean(block)) + .expect("insert block"); + store + .update_checkpoints(ForkCheckpoints::head_only(block_root)) + .expect("update head"); + + let restored = Store::from_db_state(backend) + .expect("restore store") + .expect("store exists"); + let blocks = restored + .get_signed_blocks_by_slot_range(1, 1) + .expect("get blocks by slot range"); + assert_eq!(blocks.len(), 1); + assert_eq!(blocks[0].message_hash_tree_root(), block_root); + } + + /// A lean chain rooted at a real anchor, up to `head_slot`. Every block + /// gets a real `insert_signed_block` (so it carries a `LiveChain` row, + /// like production data would), and every state is a [`child_of`] the + /// anchor, so the diff chain reconstructs against real, consistent + /// `config`/`validators` rather than an unrelated fixture's. + /// + /// `stateless_from` marks the first slot whose state is never inserted + /// (standing in for the writer never having gotten to it); every slot + /// from there to `head_slot` is left stateless the same way. Returns the + /// roots in slot order, `r0` (the anchor) included, and does not move + /// `KEY_HEAD` itself: callers do that with `set_metadata`, the same way a + /// crash would leave it pointing further than the writer had reached. + fn lean_chain_with_stateless_tail( + store: &mut Store, + head_slot: u64, + stateless_from: u64, + ) -> Vec { + let r0 = store.head().expect("head root"); + let anchor_state = store + .get_state(&r0) + .expect("get anchor state") + .expect("anchor state exists") + .expect_lean() + .clone(); + + let mut roots = vec![r0]; + let mut parent_root = r0; + let mut hbh = Vec::new(); + for slot in 1..=head_slot { + hbh.push(parent_root); + let root = signed_block(slot, parent_root).message.hash_tree_root(); + store + .insert_signed_block( + root, + SignedBeaconBlock::Lean(signed_block(slot, parent_root)), + ) + .expect("insert block"); + if slot < stateless_from { + let state = child_of(&anchor_state, slot, parent_root, hbh.clone()); + store + .insert_state(root, BeaconState::Lean(state)) + .expect("insert state"); + } + roots.push(root); + parent_root = root; + } + roots + } + + /// The regression this whole repair exists for, walking back more than + /// one hop: three blocks in a row whose states the writer never got to + /// (standing in for an unclean shutdown inside the writer's queue + /// window) are rewound past. Each of their `LiveChain` rows -- fork + /// choice's only record of them -- goes with the rewind, which is what + /// makes it stick rather than have the very next fork-choice run walk + /// right back to the stateless tip. + #[test] + fn repair_head_rewinds_three_hops_and_drops_them_from_fork_choice() { + let backend = Arc::new(InMemoryBackend::new()); + let mut store = Store::from_anchor_state( + backend.clone(), + State::from_genesis(1_000, vec![]), + DEFAULT_MILLISECONDS_PER_SLOT, + ); + let roots = lean_chain_with_stateless_tail(&mut store, 4, 2); + let (r1, r2, r3, r4) = (roots[1], roots[2], roots[3], roots[4]); + store.set_metadata(KEY_HEAD, &r4); + + // Settle every write before resuming; r2, r3 and r4 never get one. + drop(store); + + let mut resumed = Store::from_db_state(backend.clone()) + .expect("restore store") + .expect("store exists"); + resumed.repair_head().expect("repair head"); + assert_eq!( + resumed.head().expect("head"), + r1, + "rewound three hops to the newest ancestor with a persisted state" + ); + + let live_chain = resumed.get_live_chain().expect("get live chain"); + for hopped in [r2, r3, r4] { + assert!( + !live_chain.contains_key(&hopped), + "a hopped block's LiveChain row must be gone" + ); + } + assert!( + live_chain.contains_key(&r1), + "the repaired head's own LiveChain row must survive" + ); + + // Persisted, not just an in-memory correction: a second Store over + // the same backend reads back the repaired head. + drop(resumed); + let reread = Store::from_db_state(backend) + .expect("restore store") + .expect("store exists"); + assert_eq!(reread.head().expect("head"), r1); + } + + /// The walk's own bound: exactly `STATE_WRITE_QUEUE_CAPACITY + 1` missing + /// states behind the head is still within what the writer's queue can + /// explain, and repairs cleanly. + #[test] + fn repair_head_accepts_a_rewind_exactly_at_the_writer_queue_bound() { + let backend = Arc::new(InMemoryBackend::new()); + let mut store = Store::from_anchor_state( + backend.clone(), + State::from_genesis(1_000, vec![]), + DEFAULT_MILLISECONDS_PER_SLOT, + ); + // r1 has a state; r2, r3 and r4 (three hops) do not. + let roots = lean_chain_with_stateless_tail(&mut store, 4, 2); + let (r1, r4) = (roots[1], roots[4]); + store.set_metadata(KEY_HEAD, &r4); + + drop(store); + let mut resumed = Store::from_db_state(backend) + .expect("restore store") + .expect("store exists"); + resumed + .repair_head() + .expect("three hops is within the bound"); + assert_eq!(resumed.head().expect("head"), r1); + } + + /// One hop past that bound is no longer explained by the writer's queue, + /// and is reported rather than walked past. + #[test] + fn repair_head_rejects_a_rewind_one_hop_past_the_writer_queue_bound() { + let backend = Arc::new(InMemoryBackend::new()); + let mut store = Store::from_anchor_state( + backend.clone(), + State::from_genesis(1_000, vec![]), + DEFAULT_MILLISECONDS_PER_SLOT, + ); + // r1 has a state; r2 through r5 (four hops) do not. + let roots = lean_chain_with_stateless_tail(&mut store, 5, 2); + let r5 = roots[5]; + store.set_metadata(KEY_HEAD, &r5); + + drop(store); + let mut resumed = Store::from_db_state(backend) + .expect("restore store") + .expect("store exists"); + let err = resumed + .repair_head() + .expect_err("four missing states is past the bound"); + assert!( + matches!(err, Error::HeadRepairExceededWindow { hops: 4, .. }), + "unexpected error: {err:?}" + ); + } + + /// A broken parent chain reached partway through the walk is corruption, + /// not this race: the race requires the head's own block, and every + /// block it walks through, to already be on disk, only their states + /// missing. + #[test] + fn repair_head_reports_a_broken_parent_chain_mid_walk() { + let backend = Arc::new(InMemoryBackend::new()); + let mut store = Store::from_anchor_state( + backend.clone(), + State::from_genesis(1_000, vec![]), + DEFAULT_MILLISECONDS_PER_SLOT, + ); + + // r1 has a block on record and no state, but its own parent root + // names nothing this directory ever held. + let ghost = H256::repeat_byte(0xee); + let r1 = signed_block(1, ghost).message.hash_tree_root(); + store + .insert_signed_block(r1, SignedBeaconBlock::Lean(signed_block(1, ghost))) + .expect("insert block"); + store.set_metadata(KEY_HEAD, &r1); + + drop(store); + let mut resumed = Store::from_db_state(backend) + .expect("restore store") + .expect("store exists"); + let err = resumed + .repair_head() + .expect_err("a broken parent chain is corruption"); + assert!( + matches!(err, Error::UnexpectedMissingBlockHeader(root) if root == ghost), + "unexpected error: {err:?}" + ); + } + + /// `anchor_slot` and finalized's slot are both zero on a freshly + /// bootstrapped store (`init_store` seeds finalized at the anchor + /// itself), so a walk that bottoms out there hits the tie between the + /// two bounds. It must resolve to `AnchorStateLost`, which names the + /// checkpoint and a remedy, not `UnexpectedMissingState`, which names + /// neither. + #[test] + fn repair_head_resolves_the_anchor_finalized_tie_to_anchor_state_lost() { + let backend = Arc::new(InMemoryBackend::new()); + let mut store = Store::from_anchor_state( + backend.clone(), + State::from_genesis(1_000, vec![]), + DEFAULT_MILLISECONDS_PER_SLOT, + ); + assert_eq!( + store.latest_finalized().expect("finalized").slot, + 0, + "the tie this test needs: nothing has finalized past the anchor yet" + ); + + // A block on record at slot 0 itself, with no state and a parent + // this directory never held: not a descendant of the real anchor, + // just something at the same slot the walk's bound checks compare + // against. + let unknown_parent = H256::repeat_byte(0xcc); + let stale = signed_block(0, unknown_parent); + let r_stale = stale.message.hash_tree_root(); + store + .insert_signed_block(r_stale, SignedBeaconBlock::Lean(stale)) + .expect("insert block"); + store.set_metadata(KEY_HEAD, &r_stale); + + drop(store); + let mut resumed = Store::from_db_state(backend) + .expect("restore store") + .expect("store exists"); + let err = resumed + .repair_head() + .expect_err("nothing to rewind to below the tie"); + assert!( + matches!(err, Error::AnchorStateLost { .. }), + "unexpected error: {err:?}" + ); + } + + /// `repair_head` works the same way on a beacon directory with a real + /// chain behind it, not just at `init_beacon`'s bare bootstrap (see + /// `from_db_state_loads_a_beacon_directory_as_beacon` for that case). + #[test] + fn repair_head_rewinds_a_beacon_head_past_bootstrap() { + let backend = Arc::new(InMemoryBackend::new()); + let anchor_root = H256::repeat_byte(0xaa); + // Never given its own block, which is what makes the anchor state a + // snapshot rather than a diff (see `is_anchor`): a real checkpoint + // sync anchor's parent is exactly as unknown to this directory. + let unknown_parent = H256::repeat_byte(0xbb); + let mut store = Store::init_beacon( + backend.clone(), + 0, + Config::mainnet(), + anchor_root, + Checkpoint { + root: anchor_root, + slot: 0, + }, + 0, + ); + store + .insert_signed_block(anchor_root, beacon_test_block(0, unknown_parent)) + .expect("insert anchor block"); store - .insert_signed_block(side_root_4, side_block_4) - .expect("insert side block 4"); + .insert_state( + anchor_root, + beacon_test_state_with_parent(0, unknown_parent), + ) + .expect("insert anchor state"); + + // r1: a real, committed state. + let block1 = beacon_test_block(1, anchor_root); + let r1 = block1.message_hash_tree_root(); + store.insert_signed_block(r1, block1).expect("insert block"); store - .update_checkpoints(ForkCheckpoints::head_only(side_root_4)) - .expect("update head to side block 4"); + .insert_state(r1, beacon_test_state_with_parent(1, anchor_root)) + .expect("insert state"); - assert_eq!(canonical_root(&store, 0), Some(anchor_root)); - assert_eq!(canonical_root(&store, 1), None); - assert_eq!(canonical_root(&store, 2), Some(side_root_2)); - assert_eq!(canonical_root(&store, 3), None); - assert_eq!(canonical_root(&store, 4), Some(side_root_4)); + // r2: a block on record, but the writer never got to its state. + let block2 = beacon_test_block(2, r1); + let r2 = block2.message_hash_tree_root(); + store.insert_signed_block(r2, block2).expect("insert block"); + store.set_metadata(KEY_HEAD, &r2); + + drop(store); + let mut resumed = Store::from_db_state(backend) + .expect("restore store") + .expect("store exists"); + resumed.repair_head().expect("repair head"); + assert_eq!(resumed.head().expect("head"), r1); } + /// The common case: nothing for `repair_head` to do when the recorded + /// head already has a persisted state. Same head back, and the slot-1 + /// `BlockRoots` entry survives untouched, which is what proves no rewind + /// through `update_checkpoints` ran to produce that answer. #[test] - fn from_db_state_preserves_block_root_index() { + fn repair_head_leaves_an_already_settled_head_alone() { let backend = Arc::new(InMemoryBackend::new()); let mut store = Store::from_anchor_state( backend.clone(), - State::from_genesis(12345, vec![]), + State::from_genesis(1_000, vec![]), DEFAULT_MILLISECONDS_PER_SLOT, ); - - let block = signed_block(1, store.head().expect("head root")); - let block_root = block.message.hash_tree_root(); - store - .insert_signed_block(block_root, block) - .expect("insert block"); + let roots = lean_chain_with_stateless_tail(&mut store, 1, 2); + let r1 = roots[1]; store - .update_checkpoints(ForkCheckpoints::head_only(block_root)) + .update_checkpoints(ForkCheckpoints::head_only(r1)) .expect("update head"); - let restored = Store::from_db_state(backend, &genesis_config(12345, &[])) + drop(store); + + let mut resumed = Store::from_db_state(backend) .expect("restore store") .expect("store exists"); - let blocks = restored - .get_signed_blocks_by_slot_range(1, 1) - .expect("get blocks by slot range"); - assert_eq!(blocks.len(), 1); - assert_eq!(blocks[0].message.hash_tree_root(), block_root); + resumed.repair_head().expect("repair head"); + assert_eq!( + resumed.head().expect("head"), + r1, + "a head whose state is already settled is left exactly where it was" + ); + assert_eq!( + canonical_root(&resumed, 1), + Some(r1), + "no rewind ran: the slot-1 canonical entry update_checkpoints would \ + otherwise have touched is untouched" + ); } #[test] @@ -2215,7 +4859,7 @@ mod tests { let block_root = block.message.hash_tree_root(); store - .insert_signed_block(block_root, block) + .insert_signed_block(block_root, SignedBeaconBlock::Lean(block)) .expect("insert signed block"); let votes = store.extract_latest_known_attestations(); @@ -2246,7 +4890,7 @@ mod tests { ); let block_root = block.message.hash_tree_root(); store - .insert_signed_block(block_root, block) + .insert_signed_block(block_root, SignedBeaconBlock::Lean(block)) .expect("insert block"); parent = block_root; roots.push(block_root); @@ -2316,7 +4960,7 @@ mod tests { ); let kept_root = kept.message.hash_tree_root(); store - .insert_signed_block(kept_root, kept) + .insert_signed_block(kept_root, SignedBeaconBlock::Lean(kept)) .expect("insert kept block"); // A sibling off the same anchor, carrying a LATER vote from the same @@ -2332,7 +4976,7 @@ mod tests { ); let orphan_root = orphan.message.hash_tree_root(); store - .insert_signed_block(orphan_root, orphan) + .insert_signed_block(orphan_root, SignedBeaconBlock::Lean(orphan)) .expect("insert orphan block"); let window = store.extract_head_vote_window(kept_root, 3); @@ -2371,7 +5015,7 @@ mod tests { ); let first_root = first.message.hash_tree_root(); store - .insert_signed_block(first_root, first) + .insert_signed_block(first_root, SignedBeaconBlock::Lean(first)) .expect("insert first block"); let newer = make_att_data_for_target(2, root(2)); @@ -2385,7 +5029,7 @@ mod tests { ); let second_root = second.message.hash_tree_root(); store - .insert_signed_block(second_root, second) + .insert_signed_block(second_root, SignedBeaconBlock::Lean(second)) .expect("insert second block"); assert_eq!( @@ -2443,7 +5087,7 @@ mod tests { ); let head = block.message.hash_tree_root(); store - .insert_signed_block(head, block) + .insert_signed_block(head, SignedBeaconBlock::Lean(block)) .expect("insert signed block"); // A later attestation from the same validator, still only in the pool. @@ -2574,6 +5218,24 @@ mod tests { state } + /// A child of `anchor` at `slot`, inheriting its `config` and + /// `validators` rather than starting a fresh, unrelated + /// `State::from_genesis`. + /// + /// `StateDiff` omits both fields, trusting they never change from parent + /// to child, so a diff chain built on a child from an unrelated fixture + /// would still reconstruct using the *real* anchor's values: a test that + /// compared against the fixture's own (different) values would be + /// checking a premise the store never held, whether or not that + /// happened to matter for what it asserted. + fn child_of(anchor: &State, slot: u64, parent_root: H256, hbh: Vec) -> State { + let mut child = anchor.clone(); + child.slot = slot; + child.latest_block_header = header_at(slot, parent_root); + child.historical_block_hashes = hbh.try_into().unwrap(); + child + } + #[test] fn get_state_reconstructs_from_diff() { let backend = Arc::new(InMemoryBackend::new()); @@ -2593,12 +5255,13 @@ mod tests { }; let r1 = s1.latest_block_header.hash_tree_root(); insert_header(backend.as_ref(), r1, 1, r0); - store.insert_state(r1, s1.clone()).expect("insert state"); - - // Not an anchor, so no snapshot was written; only the diff. - assert!(!has_key(backend.as_ref(), Table::States, &r1)); + store + .insert_state(r1, BeaconState::Lean(s1.clone())) + .expect("insert state"); - // Hot path: the just-imported state is memoized in the cache. + // Hot path: the just-imported state is memoized in the cache, readable + // immediately regardless of whether the writer thread has committed + // it yet. assert_eq!( store .get_state(&r1) @@ -2608,73 +5271,673 @@ mod tests { s1.to_ssz() ); - // A cold store (empty cache, shared backend) reconstructs from the diff, - // byte-identically. - let cold = Store::test_store_with_backend(backend.clone()); - let reconstructed = cold - .get_state(&r1) - .expect("reconstructs from diff") - .expect("state exists"); - assert_eq!(reconstructed.to_ssz(), s1.to_ssz()); + // A cold store (empty cache, shared backend) reconstructs from the + // diff, byte-identically. Dropping the writing store first joins its + // writer thread, which is what settles the backend. + drop(store); + let cold = Store::test_store_with_backend(backend.clone()); + let reconstructed = cold + .get_state(&r1) + .expect("reconstructs from diff") + .expect("state exists"); + assert_eq!(reconstructed.to_ssz(), s1.to_ssz()); + } + + type BackendError = Box; + + /// Backend wrapper counting value reads and existence checks per table, to + /// show which tables `has_state` touches and that it never copies a value. + #[derive(Default)] + struct ReadCounts { + gets: Mutex>, + contains: Mutex>, + } + + impl ReadCounts { + fn gets(&self, table: Table) -> usize { + self.gets.lock().unwrap().get(&table).copied().unwrap_or(0) + } + fn contains(&self, table: Table) -> usize { + self.contains + .lock() + .unwrap() + .get(&table) + .copied() + .unwrap_or(0) + } + } + + struct CountingBackend { + inner: InMemoryBackend, + counts: Arc, + } + + struct CountingView<'a> { + inner: Box, + counts: Arc, + } + + impl StorageBackend for CountingBackend { + fn begin_read(&self) -> Result, BackendError> { + Ok(Box::new(CountingView { + inner: self.inner.begin_read()?, + counts: self.counts.clone(), + })) + } + + fn begin_write(&self) -> Result, BackendError> { + self.inner.begin_write() + } + } + + impl StorageReadView for CountingView<'_> { + fn read( + &self, + table: Table, + key: &[u8], + read_fn: &mut dyn FnMut(&[u8]) -> Result<(), BackendError>, + ) -> Result { + self.inner.read(table, key, read_fn) + } + + // `get` and `contains` are overridden, not left to their defaults, so + // the two can be counted apart. + fn get(&self, table: Table, key: &[u8]) -> Result>, BackendError> { + *self.counts.gets.lock().unwrap().entry(table).or_default() += 1; + self.inner.get(table, key) + } + + fn contains(&self, table: Table, key: &[u8]) -> Result { + *self + .counts + .contains + .lock() + .unwrap() + .entry(table) + .or_default() += 1; + self.inner.contains(table, key) + } + + fn prefix_iterator( + &self, + table: Table, + prefix: &[u8], + ) -> Result + '_>, BackendError> { + self.inner.prefix_iterator(table, prefix) + } + } + + fn counting_store() -> (Store, Arc) { + let counts = Arc::new(ReadCounts::default()); + let backend = Arc::new(CountingBackend { + inner: InMemoryBackend::new(), + counts: counts.clone(), + }); + let store = Store::from_parts( + backend, + Arc::new(Config::lean(0, DEFAULT_MILLISECONDS_PER_SLOT)), + Chain::Lean, + 0, + ); + (store, counts) + } + + fn put_raw(store: &Store, table: Table, root: H256) { + let mut batch = store.backend.begin_write().expect("write batch"); + batch + .put_batch(table, vec![(root.to_ssz(), vec![1, 2, 3])]) + .expect("put"); + batch.commit().expect("commit"); + } + + /// A cached state answers without any backend read, so a snapshot-only + /// anchor state is not copied out of the database to test existence. + #[test] + fn has_state_is_answered_by_the_cache_without_touching_the_backend() { + let (store, counts) = counting_store(); + let root = H256::from([7u8; 32]); + let state = BeaconState::Lean(sample_state(1, H256::ZERO, vec![])); + store.cache_state(CacheKey::BlockState(root), Arc::new(state)); + + assert!(store.has_state(&root).expect("has_state")); + for table in [Table::States, Table::StateDiffs] { + assert_eq!(counts.gets(table), 0); + assert_eq!(counts.contains(table), 0); + } + } + + /// The existence check must not promote the entry in the LRU. + #[test] + fn has_state_does_not_reorder_the_state_cache() { + let (store, _counts) = counting_store(); + let first = H256::from([1u8; 32]); + let second = H256::from([2u8; 32]); + for root in [first, second] { + let state = BeaconState::Lean(sample_state(1, H256::ZERO, vec![])); + store.cache_state(CacheKey::BlockState(root), Arc::new(state)); + } + assert!(store.has_state(&first).expect("has_state")); + let cache = store.state_cache.lock().unwrap(); + let (lru_key, _) = cache.iter().next_back().expect("non-empty"); + assert_eq!(*lru_key, CacheKey::BlockState(first)); + } + + /// A root with a diff is found without consulting `States`. + #[test] + fn has_state_checks_diffs_first_and_skips_states_on_a_hit() { + let (store, counts) = counting_store(); + let root = H256::from([3u8; 32]); + put_raw(&store, Table::StateDiffs, root); + + assert!(store.has_state(&root).expect("has_state")); + assert_eq!(counts.contains(Table::StateDiffs), 1); + assert_eq!(counts.contains(Table::States), 0); + // Never a value read. + assert_eq!(counts.gets(Table::StateDiffs), 0); + assert_eq!(counts.gets(Table::States), 0); + } + + /// A snapshot-only root (a beacon epoch anchor has no diff) is found. + #[test] + fn has_state_finds_a_snapshot_only_root() { + let (store, counts) = counting_store(); + let root = H256::from([4u8; 32]); + put_raw(&store, Table::States, root); + + assert!(store.has_state(&root).expect("has_state")); + assert_eq!(counts.gets(Table::States), 0); + } + + #[test] + fn has_state_is_false_for_an_absent_root() { + let (store, _counts) = counting_store(); + assert!(!store.has_state(&H256::from([5u8; 32])).expect("has_state")); + } + + /// A state that only the handoff buffer holds is still readable. The LRU + /// is cleared first, so a pass here cannot come from the cache, and the + /// backend was never written for this root, so it cannot come from disk. + #[test] + fn a_pending_state_is_readable_without_the_cache_or_the_backend() { + let backend = Arc::new(InMemoryBackend::new()); + let store = Store::test_store_with_backend(backend.clone()); + + let s = sample_state(1, H256::ZERO, vec![]); + let r = s.latest_block_header.hash_tree_root(); + store + .pending_states + .insert(r, Arc::new(BeaconState::Lean(s.clone()))); + store.state_cache.lock().unwrap().clear(); + + assert!(store.has_state(&r).expect("has_state")); + assert_eq!( + store + .get_state(&r) + .expect("get state") + .expect("pending state is readable") + .to_ssz(), + s.to_ssz() + ); + } + + #[test] + fn get_state_reconstructs_across_multiple_diffs() { + let backend = Arc::new(InMemoryBackend::new()); + let mut store = Store::test_store_with_backend(backend.clone()); + + // Snapshot s0, then two chained diffs s1 -> s2; each block root is the + // hash of its header, as in production. + let s0 = sample_state(0, H256::ZERO, vec![]); + let r0 = s0.latest_block_header.hash_tree_root(); + insert_header(backend.as_ref(), r0, 0, H256::ZERO); + insert_snapshot(backend.as_ref(), r0, &s0); + + let s1 = sample_state(1, r0, vec![r0]); + let r1 = s1.latest_block_header.hash_tree_root(); + insert_header(backend.as_ref(), r1, 1, r0); + store + .insert_state(r1, BeaconState::Lean(s1.clone())) + .expect("insert state"); + + let s2 = sample_state(2, r1, vec![r0, r1]); + let r2 = s2.latest_block_header.hash_tree_root(); + insert_header(backend.as_ref(), r2, 2, r1); + store + .insert_state(r2, BeaconState::Lean(s2.clone())) + .expect("insert state"); + + // Neither child is an anchor, so a cold store reconstructs s2 by walking + // the diff chain back to the s0 snapshot. Dropping the writing store + // first joins its writer thread, which is what settles the backend. + drop(store); + let cold = Store::test_store_with_backend(backend.clone()); + let reconstructed = cold + .get_state(&r2) + .expect("reconstructs across diffs") + .expect("state exists"); + assert_eq!(reconstructed.to_ssz(), s2.to_ssz()); + } + + /// Dropping the store joins the writer, so every state inserted through + /// it is on the backend afterwards and a fresh store reads them all back. + /// + /// The chain is longer than the writer's queue capacity, but that does not + /// make this a test of the blocking send path or of commit ordering: these + /// are small in-memory lean states the worker drains faster than the loop + /// can fill the queue, and the lean parent lookup goes through the shared + /// LRU rather than the backend, so an out-of-order commit would still + /// pass here. What this actually proves is narrower and still the point + /// of the task: drop joins the writer, and every write made it to the + /// backend by the time a fresh store reads it back. + #[test] + fn dropping_the_store_settles_every_queued_write() { + let backend = Arc::new(InMemoryBackend::new()); + let mut store = Store::test_store_with_backend(backend.clone()); + + let s0 = sample_state(0, H256::ZERO, vec![]); + let r0 = s0.latest_block_header.hash_tree_root(); + insert_header(backend.as_ref(), r0, 0, H256::ZERO); + insert_snapshot(backend.as_ref(), r0, &s0); + + let mut parent = s0; + let mut parent_root = r0; + let mut expected = Vec::new(); + for slot in 1..=6u64 { + let mut hbh = parent.historical_block_hashes.to_vec(); + hbh.push(parent_root); + let state = sample_state(slot, parent_root, hbh); + let root = state.latest_block_header.hash_tree_root(); + insert_header(backend.as_ref(), root, slot, parent_root); + store + .insert_state(root, BeaconState::Lean(state.clone())) + .expect("insert state"); + expected.push((root, state.clone())); + parent = state; + parent_root = root; + } + + drop(store); + + let cold = Store::test_store_with_backend(backend.clone()); + for (root, state) in expected { + assert_eq!( + cold.get_state(&root) + .expect("reconstructs from the settled backend") + .expect("state exists") + .to_ssz(), + state.to_ssz(), + ); + } + } + + // ============ State Value Fork Tagging Tests ============ + + #[test] + fn states_values_carry_a_fork_selector() { + let backend = Arc::new(InMemoryBackend::new()); + let store = Store::from_anchor_state( + backend.clone(), + State::from_genesis(7, vec![]), + DEFAULT_MILLISECONDS_PER_SLOT, + ); + let anchor = store.head().expect("head root"); + + let view = backend.begin_read().expect("read view"); + let value = view + .get(Table::States, &anchor.to_ssz()) + .expect("get") + .expect("anchor snapshot written at bootstrap"); + assert_eq!(value[0], ForkName::Lean.selector()); + } + + // ============ Beacon State Persistence Tests ============ + + /// A minimal phase0 beacon state at `slot`, with an empty validator + /// registry. Nothing under test here reads validators or history, so + /// every fixed-length vector is filled with zeroes rather than built out + /// with real content, mirroring `beacon_test_block`'s "phase0-shaped, + /// nothing fork-specific" approach. + fn beacon_test_state(slot: u64) -> BeaconState { + use ethlambda_types::beacon::containers::phase0; + use ethlambda_types::beacon::preset; + + BeaconState::Phase0(phase0::BeaconState { + genesis_time: 0, + genesis_validators_root: H256::ZERO, + slot, + fork: Default::default(), + latest_block_header: Default::default(), + block_roots: vec![H256::ZERO; preset::SLOTS_PER_HISTORICAL_ROOT] + .try_into() + .expect("the vector is built at its exact length"), + state_roots: vec![H256::ZERO; preset::SLOTS_PER_HISTORICAL_ROOT] + .try_into() + .expect("the vector is built at its exact length"), + historical_roots: Default::default(), + eth1_data: Default::default(), + eth1_data_votes: Default::default(), + eth1_deposit_index: 0, + validators: Default::default(), + balances: Default::default(), + randao_mixes: vec![H256::ZERO; preset::EPOCHS_PER_HISTORICAL_VECTOR] + .try_into() + .expect("the vector is built at its exact length"), + slashings: vec![0; preset::EPOCHS_PER_SLASHINGS_VECTOR] + .try_into() + .expect("the vector is built at its exact length"), + previous_epoch_attestations: Default::default(), + current_epoch_attestations: Default::default(), + justification_bits: Default::default(), + previous_justified_checkpoint: Default::default(), + current_justified_checkpoint: Default::default(), + finalized_checkpoint: Default::default(), + }) + } + + #[test] + fn a_beacon_state_round_trips_through_the_states_table() { + let mut store = beacon_test_store(Arc::new(InMemoryBackend::new())); + let root = H256::from([1u8; 32]); + let state = beacon_test_state(7); + + store + .insert_state(root, state.clone()) + .expect("insert beacon state"); + + let read = store.get_state(&root).expect("get").expect("state present"); + assert_eq!(read.fork_name(), state.fork_name()); + assert_eq!(read.slot(), 7); + } + + #[test] + fn a_beacon_state_with_no_known_parent_block_is_its_own_snapshot() { + // A bootstrap or checkpoint-sync anchor is the store's first-ever + // beacon state: its parent block was never imported here, so there is + // no base to diff against and `insert_state` must fall back to an + // unconditional snapshot rather than panicking on a missing parent. + let mut store = beacon_test_store(Arc::new(InMemoryBackend::new())); + let orphan_root = H256::from([9u8; 32]); + + store + .insert_state(orphan_root, beacon_test_state(42)) + .expect("insert"); + + // Nothing else was inserted, so this can only succeed if the value is + // self-contained. + assert_eq!( + store + .get_state(&orphan_root) + .expect("get") + .expect("present") + .slot(), + 42 + ); + } + + #[test] + fn a_beacon_state_read_misses_cleanly_for_an_unknown_root() { + let store = beacon_test_store(Arc::new(InMemoryBackend::new())); + assert!( + store + .get_state(&H256::from([5u8; 32])) + .expect("get") + .is_none() + ); + } + + #[test] + fn the_state_cache_is_shared_across_store_clones() { + let store = beacon_test_store(Arc::new(InMemoryBackend::new())); + let clone = store.clone(); + let key = CacheKey::BlockState(H256::from([1u8; 32])); + + clone.cache_state(key, Arc::new(beacon_test_state(7))); + assert!(store.cached_state(key).is_some()); + } + + #[test] + fn the_committee_cache_is_shared_across_store_clones() { + let store = beacon_test_store(Arc::new(InMemoryBackend::new())); + let clone = store.clone(); + let key = ShufflingKey { + epoch: 1, + decision_root: H256::from([1u8; 32]), + }; + + let (first, first_lookup) = clone + .committee_cache() + .get_or_init(key, || EpochCommittees::new(1, Vec::new(), 1)); + let (second, second_lookup) = store + .committee_cache() + .get_or_init(key, || panic!("should not rebuild")); + + assert_eq!(first_lookup, Lookup::Miss); + assert_eq!(second_lookup, Lookup::Hit); + assert!(Arc::ptr_eq(&first, &second)); + } + + #[test] + fn the_state_cache_is_bounded() { + let store = beacon_test_store(Arc::new(InMemoryBackend::new())); + for i in 0..(STATE_CACHE_CAPACITY + 4) { + let mut bytes = [0u8; 32]; + bytes[0] = i as u8; + let key = CacheKey::BlockState(H256::from(bytes)); + store.cache_state(key, Arc::new(beacon_test_state(i as u64))); + } + // The bound is the whole point: the beacon fork choice previously held + // whole states in maps with no cap at all. + let oldest = CacheKey::BlockState(H256::from([0u8; 32])); + assert!(store.cached_state(oldest).is_none()); + } + + #[test] + fn block_and_checkpoint_states_share_one_bound() { + // Two kinds in one cache, so a single capacity bounds the total. Keyed + // distinctly, so a checkpoint state never masquerades as a block state. + let store = beacon_test_store(Arc::new(InMemoryBackend::new())); + let root = H256::from([2u8; 32]); + + store.cache_state(CacheKey::BlockState(root), Arc::new(beacon_test_state(1))); + store.cache_state( + CacheKey::CheckpointState { epoch: 5, root }, + Arc::new(beacon_test_state(2)), + ); + + assert_eq!( + store + .cached_state(CacheKey::BlockState(root)) + .expect("block state") + .slot(), + 1 + ); + assert_eq!( + store + .cached_state(CacheKey::CheckpointState { epoch: 5, root }) + .expect("checkpoint state") + .slot(), + 2 + ); + // The same root at a different epoch is a different entry, since a + // checkpoint's root is the last block at or before its boundary slot. + assert!( + store + .cached_state(CacheKey::CheckpointState { epoch: 6, root }) + .is_none() + ); + } + + #[test] + fn a_beacon_state_read_is_served_from_the_cache_the_second_time() { + let mut store = beacon_test_store(Arc::new(InMemoryBackend::new())); + let root = H256::from([3u8; 32]); + store + .insert_state(root, beacon_test_state(9)) + .expect("insert"); + + // Both reads must agree; the second one is the cached path. + let first = store.get_state(&root).expect("get").expect("present"); + let second = store.get_state(&root).expect("get").expect("present"); + assert_eq!(first.slot(), 9); + assert_eq!(second.slot(), 9); + assert!(store.cached_state(CacheKey::BlockState(root)).is_some()); + } + + #[test] + fn a_decoded_beacon_state_shares_its_registry_with_the_resident_parent() { + use ethlambda_types::beacon::containers::Validator; + + let backend: Arc = Arc::new(InMemoryBackend::new()); + let mut store = beacon_test_store(backend.clone()); + let parent_root = H256::from([1u8; 32]); + let child_root = H256::from([2u8; 32]); + + let mut parent = beacon_test_state(10); + for i in 0..8u64 { + let validator = Validator { + effective_balance: i, + ..Default::default() + }; + parent.validators_mut().push(validator).unwrap(); + parent.balances_mut().push(i).unwrap(); + } + parent.apply_pending_mutations(); + store + .insert_signed_block(parent_root, beacon_test_block(10, H256::ZERO)) + .expect("insert parent block"); + store + .insert_state(parent_root, parent.clone()) + .expect("insert parent state"); + + let mut child = parent; + *child.slot_mut() = 11; + child.latest_block_header_mut().parent_root = parent_root; + child.balances_mut()[0] += 1; + child.apply_pending_mutations(); + store + .insert_signed_block(child_root, beacon_test_block(11, parent_root)) + .expect("insert child block"); + store + .insert_state(child_root, child.clone()) + .expect("insert child state"); + + // Dropping the store joins the writer, so both states are on the + // backend. A fresh store then has to decode them: the parent first, + // so that it is resident when the child is decoded. + drop(store); + let cold = beacon_test_store(backend); + let resident_parent = cold.get_state(&parent_root).expect("get").expect("present"); + let decoded = cold.get_state(&child_root).expect("get").expect("present"); + + assert!(decoded.validators().ptr_eq(resident_parent.validators())); + assert_eq!(decoded.to_ssz(), child.to_ssz()); } #[test] - fn get_state_reconstructs_across_multiple_diffs() { - let backend = Arc::new(InMemoryBackend::new()); - let mut store = Store::test_store_with_backend(backend.clone()); + fn a_cold_beacon_state_folds_every_delta_back_from_its_snapshot() { + let backend: Arc = Arc::new(InMemoryBackend::new()); + let mut store = beacon_test_store(backend.clone()); + let interval = ForkName::Electra.snapshot_interval(); + + // Slot 0 and slot `interval` are snapshots (no parent, then a boundary + // crossing), and every slot between them is a delta on its parent. So + // the reads cover a borrowed snapshot with nothing to fold, and chains + // of 1 up to `interval - 1` deltas applied to the borrowed snapshot. + let mut expected = Vec::new(); + let mut parent = H256::ZERO; + for slot in 0..=interval { + let root = H256::from([(slot + 1) as u8; 32]); + let state = beacon_test_state_with_parent(slot, parent); + store + .insert_signed_block(root, beacon_test_block(slot, parent)) + .expect("insert block"); + expected.push((root, state.to_ssz())); + store.insert_state(root, state).expect("insert state"); + parent = root; + } - // Snapshot s0, then two chained diffs s1 -> s2; each block root is the - // hash of its header, as in production. - let s0 = sample_state(0, H256::ZERO, vec![]); - let r0 = s0.latest_block_header.hash_tree_root(); - insert_header(backend.as_ref(), r0, 0, H256::ZERO); - insert_snapshot(backend.as_ref(), r0, &s0); + // Dropping the store joins the writer; a fresh one has an empty cache, + // so every read below reconstructs from the backend. + drop(store); + let cold = beacon_test_store(backend); + for (root, encoded) in expected.into_iter().rev() { + let state = cold.get_state(&root).expect("get").expect("present"); + assert_eq!(state.to_ssz(), encoded); + } + } - let s1 = sample_state(1, r0, vec![r0]); - let r1 = s1.latest_block_header.hash_tree_root(); - insert_header(backend.as_ref(), r1, 1, r0); - store.insert_state(r1, s1.clone()).expect("insert state"); + /// `beacon_test_state` with its parent linked in, the way `insert_state`'s + /// beacon arm expects: it reads the base to diff against off the + /// post-state's own `latest_block_header.parent_root`, mirroring how the + /// lean arm already recovers its base. + fn beacon_test_state_with_parent(slot: u64, parent_root: H256) -> BeaconState { + let mut state = beacon_test_state(slot); + state.latest_block_header_mut().parent_root = parent_root; + state + } - let s2 = sample_state(2, r1, vec![r0, r1]); - let r2 = s2.latest_block_header.hash_tree_root(); - insert_header(backend.as_ref(), r2, 2, r1); - store.insert_state(r2, s2.clone()).expect("insert state"); + #[test] + fn a_beacon_state_reconstructs_across_a_whole_snapshot_interval() { + let mut store = beacon_test_store(Arc::new(InMemoryBackend::new())); + let interval = ForkName::Electra.snapshot_interval(); - // Neither child is an anchor, so a cold store reconstructs s2 by walking - // the diff chain back to the s0 snapshot. - assert!(!has_key(backend.as_ref(), Table::States, &r1)); - assert!(!has_key(backend.as_ref(), Table::States, &r2)); - let cold = Store::test_store_with_backend(backend.clone()); - let reconstructed = cold - .get_state(&r2) - .expect("reconstructs across diffs") - .expect("state exists"); - assert_eq!(reconstructed.to_ssz(), s2.to_ssz()); + // A chain one slot longer than the interval, so the walk crosses a + // snapshot boundary and the fold has real work to do. + let mut roots = Vec::new(); + let mut parent = H256::ZERO; + for slot in 0..=interval { + let root = H256::from([(slot + 1) as u8; 32]); + let block = beacon_test_block(slot, parent); + store + .insert_signed_block(root, block) + .expect("insert block"); + store + .insert_state(root, beacon_test_state_with_parent(slot, parent)) + .expect("insert state"); + roots.push((root, slot)); + parent = root; + } + + for (root, slot) in roots { + let state = store.get_state(&root).expect("get").expect("present"); + assert_eq!(state.slot(), slot, "wrong state for root at slot {slot}"); + } } #[test] - fn insert_state_snapshots_only_on_boundary_crossing() { + fn a_beacon_delta_chain_writes_snapshots_only_at_the_interval() { + // The point of the delta layer: one snapshot per interval, not one per + // block. A full mainnet snapshot per block is what this replaces. let backend = Arc::new(InMemoryBackend::new()); - let mut store = Store::test_store_with_backend(backend.clone()); + let mut store = beacon_test_store(backend.clone()); + let interval = ForkName::Electra.snapshot_interval(); - let s0 = sample_state(SNAPSHOT_ANCHOR_INTERVAL - 1, H256::ZERO, vec![]); - let r0 = s0.latest_block_header.hash_tree_root(); - insert_header(backend.as_ref(), r0, s0.slot, H256::ZERO); - insert_snapshot(backend.as_ref(), r0, &s0); - - // Crossing the interval boundary records an anchor. - let s1 = sample_state(SNAPSHOT_ANCHOR_INTERVAL, r0, vec![r0]); - let r1 = s1.latest_block_header.hash_tree_root(); - insert_header(backend.as_ref(), r1, s1.slot, r0); - store.insert_state(r1, s1.clone()).expect("insert state"); - assert!(has_key(backend.as_ref(), Table::States, &r1)); + let mut parent = H256::ZERO; + for slot in 0..interval { + let root = H256::from([(slot + 1) as u8; 32]); + store + .insert_signed_block(root, beacon_test_block(slot, parent)) + .expect("insert block"); + store + .insert_state(root, beacon_test_state_with_parent(slot, parent)) + .expect("insert state"); + parent = root; + } - // A non-crossing child does not. - let s2 = sample_state(SNAPSHOT_ANCHOR_INTERVAL + 1, r1, vec![r0, r1]); - let r2 = s2.latest_block_header.hash_tree_root(); - insert_header(backend.as_ref(), r2, s2.slot, r1); - store.insert_state(r2, s2.clone()).expect("insert state"); - assert!(!has_key(backend.as_ref(), Table::States, &r2)); + // Dropping the store joins the writer, which is what settles the + // backend; counting rows against an unsettled writer would only make + // this one-sided assertion easier to pass, not harder. + drop(store); + let view = backend.begin_read().expect("read view"); + let snapshots = view + .prefix_iterator(Table::States, &[]) + .expect("iterator") + .filter_map(Result::ok) + .count(); + assert!( + snapshots < interval as usize, + "expected fewer snapshots than blocks, got {snapshots} for {interval} blocks" + ); } // ============ PayloadBuffer Tests ============ @@ -3404,254 +6667,831 @@ mod tests { make_dummy_sig(), ); - // Slot 2: 1 validator - let data2 = make_att_data(2); - buf.insert( - HashedAttestationData::new(data2.clone()), - 0, - make_dummy_sig(), - ); - assert_eq!(buf.total_signatures(), 4); + // Slot 2: 1 validator + let data2 = make_att_data(2); + buf.insert( + HashedAttestationData::new(data2.clone()), + 0, + make_dummy_sig(), + ); + assert_eq!(buf.total_signatures(), 4); + + // Insert slot 3 — should evict slot 1 (3 sigs), now total = 2 + let data3 = make_att_data(3); + buf.insert(HashedAttestationData::new(data3), 0, make_dummy_sig()); + + let slot1_root = HashedAttestationData::new(data1).root(); + assert!(!buf.data.contains_key(&slot1_root)); + assert_eq!(buf.total_signatures(), 2); // slot 2 (1) + slot 3 (1) + assert_eq!(buf.len(), 2); + } + + /// `Store::from_anchor_state` writes the header but no `BlockProof` + /// row for the slot-0 anchor. `get_signed_block` must synthesize an empty + /// proof so the genesis block can still be served on BlocksByRoot / + /// `/lean/v0/blocks/finalized`. + #[test] + fn get_signed_block_synthesizes_blank_proof_for_genesis_anchor() { + let backend: Arc = Arc::new(InMemoryBackend::new()); + let store = Store::from_anchor_state( + backend, + State::from_genesis(0, vec![]), + DEFAULT_MILLISECONDS_PER_SLOT, + ); + + let head_root = store.head().expect("head root must exist"); + let signed = store + .get_signed_block(&head_root) + .expect("genesis block must be retrievable with synthetic proof") + .expect("genesis block must be retrievable with synthetic proof"); + let SignedBeaconBlock::Lean(signed) = signed else { + panic!("a lean store must read back a lean block"); + }; + + assert_eq!(signed.message.slot, 0); + assert_eq!(signed.proof, MultiMessageAggregate::default()); + } + + /// The synthesis branch must be confined to the slot-0 anchor: a + /// non-genesis block whose `BlockProof` row is missing is treated + /// as storage corruption and surfaces as `None`, not a fabricated block. + #[test] + fn get_signed_block_returns_none_for_non_genesis_with_missing_proof() { + let backend: Arc = Arc::new(InMemoryBackend::new()); + + // Hand-insert a slot-1 header (and empty body, via `EMPTY_BODY_ROOT`) + // but skip the `BlockProof` row. This mimics the corruption case + // the guard is meant to catch, without going through the normal + // `insert_signed_block` write path which always writes all three rows. + let header = BlockHeader { + slot: 1, + proposer_index: 0, + parent_root: H256::ZERO, + state_root: H256::ZERO, + body_root: *EMPTY_BODY_ROOT, + }; + let root = header.hash_tree_root(); + let mut batch = backend.begin_write().expect("write batch"); + batch + .put_batch(Table::BlockHeaders, vec![(root.to_ssz(), header.to_ssz())]) + .expect("put header"); + batch.commit().expect("commit"); + + let store = Store::from_anchor_state( + backend, + State::from_genesis(0, vec![]), + DEFAULT_MILLISECONDS_PER_SLOT, + ); + assert!( + store + .get_signed_block(&root) + .expect("Failed to get signed block") + .is_none() + ); + } + + /// The bootstrap anchor is stored as a full snapshot in `States`, the base of + /// every diff chain that reconstruction terminates at. + #[test] + fn from_anchor_state_stores_bootstrap_snapshot() { + let backend: Arc = Arc::new(InMemoryBackend::new()); + let store = Store::from_anchor_state( + backend.clone(), + State::from_genesis(0, vec![]), + DEFAULT_MILLISECONDS_PER_SLOT, + ); + + let anchor_root = store.head().expect("Failed to get head block root"); + assert!(has_key(backend.as_ref(), Table::States, &anchor_root)); + } + + // ============ from_db_state Tests ============ + + #[test] + fn from_db_state_is_none_on_an_untouched_backend() { + let backend = Arc::new(InMemoryBackend::new()); + + assert!(Store::from_db_state(backend).unwrap().is_none()); + } + + #[test] + fn from_db_state_loads_a_lean_directory_as_lean() { + let backend = Arc::new(InMemoryBackend::new()); + Store::from_anchor_state( + backend.clone(), + State::from_genesis(0, Vec::new()), + DEFAULT_MILLISECONDS_PER_SLOT, + ); + + let store = Store::from_db_state(backend).unwrap().unwrap(); + + assert_eq!(store.chain(), Chain::Lean); + } + + /// A beacon directory loads rather than erroring: judging whether it is + /// the chain the caller wanted is the caller's job now. + #[test] + fn from_db_state_loads_a_beacon_directory_as_beacon() { + let backend = Arc::new(InMemoryBackend::new()); + let anchor = BeaconCheckpoint { + epoch: 0, + root: H256::from([1u8; 32]), + }; + Store::init_beacon( + backend.clone(), + 1_606_824_023, + Config::mainnet(), + anchor.root, + Store::beacon_checkpoint_as_stored(anchor), + 0, + ); + + let mut store = Store::from_db_state(backend).unwrap().unwrap(); + + assert_eq!(store.chain(), Chain::Beacon); + assert_eq!(store.config().genesis_time, 1_606_824_023); + + // `init_beacon` alone seeds the head at the checkpoint root before + // the anchor block/state pair that follows it is ever inserted, so + // `repair_head` must leave a directory shaped exactly like this one + // alone rather than reporting corruption; see its doc. Pinned here, + // on purpose, rather than left to be covered incidentally by + // whichever other test happens to build this shape. + store.repair_head().expect("repair head leaves this alone"); + assert_eq!(store.head().expect("head"), anchor.root); + } + + #[test] + fn a_store_hands_back_the_runtime_config_it_persisted() { + let backend = Arc::new(InMemoryBackend::new()); + let store = Store::from_anchor_state( + backend.clone(), + State::from_genesis(7, vec![]), + DEFAULT_MILLISECONDS_PER_SLOT, + ); + + // Returned by value behind an Arc, so a caller can hold it across the + // &mut Store that every beacon fork-choice entry point takes. + let config = store.config(); + assert_eq!(config.genesis_time, 7); + + // A lean store's config is the lean preset: no beacon fork ever reads + // as activated, which is what stops a beacon-shaped gate firing here. + assert_eq!( + config.altair_fork_epoch, + ethlambda_types::beacon::constants::FAR_FUTURE_EPOCH + ); + + // And it survives a reopen through Metadata["config"]. + let reopened = Store::from_db_state(backend) + .expect("reopen") + .expect("populated directory"); + assert_eq!(reopened.config().genesis_time, 7); + } + + // ============ Beacon Fork-Choice Scratch Tests ============ + + #[test] + fn fork_choice_scratch_is_shared_across_store_clones() { + let store = Store::test_store(); + let mut clone = store.clone(); + + // Shared behind a mutex like the payload buffers, so a handler holding + // one clone sees what another wrote. + clone.set_proposer_boost_root(H256::from([9u8; 32])); + assert_eq!(store.proposer_boost_root(), H256::from([9u8; 32])); + + clone.insert_equivocating_index(42); + assert!(store.is_equivocating(42)); + assert!(!store.is_equivocating(43)); + + clone.set_block_timeliness(H256::from([1u8; 32]), true); + assert_eq!(store.block_timeliness(&H256::from([1u8; 32])), Some(true)); + assert_eq!(store.block_timeliness(&H256::from([2u8; 32])), None); + } + + #[test] + fn latest_messages_skip_equivocators() { + let mut store = Store::test_store(); + let message = LatestMessage { + epoch: 3, + root: H256::from([7u8; 32]), + }; + + store.set_latest_message(1, message); + store.set_latest_message(2, message); + store.insert_equivocating_index(2); + + // get_weight excludes an equivocator's vote entirely rather than + // letting it count for either side of the fork it created, so the + // filter belongs with the read. + let mut seen = Vec::new(); + store.for_each_non_equivocating_latest_message(|index, _| seen.push(index)); + assert_eq!(seen, vec![1]); + } + + #[test] + fn a_pow_block_is_looked_up_by_its_own_hash() { + let mut store = Store::test_store(); + let block = PowBlock { + block_hash: H256::from([4u8; 32]), + parent_hash: H256::from([3u8; 32]), + total_difficulty: Uint256::from(99u64), + }; + store.insert_beacon_pow_block(block); + + assert_eq!( + store + .beacon_pow_block(H256::from([4u8; 32])) + .map(|b| b.parent_hash), + Some(H256::from([3u8; 32])) + ); + assert!(store.beacon_pow_block(H256::from([5u8; 32])).is_none()); + } + + #[test] + fn a_payload_status_is_looked_up_by_the_execution_block_hash() { + let mut store = Store::test_store(); + let status = PayloadStatusV1 { + status: PayloadStatusEnum::Syncing, + latest_valid_hash: None, + validation_error: None, + }; + + store.insert_beacon_payload_status(ExecutionBlockHash::repeat_byte(4), status.clone()); + + assert_eq!( + store.beacon_payload_status(ExecutionBlockHash::repeat_byte(4)), + Some(status) + ); + assert_eq!( + store.beacon_payload_status(ExecutionBlockHash::repeat_byte(5)), + None + ); + } + + #[test] + fn optimistic_roots_round_trip_and_clear() { + let mut store = Store::test_store(); + assert!(!store.is_beacon_optimistic(H256::repeat_byte(1))); + assert!(!store.has_beacon_optimistic_roots()); + + store.insert_beacon_optimistic_root(H256::repeat_byte(1), 7); + assert!(store.is_beacon_optimistic(H256::repeat_byte(1))); + assert!(store.has_beacon_optimistic_roots()); + + store.remove_beacon_optimistic_root(H256::repeat_byte(1)); + assert!(!store.is_beacon_optimistic(H256::repeat_byte(1))); + assert!(!store.has_beacon_optimistic_roots()); + } + + /// The set fills once per import while an execution client is state + /// syncing, and neither `mark_validated` nor `invalidate_subtree` ever + /// sees those roots, so finality is the only thing that empties it. + #[test] + fn optimistic_roots_prune_strictly_below_the_finalized_slot() { + let mut store = Store::test_store(); + let below = H256::repeat_byte(1); + let at = H256::repeat_byte(2); + let above = H256::repeat_byte(3); + store.insert_beacon_optimistic_root(below, 4); + store.insert_beacon_optimistic_root(at, 5); + store.insert_beacon_optimistic_root(above, 6); + + store.prune_beacon_optimistic_roots(5); + + assert!(!store.is_beacon_optimistic(below)); + assert!(store.is_beacon_optimistic(at)); + assert!(store.is_beacon_optimistic(above)); + } + + #[test] + fn el_block_hashes_prune_strictly_below_the_finalized_slot() { + let mut store = Store::test_store(); + let below = H256::repeat_byte(1); + let at = H256::repeat_byte(2); + let above = H256::repeat_byte(3); + store.insert_beacon_el_block_hash(below, 4, ExecutionBlockHash::repeat_byte(0xa1)); + store.insert_beacon_el_block_hash(at, 5, ExecutionBlockHash::repeat_byte(0xa2)); + store.insert_beacon_el_block_hash(above, 6, ExecutionBlockHash::repeat_byte(0xa3)); + + store.prune_beacon_el_block_hashes(5, at); + + // Slot 4 is gone; the finalized block itself (slot 5) is kept, because + // forkchoiceUpdated needs its hash for `finalized_block_hash`. + assert_eq!(store.beacon_el_block_hash(below), None); + assert_eq!( + store.beacon_el_block_hash(at), + Some(ExecutionBlockHash::repeat_byte(0xa2)) + ); + assert_eq!( + store.beacon_el_block_hash(above), + Some(ExecutionBlockHash::repeat_byte(0xa3)) + ); + } + + /// A checkpoint names the last block at *or before* its epoch boundary, so + /// a missed proposal there puts the finalized block below the slot the + /// checkpoint is stored as. The slot bound alone would drop exactly the + /// hash `forkchoiceUpdated` sends as `finalized_block_hash`. + #[test] + fn el_block_hashes_keep_the_finalized_root_below_a_skipped_epoch_boundary() { + let mut store = Store::test_store(); + let finalized = H256::repeat_byte(1); + let stale = H256::repeat_byte(2); + let head = H256::repeat_byte(3); + // Slot 30 proposed, 31 skipped: the epoch that starts at 32 finalizes + // with its checkpoint root still sitting at slot 30. + store.insert_beacon_el_block_hash(finalized, 30, ExecutionBlockHash::repeat_byte(0xb1)); + store.insert_beacon_el_block_hash(stale, 29, ExecutionBlockHash::repeat_byte(0xb2)); + store.insert_beacon_el_block_hash(head, 33, ExecutionBlockHash::repeat_byte(0xb3)); - // Insert slot 3 — should evict slot 1 (3 sigs), now total = 2 - let data3 = make_att_data(3); - buf.insert(HashedAttestationData::new(data3), 0, make_dummy_sig()); + store.prune_beacon_el_block_hashes(32, finalized); - let slot1_root = HashedAttestationData::new(data1).root(); - assert!(!buf.data.contains_key(&slot1_root)); - assert_eq!(buf.total_signatures(), 2); // slot 2 (1) + slot 3 (1) - assert_eq!(buf.len(), 2); + assert_eq!( + store.beacon_el_block_hash(finalized), + Some(ExecutionBlockHash::repeat_byte(0xb1)), + "the finalized checkpoint's own hash must survive its epoch's prune" + ); + assert_eq!(store.beacon_el_block_hash(stale), None); + assert_eq!( + store.beacon_el_block_hash(head), + Some(ExecutionBlockHash::repeat_byte(0xb3)) + ); } - /// `Store::from_anchor_state` writes the header but no `BlockProof` - /// row for the slot-0 anchor. `get_signed_block` must synthesize an empty - /// proof so the genesis block can still be served on BlocksByRoot / - /// `/lean/v0/blocks/finalized`. #[test] - fn get_signed_block_synthesizes_blank_proof_for_genesis_anchor() { - let backend: Arc = Arc::new(InMemoryBackend::new()); - let store = Store::from_anchor_state( + fn a_fresh_beacon_store_seeds_every_key_its_accessors_read() { + let backend = Arc::new(InMemoryBackend::new()); + let config = Config::mainnet(); + let store = Store::init_beacon( backend, - State::from_genesis(0, vec![]), - DEFAULT_MILLISECONDS_PER_SLOT, + 1_606_824_023, + config, + H256::ZERO, + Checkpoint::default(), + 0, ); - let head_root = store.head().expect("head root must exist"); - let signed = store - .get_signed_block(&head_root) - .expect("genesis block must be retrievable with synthetic proof") - .expect("genesis block must be retrievable with synthetic proof"); + assert_eq!(store.chain(), Chain::Beacon); + assert_eq!(store.config().genesis_time, 1_606_824_023); - assert_eq!(signed.message.slot, 0); - assert_eq!(signed.proof, MultiMessageAggregate::default()); + // Every beacon key an accessor reads must be seeded, or the first read + // panics. This is the test that makes get_metadata's panic honest. + // Seeded at genesis rather than at zero: `KEY_TIME` is an absolute Unix + // millisecond on both chains, so genesis is the value that means "the + // clock has not moved yet". + assert_eq!(store.time_ms().expect("time"), 1_606_824_023 * 1_000); + assert_eq!(store.current_slot(), 0); + assert_eq!( + store.beacon_justified_checkpoint(), + BeaconCheckpoint::default() + ); + assert_eq!( + store.beacon_finalized_checkpoint(), + BeaconCheckpoint::default() + ); + assert_eq!( + store.beacon_unrealized_justified_checkpoint(), + BeaconCheckpoint::default() + ); + assert_eq!( + store.beacon_unrealized_finalized_checkpoint(), + BeaconCheckpoint::default() + ); + // Seeded, unlike every other beacon key: the anchor is the store's + // first head. `beacon_head` still answers `None` here, because it + // pairs that root with the slot from its own header row and this + // store has no block under the zero root yet. + assert_eq!(store.head().expect("head"), H256::ZERO); + assert_eq!(store.beacon_head(), None); } - /// The synthesis branch must be confined to the slot-0 anchor: a - /// non-genesis block whose `BlockProof` row is missing is treated - /// as storage corruption and surfaces as `None`, not a fabricated block. #[test] - fn get_signed_block_returns_none_for_non_genesis_with_missing_proof() { - let backend: Arc = Arc::new(InMemoryBackend::new()); + fn finalized_state_root_answers_on_a_lean_directory() { + let backend = Arc::new(InMemoryBackend::new()); + let state = State::from_genesis(0, Vec::new()); + let store = Store::from_anchor_state(backend, state, DEFAULT_MILLISECONDS_PER_SLOT); - // Hand-insert a slot-1 header (and empty body, via `EMPTY_BODY_ROOT`) - // but skip the `BlockProof` row. This mimics the corruption case - // the guard is meant to catch, without going through the normal - // `insert_signed_block` write path which always writes all three rows. - let header = BlockHeader { - slot: 1, - proposer_index: 0, - parent_root: H256::ZERO, - state_root: H256::ZERO, - body_root: *EMPTY_BODY_ROOT, - }; - let root = header.hash_tree_root(); - let mut batch = backend.begin_write().expect("write batch"); - batch - .put_batch(Table::BlockHeaders, vec![(root.to_ssz(), header.to_ssz())]) - .expect("put header"); - batch.commit().expect("commit"); + let root = store.finalized_state_root().unwrap(); - let store = Store::from_anchor_state( - backend, - State::from_genesis(0, vec![]), - DEFAULT_MILLISECONDS_PER_SLOT, + assert_eq!(root, store.latest_finalized().unwrap().root); + } + + #[test] + fn finalized_state_root_answers_on_a_beacon_directory() { + let anchor = BeaconCheckpoint { + epoch: 4, + root: H256::from([5u8; 32]), + }; + let store = Store::init_beacon( + Arc::new(InMemoryBackend::new()), + 0, + Config::mainnet(), + anchor.root, + Store::beacon_checkpoint_as_stored(anchor), + 0, ); - assert!( - store - .get_signed_block(&root) - .expect("Failed to get signed block") - .is_none() + + assert_eq!(store.finalized_state_root().unwrap(), anchor.root); + } + + /// A directory whose checkpoint names no root was written and never + /// anchored. There is nothing to resume from, and it is not an empty + /// directory either. + #[test] + fn finalized_state_root_rejects_a_directory_with_no_anchor() { + let store = Store::init_beacon( + Arc::new(InMemoryBackend::new()), + 0, + Config::mainnet(), + H256::ZERO, + Store::beacon_checkpoint_as_stored(BeaconCheckpoint::default()), + 0, ); + + assert!(matches!( + store.finalized_state_root(), + Err(Error::UnanchoredDirectory) + )); } - /// The bootstrap anchor is stored as a full snapshot in `States`, the base of - /// every diff chain that reconstruction terminates at. #[test] - fn from_anchor_state_stores_bootstrap_snapshot() { - let backend: Arc = Arc::new(InMemoryBackend::new()); - let store = Store::from_anchor_state( - backend.clone(), - State::from_genesis(0, vec![]), - DEFAULT_MILLISECONDS_PER_SLOT, + fn the_beacon_clock_head_and_checkpoints_round_trip() { + // Anchored at a block the store then holds, mirroring + // `get_forkchoice_store`: the head row names the anchor from + // bootstrap on, and moving off it diffs the canonical index across + // both branches, so the anchor's own header has to be there. + let anchor = beacon_test_block(0, H256::ZERO); + let anchor_root = anchor.message_hash_tree_root(); + let mut store = Store::init_beacon( + Arc::new(InMemoryBackend::new()), + 0, + Config::mainnet(), + anchor_root, + Checkpoint::default(), + 0, ); + store + .insert_signed_block(anchor_root, anchor) + .expect("insert anchor"); + + // Metadata["time"] is one Unix-millisecond row for both chains, so it + // means the same thing here as on a lean directory, and the beacon + // handlers convert to the specification's seconds at their own edges + // rather than the store keeping a second unit for them. + store.set_time_ms(1_606_824_023_000).expect("set time"); + assert_eq!(store.time_ms().expect("time"), 1_606_824_023_000); + + // The realized pair lives in lean's own rows, slot-denominated: an + // epoch is stored as its start slot and divides back out exactly. The + // head is passed through unchanged here, which is what a + // checkpoint-only advance looks like on the beacon arm. + let cp = BeaconCheckpoint { + epoch: 3, + root: H256::from([7u8; 32]), + }; + let stored = Store::beacon_checkpoint_as_stored(cp); + assert_eq!(stored.slot, 3 * SLOTS_PER_EPOCH); + store + .update_checkpoints(ForkCheckpoints::new(anchor_root, Some(stored), None)) + .expect("advance justified"); + assert_eq!(store.beacon_justified_checkpoint(), cp); + assert_eq!(store.beacon_head(), Some((0, anchor_root))); + + // A real head move: derived from `KEY_HEAD` plus that block's own + // header row, so the slot comes back with it. + let child = beacon_test_block(9, anchor_root); + let child_root = child.message_hash_tree_root(); + store + .insert_signed_block(child_root, child) + .expect("insert child"); + store + .update_checkpoints(ForkCheckpoints::head_only(child_root)) + .expect("record head"); + assert_eq!(store.beacon_head(), Some((9, child_root))); + assert_eq!(store.head().expect("head"), child_root); + } - let anchor_root = store.head().expect("Failed to get head block root"); - assert!(has_key(backend.as_ref(), Table::States, &anchor_root)); + #[test] + fn an_unrealized_justification_is_scratch_not_chain_history() { + // Shared across clones of one `Store`, since the scratch sits behind + // an `Arc`, but gone once the process reopens the directory: a + // restarted node refills the map as it re-imports the unfinalized + // window. + let backend = Arc::new(InMemoryBackend::new()); + let mut store = beacon_test_store(backend.clone()); + let root = H256::from([1u8; 32]); + let cp = BeaconCheckpoint { + epoch: 5, + root: H256::from([2u8; 32]), + }; + + store.set_unrealized_justification(root, cp); + assert_eq!(store.unrealized_justification(&root), Some(cp)); + assert_eq!(store.unrealized_justification(&H256::from([3u8; 32])), None); + assert_eq!(store.clone().unrealized_justification(&root), Some(cp)); + + let reopened = beacon_test_store(backend); + assert_eq!(reopened.unrealized_justification(&root), None); } - // ============ from_db_state Tests ============ + // ============ Data Column Tests ============ + + fn sidecar_bytes(marker: u8) -> Vec { + vec![marker; 16] + } #[test] - fn from_db_state_returns_none_on_empty_backend() { - let backend: Arc = Arc::new(InMemoryBackend::new()); - assert!( - Store::from_db_state(backend, &genesis_config(12345, &[])) - .expect("Failed to get store") - .is_none() + fn a_sidecar_round_trips_by_slot_root_and_index() { + let store = beacon_test_store(Arc::new(InMemoryBackend::new())); + let root = H256::repeat_byte(1); + store + .put_data_column_sidecar(7, &root, 3, sidecar_bytes(0xab)) + .unwrap(); + assert_eq!( + store.get_data_column_sidecar(7, &root, 3).unwrap(), + Some(sidecar_bytes(0xab)) ); + assert_eq!(store.get_data_column_sidecar(7, &root, 4).unwrap(), None); } #[test] - fn from_db_state_returns_some_on_matching_genesis() { - let backend: Arc = Arc::new(InMemoryBackend::new()); - // Write an initial state to the backend. - let _ = Store::from_anchor_state( - backend.clone(), - State::from_genesis(12345, vec![]), - DEFAULT_MILLISECONDS_PER_SLOT, + fn has_data_column_reads_the_same_key_the_verified_table_is_written_under() { + let store = beacon_test_store(Arc::new(InMemoryBackend::new())); + let root = H256::repeat_byte(1); + store + .put_data_column_sidecar(7, &root, 3, sidecar_bytes(0xab)) + .unwrap(); + + assert!(store.has_data_column(7, &root, 3)); + assert!( + !store.has_data_column(7, &root, 4), + "a different index at the same slot and root must not be reported present" ); assert!( - Store::from_db_state(backend, &genesis_config(12345, &[])) - .expect("Failed to get store") - .is_some() + !store.has_data_column(7, &H256::repeat_byte(2), 3), + "a different root must not be reported present" ); } - /// Previously this returned `None` ("treat as empty"), which let the caller - /// write a fresh anchor over another network's rows. It is now fatal. #[test] - fn from_db_state_errors_on_genesis_time_mismatch() { - let backend: Arc = Arc::new(InMemoryBackend::new()); - // Write an initial state to the backend. - let _ = Store::from_anchor_state( - backend.clone(), - State::from_genesis(12345, vec![]), - DEFAULT_MILLISECONDS_PER_SLOT, + fn a_parked_sidecar_is_invisible_to_the_verified_table() { + // The separation the availability gate rests on: `PendingDataColumns` + // holds rows nothing has verified, and `data_column_indices_for` is + // what decides whether a held block's custody set is complete. + let store = beacon_test_store(Arc::new(InMemoryBackend::new())); + let root = H256::repeat_byte(1); + store + .put_pending_data_column_sidecar(7, &root, 3, sidecar_bytes(0xab)) + .unwrap(); + + assert_eq!( + store.data_column_indices_for(7, &root).unwrap(), + Vec::::new() ); - // `Store` is not `Debug`, so unwrap the error by pattern rather than - // with `expect_err`. - let Err(err) = Store::from_db_state(backend, &genesis_config(99999, &[])) else { - panic!("genesis time mismatch must be fatal"); - }; - assert!(matches!( - err, - Error::GenesisMismatch(GenesisMismatch::GenesisTime { - expected: 99999, - got: 12345, - }) - )); + assert_eq!(store.get_data_column_sidecar(7, &root, 3).unwrap(), None); } - /// The case neither the state nor the validator registry can see: the slot - /// duration is deliberately absent from the SSZ state, so it has to be - /// caught against the persisted config. #[test] - fn from_db_state_errors_on_slot_duration_mismatch() { - let backend: Arc = Arc::new(InMemoryBackend::new()); - let _ = Store::from_anchor_state( - backend.clone(), - State::from_genesis(12345, vec![]), - DEFAULT_MILLISECONDS_PER_SLOT, - ); + fn taking_a_parked_sidecar_hands_it_back_once() { + let store = beacon_test_store(Arc::new(InMemoryBackend::new())); + let root = H256::repeat_byte(1); + store + .put_pending_data_column_sidecar(7, &root, 3, sidecar_bytes(0xab)) + .unwrap(); - let mut genesis = genesis_config(12345, &[]); - genesis.milliseconds_per_slot = 8_000; - let Err(err) = Store::from_db_state(backend, &genesis) else { - panic!("slot duration mismatch must be fatal"); - }; - assert!(matches!( - err, - Error::GenesisMismatch(GenesisMismatch::SlotDuration { - expected: 8_000, - got: DEFAULT_MILLISECONDS_PER_SLOT, - }) - )); + assert_eq!( + store.take_pending_data_column_sidecar(7, &root, 3).unwrap(), + Some(sidecar_bytes(0xab)) + ); + assert_eq!( + store.take_pending_data_column_sidecar(7, &root, 3).unwrap(), + None, + "the row goes with the read, so a replayed key cannot be replayed twice" + ); } - /// A data directory written before the slot duration was persisted holds a - /// bare SSZ `StateConfig` under `KEY_CONFIG`. It ran the default cadence, - /// so it must still resume rather than fail to decode. #[test] - fn from_db_state_resumes_a_pre_slot_duration_data_directory() { - use ethlambda_types::state::StateConfig; + fn clearing_the_parked_table_spares_nothing_and_touches_no_other_table() { + // Run at startup, when the in-memory index into this table is gone. + // The bound has to cover an all-ones key too, which a `u64::MAX` slot + // prefix would sort before rather than delete. + let store = beacon_test_store(Arc::new(InMemoryBackend::new())); + let root = H256::repeat_byte(1); + let extreme = H256::repeat_byte(0xff); + store + .put_pending_data_column_sidecar(0, &root, 0, sidecar_bytes(1)) + .unwrap(); + store + .put_pending_data_column_sidecar(u64::MAX, &extreme, u64::MAX, sidecar_bytes(2)) + .unwrap(); + store + .put_data_column_sidecar(7, &root, 3, sidecar_bytes(3)) + .unwrap(); - let backend: Arc = Arc::new(InMemoryBackend::new()); - let _ = Store::from_anchor_state( - backend.clone(), - State::from_genesis(12345, vec![]), - DEFAULT_MILLISECONDS_PER_SLOT, + store.clear_pending_data_column_sidecars().unwrap(); + + assert_eq!( + store.take_pending_data_column_sidecar(0, &root, 0).unwrap(), + None + ); + assert_eq!( + store + .take_pending_data_column_sidecar(u64::MAX, &extreme, u64::MAX) + .unwrap(), + None + ); + assert_eq!( + store.get_data_column_sidecar(7, &root, 3).unwrap(), + Some(sidecar_bytes(3)), + "the verified table is not what a restart throws away" ); + } - // Roll `KEY_CONFIG` back to the legacy layout. - let legacy = StateConfig { - genesis_time: 12345, - }; - let mut batch = backend.begin_write().expect("write batch"); - let entries = vec![(KEY_CONFIG.to_vec(), legacy.to_ssz())]; - batch - .put_batch(Table::Metadata, entries) - .expect("put legacy config"); - batch.commit().expect("commit"); + #[test] + fn the_indices_of_one_block_are_listed_in_order() { + let store = beacon_test_store(Arc::new(InMemoryBackend::new())); + let root = H256::repeat_byte(2); + for index in [9, 1, 4] { + store + .put_data_column_sidecar(11, &root, index, sidecar_bytes(index as u8)) + .unwrap(); + } + // A sibling block at the same slot must not leak into the answer. + store + .put_data_column_sidecar(11, &H256::repeat_byte(3), 7, sidecar_bytes(7)) + .unwrap(); - let store = Store::from_db_state(backend, &genesis_config(12345, &[])) - .expect("legacy config must decode") - .expect("store must be resumable"); assert_eq!( - *store.config(), - ChainConfig::new(12345, DEFAULT_MILLISECONDS_PER_SLOT) + store.data_column_indices_for(11, &root).unwrap(), + vec![1, 4, 9] ); } - /// The case a `genesis_time`-only check cannot see: same network start - /// time, different validator registry. #[test] - fn from_db_state_errors_on_validator_set_mismatch() { - let backend: Arc = Arc::new(InMemoryBackend::new()); - let persisted = vec![validator(0, 1), validator(1, 2)]; - let _ = Store::from_anchor_state( + fn the_anchor_slot_survives_a_resume_and_no_write_moves_it() { + // The whole reason this is persisted rather than derived: by the time a + // resumed store reads it back, `latest_finalized` has moved off the + // anchor, and storing sidecars must not be able to move it either. + let backend = Arc::new(InMemoryBackend::new()); + let store = Store::init_beacon( backend.clone(), - State::from_genesis(12345, persisted.clone()), + 0, + Config::mainnet(), + H256::ZERO, + Checkpoint::default(), + 4_096, + ); + assert_eq!(store.anchor_slot(), 4_096); + + let root = H256::repeat_byte(4); + for slot in [4_200, 4_100, 9_000] { + store + .put_data_column_sidecar(slot, &root, 0, sidecar_bytes(1)) + .unwrap(); + } + assert_eq!(store.anchor_slot(), 4_096); + + let resumed = Store::from_db_state(backend).unwrap().unwrap(); + assert_eq!(resumed.anchor_slot(), 4_096); + } + + #[test] + fn a_genesis_bootstrapped_lean_store_anchors_at_zero() { + let store = Store::from_anchor_state( + Arc::new(InMemoryBackend::new()), + State::from_genesis(0, Vec::new()), DEFAULT_MILLISECONDS_PER_SLOT, ); + assert_eq!(store.anchor_slot(), 0); + } + + /// A beacon store with a real anchor block behind its head, unlike + /// `beacon_test_store`'s bare zero-root stub: `update_checkpoints` walks + /// back from the old head to find the common ancestor with the new one, + /// and that walk needs a block entry for whatever root it starts from. + /// The data-column range tests below need to move the head, since + /// `data_column_sidecars_in_range` now reads `Table::BlockRoots` to learn + /// each slot's canonical root. + fn beacon_test_store_with_anchor() -> Store { + let mut store = beacon_test_store(Arc::new(InMemoryBackend::new())); + store + .insert_signed_block(H256::ZERO, beacon_test_block(0, H256::ZERO)) + .expect("insert anchor block"); + store + } - let mut foreign = persisted; - foreign[1] = validator(1, 9); - let Err(err) = Store::from_db_state(backend, &genesis_config(12345, &foreign)) else { - panic!("validator set mismatch must be fatal"); - }; - assert!(matches!( - err, - Error::GenesisMismatch(GenesisMismatch::ValidatorPubkey { index: 1 }) - )); + /// Inserts `block` and advances the store's head to its root, so the + /// slot it names reads back as canonical from `Table::BlockRoots`: what + /// `update_checkpoints` maintains on every head move, on both chains. + fn make_canonical(store: &mut Store, block: SignedBeaconBlock) -> H256 { + let root = block.message_hash_tree_root(); + store.insert_signed_block(root, block).expect("insert"); + store + .update_checkpoints(ForkCheckpoints::head_only(root)) + .expect("advance head"); + root } #[test] - fn from_db_state_returns_none_when_latest_finalized_is_missing() { - let backend: Arc = Arc::new(InMemoryBackend::new()); - // Write only KEY_CONFIG, leaving KEY_LATEST_FINALIZED absent. - let config = ChainConfig::new(12345, DEFAULT_MILLISECONDS_PER_SLOT); - let mut batch = backend.begin_write().expect("write batch"); - batch - .put_batch( - Table::Metadata, - vec![(KEY_CONFIG.to_vec(), config.to_ssz())], - ) - .expect("put config"); - batch.commit().expect("commit"); - assert!( - Store::from_db_state(backend, &genesis_config(12345, &[])) - .expect("Failed to get store") - .is_none() + fn sidecars_are_scanned_in_slot_order_across_a_range() { + // A real linear chain, not three siblings of the anchor: advancing + // the head to `root_3` in one move is what makes slots 1, 2 and 3 all + // canonical at once, since `update_checkpoints` walks every + // intermediate ancestor on its way back to the common ancestor with + // the old head. + let mut store = beacon_test_store_with_anchor(); + let block_1 = beacon_test_block(1, H256::ZERO); + let root_1 = block_1.message_hash_tree_root(); + store.insert_signed_block(root_1, block_1).expect("insert"); + let block_2 = beacon_test_block(2, root_1); + let root_2 = block_2.message_hash_tree_root(); + store.insert_signed_block(root_2, block_2).expect("insert"); + let root_3 = make_canonical(&mut store, beacon_test_block(3, root_2)); + + for (slot, root) in [(1, root_1), (2, root_2), (3, root_3)] { + store + .put_data_column_sidecar(slot, &root, 0, sidecar_bytes(slot as u8)) + .unwrap(); + } + let found = store.data_column_sidecars_in_range(1, 3, &[0]).unwrap(); + assert_eq!(found.len(), 2, "the range is half open: [1, 3)"); + assert_eq!(found[0], sidecar_bytes(1)); + assert_eq!(found[1], sidecar_bytes(2)); + } + + #[test] + fn the_range_query_returns_exactly_the_requested_columns() { + // Every existing test before this one wrote and queried only column + // index 0, so an inverted or dropped column filter would have passed + // the whole suite regardless. + let mut store = beacon_test_store_with_anchor(); + let root = make_canonical(&mut store, beacon_test_block(9, H256::ZERO)); + for index in [0, 1, 2, 3] { + store + .put_data_column_sidecar(9, &root, index, sidecar_bytes(0x10 + index as u8)) + .unwrap(); + } + + let found = store.data_column_sidecars_in_range(9, 10, &[1, 2]).unwrap(); + + // Columns 0 and 3 must not appear at all. + assert_eq!(found, vec![sidecar_bytes(0x11), sidecar_bytes(0x12)]); + } + + #[test] + fn a_sibling_roots_columns_never_leak_into_a_range_answer() { + // Gossip import only requires a sidecar's block to name a known, + // finalized-descendant parent, not a canonical one, so a live fork + // can leave both siblings' columns stored at one slot. The + // specification asks a range response to be "consistent from a + // single chain within the context of the request": only the + // canonical root's columns may come back, even though this node + // holds both. + let mut store = beacon_test_store_with_anchor(); + let canonical_root = make_canonical(&mut store, beacon_test_block(9, H256::ZERO)); + + let mut sibling = match beacon_test_block(9, H256::ZERO) { + SignedBeaconBlock::Phase0(block) => block, + other => panic!("expected phase0, got {}", other.fork_name()), + }; + sibling.message.body.graffiti = H256::repeat_byte(0xee); + let sibling = SignedBeaconBlock::Phase0(sibling); + let sibling_root = sibling.message_hash_tree_root(); + assert_ne!( + sibling_root, canonical_root, + "the fixture's premise changed" ); + store + .insert_signed_block(sibling_root, sibling) + .expect("insert sibling, never made canonical"); + + store + .put_data_column_sidecar(9, &canonical_root, 1, sidecar_bytes(0xaa)) + .unwrap(); + store + .put_data_column_sidecar(9, &sibling_root, 1, sidecar_bytes(0xbb)) + .unwrap(); + + let found = store.data_column_sidecars_in_range(9, 10, &[1]).unwrap(); + assert_eq!(found, vec![sidecar_bytes(0xaa)]); + } + + #[test] + fn deleting_live_chain_entries_removes_exactly_those_roots() { + let mut store = Store::test_store(); + let kept = H256::repeat_byte(1); + let removed = H256::repeat_byte(2); + + store.insert_live_chain_entry(9, kept, H256::ZERO); + store.insert_live_chain_entry(9, removed, H256::ZERO); + assert_eq!(store.block_index().len(), 2); + + store.delete_live_chain_entries(&[(9, removed)]); + + let index = store.block_index(); + assert!(index.contains_key(&kept)); + assert!(!index.contains_key(&removed)); } } diff --git a/crates/validator/Cargo.toml b/crates/validator/Cargo.toml new file mode 100644 index 000000000..1270c639c --- /dev/null +++ b/crates/validator/Cargo.toml @@ -0,0 +1,74 @@ +[package] +name = "ethlambda-validator" +authors.workspace = true +edition.workspace = true +keywords.workspace = true +license.workspace = true +readme.workspace = true +repository.workspace = true +rust-version.workspace = true +version.workspace = true + +[dependencies] +ethlambda-types.workspace = true +ethlambda-metrics.workspace = true + +# A produced block is decoded and re-published as SSZ, not JSON: signing it +# needs its `hash_tree_root`, which only the typed container can give. See +# `beacon_node::block_contents`. +libssz.workspace = true +libssz-derive.workspace = true +libssz-types.workspace = true + +serde.workspace = true +serde_json.workspace = true +serde_yaml_ng.workspace = true +hex.workspace = true +thiserror.workspace = true +tracing.workspace = true +# `time` for the duty loop's sleep between slots. Declared here rather than +# relied on through workspace feature unification, so this crate still builds +# if it is ever compiled on its own. +tokio = { workspace = true, features = ["time"] } +reqwest = { workspace = true, features = ["json"] } + +# BLS12-381. Same version and backend the state transition verifies with, so a +# signature produced here and one checked there come from one implementation. +blst = "0.3.16" + +# `BeaconNodeApi` is held behind a trait object so the duty services can be +# driven by a mock. Native async-fn-in-trait is not dyn-safe, and spelling out +# the `impl Future + Send` bounds at every method is noise this trait does not +# need. +async-trait = "0.1" + +# EIP-2335 keystore decryption. +scrypt = { version = "0.11", default-features = false } +pbkdf2 = { version = "0.12", default-features = false, features = ["hmac"] } +hmac = "0.12" +sha2.workspace = true +aes = "0.8" +ctr = "0.9" +unicode-normalization = "0.1" +uuid = { version = "1", features = ["v4"] } +# `serde` feature: `ImportRequest.passwords` deserializes straight into a +# `Zeroizing>` rather than a plain `Vec` that would leave +# plaintext passwords behind in freed memory. +zeroize = { version = "1", features = ["serde"] } + +axum = "0.8.1" + +# Constant-time comparison for the keymanager API's bearer token. +subtle = "2.6" + +# `O_NOFOLLOW` for writing key material without following a pre-placed +# symlink at a predictable, pubkey-derived path (see `secure_fs`). Already in +# the workspace's dependency tree transitively; this makes it a direct one. +[target.'cfg(unix)'.dependencies] +libc = "0.2" + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "test-util"] } +tower = { version = "0.5", features = ["util"] } +http-body-util = "0.1" +tempfile = "3" diff --git a/crates/validator/src/aggregation.rs b/crates/validator/src/aggregation.rs new file mode 100644 index 000000000..493ae187e --- /dev/null +++ b/crates/validator/src/aggregation.rs @@ -0,0 +1,494 @@ +//! Folding a committee's votes into one aggregate and publishing it. +//! +//! # What this client does and does not build +//! +//! It does not build the aggregate. The beacon node does, out of the votes it +//! collected on the subnet this client subscribed to, and hands back the best +//! one it has. What this client contributes is the wrapper: which of its +//! validators is publishing, the proof that validator was selected, and a +//! signature over both. +//! +//! That division matters for electra. A gossiped aggregate must cover exactly +//! one committee, even though the container widened to allow more; the +//! multi-committee form exists only on chain, assembled by a block proposer out +//! of several single-committee aggregates. Since this client never constructs +//! an aggregate, it cannot get that wrong. +//! +//! # Why the attestation data comes from the caller +//! +//! The aggregate asked for is the one covering the votes *this client's +//! validators cast*, which means the attestation data they actually signed. +//! Re-fetching it here would ask the beacon node again, and if the head moved +//! in between the answer would be different data, whose aggregate contains none +//! of this client's votes. So the attestation duty hands its data forward, and +//! a slot whose attestation failed is a slot with nothing to aggregate. +//! +//! # None of this is slashable +//! +//! Unlike every other duty here, aggregation signs nothing that can cost stake. +//! The aggregate's own signature belongs to the attesters and was produced by +//! somebody else; the selection proof commits to a slot, not a chain position; +//! and the wrapper signature says only "I collected these". Publishing two +//! different aggregates for one slot is wasteful, not punishable, which is why +//! there is no guard in this module. + +use std::collections::BTreeMap; +use std::sync::Arc; + +use ethlambda_types::beacon::containers::electra::{AggregateAndProof, SignedAggregateAndProof}; +use ethlambda_types::beacon::containers::shared::AttestationData; +use ethlambda_types::beacon::fork::ForkName; +use ethlambda_types::beacon::primitives::{HashTreeRoot as _, Slot}; +use tokio::sync::RwLock; +use tracing::{info, warn}; + +use crate::aggregation_selection::selection_for; +use crate::beacon_node::BeaconNodeApi; +use crate::beacon_node::dto::{AttesterDutyDto, parse_pubkey}; +use crate::error::{Error, Result}; +use crate::keys::ValidatorStore; +use crate::signing::SigningContext; + +pub struct AggregationService { + beacon_node: Arc, + context: Arc, +} + +impl AggregationService { + pub fn new(beacon_node: Arc, context: Arc) -> Self { + Self { + beacon_node, + context, + } + } + + /// Publish an aggregate for every duty at `slot` this client was selected + /// for. + /// + /// `data` must be what this slot's attestations were signed over. See the + /// module doc for why it is not re-fetched. + /// + /// Returns how many aggregates were published, which is zero whenever none + /// of this client's validators was selected. That is the ordinary case: a + /// validator aggregates a few times a day. + pub async fn aggregate( + &self, + slot: Slot, + data: &AttestationData, + duties: &[AttesterDutyDto], + store: &RwLock, + ) -> Result { + if duties.is_empty() { + return Ok(0); + } + + // Which of this client's validators aggregates, and with what proof. + // + // The read guard covers the whole selection pass and no await, the way + // the attestation path's does: `selection_for` signs, and the lock is + // write-preferring, so holding this across the fetches below would put + // every later duty behind any keymanager writer that queued meanwhile. + let selected: Vec<(AttesterDutyDto, _)> = { + let store = store.read().await; + duties + .iter() + .filter_map(|duty| match selection_for(&self.context, &store, duty) { + Ok(Some(proof)) => Some((duty.clone(), proof)), + Ok(None) => None, + Err(err) => { + warn!( + %slot, + validator = duty.validator_index, + %err, + "Could not compute aggregator selection; skipping this duty" + ); + None + } + }) + .collect() + }; + + if selected.is_empty() { + return Ok(0); + } + + // One fetch per committee, not per validator. Two of this client's + // validators can be selected for the same committee, and they publish + // one wrapper each around the identical aggregate; asking the node + // twice for it would be the same answer at twice the cost. + let data_root = data.hash_tree_root(); + let mut aggregates = BTreeMap::new(); + for (duty, _) in &selected { + if aggregates.contains_key(&duty.committee_index) { + continue; + } + match self + .beacon_node + .aggregate_attestation(slot, data_root, duty.committee_index) + .await + { + Ok(aggregate) => { + aggregates.insert(duty.committee_index, aggregate); + } + // Not an error for the slot. A node with nothing to fold + // answers 404, which the specification treats as an ordinary + // outcome, and one committee's missing aggregate must not stop + // another committee's from going out. + Err(err) => warn!( + %slot, + committee = duty.committee_index, + %err, + "No aggregate available for this committee" + ), + } + } + + if aggregates.is_empty() { + return Ok(0); + } + + // Every aggregate in this batch must share one fork, since the batch + // goes out under a single header. They came from one slot, so a + // disagreement means the nodes behind failover disagree about where a + // fork boundary sits. + let fork = self.batch_fork(&aggregates, slot)?; + + let signed = { + let store = store.read().await; + let mut signed = Vec::with_capacity(selected.len()); + for (duty, selection_proof) in &selected { + let Some(aggregate) = aggregates.get(&duty.committee_index) else { + continue; + }; + let pubkey = match parse_pubkey(&duty.pubkey) { + Ok(pubkey) => pubkey, + Err(err) => { + warn!(%slot, validator = duty.validator_index, %err, "Duty carried an unreadable pubkey"); + continue; + } + }; + + let message = AggregateAndProof { + aggregator_index: duty.validator_index, + aggregate: aggregate.attestation.clone(), + selection_proof: *selection_proof, + }; + // Signed over the whole wrapper, not over the aggregate: that + // is what binds this validator's index and its selection proof + // to the votes it is republishing. + let root = message.hash_tree_root(); + let signature = match self + .context + .sign_aggregate_and_proof(&store, &pubkey, root, slot) + { + Ok(signature) => signature, + Err(err) => { + warn!(%slot, validator = duty.validator_index, %err, "Failed to sign aggregate"); + continue; + } + }; + signed.push(SignedAggregateAndProof { message, signature }); + } + signed + }; + + if signed.is_empty() { + return Ok(0); + } + + let count = signed.len(); + self.beacon_node.publish_aggregates(fork, &signed).await?; + info!(%slot, count, fork = fork.as_str(), "Published aggregates"); + crate::metrics::inc_aggregates_published(count as u64); + Ok(count) + } + + /// The one fork every aggregate in this batch was produced under. + /// + /// Split out so the disagreement case has somewhere to be explained. The + /// batch is published under a single `Eth-Consensus-Version`, so a batch + /// spanning two forks could not be announced truthfully; with failover in + /// play the aggregates can come from different nodes, which is the only way + /// that happens. + fn batch_fork( + &self, + aggregates: &BTreeMap, + slot: Slot, + ) -> Result { + let mut forks = aggregates.values().map(|aggregate| aggregate.fork); + let first = forks.next().expect("the caller checked for emptiness"); + if let Some(other) = forks.find(|fork| *fork != first) { + return Err(Error::InconsistentResponse(format!( + "aggregates for slot {slot} came back as both {} and {}; they cannot be \ + published under one consensus version", + first.as_str(), + other.as_str() + ))); + } + Ok(first) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::aggregation_selection::is_aggregator; + use crate::beacon_node::dto::encode_hex; + use crate::beacon_node::mock::MockBeaconNode; + use ethlambda_types::beacon::config::Config; + use ethlambda_types::beacon::containers::shared::Checkpoint; + use ethlambda_types::beacon::primitives::{BlsPubkey, Root}; + + fn secret() -> [u8; 32] { + hex::decode("000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f") + .expect("valid hex") + .try_into() + .expect("32 bytes") + } + + fn context() -> Arc { + Arc::new(SigningContext { + config: Config::mainnet(), + genesis_validators_root: Root::ZERO, + }) + } + + fn store() -> (RwLock, BlsPubkey) { + let mut store = ValidatorStore::new(); + let pubkey = store.insert_secret("test", &secret()).expect("inserts"); + (RwLock::new(store), pubkey) + } + + fn data(slot: Slot) -> AttestationData { + AttestationData { + slot, + index: 0, + beacon_block_root: Root::repeat_byte(7), + source: Checkpoint { + epoch: slot / 32 - 1, + root: Root::ZERO, + }, + target: Checkpoint { + epoch: slot / 32, + root: Root::ZERO, + }, + } + } + + fn duty( + pubkey: &BlsPubkey, + validator_index: u64, + slot: Slot, + committee_index: u64, + ) -> AttesterDutyDto { + AttesterDutyDto { + pubkey: encode_hex(&pubkey.0), + validator_index, + committee_index, + committee_length: 128, + committees_at_slot: 64, + validator_committee_index: 7, + slot, + } + } + + /// A slot this key is selected to aggregate at, and one it is not. Which + /// slots those are is a property of SHA-256, so they are found rather than + /// written down. + fn slots(pubkey: &BlsPubkey, store: &ValidatorStore) -> (Slot, Slot) { + let context = context(); + let mut selected = None; + let mut rejected = None; + for slot in 3_200..3_400 { + let proof = context + .sign_selection_proof(store, pubkey, slot) + .expect("signs"); + if is_aggregator(128, &proof) { + selected.get_or_insert(slot); + } else { + rejected.get_or_insert(slot); + } + if selected.is_some() && rejected.is_some() { + break; + } + } + ( + selected.expect("some slot selects this key"), + rejected.expect("some slot does not"), + ) + } + + async fn fixture() -> (RwLock, BlsPubkey, Slot, Slot) { + let (store, pubkey) = store(); + let (selected, rejected) = { + let guard = store.read().await; + slots(&pubkey, &guard) + }; + (store, pubkey, selected, rejected) + } + + #[tokio::test] + async fn a_selected_validator_publishes_one_aggregate() { + let (store, pubkey, slot, _) = fixture().await; + let node = Arc::new(MockBeaconNode::new().with_aggregate(data(slot))); + let service = AggregationService::new(node.clone(), context()); + + let published = service + .aggregate(slot, &data(slot), &[duty(&pubkey, 1, slot, 2)], &store) + .await + .expect("aggregates"); + + assert_eq!(published, 1); + assert_eq!(node.published_aggregates().len(), 1); + } + + /// Nothing is asked of the beacon node when this client was not selected. + /// That is the ordinary case, and it must cost nothing. + #[tokio::test] + async fn an_unselected_validator_asks_the_node_for_nothing() { + let (store, pubkey, _, slot) = fixture().await; + let node = Arc::new(MockBeaconNode::new().with_aggregate(data(slot))); + let service = AggregationService::new(node.clone(), context()); + + let published = service + .aggregate(slot, &data(slot), &[duty(&pubkey, 1, slot, 2)], &store) + .await + .expect("no-op"); + + assert_eq!(published, 0); + assert!(node.aggregate_requests().is_empty()); + assert!(node.published_aggregates().is_empty()); + } + + /// The aggregate asked for must be keyed on the data this client's + /// validators actually signed. Asking for anything else fetches an + /// aggregate containing none of their votes. + #[tokio::test] + async fn the_aggregate_is_requested_for_the_signed_datas_root() { + let (store, pubkey, slot, _) = fixture().await; + let node = Arc::new(MockBeaconNode::new().with_aggregate(data(slot))); + + AggregationService::new(node.clone(), context()) + .aggregate(slot, &data(slot), &[duty(&pubkey, 1, slot, 5)], &store) + .await + .expect("aggregates"); + + let asked = node.aggregate_requests(); + assert_eq!(asked.len(), 1); + assert_eq!(asked[0].0, slot); + assert_eq!(asked[0].1, data(slot).hash_tree_root()); + assert_eq!(asked[0].2, 5, "the committee index must be passed through"); + } + + /// Two validators selected for one committee share a fetch and publish one + /// wrapper each. + #[tokio::test] + async fn two_validators_in_one_committee_share_a_single_fetch() { + let (store, pubkey, slot, _) = fixture().await; + let node = Arc::new(MockBeaconNode::new().with_aggregate(data(slot))); + let duties = vec![duty(&pubkey, 1, slot, 2), duty(&pubkey, 2, slot, 2)]; + + let published = AggregationService::new(node.clone(), context()) + .aggregate(slot, &data(slot), &duties, &store) + .await + .expect("aggregates"); + + assert_eq!(published, 2, "one wrapper per selected validator"); + assert_eq!( + node.aggregate_requests().len(), + 1, + "but only one fetch, since it is the same aggregate" + ); + } + + /// The signature covers the whole wrapper, which is what binds the + /// aggregator's index and its selection proof to the votes it republishes. + #[tokio::test] + async fn the_published_signature_covers_the_whole_wrapper() { + use blst::min_pk::{PublicKey, Signature}; + + let (store, pubkey, slot, _) = fixture().await; + let node = Arc::new(MockBeaconNode::new().with_aggregate(data(slot))); + + AggregationService::new(node.clone(), context()) + .aggregate(slot, &data(slot), &[duty(&pubkey, 1, slot, 2)], &store) + .await + .expect("aggregates"); + + let (fork, list) = node.published_aggregates().remove(0); + assert_eq!(fork, ForkName::Electra); + let entry = &list[0]; + assert_eq!(entry.message.aggregator_index, 1); + + let root = context().aggregate_and_proof_signing_root(entry.message.hash_tree_root(), slot); + let pk = PublicKey::from_bytes(&pubkey.0).expect("valid pubkey"); + let sig = Signature::from_bytes(&entry.signature.0).expect("valid signature"); + assert_eq!( + sig.verify( + true, + root.as_slice(), + b"BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_", + &[], + &pk, + true + ), + blst::BLST_ERROR::BLST_SUCCESS + ); + } + + /// The selection proof published must be the same signature the selection + /// was decided by. A beacon node re-derives the decision from it, so a + /// different one would be rejected. + #[tokio::test] + async fn the_published_proof_is_the_one_selection_was_decided_by() { + let (store, pubkey, slot, _) = fixture().await; + let node = Arc::new(MockBeaconNode::new().with_aggregate(data(slot))); + + AggregationService::new(node.clone(), context()) + .aggregate(slot, &data(slot), &[duty(&pubkey, 1, slot, 2)], &store) + .await + .expect("aggregates"); + + let (_, list) = node.published_aggregates().remove(0); + + let guard = store.read().await; + let expected = context() + .sign_selection_proof(&guard, &pubkey, slot) + .expect("signs"); + assert_eq!(list[0].message.selection_proof, expected); + assert!( + is_aggregator(128, &list[0].message.selection_proof), + "the published proof must be one that actually selects" + ); + } + + /// A node with nothing to fold answers 404, which the specification treats + /// as ordinary. One committee's missing aggregate must not stop another's. + #[tokio::test] + async fn a_committee_with_no_aggregate_does_not_stop_the_others() { + let (store, pubkey, slot, _) = fixture().await; + let node = Arc::new(MockBeaconNode::new()); // no aggregate set: every fetch 404s + let service = AggregationService::new(node.clone(), context()); + + let published = service + .aggregate(slot, &data(slot), &[duty(&pubkey, 1, slot, 2)], &store) + .await + .expect("a missing aggregate is not an error for the slot"); + + assert_eq!(published, 0); + assert!(node.published_aggregates().is_empty()); + } + + #[tokio::test] + async fn no_duties_means_no_work() { + let (store, _, slot, _) = fixture().await; + let node = Arc::new(MockBeaconNode::new().with_aggregate(data(slot))); + + let published = AggregationService::new(node.clone(), context()) + .aggregate(slot, &data(slot), &[], &store) + .await + .expect("no-op"); + assert_eq!(published, 0); + assert!(node.aggregate_requests().is_empty()); + } +} diff --git a/crates/validator/src/aggregation_selection.rs b/crates/validator/src/aggregation_selection.rs new file mode 100644 index 000000000..d29aa159d --- /dev/null +++ b/crates/validator/src/aggregation_selection.rs @@ -0,0 +1,231 @@ +//! Whether a validator is an aggregator for a committee, and why it is not a +//! choice. +//! +//! # What the rule is for +//! +//! Every attester in a committee broadcasts its own vote. Somebody has to fold +//! those into one aggregate signature, or a block would have to carry a +//! signature per attester. The protocol picks who, and it picks by a rule +//! nobody can steer. +//! +//! A validator signs the slot under a dedicated domain, hashes that signature, +//! and is an aggregator when the first eight bytes, read as a little-endian +//! integer, divide evenly by a modulus derived from the committee's size. BLS +//! signatures are deterministic, so a validator gets exactly one answer per +//! slot and cannot search for a better one. It cannot decline either: the same +//! computation is what a beacon node checks the resulting aggregate against. +//! +//! # The modulus, and why the count is only approximate +//! +//! `committee_length / TARGET_AGGREGATORS_PER_COMMITTEE`, floored, and at least +//! one. With a 128-member committee and a target of 16 the modulus is 8, so +//! roughly one member in eight is selected, which is roughly sixteen +//! aggregators. Roughly, not exactly: each member's signature hash is +//! independent, so the count is binomial around the target rather than fixed, +//! and the protocol wants redundancy here rather than precision. Having no +//! aggregator for a committee costs that committee's votes their cheap path +//! into a block. +//! +//! A committee smaller than the target makes the division zero, which is why +//! the modulus floors at one. `x % 1 == 0` for every `x`, so every member of a +//! very small committee aggregates. That is the intended outcome, not an edge +//! case to guard: a committee that cannot supply sixteen aggregators should +//! supply all of them. + +use ethlambda_types::beacon::primitives::BlsSignature; +use sha2::{Digest, Sha256}; + +use crate::beacon_node::dto::{AttesterDutyDto, parse_pubkey}; +use crate::error::Result; +use crate::keys::ValidatorStore; +use crate::signing::SigningContext; + +/// How many aggregators the protocol aims for per committee. +/// +/// Re-exported rather than defined here: it governs how this client behaves, +/// not what the chain agrees about, but the beacon node's +/// `/eth/v1/config/spec` reports it too, and the two must name one value. +pub use ethlambda_types::beacon::constants::TARGET_AGGREGATORS_PER_COMMITTEE; + +/// Whether `selection_proof` selects its signer as an aggregator for a +/// committee of `committee_length` members. +/// +/// `selection_proof` must be the signature over the committee's slot under the +/// selection domain, and nothing else. Any other signature would still produce +/// a `bool` here, and it would be the wrong one in a way nothing downstream +/// could detect, because a beacon node re-derives this from the slot signature +/// carried in the aggregate. +pub fn is_aggregator(committee_length: u64, selection_proof: &BlsSignature) -> bool { + let modulo = (committee_length / TARGET_AGGREGATORS_PER_COMMITTEE).max(1); + + let digest = Sha256::digest(selection_proof.0); + // The specification's `bytes_to_uint64`, which is little-endian. Reading + // these eight bytes the other way round would still select about one + // validator in `modulo`, so the mistake would look statistically correct + // and produce aggregates every beacon node rejects. + let mut head = [0u8; 8]; + head.copy_from_slice(&digest[..8]); + u64::from_le_bytes(head) % modulo == 0 +} + +/// The selection proof for `duty`, if it selects that validator as an +/// aggregator for that committee. +/// +/// One function for both callers, because they have to agree. The subscription +/// sent at the start of an epoch tells the beacon node which committees this +/// client will aggregate for, and the aggregation tick later in each slot acts +/// on that claim. A client that computed the two differently would either +/// claim a role it never performs, leaving a committee's votes to nobody, or +/// perform one it never claimed, against a node that did not collect the votes +/// to fold. +/// +/// Returns the proof rather than a bare `bool` because the caller that acts on +/// it needs the signature itself: it goes into the published +/// `AggregateAndProof`, which is what makes the selection verifiable rather +/// than self-declared. +/// +/// An unreadable pubkey or an unknown validator is an error rather than a +/// silent `None`. Both mean the duty cannot be served at all, and reporting +/// them as "not an aggregator" would hide a misconfiguration behind a role +/// this validator was never going to fill anyway. +pub fn selection_for( + context: &SigningContext, + store: &ValidatorStore, + duty: &AttesterDutyDto, +) -> Result> { + let pubkey = parse_pubkey(&duty.pubkey)?; + let proof = context.sign_selection_proof(store, &pubkey, duty.slot)?; + Ok(is_aggregator(duty.committee_length, &proof).then_some(proof)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A signature whose SHA-256 digest starts with the little-endian bytes of + /// `target`, found by search. Lets a test state the selection outcome it + /// wants rather than hunting for a signature that happens to produce it. + fn signature_hashing_to_multiple_of(modulo: u64) -> BlsSignature { + for seed in 0u32..100_000 { + let mut bytes = [0u8; 96]; + bytes[..4].copy_from_slice(&seed.to_le_bytes()); + let signature = BlsSignature(bytes); + let digest = Sha256::digest(signature.0); + let mut head = [0u8; 8]; + head.copy_from_slice(&digest[..8]); + if u64::from_le_bytes(head) % modulo == 0 { + return signature; + } + } + panic!("no signature found hashing to a multiple of {modulo}"); + } + + fn signature_not_hashing_to_multiple_of(modulo: u64) -> BlsSignature { + for seed in 0u32..100_000 { + let mut bytes = [0u8; 96]; + bytes[..4].copy_from_slice(&seed.to_le_bytes()); + let signature = BlsSignature(bytes); + let digest = Sha256::digest(signature.0); + let mut head = [0u8; 8]; + head.copy_from_slice(&digest[..8]); + if u64::from_le_bytes(head) % modulo != 0 { + return signature; + } + } + panic!("no signature found that is not a multiple of {modulo}"); + } + + /// A committee smaller than the target divides to zero, and a modulus of + /// zero would panic. Flooring at one makes every member an aggregator, + /// which is the intended answer: a committee that cannot supply the target + /// number should supply all of them. + #[test] + fn every_member_of_a_committee_below_the_target_is_an_aggregator() { + for length in [0, 1, TARGET_AGGREGATORS_PER_COMMITTEE - 1] { + // Any signature at all, including one that fails a larger modulus. + let signature = signature_not_hashing_to_multiple_of(8); + assert!( + is_aggregator(length, &signature), + "committee of {length} must select everyone" + ); + } + } + + #[test] + fn a_committee_of_exactly_the_target_still_selects_everyone() { + // 16 / 16 is 1, so the modulus is 1 and every remainder is zero. + let signature = signature_not_hashing_to_multiple_of(8); + assert!(is_aggregator(TARGET_AGGREGATORS_PER_COMMITTEE, &signature)); + } + + /// A mainnet-sized committee: 128 members, target 16, modulus 8. + #[test] + fn a_full_committee_selects_only_matching_signatures() { + let selected = signature_hashing_to_multiple_of(8); + let rejected = signature_not_hashing_to_multiple_of(8); + + assert!(is_aggregator(128, &selected)); + assert!(!is_aggregator(128, &rejected)); + } + + /// The same signature must give the same answer every time. A validator + /// gets one answer per slot and cannot search for a better one, which is + /// the property that makes the role unsteerable. + #[test] + fn the_answer_is_a_function_of_the_signature_alone() { + let signature = signature_hashing_to_multiple_of(8); + let first = is_aggregator(128, &signature); + for _ in 0..10 { + assert_eq!(is_aggregator(128, &signature), first); + } + } + + /// Over many signatures the selected fraction should sit near one in + /// `modulo`. Loose bounds on purpose: the count is binomial, and a test + /// that pinned it exactly would be testing SHA-256's output rather than + /// this function. + #[test] + fn roughly_one_in_modulo_signatures_is_selected() { + let mut selected = 0; + let total = 8_000; + for seed in 0u32..total { + let mut bytes = [0u8; 96]; + bytes[..4].copy_from_slice(&seed.to_le_bytes()); + if is_aggregator(128, &BlsSignature(bytes)) { + selected += 1; + } + } + // Modulus 8, so expect about 1000 of 8000. + assert!( + (700..=1300).contains(&selected), + "expected roughly an eighth of {total} to be selected, got {selected}" + ); + } + + /// The digest is read little-endian, as the specification's + /// `bytes_to_uint64` does. Big-endian would also select about one in + /// `modulo`, so the statistics test above cannot catch the difference; + /// only comparing the two orderings on a signature where they disagree + /// can. + #[test] + fn the_digest_is_read_little_endian() { + let signature = (0u32..100_000) + .find_map(|seed| { + let mut bytes = [0u8; 96]; + bytes[..4].copy_from_slice(&seed.to_le_bytes()); + let digest = Sha256::digest(bytes); + let mut head = [0u8; 8]; + head.copy_from_slice(&digest[..8]); + let little = u64::from_le_bytes(head) % 8 == 0; + let big = u64::from_be_bytes(head) % 8 == 0; + (little != big).then_some((BlsSignature(bytes), little)) + }) + .expect("some signature must distinguish the two byte orders"); + + assert_eq!( + is_aggregator(128, &signature.0), + signature.1, + "the little-endian reading must be the one that decides" + ); + } +} diff --git a/crates/validator/src/attestation.rs b/crates/validator/src/attestation.rs new file mode 100644 index 000000000..c274f2bfb --- /dev/null +++ b/crates/validator/src/attestation.rs @@ -0,0 +1,654 @@ +//! Producing and publishing this slot's attestations. + +use std::sync::Arc; + +use ethlambda_types::beacon::containers::shared::AttestationData; +use ethlambda_types::beacon::primitives::Slot; +use tokio::sync::RwLock; +use tracing::{error, info, warn}; + +use crate::attestation_guard::AttestationGuard; +use crate::beacon_node::dto::{ + AttestationDataOutDto, AttesterDutyDto, SingleAttestationDto, encode_hex, parse_pubkey, +}; +use crate::beacon_node::{BeaconNodeApi, validate_attestation_data}; +use crate::error::Result; +use crate::keys::ValidatorStore; +use crate::signing::SigningContext; + +/// What one slot's attestation duty produced. +/// +/// The data is carried out alongside the count because the aggregation duty +/// later in the same slot needs it, and needs *this* one: it identifies the +/// aggregate covering the votes these validators just cast. Re-fetching it +/// there would ask the beacon node again, and a head that moved in between +/// would give different data whose aggregate contains none of them. See +/// [`crate::aggregation`]. +#[derive(Debug, Clone)] +pub struct Attested { + /// How many attestations the beacon node accepted. + pub published: usize, + /// The data every attestation in this slot was signed over, or `None` when + /// there was no duty to fetch it for. + /// + /// An `Option` rather than a zeroed default, because a caller handed a + /// default would ask for the aggregate of an attestation nobody made. It is + /// `Some` whenever the fetch succeeded, even if nothing was published: + /// aggregation is a duty over the *whole committee's* votes, so it is still + /// owed when this client's own signatures were refused. + pub data: Option, +} + +pub struct AttestationService { + beacon_node: Arc, + context: Arc, + /// What this process has already signed, per validator. + /// + /// A `std::sync::Mutex` rather than a `tokio` one on purpose: the critical + /// section is a hash lookup and an insert with no `.await` inside it, so an + /// async mutex would buy nothing and cost a scheduling point. The guard is + /// taken and dropped per validator inside the signing loop, which already + /// holds the store's read lock, so it must not be the thing that blocks. + /// + /// Not slashing protection. See [`AttestationGuard`] for exactly what it + /// does and does not cover. + guard: std::sync::Mutex, +} + +impl AttestationService { + pub fn new(beacon_node: Arc, context: Arc) -> Self { + Self { + beacon_node, + context, + guard: std::sync::Mutex::new(AttestationGuard::new()), + } + } + + /// Sign and publish every duty held for `slot`. + /// + /// One `attestation_data` fetch is shared by every validator attesting at + /// this slot, and every signature goes out in one submission: the data does + /// not depend on the validator, and the beacon node's pool endpoint takes a + /// list. + /// + /// Returns how many attestations were published, and the data they were + /// signed over, which this slot's aggregation duty needs. + /// + /// # Do not retry this call within a slot + /// + /// On a submission failure every signature in the batch has already been + /// produced. Calling this again re-fetches the attestation data, and if the + /// head moved in between it signs a *different* message for the same + /// validator and slot. This client keeps no slashing-protection record, so + /// nothing would catch that, and two distinct attestations for one slot from + /// one validator is a slashable offence. + /// + /// A caller that wants to retry must resubmit the batch this call already + /// built, not call this again. Today's duty loop does neither: it logs the + /// failure and waits for the next slot, which is the safe default. + /// + /// The `Eth-Consensus-Version` header is derived here from the fetched + /// attestation data's target epoch, rather than taken from the caller: a + /// caller-supplied fork name could be stale across a fork boundary with + /// nothing to catch it, whereas the target epoch is what the signing + /// domain is already keyed on for this same message. + pub async fn attest( + &self, + slot: Slot, + duties: &[AttesterDutyDto], + store: &RwLock, + ) -> Result { + if duties.is_empty() { + return Ok(Attested { + published: 0, + data: None, + }); + } + + let data = self.beacon_node.attestation_data(slot).await?; + + // Checked again here, having already been checked by the + // implementation this call went through (see the contract on + // `BeaconNodeApi::attestation_data`). Not redundant: the trait is + // public, so this is the last point at which a wrong answer from an + // implementation that failed to honour its contract can be stopped + // before it becomes a signature. + // + // This is *not* where failover happens, and an earlier comment here + // wrongly said it was. `FallbackBeaconNode` wraps the call above, so by + // the time this runs a node's answer has already been accepted and + // returned; failing here loses the slot rather than trying the next + // node. That is why the same check now lives in the implementation, + // where an `Err` is something failover can act on. + validate_attestation_data(slot, &data)?; + + // Consistent with the check just above: `data.target.epoch` is now + // known to equal `expected_target_epoch`, so this is the fork this + // attestation actually signs under. + let fork_name = self + .context + .config + .fork_at_epoch(data.target.epoch) + .as_str(); + + let data_dto = AttestationDataOutDto::from(&data); + + // The read guard is scoped to this block alone and must never be widened to cover an + // await. `tokio::sync::RwLock` is write-preferring, so a keymanager + // import or delete queuing for the write lock blocks every read behind + // it; keeping this guard narrow bounds how long that wait can be. The + // import holds its own write lock for the inserts alone, doing the slow + // EIP-2335 derivation outside it (see `http_api::keystores::import`), + // so this is the duty path keeping its half of the same bargain. + let mut attestations = Vec::with_capacity(duties.len()); + { + let _timing = crate::metrics::time_signing(); + let store = store.read().await; + for duty in duties { + let pubkey = match parse_pubkey(&duty.pubkey) { + Ok(pubkey) => pubkey, + Err(err) => { + error!(%slot, validator = duty.validator_index, %err, "Duty carried an unreadable pubkey"); + continue; + } + }; + + // Refuse before signing, not after: a signature that exists is + // a signature that can escape, whatever the code after it + // intended. This closes the double-vote shapes reachable + // within one run (a backward clock step, a schedule replaced + // mid-epoch); it is not slashing protection and does not + // pretend to be. See `AttestationGuard`. + // + // Held per validator, and dropped before the next iteration. + // The lock is poisoned only by a panic while it is held, and + // nothing inside can panic; `unwrap` on it would still be a + // crash in the duty path, so a poisoned lock is treated as a + // refusal for every remaining validator instead. + match self.guard.lock() { + Ok(mut guard) => { + if let Err(refusal) = guard.check_and_record(&pubkey, &data) { + warn!( + %slot, + validator = duty.validator_index, + %refusal, + "Refusing to sign: this process already signed a conflicting \ + attestation for this validator" + ); + crate::metrics::inc_attestations_refused(); + continue; + } + } + Err(err) => { + error!(%slot, %err, "Attestation guard is poisoned; refusing to sign"); + crate::metrics::inc_attestations_refused(); + continue; + } + } + + // One validator failing must not cost the others their attestation. + let signature = match self.context.sign_attestation(&store, &pubkey, &data) { + Ok(signature) => signature, + Err(err) => { + error!(%slot, validator = duty.validator_index, %err, "Failed to sign attestation"); + crate::metrics::inc_signing_failures(); + continue; + } + }; + + attestations.push(SingleAttestationDto { + committee_index: duty.committee_index, + attester_index: duty.validator_index, + data: data_dto.clone(), + signature: encode_hex(&signature.0), + }); + } + } + + if attestations.is_empty() { + return Ok(Attested { + published: 0, + data: Some(data), + }); + } + + let submitted = attestations.len(); + let published = self + .beacon_node + .submit_attestations(&attestations, fork_name) + .await?; + if published < submitted { + warn!( + %slot, + published, + submitted, + "Some attestations in this slot's batch were rejected by the beacon node" + ); + } + info!(%slot, count = published, "Published attestations"); + crate::metrics::inc_attestations_published(published as u64); + Ok(Attested { + published, + data: Some(data), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + // Used by the tests only: the signing path itself no longer constructs an + // error, since `validate_attestation_data` produces them now. + use crate::beacon_node::mock::MockBeaconNode; + use crate::error::Error; + use ethlambda_types::beacon::config::Config; + use ethlambda_types::beacon::containers::shared::{AttestationData, Checkpoint}; + use ethlambda_types::beacon::preset; + use ethlambda_types::beacon::primitives::Root; + + fn secret() -> [u8; 32] { + hex::decode("000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f") + .expect("valid hex") + .try_into() + .expect("32 bytes") + } + + /// A second, unrelated key. + /// + /// Tests that exercise two duties in one slot need two *validators*, not + /// one key used twice. A pubkey resolves to exactly one validator index on + /// chain, and `ValidatorStore` is keyed by pubkey so a duplicate collapses, + /// so one key under two indices is a shape that cannot occur. It also now + /// trips `AttestationGuard`, correctly: two duties for one validator in one + /// epoch is the double-vote shape the guard exists to refuse. + fn other_secret() -> [u8; 32] { + let mut bytes = secret(); + // Perturb a low byte: still a valid scalar, comfortably below the + // curve order, and a different key from `secret()`. + bytes[31] ^= 0x01; + bytes + } + + fn context() -> Arc { + Arc::new(SigningContext { + config: Config::mainnet(), + genesis_validators_root: Root::ZERO, + }) + } + + fn duty( + pubkey: &str, + validator_index: u64, + slot: u64, + committee_index: u64, + ) -> AttesterDutyDto { + AttesterDutyDto { + pubkey: pubkey.to_string(), + validator_index, + committee_index, + committee_length: 128, + committees_at_slot: 64, + validator_committee_index: 7, + slot, + } + } + + #[tokio::test] + async fn signs_and_submits_one_attestation_per_duty() { + let mut store = ValidatorStore::new(); + let first = store.insert_secret("test", &secret()).expect("inserts"); + let second = store + .insert_secret("test-2", &other_secret()) + .expect("inserts"); + + let node = Arc::new(MockBeaconNode::new().with_attestation_data(96)); + let service = AttestationService::new(node.clone(), context()); + + let duties = vec![ + duty(&encode_hex(&first.0), 1337, 96, 3), + duty(&encode_hex(&second.0), 1338, 96, 3), + ]; + let store = RwLock::new(store); + let published = service.attest(96, &duties, &store).await.expect("attests"); + + assert_eq!(published.published, 2); + let submitted = node.submitted(); + assert_eq!(submitted.len(), 2); + assert_eq!(submitted[0].attester_index, 1337); + assert_eq!(submitted[1].attester_index, 1338); + assert_eq!(submitted[0].committee_index, 3); + assert_eq!( + node.attestation_data_call_count(), + 1, + "the data must be fetched once and shared" + ); + } + + /// The guard engaging in the real signing path, not just in isolation. + /// + /// This is the backward-clock-step shape: the duty loop derives its slot + /// from the wall clock, so an NTP correction can re-enter a slot already + /// attested and call `attest` again for it. The first call must publish; + /// the second must publish nothing and must not reach the beacon node with + /// a second signature. + #[tokio::test] + async fn attesting_the_same_slot_twice_publishes_only_once() { + let mut store = ValidatorStore::new(); + let pubkey = store.insert_secret("test", &secret()).expect("inserts"); + + let node = Arc::new(MockBeaconNode::new().with_attestation_data(96)); + let service = AttestationService::new(node.clone(), context()); + + let duties = vec![duty(&encode_hex(&pubkey.0), 1337, 96, 3)]; + let store = RwLock::new(store); + + let first = service.attest(96, &duties, &store).await.expect("attests"); + let second = service + .attest(96, &duties, &store) + .await + .expect("the second call is not an error, it simply signs nothing"); + + assert_eq!(first.published, 1); + assert_eq!(second.published, 0, "the second attempt must be refused"); + assert_eq!( + node.submitted().len(), + 1, + "only one signature may ever reach the beacon node" + ); + } + + /// The mid-epoch schedule replacement shape: the same validator moved to a + /// different slot within one epoch. Both slots are in epoch 3, so the + /// second is a double vote even though the slot differs. + #[tokio::test] + async fn attesting_a_second_slot_in_one_epoch_is_refused() { + let mut store = ValidatorStore::new(); + let pubkey = store.insert_secret("test", &secret()).expect("inserts"); + let pubkey_hex = encode_hex(&pubkey.0); + + // 96 and 101 are both in epoch 3 (96 / 32 == 101 / 32 == 3). + let node = Arc::new(MockBeaconNode::new().with_attestation_data(96)); + let service = AttestationService::new(node.clone(), context()); + let store = RwLock::new(store); + + let first = service + .attest(96, &[duty(&pubkey_hex, 1337, 96, 3)], &store) + .await + .expect("attests"); + assert_eq!(first.published, 1); + + let node_again = Arc::new(MockBeaconNode::new().with_attestation_data(101)); + // Same service, so the same guard; a new mock only because the mock + // pins one slot's data at a time. + let service = AttestationService { + beacon_node: node_again.clone(), + context: context(), + guard: service.guard, + }; + let second = service + .attest(101, &[duty(&pubkey_hex, 1337, 101, 3)], &store) + .await + .expect("not an error, simply signs nothing"); + + assert_eq!( + second.published, 0, + "a second slot in one target epoch must be refused" + ); + assert!(node_again.submitted().is_empty()); + } + + /// Distinct from the test above: both duties there share one committee + /// index, so it cannot tell "each duty's own committee index is + /// forwarded" apart from "the shared attestation data leaked its index + /// into every output". Here the two duties disagree, which only the + /// former explains. + #[tokio::test] + async fn each_duty_keeps_its_own_committee_index() { + let mut store = ValidatorStore::new(); + let first = store.insert_secret("test", &secret()).expect("inserts"); + let second = store + .insert_secret("test-2", &other_secret()) + .expect("inserts"); + + let node = Arc::new(MockBeaconNode::new().with_attestation_data(96)); + let service = AttestationService::new(node.clone(), context()); + + let duties = vec![ + duty(&encode_hex(&first.0), 1337, 96, 3), + duty(&encode_hex(&second.0), 1338, 96, 5), + ]; + let store = RwLock::new(store); + let published = service.attest(96, &duties, &store).await.expect("attests"); + + assert_eq!(published.published, 2); + let submitted = node.submitted(); + assert_eq!(submitted[0].committee_index, 3); + assert_eq!(submitted[1].committee_index, 5); + assert_eq!( + node.attestation_data_call_count(), + 1, + "the data must still be fetched once and shared" + ); + } + + #[tokio::test] + async fn a_validator_without_a_key_does_not_block_the_others() { + let mut store = ValidatorStore::new(); + let known = store.insert_secret("test", &secret()).expect("inserts"); + + let node = Arc::new(MockBeaconNode::new().with_attestation_data(96)); + let service = AttestationService::new(node.clone(), context()); + + let duties = vec![ + duty(&encode_hex(&[0x99; 48]), 1, 96, 3), + duty(&encode_hex(&known.0), 1337, 96, 3), + ]; + let store = RwLock::new(store); + let published = service.attest(96, &duties, &store).await.expect("attests"); + + assert_eq!(published.published, 1); + assert_eq!(node.submitted()[0].attester_index, 1337); + } + + #[tokio::test] + async fn nothing_is_submitted_when_every_validator_failed_to_sign() { + // Distinct from `no_duties_means_no_request_at_all`: that test hits + // the early `duties.is_empty()` guard before any fetch happens. This + // one has duties, so the fetch does happen, and exercises the guard + // after the signing loop, where the loop produced nothing to send. + let store = RwLock::new(ValidatorStore::new()); + let node = Arc::new(MockBeaconNode::new().with_attestation_data(96)); + let service = AttestationService::new(node.clone(), context()); + + let duties = vec![ + duty(&encode_hex(&[0x99; 48]), 1, 96, 3), + duty(&encode_hex(&[0x88; 48]), 2, 96, 3), + ]; + let published = service.attest(96, &duties, &store).await.expect("attests"); + + assert_eq!(published.published, 0); + assert!(node.submitted().is_empty()); + assert_eq!( + node.attestation_data_call_count(), + 1, + "the fetch still happens; only the submission is skipped" + ); + } + + /// The aggregation duty later in the slot asks for the aggregate covering + /// exactly this data, so it has to come back from here rather than be + /// re-fetched: a head that moved in between would give different data + /// whose aggregate contains none of these votes. + #[tokio::test] + async fn the_attested_data_is_handed_back_for_aggregation() { + let mut store = ValidatorStore::new(); + let pubkey = store.insert_secret("test", &secret()).expect("inserts"); + let store = RwLock::new(store); + let node = Arc::new(MockBeaconNode::new().with_attestation_data(96)); + let service = AttestationService::new(node, context()); + + let attested = service + .attest(96, &[duty(&encode_hex(&pubkey.0), 1337, 96, 3)], &store) + .await + .expect("attests"); + + let data = attested.data.expect("the fetched data must come back"); + assert_eq!(data.slot, 96); + assert_eq!(data.target.epoch, 3); + } + + /// With no duty there is nothing to fetch, so there is no data either. + /// A zeroed default here would have the aggregation duty ask for the + /// aggregate of an attestation nobody made. + #[tokio::test] + async fn no_duties_means_no_data_to_aggregate_against() { + let service = AttestationService::new(Arc::new(MockBeaconNode::new()), context()); + let attested = service + .attest(96, &[], &RwLock::new(ValidatorStore::new())) + .await + .expect("no-op"); + assert!(attested.data.is_none()); + } + + #[tokio::test] + async fn no_duties_means_no_request_at_all() { + let node = Arc::new(MockBeaconNode::new()); + let service = AttestationService::new(node.clone(), context()); + let published = service + .attest(96, &[], &RwLock::new(ValidatorStore::new())) + .await + .expect("no-op"); + assert_eq!(published.published, 0); + assert_eq!(node.attestation_data_call_count(), 0); + } + + #[tokio::test] + async fn a_failing_attestation_data_fetch_is_reported() { + let node = Arc::new(MockBeaconNode::new()); + let service = AttestationService::new(node, context()); + let mut store = ValidatorStore::new(); + let pubkey = store.insert_secret("test", &secret()).expect("inserts"); + let store = RwLock::new(store); + let err = service + .attest(96, &[duty(&encode_hex(&pubkey.0), 1, 96, 0)], &store) + .await + .expect_err("must fail"); + assert!(err.to_string().contains("no attestation data"), "got {err}"); + } + + /// The regression test for the missing check this finding is about: a + /// beacon node primed for slot 96 answers a request for slot 97 with + /// that stale data, `data.slot` (96) disagreeing with the requested slot + /// (97). Before the fix in `attest`, nothing compared the two and the + /// mismatched data was signed and submitted anyway. + #[tokio::test] + async fn attestation_data_for_the_wrong_slot_is_rejected() { + let mut store = ValidatorStore::new(); + let pubkey = store.insert_secret("test", &secret()).expect("inserts"); + let store = RwLock::new(store); + let pubkey_hex = encode_hex(&pubkey.0); + + let node = Arc::new(MockBeaconNode::new().with_attestation_data(96)); + let service = AttestationService::new(node.clone(), context()); + + let duties = vec![duty(&pubkey_hex, 1337, 97, 3)]; + let err = service + .attest(97, &duties, &store) + .await + .expect_err("must reject data answering for a different slot"); + + assert!(matches!(err, Error::InconsistentResponse(_)), "got {err:?}"); + assert!( + node.submitted().is_empty(), + "a mismatched response must never be signed or submitted" + ); + } + + /// Distinct from the slot check above: here the slot the node answers + /// with matches what was asked for, but the target checkpoint's epoch + /// does not correspond to that slot. Since the signing domain is chosen + /// from `data.target.epoch`, this is an independent way a broken node + /// could get a signature out of this client that it should not. + #[tokio::test] + async fn attestation_data_with_a_target_epoch_inconsistent_with_its_slot_is_rejected() { + let mut store = ValidatorStore::new(); + let pubkey = store.insert_secret("test", &secret()).expect("inserts"); + let store = RwLock::new(store); + let pubkey_hex = encode_hex(&pubkey.0); + + let mut node = MockBeaconNode::new(); + node.attestation_data = Some(AttestationData { + slot: 96, + index: 0, + beacon_block_root: Root::ZERO, + source: Checkpoint { + epoch: 0, + root: Root::ZERO, + }, + // Slot 96 belongs to epoch 3 (96 / 32); 999 is deliberately wrong. + target: Checkpoint { + epoch: 999, + root: Root::ZERO, + }, + }); + let node = Arc::new(node); + let service = AttestationService::new(node.clone(), context()); + + let duties = vec![duty(&pubkey_hex, 1337, 96, 3)]; + let err = service + .attest(96, &duties, &store) + .await + .expect_err("must reject an internally inconsistent target epoch"); + + assert!(matches!(err, Error::InconsistentResponse(_)), "got {err:?}"); + assert!(node.submitted().is_empty()); + } + + /// The regression test for why `attest` derives the `Eth-Consensus-Version` + /// header from `data.target.epoch` rather than accepting it from the + /// caller: the two slots below are one epoch apart and straddle mainnet's + /// altair boundary, so if the header were derived from anything else (a + /// cached value, the wrong epoch, an off-by-one), at least one assertion + /// here would fail. A case where the two epochs' forks agree would not + /// tell them apart. + #[tokio::test] + async fn the_consensus_version_header_tracks_the_target_epochs_fork() { + let mut store = ValidatorStore::new(); + let pubkey = store.insert_secret("test", &secret()).expect("inserts"); + let pubkey_hex = encode_hex(&pubkey.0); + let store = RwLock::new(store); + + let altair_fork_epoch = Config::mainnet().altair_fork_epoch; + let last_phase0_slot = altair_fork_epoch * preset::SLOTS_PER_EPOCH - 1; + let first_altair_slot = altair_fork_epoch * preset::SLOTS_PER_EPOCH; + + let phase0_node = Arc::new(MockBeaconNode::new().with_attestation_data(last_phase0_slot)); + AttestationService::new(phase0_node.clone(), context()) + .attest( + last_phase0_slot, + &[duty(&pubkey_hex, 1337, last_phase0_slot, 3)], + &store, + ) + .await + .expect("attests"); + + let altair_node = Arc::new(MockBeaconNode::new().with_attestation_data(first_altair_slot)); + AttestationService::new(altair_node.clone(), context()) + .attest( + first_altair_slot, + &[duty(&pubkey_hex, 1337, first_altair_slot, 3)], + &store, + ) + .await + .expect("attests"); + + assert_eq!( + phase0_node.last_submitted_fork_name().as_deref(), + Some("phase0") + ); + assert_eq!( + altair_node.last_submitted_fork_name().as_deref(), + Some("altair") + ); + } +} diff --git a/crates/validator/src/attestation_guard.rs b/crates/validator/src/attestation_guard.rs new file mode 100644 index 000000000..5a5ebc3e4 --- /dev/null +++ b/crates/validator/src/attestation_guard.rs @@ -0,0 +1,377 @@ +//! An in-process guard against signing two conflicting attestations. +//! +//! # What this is not +//! +//! **This is not slashing protection.** It holds no history on disk, so it +//! knows nothing about what a previous run of this process signed, and nothing +//! about what another process holding the same keys is signing right now. A +//! restart empties it. Its records are gone the moment the process is. +//! +//! This client keeps no signing history by design (see the crate +//! documentation). That decision is not reversed here and this module is not a +//! step toward reversing it: durable slashing protection is a different thing, +//! with a durability requirement this deliberately does not have. +//! +//! # What it is +//! +//! A cheap check that closes the double-vote shapes this client can reach +//! *within one run*, which were otherwise reachable through ordinary +//! operation rather than through operator error: +//! +//! - **A backward wall-clock step.** The duty loop derives its slot from +//! `SystemTime::now()`, which is not monotonic. An NTP correction of a few +//! seconds re-enters a slot already attested, and the re-fetched attestation +//! data can differ if a block arrived in between. Two different attestations +//! under one target epoch is a double vote. +//! - **A schedule replaced mid-epoch.** A duty refresh that fails at an epoch +//! boundary and succeeds a few slots later can move a validator to a +//! different slot in the *same* epoch, and the loop then attests a second +//! time under that target. +//! +//! Neither needs a hostile beacon node or a second instance. Both are ordinary +//! things that happen to running systems. +//! +//! # The rule +//! +//! EIP-3076's minimal variant, per validator: remember the highest source and +//! target epoch signed, and refuse anything that does not strictly advance the +//! target or that would reach back below the source. +//! +//! That is deliberately more conservative than the full slashing conditions, +//! which would need the whole history to evaluate. With one record per +//! validator it cannot distinguish "surrounds an attestation from six epochs +//! ago" from "surrounds nothing"; requiring monotonic progress makes the +//! question unnecessary. An honest validator's attestations already advance +//! this way, so the conservatism costs nothing in normal operation. +//! +//! Refusing is always safe here: a skipped attestation is a missed reward, and +//! a double vote is a slashing. + +use std::collections::HashMap; + +use ethlambda_types::beacon::containers::shared::AttestationData; +use ethlambda_types::beacon::primitives::{BlsPubkey, Epoch}; + +/// The highest source and target epoch signed for one validator. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct Signed { + source: Epoch, + target: Epoch, +} + +/// Why an attestation was refused. +/// +/// Carried out of [`AttestationGuard::check`] so the caller can log which rule +/// fired rather than a bare "refused". The two are different operational +/// situations: a repeated target usually means the clock moved or a schedule +/// was replaced, while a regressed source means something stranger and is +/// worth looking at. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Refusal { + /// The target epoch is at or below one already signed for this validator. + /// + /// The double-vote shape. Equality is refused as well as regression: the + /// same target signed twice is a double vote unless the two attestations + /// are byte-identical, and this guard deliberately does not keep enough to + /// tell those apart. Re-signing identical data would be harmless but is + /// also pointless, since the client does not retry within a slot. + TargetNotAdvanced { signed: Epoch, proposed: Epoch }, + /// The source epoch is below one already signed for this validator. + /// + /// The surround shape: a lower source with a higher target surrounds the + /// earlier attestation. + SourceRegressed { signed: Epoch, proposed: Epoch }, +} + +impl std::fmt::Display for Refusal { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::TargetNotAdvanced { signed, proposed } => write!( + f, + "target epoch {proposed} does not advance past {signed}, already signed this run" + ), + Self::SourceRegressed { signed, proposed } => write!( + f, + "source epoch {proposed} is below {signed}, already signed this run" + ), + } + } +} + +/// Per-validator record of the highest attestation signed in this run. +/// +/// Not `Clone`: one guard per process is the point. A copy would be a second +/// opinion about what has been signed, which is worse than no opinion. +#[derive(Debug, Default)] +pub struct AttestationGuard { + signed: HashMap, +} + +impl AttestationGuard { + pub fn new() -> Self { + Self::default() + } + + /// Whether `data` may be signed for `pubkey`, without recording anything. + /// + /// Separate from [`Self::record`] so a caller can decide before paying for + /// a signature, and so this stays a pure function over the guard's state + /// and therefore directly testable. + pub fn check(&self, pubkey: &BlsPubkey, data: &AttestationData) -> Result<(), Refusal> { + let Some(signed) = self.signed.get(pubkey) else { + // Nothing signed for this validator this run. Note what this does + // *not* mean: a previous run may have signed anything at all. That + // gap is the crate-level scope decision, not an oversight here. + return Ok(()); + }; + + if data.target.epoch <= signed.target { + return Err(Refusal::TargetNotAdvanced { + signed: signed.target, + proposed: data.target.epoch, + }); + } + if data.source.epoch < signed.source { + return Err(Refusal::SourceRegressed { + signed: signed.source, + proposed: data.source.epoch, + }); + } + Ok(()) + } + + /// Record that `data` was signed for `pubkey`. + /// + /// Takes the maximum of each epoch rather than overwriting, so that a + /// caller recording out of order cannot lower the bar. Nothing does that + /// today; the guard is cheap to make order-independent and expensive to + /// debug if it ever is not. + pub fn record(&mut self, pubkey: BlsPubkey, data: &AttestationData) { + let entry = self.signed.entry(pubkey).or_insert(Signed { + source: data.source.epoch, + target: data.target.epoch, + }); + entry.source = entry.source.max(data.source.epoch); + entry.target = entry.target.max(data.target.epoch); + } + + /// Check and record in one step, for the common call site. + /// + /// Recording only on success is what makes a refusal idempotent: a + /// refused attestation must not move the bar it was measured against. + pub fn check_and_record( + &mut self, + pubkey: &BlsPubkey, + data: &AttestationData, + ) -> Result<(), Refusal> { + self.check(pubkey, data)?; + self.record(*pubkey, data); + Ok(()) + } + + /// How many validators have signed something this run. For metrics. + pub fn len(&self) -> usize { + self.signed.len() + } + + pub fn is_empty(&self) -> bool { + self.signed.is_empty() + } +} + +#[cfg(test)] +mod tests { + use ethlambda_types::beacon::containers::shared::Checkpoint; + use ethlambda_types::beacon::primitives::{H256, Root}; + + use super::*; + + fn pubkey(byte: u8) -> BlsPubkey { + BlsPubkey([byte; 48]) + } + + /// An attestation at `slot` voting source `source` and target `target`. + /// + /// `beacon_block_root` is a parameter because the double-vote case that + /// matters most is two attestations with one target epoch and *different* + /// roots, which is what a re-fetch after a block arrival produces. + fn data(slot: u64, source: Epoch, target: Epoch, root: u8) -> AttestationData { + AttestationData { + slot, + index: 0, + beacon_block_root: H256([root; 32]), + source: Checkpoint { + epoch: source, + root: Root::ZERO, + }, + target: Checkpoint { + epoch: target, + root: Root::ZERO, + }, + } + } + + #[test] + fn a_first_attestation_is_allowed() { + let mut guard = AttestationGuard::new(); + assert!( + guard + .check_and_record(&pubkey(1), &data(96, 2, 3, 0xaa)) + .is_ok() + ); + } + + #[test] + fn advancing_the_target_is_allowed() { + let mut guard = AttestationGuard::new(); + guard + .check_and_record(&pubkey(1), &data(96, 2, 3, 0xaa)) + .expect("first"); + + assert!( + guard + .check_and_record(&pubkey(1), &data(128, 3, 4, 0xbb)) + .is_ok() + ); + } + + /// The backward-clock-step case. Same validator, same target epoch, a + /// different block root because the data was re-fetched after a block + /// arrived. This is the double vote the guard exists to stop. + #[test] + fn re_attesting_one_target_with_different_data_is_refused() { + let mut guard = AttestationGuard::new(); + guard + .check_and_record(&pubkey(1), &data(96, 2, 3, 0xaa)) + .expect("first"); + + let err = guard + .check_and_record(&pubkey(1), &data(96, 2, 3, 0xbb)) + .expect_err("a second vote for one target must be refused"); + + assert_eq!( + err, + Refusal::TargetNotAdvanced { + signed: 3, + proposed: 3 + } + ); + } + + /// The mid-epoch schedule replacement case: a different *slot* in the same + /// epoch, which is still one target epoch and still a double vote. + #[test] + fn a_different_slot_in_the_same_target_epoch_is_refused() { + let mut guard = AttestationGuard::new(); + guard + .check_and_record(&pubkey(1), &data(96, 2, 3, 0xaa)) + .expect("first"); + + assert!( + guard + .check_and_record(&pubkey(1), &data(101, 2, 3, 0xbb)) + .is_err(), + "a second slot under one target epoch is a double vote" + ); + } + + #[test] + fn a_regressed_target_is_refused() { + let mut guard = AttestationGuard::new(); + guard + .check_and_record(&pubkey(1), &data(128, 3, 4, 0xaa)) + .expect("first"); + + assert!( + guard + .check_and_record(&pubkey(1), &data(96, 2, 3, 0xbb)) + .is_err() + ); + } + + /// The surround shape: a later target with an earlier source surrounds the + /// attestation already signed. + #[test] + fn a_regressed_source_under_an_advancing_target_is_refused() { + let mut guard = AttestationGuard::new(); + guard + .check_and_record(&pubkey(1), &data(128, 3, 4, 0xaa)) + .expect("first"); + + let err = guard + .check_and_record(&pubkey(1), &data(160, 1, 5, 0xbb)) + .expect_err("a surrounding vote must be refused"); + + assert_eq!( + err, + Refusal::SourceRegressed { + signed: 3, + proposed: 1 + } + ); + } + + /// The property that makes this per-validator rather than per-slot: two + /// validators in one epoch attest at different slots, and the second must + /// not be blocked by the first. + #[test] + fn one_validator_does_not_block_another_in_the_same_epoch() { + let mut guard = AttestationGuard::new(); + guard + .check_and_record(&pubkey(1), &data(96, 2, 3, 0xaa)) + .expect("first validator"); + + assert!( + guard + .check_and_record(&pubkey(2), &data(101, 2, 3, 0xaa)) + .is_ok(), + "a second validator attesting later in the same epoch must be allowed" + ); + } + + /// A refusal must not move the bar, or one refused attestation would + /// change what the next check compares against. + #[test] + fn a_refusal_records_nothing() { + let mut guard = AttestationGuard::new(); + guard + .check_and_record(&pubkey(1), &data(128, 3, 4, 0xaa)) + .expect("first"); + + guard + .check_and_record(&pubkey(1), &data(160, 1, 5, 0xbb)) + .expect_err("surround refused"); + + // The recorded source must still be 3, not 1: a valid attestation + // advancing from the real record is still accepted. + assert!( + guard + .check_and_record(&pubkey(1), &data(160, 3, 5, 0xcc)) + .is_ok(), + "the refused attempt must not have lowered the recorded source" + ); + } + + #[test] + fn check_alone_does_not_record() { + let guard = AttestationGuard::new(); + let data = data(96, 2, 3, 0xaa); + + guard.check(&pubkey(1), &data).expect("allowed"); + guard + .check(&pubkey(1), &data) + .expect("still allowed, because check records nothing"); + } + + #[test] + fn recording_out_of_order_does_not_lower_the_bar() { + let mut guard = AttestationGuard::new(); + guard.record(pubkey(1), &data(128, 3, 4, 0xaa)); + guard.record(pubkey(1), &data(96, 2, 3, 0xbb)); + + assert!( + guard.check(&pubkey(1), &data(101, 2, 3, 0xcc)).is_err(), + "the earlier record must not have displaced the later one" + ); + } +} diff --git a/crates/validator/src/beacon_node/block_contents.rs b/crates/validator/src/beacon_node/block_contents.rs new file mode 100644 index 000000000..4ea71218e --- /dev/null +++ b/crates/validator/src/beacon_node/block_contents.rs @@ -0,0 +1,534 @@ +//! What `produceBlockV3` returns and `publishBlockV2` takes, as SSZ. +//! +//! The SSZ counterpart to [`super::dto`], and here for the same reason those +//! types are: these are shapes of one transport, not of the chain. The +//! difference is that this transport is the one that matters for a block. +//! +//! # Why SSZ rather than the JSON this crate uses everywhere else +//! +//! To sign a block this client must compute its `hash_tree_root`, and a root +//! can only be computed from the typed container. Going through JSON would +//! mean hand-writing a field-for-field mapping of an entire `BeaconBlockBody`, +//! its `ExecutionPayload`, and every operation list inside them, and then +//! trusting that mapping to be exact: a single wrong field order, a missing +//! list, one integer read as decimal that was meant as hex, and the client +//! signs a root that is not the block's. The failure is silent at the point it +//! happens and shows up as a block the network rejects. +//! +//! Decoding the specification's own serialization removes that whole class of +//! bug. The bytes the beacon node sent *are* the block; the root falls out of +//! it. +//! +//! The endpoints support it: `Accept: application/octet-stream` on +//! `produceBlockV3`, `Content-Type: application/octet-stream` on +//! `publishBlockV2`. +//! +//! # `BlockContents` is not a consensus container +//! +//! It is defined in `ethereum/beacon-APIs` and nowhere else. `consensus-specs` +//! never mentions the name, and therefore never states its SSZ encoding +//! either: the three-field container below is what every implementation +//! encodes and decodes, but it is convention rather than specification. That is +//! precisely why it lives in this crate rather than in `ethlambda-types`, which +//! is the authority on containers the chain itself agrees about. +//! +//! # What the block is wrapped in, per fork +//! +//! From Deneb onward a produced block does not travel alone: it comes with the +//! blobs it commits to and the proofs for them, because the beacon node has to +//! broadcast those alongside the block and cannot reconstruct them from the +//! block itself. +//! +//! | Fork | Produced | Published | +//! |---|---|---| +//! | Deneb, Electra, Fulu | `BlockContents` | `SignedBlockContents` | +//! | Gloas and later | bare block; blobs move to the payload envelope | bare signed block | +//! +//! Deneb shares that envelope but not the block inside it: its +//! `BeaconBlockBody` has twelve fields where electra's has thirteen, so the two +//! have different fixed-size prefixes and a deneb body cannot be decoded as an +//! electra one. Rather than carry a second container pair, this client refuses +//! deneb outright. It could not serve such a chain anyway: the attestations it +//! submits are electra's `SingleAttestation`, which has no pre-electra form. +//! +//! Fulu is the trap. PeerDAS did **not** turn the response back into a bare +//! block, which is the natural guess given that fulu moves blob distribution to +//! column sampling. The container is unchanged in shape. What changed is +//! `kzg_proofs`: it carries one proof per *cell* rather than one per blob, so +//! its element count is `CELLS_PER_EXT_BLOB` times larger and its SSZ list +//! limit is `FIELD_ELEMENTS_PER_EXT_BLOB * MAX_BLOB_COMMITMENTS_PER_BLOCK` +//! rather than `MAX_BLOB_COMMITMENTS_PER_BLOCK`. +//! +//! The limit is where reusing one type for both forks goes wrong, and it is +//! worth being precise about when. An SSZ list's limit bounds how many +//! elements decode, so the electra container rejects any body carrying more +//! than `MAX_BLOB_COMMITMENTS_PER_BLOCK` proofs. A fulu block carries +//! `CELLS_PER_EXT_BLOB` of them per blob, so the two limits agree until a +//! block holds more than `MAX_BLOB_COMMITMENTS_PER_BLOCK / CELLS_PER_EXT_BLOB` +//! blobs, which is thirty-two. +//! +//! Today's blocks are nowhere near that, so a single shared type would appear +//! to work. It would stop working silently, because fulu made the per-block +//! blob limit a function of the epoch rather than a fixed preset, precisely so +//! that later forks can raise it without a new container. The first block past +//! thirty-two blobs would fail to decode, in a client that had been correct for +//! months. + +use ethlambda_types::beacon::containers::deneb::Blob; +use ethlambda_types::beacon::containers::electra::{BeaconBlock, SignedBeaconBlock}; +use ethlambda_types::beacon::fork::ForkName; +use ethlambda_types::beacon::preset; +use ethlambda_types::beacon::primitives::{BlsSignature, KzgProof}; +use libssz::{SszDecode, SszEncode}; +use libssz_derive::{SszDecode, SszEncode}; +use libssz_types::SszList; + +// `Result` is deliberately not imported. `libssz_derive`'s generated code +// names `Result` unqualified and means `std`'s, so this crate's one-argument +// alias in scope would make every derive in this file fail to compile. +use crate::error::Error; + +/// One proof per blob, as deneb and electra produce them. +pub type BlobKzgProofs = SszList; + +/// One proof per *cell*, as fulu produces them under EIP-7594. +/// +/// The limit is `FIELD_ELEMENTS_PER_EXT_BLOB * MAX_BLOB_COMMITMENTS_PER_BLOCK`, +/// which is the bound `CellKZGProofs` carries in the fulu validator guide. It +/// is far larger than the count any real block holds (`CELLS_PER_EXT_BLOB` per +/// blob, so 768 for six blobs) because an SSZ limit bounds the type, not the +/// value. +pub type CellKzgProofs = SszList< + KzgProof, + { preset::FIELD_ELEMENTS_PER_EXT_BLOB * preset::MAX_BLOB_COMMITMENTS_PER_BLOCK }, +>; + +pub type Blobs = SszList; + +/// `produceBlockV3`'s body for deneb and electra. +#[derive(Debug, Clone, PartialEq, SszEncode, SszDecode)] +pub struct BlockContents { + pub block: BeaconBlock, + pub kzg_proofs: BlobKzgProofs, + pub blobs: Blobs, +} + +/// `publishBlockV2`'s body for deneb and electra. +/// +/// The same three fields with the block signed. Field order and names are +/// load-bearing: SSZ encodes by declaration order, so swapping two fields +/// produces bytes a node decodes into a different block without complaining. +#[derive(Debug, Clone, PartialEq, SszEncode, SszDecode)] +pub struct SignedBlockContents { + pub signed_block: SignedBeaconBlock, + pub kzg_proofs: BlobKzgProofs, + pub blobs: Blobs, +} + +/// `produceBlockV3`'s body for fulu. See the module doc for why this is not +/// [`BlockContents`]. +#[derive(Debug, Clone, PartialEq, SszEncode, SszDecode)] +pub struct FuluBlockContents { + pub block: BeaconBlock, + pub kzg_proofs: CellKzgProofs, + pub blobs: Blobs, +} + +/// `publishBlockV2`'s body for fulu. +#[derive(Debug, Clone, PartialEq, SszEncode, SszDecode)] +pub struct FuluSignedBlockContents { + pub signed_block: SignedBeaconBlock, + pub kzg_proofs: CellKzgProofs, + pub blobs: Blobs, +} + +/// A block a beacon node produced, with whatever travelled alongside it. +/// +/// The proofs and blobs are kept in the shape they arrived in rather than +/// merged into one type, because the two shapes have different SSZ list limits +/// and merging them would mean re-encoding a fulu block's proofs under deneb's +/// tree depth. +#[derive(Debug, Clone, PartialEq)] +pub struct ProducedBlock { + /// The fork the beacon node named, kept because publishing has to name the + /// same one back. + /// + /// Not recomputed from the slot. The publish body is a re-encoding of + /// exactly what the node produced, under the container its header selected, + /// so the header sent with it must be that same fork; deriving it from this + /// client's own schedule instead could only ever disagree. + /// + /// It is finer-grained than [`Contents`] on purpose: deneb and electra + /// share a payload shape but are different forks, and the wire needs to be + /// told which. + pub fork: ForkName, + pub contents: Contents, +} + +/// The block and the blob material that came with it. +#[derive(Debug, Clone, PartialEq)] +pub enum Contents { + /// Deneb or electra: one proof per blob. + WithBlobProofs { + block: BeaconBlock, + kzg_proofs: BlobKzgProofs, + blobs: Blobs, + }, + /// Fulu: one proof per cell. + WithCellProofs { + block: BeaconBlock, + kzg_proofs: CellKzgProofs, + blobs: Blobs, + }, +} + +impl ProducedBlock { + /// Decode a `produceBlockV3` body, choosing the container by the fork the + /// node named in `Eth-Consensus-Version`. + /// + /// The fork cannot be recovered from the bytes: SSZ carries no type tag, + /// and a deneb and a fulu `BlockContents` differ only in a list limit that + /// does not appear in the encoding. It has to come from the header, which + /// is exactly why that header is required. + /// + /// Forks before electra are refused rather than decoded, deneb included. + /// Deneb's block body is a field shorter than electra's, so it needs its + /// own container pair; the rest need four more. None of them would buy + /// anything, because the attestations this client submits are electra's + /// `SingleAttestation` and have no earlier form, so a pre-electra chain is + /// one it cannot serve whatever it does with blocks. + pub fn from_ssz(fork: ForkName, bytes: &[u8]) -> crate::error::Result { + let decode = |what: &str, err: libssz::DecodeError| { + Error::Decode(format!( + "produced block: {what} for fork {} did not decode: {err:?}", + fork.as_str() + )) + }; + + match fork { + ForkName::Electra => { + let contents = BlockContents::from_ssz_bytes(bytes) + .map_err(|err| decode("block contents", err))?; + Ok(Self { + fork, + contents: Contents::WithBlobProofs { + block: contents.block, + kzg_proofs: contents.kzg_proofs, + blobs: contents.blobs, + }, + }) + } + ForkName::Fulu => { + let contents = FuluBlockContents::from_ssz_bytes(bytes) + .map_err(|err| decode("fulu block contents", err))?; + Ok(Self { + fork, + contents: Contents::WithCellProofs { + block: contents.block, + kzg_proofs: contents.kzg_proofs, + blobs: contents.blobs, + }, + }) + } + ForkName::Phase0 + | ForkName::Altair + | ForkName::Bellatrix + | ForkName::Capella + | ForkName::Deneb + | ForkName::Lean => Err(Error::InconsistentResponse(format!( + "beacon node produced a block for fork {}, which this client does not propose \ + under; electra is the earliest supported", + fork.as_str() + ))), + } + } + + /// The block itself, whichever wrapper it came in. + pub fn block(&self) -> &BeaconBlock { + match &self.contents { + Contents::WithBlobProofs { block, .. } | Contents::WithCellProofs { block, .. } => { + block + } + } + } + + /// How many blobs came with the block. For logging. + pub fn blob_count(&self) -> usize { + match &self.contents { + Contents::WithBlobProofs { blobs, .. } | Contents::WithCellProofs { blobs, .. } => { + blobs.len() + } + } + } + + /// Attach `signature` and encode the body `publishBlockV2` expects. + /// + /// Consumes the block: the proofs and blobs are moved into the published + /// body rather than copied, and a blob is 128 KiB, so a six-blob block + /// would otherwise be three quarters of a megabyte cloned for nothing. + pub fn into_signed_ssz(self, signature: BlsSignature) -> Vec { + match self.contents { + Contents::WithBlobProofs { + block, + kzg_proofs, + blobs, + } => SignedBlockContents { + signed_block: SignedBeaconBlock { + message: block, + signature, + }, + kzg_proofs, + blobs, + } + .to_ssz(), + Contents::WithCellProofs { + block, + kzg_proofs, + blobs, + } => FuluSignedBlockContents { + signed_block: SignedBeaconBlock { + message: block, + signature, + }, + kzg_proofs, + blobs, + } + .to_ssz(), + } + } +} + +/// The smallest well-formed electra block: every list empty, every scalar zero, +/// with the slot and proposer a caller cares about. +/// +/// Lives here rather than in the test module below so that +/// [`crate::beacon_node::mock::MockBeaconNode`] can answer `produce_block` with +/// the same fixture these tests decode, instead of a second one that could +/// drift from it. +/// +/// Spelled out field by field because neither `ExecutionPayload` nor +/// `ExecutionRequests` derives `Default`, which is the right call in that +/// crate: a zeroed execution payload is not one any chain would accept, and a +/// `Default` would let one be constructed by accident. +#[cfg(test)] +pub fn empty_block_for( + slot: ethlambda_types::beacon::primitives::Slot, + proposer_index: ethlambda_types::beacon::primitives::ValidatorIndex, +) -> BeaconBlock { + use ethlambda_types::beacon::containers::deneb::ExecutionPayload; + use ethlambda_types::beacon::containers::electra::{BeaconBlockBody, ExecutionRequests}; + + let payload = ExecutionPayload { + parent_hash: Default::default(), + fee_recipient: Default::default(), + state_root: Default::default(), + receipts_root: Default::default(), + logs_bloom: vec![0u8; preset::BYTES_PER_LOGS_BLOOM] + .try_into() + .expect("a logs bloom is BYTES_PER_LOGS_BLOOM long by construction"), + prev_randao: Default::default(), + block_number: 0, + gas_limit: 0, + gas_used: 0, + timestamp: 0, + extra_data: Default::default(), + base_fee_per_gas: Default::default(), + block_hash: Default::default(), + transactions: Default::default(), + withdrawals: Default::default(), + blob_gas_used: 0, + excess_blob_gas: 0, + }; + + BeaconBlock { + slot, + proposer_index, + parent_root: Default::default(), + state_root: Default::default(), + body: BeaconBlockBody { + randao_reveal: BlsSignature([0; 96]), + eth1_data: Default::default(), + graffiti: Default::default(), + proposer_slashings: Default::default(), + attester_slashings: Default::default(), + attestations: Default::default(), + deposits: Default::default(), + voluntary_exits: Default::default(), + sync_aggregate: Default::default(), + execution_payload: payload, + bls_to_execution_changes: Default::default(), + blob_kzg_commitments: Default::default(), + execution_requests: ExecutionRequests { + deposits: Default::default(), + withdrawals: Default::default(), + consolidations: Default::default(), + }, + }, + } +} + +/// A zeroed blob. `Blob` deliberately has no `Default`, because zeroing 128 KiB +/// by accident is exactly the mistake that derive would invite. +#[cfg(test)] +pub fn empty_blob() -> Blob { + vec![0u8; preset::BYTES_PER_BLOB] + .try_into() + .expect("a blob is BYTES_PER_BLOB long by construction") +} + +#[cfg(test)] +mod tests { + use super::*; + use ethlambda_types::beacon::primitives::HashTreeRoot as _; + + fn empty_block() -> BeaconBlock { + empty_block_for(1234, 7) + } + + fn blob() -> Blob { + empty_blob() + } + + fn contents_bytes(proofs: usize, blobs: usize) -> Vec { + BlockContents { + block: empty_block(), + kzg_proofs: vec![KzgProof([3; 48]); proofs] + .try_into() + .expect("in bounds"), + blobs: vec![blob(); blobs].try_into().expect("in bounds"), + } + .to_ssz() + } + + #[test] + fn an_electra_block_round_trips_through_the_contents_container() { + let bytes = contents_bytes(2, 2); + let produced = ProducedBlock::from_ssz(ForkName::Electra, &bytes).expect("decodes"); + + assert_eq!(produced.block().slot, 1234); + assert_eq!(produced.block().proposer_index, 7); + assert_eq!(produced.blob_count(), 2); + assert_eq!(produced.fork, ForkName::Electra); + assert!(matches!(produced.contents, Contents::WithBlobProofs { .. })); + } + + /// The property the whole module exists for: the root this client signs + /// must be the root of the block the node produced, not of anything this + /// code reassembled. Decoding and re-encoding must leave it untouched. + #[test] + fn the_blocks_root_survives_decoding_and_signing() { + let block = empty_block(); + let expected = block.hash_tree_root(); + + let bytes = contents_bytes(1, 1); + let produced = ProducedBlock::from_ssz(ForkName::Electra, &bytes).expect("decodes"); + assert_eq!(produced.block().hash_tree_root(), expected); + + let signed = produced.into_signed_ssz(BlsSignature([9; 96])); + let decoded = SignedBlockContents::from_ssz_bytes(&signed).expect("decodes"); + assert_eq!( + decoded.signed_block.message.hash_tree_root(), + expected, + "signing must not disturb the block the root was taken over" + ); + assert_eq!(decoded.signed_block.signature, BlsSignature([9; 96])); + } + + /// Fulu's proofs and blobs must reach the published body unchanged. A + /// dropped proof is a block the network rejects for unavailable data. + #[test] + fn a_fulu_blocks_cell_proofs_and_blobs_survive_the_round_trip() { + let bytes = FuluBlockContents { + block: empty_block(), + kzg_proofs: vec![KzgProof([5; 48]); 256].try_into().expect("in bounds"), + blobs: vec![blob(); 2].try_into().expect("in bounds"), + } + .to_ssz(); + + let produced = ProducedBlock::from_ssz(ForkName::Fulu, &bytes).expect("decodes"); + assert_eq!(produced.fork, ForkName::Fulu); + assert!(matches!(produced.contents, Contents::WithCellProofs { .. })); + assert_eq!(produced.blob_count(), 2); + + let signed = produced.into_signed_ssz(BlsSignature([1; 96])); + let decoded = FuluSignedBlockContents::from_ssz_bytes(&signed).expect("decodes"); + assert_eq!(decoded.kzg_proofs.len(), 256); + assert_eq!(decoded.blobs.len(), 2); + } + + /// Where the two containers actually diverge, and the reason they are two + /// containers. + /// + /// A six-blob fulu block carries 768 cell proofs, which still fits + /// electra's limit; an earlier draft of this test asserted otherwise and + /// was wrong. The limits agree until a block holds more than + /// `MAX_BLOB_COMMITMENTS_PER_BLOCK / CELLS_PER_EXT_BLOB` blobs, so the + /// fixture is one blob past that. Fulu made the per-block blob limit + /// depend on the epoch so later forks can raise it, which is what makes + /// this a future a client will meet rather than a hypothetical. + #[test] + fn a_block_past_thirty_two_blobs_does_not_fit_the_electra_container() { + let blobs = preset::MAX_BLOB_COMMITMENTS_PER_BLOCK / preset::CELLS_PER_EXT_BLOB + 1; + let count = blobs * preset::CELLS_PER_EXT_BLOB; + assert!( + count > preset::MAX_BLOB_COMMITMENTS_PER_BLOCK, + "the fixture must exceed electra's limit, or this test proves nothing" + ); + + let bytes = FuluBlockContents { + block: empty_block(), + kzg_proofs: vec![KzgProof([5; 48]); count] + .try_into() + .expect("in bounds"), + blobs: vec![blob(); blobs].try_into().expect("in bounds"), + } + .to_ssz(); + + ProducedBlock::from_ssz(ForkName::Fulu, &bytes).expect("fulu decodes it"); + ProducedBlock::from_ssz(ForkName::Electra, &bytes) + .expect_err("electra's proof limit must reject it"); + } + + /// The other side of the same boundary: an ordinary fulu block's cell + /// proofs do fit electra's limit, so nothing here can be relied on to + /// catch a fork mix-up at everyday blob counts. The fork header is the + /// only thing that distinguishes them. + #[test] + fn an_ordinary_fulu_blocks_proofs_still_fit_the_electra_container() { + let count = 6 * preset::CELLS_PER_EXT_BLOB; + assert!(count < preset::MAX_BLOB_COMMITMENTS_PER_BLOCK); + + let bytes = FuluBlockContents { + block: empty_block(), + kzg_proofs: vec![KzgProof([5; 48]); count] + .try_into() + .expect("in bounds"), + blobs: vec![blob(); 6].try_into().expect("in bounds"), + } + .to_ssz(); + + ProducedBlock::from_ssz(ForkName::Electra, &bytes) + .expect("at six blobs the electra container accepts fulu's proofs"); + } + + #[test] + fn a_pre_electra_fork_is_refused_rather_than_decoded() { + for fork in [ForkName::Capella, ForkName::Deneb] { + let err = + ProducedBlock::from_ssz(fork, &contents_bytes(0, 0)).expect_err("must refuse"); + assert!( + matches!(err, Error::InconsistentResponse(_)), + "{} gave {err:?}", + fork.as_str() + ); + } + } + + #[test] + fn a_truncated_body_is_a_decode_error_not_a_panic() { + let bytes = contents_bytes(1, 1); + let err = ProducedBlock::from_ssz(ForkName::Electra, &bytes[..bytes.len() / 2]) + .expect_err("must fail"); + assert!(matches!(err, Error::Decode(_)), "got {err:?}"); + } +} diff --git a/crates/validator/src/beacon_node/dto.rs b/crates/validator/src/beacon_node/dto.rs new file mode 100644 index 000000000..df5708f45 --- /dev/null +++ b/crates/validator/src/beacon_node/dto.rs @@ -0,0 +1,968 @@ +//! The Beacon API's JSON representations, and their conversions to the SSZ +//! containers the rest of the crate uses. +//! +//! These types exist here rather than as serde derives on the containers in +//! `ethlambda-types` for two reasons. A `Deserialize` on a consensus container +//! is a footgun in a crate whose whole job is to be the authority on what a +//! block means. And the conventions below, integers quoted as strings, byte +//! vectors as `0x` hex, are properties of one transport, not of the types. +//! +//! # Only the fields this client reads +//! +//! These types declare only what is actually used, not every field the schema +//! marks required, relying on serde ignoring unknown fields. That is deliberate: +//! the client must tolerate any conformant node, so a node that carries an extra +//! field, or a fork that adds one, must not stop it attesting. Do not "complete" +//! these against the schema. Checked against `ethereum/beacon-APIs`: +//! +//! | Schema | Spec requires | Declared here | +//! |---|---|---| +//! | `AttesterDuty` | pubkey, validator_index, committee_index, committee_length, committees_at_slot, validator_committee_index, slot | all seven, all used | +//! | Genesis | genesis_time, genesis_validators_root, genesis_fork_version | the first two; the fork version comes from `/config/spec` | +//! | Syncing | head_slot, sync_distance, is_syncing, is_optimistic, el_offline | is_syncing and is_optimistic gate signing; el_offline is logged | +//! | `ValidatorResponse` | index, balance, status, validator | index, status, validator.pubkey | +//! | `ProposerDuty` | pubkey, validator_index, slot | all three, all used | +//! | `ProposerPreparation` | validator_index, fee_recipient | both, sent not read | +//! +//! The API is JSON throughout on this path. The specification permits SSZ on +//! two of the endpoints used here, but no implementation exercises it: see the +//! design document's "Wire format" section for the evidence. + +use ethlambda_types::beacon::config::Config; +use ethlambda_types::beacon::containers::electra::{ + AggregationBits, Attestation, CommitteeBits, SignedAggregateAndProof, +}; +use ethlambda_types::beacon::containers::shared::{AttestationData, Checkpoint}; +use ethlambda_types::beacon::fork::ForkName; +use ethlambda_types::beacon::primitives::{ + BLS_PUBKEY_SIZE, BLS_SIGNATURE_SIZE, BlsPubkey, BlsSignature, CommitteeIndex, Epoch, Root, + Slot, ValidatorIndex, Version, +}; +use libssz::SszDecode as _; +use serde::{Deserialize, Serialize}; + +use crate::error::{Error, Result}; + +/// The Beacon API quotes every 64-bit integer as a JSON string, so that a +/// consumer with 53-bit numbers cannot silently round one. +pub mod quoted_u64 { + use serde::{Deserialize as _, Deserializer, Serializer}; + + pub fn serialize(value: &u64, serializer: S) -> Result { + serializer.serialize_str(&value.to_string()) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result { + let text = String::deserialize(deserializer)?; + text.parse().map_err(serde::de::Error::custom) + } +} + +/// The envelope almost every Beacon API response uses. +#[derive(Debug, Deserialize)] +pub struct DataResponse { + pub data: T, +} + +/// The envelope the fork-dependent endpoints use: `data`, plus the fork that +/// decides which shape `data` is. +#[derive(Debug, Deserialize)] +pub struct VersionedResponse { + pub version: String, + pub data: T, +} + +/// A duties response additionally pins the block its schedule depends on. +#[derive(Debug, Deserialize)] +pub struct DutiesResponse { + pub dependent_root: String, + pub data: T, +} + +/// The beacon-APIs `IndexedErrorMessage` schema: a batch endpoint answers 400 +/// with this when some, but not necessarily all, of what was submitted was +/// rejected. `POST /eth/v2/beacon/pool/attestations` still stores and +/// gossips whichever entries were valid, so this is not "the request +/// failed", it is "here is exactly which entries did". +#[derive(Debug, Clone, Deserialize)] +pub struct IndexedErrorResponse { + pub code: u16, + pub message: String, + #[serde(default)] + pub failures: Vec, +} + +/// One rejected entry from an [`IndexedErrorResponse`]. +/// +/// `index` is the entry's position in the array this client submitted, not a +/// validator index; unlike the `u64`s elsewhere in this module it is not +/// quoted, since the schema types it as a plain integer, not one of the +/// wire's uint64 fields large enough to need string encoding. +#[derive(Debug, Clone, Deserialize)] +pub struct IndexedFailure { + pub index: u64, + pub message: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct GenesisDto { + #[serde(with = "quoted_u64")] + pub genesis_time: u64, + pub genesis_validators_root: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct SyncingDto { + /// The node's consensus head is behind the network's. + pub is_syncing: bool, + /// The node is tracking a head its execution client has not validated. + /// + /// `Option` because a node that predates optimistic sync, or one that is + /// simply not conformant, may omit it. Absent is read as `false`: the + /// specification marks the field required, so a missing one says nothing, + /// and reading it as `true` would refuse every duty against such a node. + pub is_optimistic: Option, + /// The node's execution client is unreachable. + /// + /// Not a gate on signing, unlike the two above, and modelled only so it can + /// be reported. A node whose execution client has just gone offline still + /// has the head it validated before that, which is a head worth attesting + /// to; what it cannot do is validate new payloads, and it reports that + /// through `is_optimistic` when it starts to matter. + pub el_offline: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct CheckpointDto { + #[serde(with = "quoted_u64")] + pub epoch: Epoch, + pub root: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AttestationDataDto { + #[serde(with = "quoted_u64")] + pub slot: Slot, + #[serde(with = "quoted_u64")] + pub index: CommitteeIndex, + pub beacon_block_root: String, + pub source: CheckpointDto, + pub target: CheckpointDto, +} + +/// An electra-shaped `Attestation`, as `/eth/v2/validator/aggregate_attestation` +/// returns it in JSON. +/// +/// # Why an aggregate goes through JSON when a block does not +/// +/// Because the node sends JSON whatever this client asks for. Lighthouse v8.2.2 +/// answers this endpoint with `200 application/json` to a request whose +/// `Accept` names only SSZ, where the specification says it should answer SSZ +/// or `406`. It is the most widely run consensus client, so a client that only +/// takes SSZ here aggregates for nobody against it. +/// +/// It is also cheap to get right here, unlike for a block. An `Attestation` is +/// four fields, and the two bitfields arrive as hex of their exact SSZ +/// encoding, so they decode through SSZ rather than through anything +/// hand-written. The root this client later signs over is therefore computed +/// from the same bytes the node would have sent as SSZ. +#[derive(Debug, Clone, Deserialize)] +pub struct AttestationDto { + pub aggregation_bits: String, + pub data: AttestationDataDto, + pub signature: String, + pub committee_bits: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AttesterDutyDto { + pub pubkey: String, + #[serde(with = "quoted_u64")] + pub validator_index: ValidatorIndex, + #[serde(with = "quoted_u64")] + pub committee_index: CommitteeIndex, + #[serde(with = "quoted_u64")] + pub committee_length: u64, + #[serde(with = "quoted_u64")] + pub committees_at_slot: u64, + #[serde(with = "quoted_u64")] + pub validator_committee_index: u64, + #[serde(with = "quoted_u64")] + pub slot: Slot, +} + +/// One proposer duty, from `GET /eth/v1/validator/duties/proposer/{epoch}`. +/// +/// Three fields where an attester duty has seven, because a proposer has no +/// committee: a block is proposed by one validator on its own behalf, so there +/// is nothing to say about position or membership. +#[derive(Debug, Clone, Deserialize)] +pub struct ProposerDutyDto { + pub pubkey: String, + #[serde(with = "quoted_u64")] + pub validator_index: ValidatorIndex, + #[serde(with = "quoted_u64")] + pub slot: Slot, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ValidatorEntryDto { + #[serde(with = "quoted_u64")] + pub index: ValidatorIndex, + pub status: String, + pub validator: ValidatorInnerDto, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ValidatorInnerDto { + pub pubkey: String, +} + +/// What a validator client submits for one attester duty, from Electra on. +#[derive(Debug, Clone, Serialize)] +pub struct SingleAttestationDto { + #[serde(with = "quoted_u64")] + pub committee_index: CommitteeIndex, + #[serde(with = "quoted_u64")] + pub attester_index: ValidatorIndex, + pub data: AttestationDataOutDto, + pub signature: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct AttestationDataOutDto { + #[serde(with = "quoted_u64")] + pub slot: Slot, + #[serde(with = "quoted_u64")] + pub index: CommitteeIndex, + pub beacon_block_root: String, + pub source: CheckpointOutDto, + pub target: CheckpointOutDto, +} + +#[derive(Debug, Clone, Serialize)] +pub struct CheckpointOutDto { + #[serde(with = "quoted_u64")] + pub epoch: Epoch, + pub root: String, +} + +/// One entry of `POST /eth/v1/validator/prepare_beacon_proposer`. +/// +/// The body is a bare array of these, with no `{"data": ...}` envelope, unlike +/// almost everything else on this API. +#[derive(Debug, Clone, Serialize)] +pub struct ProposerPreparationDto { + #[serde(with = "quoted_u64")] + pub validator_index: ValidatorIndex, + /// The execution address this validator's block rewards should be paid to, + /// `0x`-prefixed. + pub fee_recipient: String, +} + +/// An electra-shaped `Attestation`, for sending back. +/// +/// The bitfields are hex of their SSZ encoding, the same form the node sends +/// them in, so they round-trip through SSZ exactly rather than through anything +/// hand-written. +#[derive(Debug, Clone, Serialize)] +pub struct AttestationOutDto { + pub aggregation_bits: String, + pub data: AttestationDataOutDto, + pub signature: String, + pub committee_bits: String, +} + +impl From<&Attestation> for AttestationOutDto { + fn from(attestation: &Attestation) -> Self { + use libssz::SszEncode as _; + Self { + aggregation_bits: encode_hex(&attestation.aggregation_bits.to_ssz()), + data: AttestationDataOutDto::from(&attestation.data), + signature: encode_hex(&attestation.signature.0), + committee_bits: encode_hex(&attestation.committee_bits.to_ssz()), + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct AggregateAndProofOutDto { + #[serde(with = "quoted_u64")] + pub aggregator_index: ValidatorIndex, + pub aggregate: AttestationOutDto, + pub selection_proof: String, +} + +/// One entry of `POST /eth/v2/validator/aggregate_and_proofs`, whose body is a +/// bare array of these even for a single aggregate. +#[derive(Debug, Clone, Serialize)] +pub struct SignedAggregateAndProofOutDto { + pub message: AggregateAndProofOutDto, + pub signature: String, +} + +impl From<&SignedAggregateAndProof> for SignedAggregateAndProofOutDto { + fn from(signed: &SignedAggregateAndProof) -> Self { + Self { + message: AggregateAndProofOutDto { + aggregator_index: signed.message.aggregator_index, + aggregate: AttestationOutDto::from(&signed.message.aggregate), + selection_proof: encode_hex(&signed.message.selection_proof.0), + }, + signature: encode_hex(&signed.signature.0), + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct CommitteeSubscriptionDto { + #[serde(with = "quoted_u64")] + pub validator_index: ValidatorIndex, + #[serde(with = "quoted_u64")] + pub committee_index: CommitteeIndex, + #[serde(with = "quoted_u64")] + pub committees_at_slot: u64, + #[serde(with = "quoted_u64")] + pub slot: Slot, + pub is_aggregator: bool, +} + +// -- Conversions ------------------------------------------------------------- + +/// Parses explicitly rather than declaring `Root` (which has its own +/// `Deserialize`) directly on the DTOs. A malformed root then fails at this +/// conversion boundary with this crate's own [`Error::Decode`], naming what +/// was wrong, instead of as a serde error part-way through decoding a +/// response. +pub fn parse_root(text: &str) -> Result { + let bytes = hex::decode(text.strip_prefix("0x").unwrap_or(text)) + .map_err(|err| Error::Decode(format!("bad root hex: {err}")))?; + let array: [u8; 32] = bytes.try_into().map_err(|got: Vec| { + Error::Decode(format!("root is {} bytes, expected 32", got.len())) + })?; + Ok(Root::from(array)) +} + +/// Decode one `0x`-prefixed hex field, naming it in the error. +fn parse_hex(text: &str, what: &str) -> Result> { + hex::decode(text.strip_prefix("0x").unwrap_or(text)) + .map_err(|err| Error::Decode(format!("bad {what} hex: {err}"))) +} + +pub fn parse_signature(text: &str) -> Result { + let bytes = parse_hex(text, "signature")?; + let array: [u8; BLS_SIGNATURE_SIZE] = bytes.try_into().map_err(|got: Vec| { + Error::Decode(format!( + "signature is {} bytes, expected {BLS_SIGNATURE_SIZE}", + got.len() + )) + })?; + Ok(BlsSignature(array)) +} + +pub fn parse_pubkey(text: &str) -> Result { + let bytes = hex::decode(text.strip_prefix("0x").unwrap_or(text)) + .map_err(|err| Error::Decode(format!("bad pubkey hex: {err}")))?; + let array: [u8; BLS_PUBKEY_SIZE] = bytes.try_into().map_err(|got: Vec| { + Error::Decode(format!( + "pubkey is {} bytes, expected {BLS_PUBKEY_SIZE}", + got.len() + )) + })?; + Ok(BlsPubkey(array)) +} + +/// `0x`-prefixed hex, through the same adapter the beacon node writes its own +/// responses with. +pub fn encode_hex(bytes: &[u8]) -> String { + ethlambda_types::beacon::serde_helpers::HexPrefixed(bytes).to_string() +} + +/// Reads one `0x`-prefixed, 4-byte fork version out of a `GET +/// /eth/v1/config/spec` response. `None` when `key` is absent (the fork is +/// simply not named), `Err` when it is present but malformed. +fn spec_version(value: &serde_json::Value, key: &str) -> Result> { + let Some(field) = value.get(key) else { + return Ok(None); + }; + let text = field + .as_str() + .ok_or_else(|| Error::Decode(format!("config/spec: {key} is not a string")))?; + let bytes = hex::decode(text.strip_prefix("0x").unwrap_or(text)) + .map_err(|err| Error::Decode(format!("config/spec: bad hex for {key}: {err}")))?; + let version: Version = bytes.try_into().map_err(|got: Vec| { + Error::Decode(format!( + "config/spec: {key} is {} bytes, expected 4", + got.len() + )) + })?; + Ok(Some(version)) +} + +/// Reads one quoted `u64` out of a `GET /eth/v1/config/spec` response. `None` +/// when `key` is absent, `Err` when it is present but malformed. +fn spec_u64(value: &serde_json::Value, key: &str) -> Result> { + let Some(field) = value.get(key) else { + return Ok(None); + }; + field + .as_str() + .ok_or_else(|| Error::Decode(format!("config/spec: {key} is not a string")))? + .parse() + .map(Some) + .map_err(|err| Error::Decode(format!("config/spec: bad integer for {key}: {err}"))) +} + +/// Build a [`Config`] from a `GET /eth/v1/config/spec` response. +/// +/// Starts from the mainnet defaults and overrides only what the response +/// names, because a validator client has to work against a network whose fork +/// schedule differs while the compile-time presets, which fix SSZ list bounds, +/// necessarily do not. +/// +/// An absent key keeps its default: a fork this network has not scheduled is +/// normal. A key that is present but unparseable is an error, because silently +/// falling back to a mainnet default would leave the client signing under the +/// wrong fork version, which the network rejects without telling us why. +/// +/// Phase0 is handled outside the `ForkName::ALL` loop: its version is named +/// `GENESIS_FORK_VERSION` rather than `PHASE0_FORK_VERSION`, and it has no +/// `PHASE0_FORK_EPOCH` at all, since phase0's activation is always epoch 0. +pub fn config_from_spec_response(value: &serde_json::Value) -> Result { + let mut config = Config::mainnet(); + + // `SLOT_DURATION_MS` first, `SECONDS_PER_SLOT` only as a fallback. + // + // The specification deleted `SECONDS_PER_SLOT` outright; it appears nowhere + // in the configs, presets or specs, and a spec-current node no longer emits + // it. Reading only that key, as this did, meant a node that had moved on + // left the slot length at its compiled-in mainnet default. On mainnet that + // default is right and the bug is invisible. On anything else the client + // would run a 12-second clock against a chain with a different slot and + // miss every duty, while the startup log printed the wrong number + // confidently. + // + // The fallback stays because the key's removal is recent and deployed nodes + // still send it. A node that sends both is asked to agree with itself. + let duration_ms = spec_u64(value, "SLOT_DURATION_MS")?; + let seconds = spec_u64(value, "SECONDS_PER_SLOT")?; + if let (Some(duration_ms), Some(seconds)) = (duration_ms, seconds) + && duration_ms != seconds * 1_000 + { + return Err(Error::Decode(format!( + "config/spec: SLOT_DURATION_MS is {duration_ms} but SECONDS_PER_SLOT is {seconds}; \ + they describe the same slot and disagree" + ))); + } + if let Some(slot_duration_ms) = duration_ms.or_else(|| seconds.map(|s| s * 1_000)) { + if slot_duration_ms == 0 { + return Err(Error::Decode( + "config/spec: the slot duration is 0".to_string(), + )); + } + config.slot_duration_ms = slot_duration_ms; + // Kept in step because other code still reads it. Truncating is + // correct for every network that reports a whole number of seconds, + // and the millisecond field is what the clock actually uses. + config.seconds_per_slot = slot_duration_ms / 1_000; + } + + // The duty offsets. Absent means the node has not moved to the + // basis-point form yet, in which case the mainnet defaults already in + // `Config` are the right answer for every network that predates it. + if let Some(bps) = spec_u64(value, "ATTESTATION_DUE_BPS")? { + config.attestation_due_bps = bps; + } + if let Some(bps) = spec_u64(value, "AGGREGATE_DUE_BPS")? { + config.aggregate_due_bps = bps; + } + if let Some(version) = spec_version(value, "GENESIS_FORK_VERSION")? { + config = config.with_fork_version(ForkName::Phase0, version); + } + + for fork in ForkName::ALL { + if fork == ForkName::Phase0 { + continue; + } + let name = fork.as_str().to_uppercase(); + if let Some(epoch) = spec_u64(value, &format!("{name}_FORK_EPOCH"))? { + config = config.with_fork_epoch(fork, epoch); + } + if let Some(version) = spec_version(value, &format!("{name}_FORK_VERSION"))? { + config = config.with_fork_version(fork, version); + } + } + + Ok(config) +} + +impl TryFrom<&AttestationDto> for Attestation { + type Error = Error; + + /// The bitfields decode through SSZ, not by hand: the hex *is* their SSZ + /// encoding, sentinel bit included for the bitlist, so decoding it this way + /// cannot disagree with what the node would have sent as SSZ. + fn try_from(dto: &AttestationDto) -> Result { + let aggregation_bits = + AggregationBits::from_ssz_bytes(&parse_hex(&dto.aggregation_bits, "aggregation_bits")?) + .map_err(|err| Error::Decode(format!("aggregation_bits: {err:?}")))?; + let committee_bits = + CommitteeBits::from_ssz_bytes(&parse_hex(&dto.committee_bits, "committee_bits")?) + .map_err(|err| Error::Decode(format!("committee_bits: {err:?}")))?; + Ok(Attestation { + aggregation_bits, + data: AttestationData::try_from(&dto.data)?, + signature: parse_signature(&dto.signature)?, + committee_bits, + }) + } +} + +impl TryFrom<&AttestationDataDto> for AttestationData { + type Error = Error; + + fn try_from(dto: &AttestationDataDto) -> Result { + Ok(AttestationData { + slot: dto.slot, + index: dto.index, + beacon_block_root: parse_root(&dto.beacon_block_root)?, + source: Checkpoint { + epoch: dto.source.epoch, + root: parse_root(&dto.source.root)?, + }, + target: Checkpoint { + epoch: dto.target.epoch, + root: parse_root(&dto.target.root)?, + }, + }) + } +} + +impl From<&AttestationData> for AttestationDataOutDto { + fn from(data: &AttestationData) -> Self { + Self { + slot: data.slot, + index: data.index, + beacon_block_root: encode_hex(&data.beacon_block_root.0), + source: CheckpointOutDto { + epoch: data.source.epoch, + root: encode_hex(&data.source.root.0), + }, + target: CheckpointOutDto { + epoch: data.target.epoch, + root: encode_hex(&data.target.root.0), + }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A real `GET /eth/v1/validator/attestation_data` body. + const ATTESTATION_DATA: &str = r#"{ + "data": { + "slot": "12345678", + "index": "0", + "beacon_block_root": "0x2f6ef3b4f5a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b7c6", + "source": { "epoch": "385801", "root": "0x1111111111111111111111111111111111111111111111111111111111111111" }, + "target": { "epoch": "385802", "root": "0x2222222222222222222222222222222222222222222222222222222222222222" } + } + }"#; + + /// A real `POST /eth/v1/validator/duties/attester/{epoch}` body. + const DUTIES: &str = r#"{ + "dependent_root": "0x3333333333333333333333333333333333333333333333333333333333333333", + "execution_optimistic": false, + "data": [{ + "pubkey": "0x9612d7a727c9d0a22e185a1c768478dfe919cada9266988cb32359c11f2b7b27f4ae4040902382ae2910c15e2b420d07", + "validator_index": "1337", + "committee_index": "3", + "committee_length": "128", + "committees_at_slot": "64", + "validator_committee_index": "17", + "slot": "12345678" + }] + }"#; + + #[test] + fn parses_attestation_data_and_converts_to_the_container() { + let response: DataResponse = + serde_json::from_str(ATTESTATION_DATA).expect("parses"); + assert_eq!(response.data.slot, 12_345_678); + assert_eq!(response.data.index, 0); + + let data = AttestationData::try_from(&response.data).expect("converts"); + assert_eq!(data.slot, 12_345_678); + assert_eq!(data.target.epoch, 385_802); + assert_eq!(data.source.epoch, 385_801); + assert_eq!(data.target.root.0[0], 0x22); + } + + #[test] + fn parses_the_duties_envelope_and_its_dependent_root() { + let response: DutiesResponse> = + serde_json::from_str(DUTIES).expect("parses"); + assert_eq!( + response.dependent_root, + "0x3333333333333333333333333333333333333333333333333333333333333333" + ); + assert_eq!(response.data.len(), 1); + let duty = &response.data[0]; + assert_eq!(duty.validator_index, 1337); + assert_eq!(duty.committee_index, 3); + assert_eq!(duty.committees_at_slot, 64); + assert_eq!(duty.slot, 12_345_678); + parse_pubkey(&duty.pubkey).expect("pubkey parses"); + } + + #[test] + fn a_submitted_attestation_quotes_its_integers() { + let data = AttestationData { + slot: 12_345_678, + index: 0, + beacon_block_root: Root::ZERO, + source: Checkpoint { + epoch: 1, + root: Root::ZERO, + }, + target: Checkpoint { + epoch: 2, + root: Root::ZERO, + }, + }; + let dto = SingleAttestationDto { + committee_index: 3, + attester_index: 1337, + data: AttestationDataOutDto::from(&data), + signature: encode_hex(&[0u8; 96]), + }; + let json = serde_json::to_string(&dto).expect("serialises"); + + assert!(json.contains(r#""committee_index":"3""#), "got {json}"); + assert!(json.contains(r#""attester_index":"1337""#), "got {json}"); + assert!(json.contains(r#""slot":"12345678""#), "got {json}"); + assert!(json.contains(r#""signature":"0x0000"#), "got {json}"); + } + + /// A real `/eth/v2/validator/aggregate_attestation` body, captured from + /// Lighthouse v8.2.2 on a kurtosis devnet. It came back as JSON to a + /// request whose `Accept` named only SSZ, which is why this client decodes + /// aggregates from JSON at all. + const LIGHTHOUSE_AGGREGATE: &str = r#"{ + "version": "fulu", + "data": { + "aggregation_bits": "0x3f", + "data": { + "slot": "5", + "index": "0", + "beacon_block_root": "0x7080aca835d465ab7ffad4f2405cc8f2cb7d5e0f511d131f2263344f2be95b55", + "source": { "epoch": "0", "root": "0x0000000000000000000000000000000000000000000000000000000000000000" }, + "target": { "epoch": "0", "root": "0x96e2147b3fe25eb01b4b310790bfc89ffe9db275f95f05433c1384847ea9e5f5" } + }, + "signature": "0xae80eb73a6b1716689185b991ce5cd0f9b5b12d699d2beaf32a89704a5335a3c85a91d5c2808639f3d777115fc9fadda00e34157d9abc92944f51f0abcc025426ca5bf9f3dd645d3fda8cbb9d1c88abdd7661c3a23160f10137be5c9bb16f04e", + "committee_bits": "0x0100000000000000" + } + }"#; + + #[test] + fn a_lighthouse_aggregate_decodes_into_the_electra_container() { + let response: VersionedResponse = + serde_json::from_str(LIGHTHOUSE_AGGREGATE).expect("parses"); + assert_eq!(response.version, "fulu"); + + let attestation = Attestation::try_from(&response.data).expect("converts"); + assert_eq!(attestation.data.slot, 5); + assert_eq!(attestation.data.index, 0, "electra requires index 0"); + + // 0x3f is a five-member committee, all voting, plus the bitlist's + // sentinel bit. Decoding it through SSZ is what keeps the sentinel + // from being read as a sixth voter. + assert_eq!(attestation.aggregation_bits.len(), 5); + assert!((0..5).all(|i| attestation.aggregation_bits.get(i).unwrap_or(false))); + + // One committee named, committee 0, as electra's gossip rules require. + assert!(attestation.committee_bits.get(0).unwrap_or(false)); + assert!((1..64).all(|i| !attestation.committee_bits.get(i).unwrap_or(false))); + } + + /// The bitfields decode through SSZ, so re-encoding must give back the + /// exact bytes the node sent. That is what makes the root this client signs + /// over the one the node would have produced from SSZ. + #[test] + fn an_aggregates_bitfields_round_trip_byte_for_byte() { + use libssz::SszEncode as _; + let response: VersionedResponse = + serde_json::from_str(LIGHTHOUSE_AGGREGATE).expect("parses"); + let attestation = Attestation::try_from(&response.data).expect("converts"); + + assert_eq!(encode_hex(&attestation.aggregation_bits.to_ssz()), "0x3f"); + assert_eq!( + encode_hex(&attestation.committee_bits.to_ssz()), + "0x0100000000000000" + ); + assert_eq!( + encode_hex(&attestation.signature.0), + response.data.signature + ); + } + + /// What gets sent back: the aggregate that came in, wrapped and signed, + /// with the index quoted and the bitfields in the same hex form they + /// arrived in. Lighthouse refuses an SSZ body on this endpoint, so this + /// JSON is the only form that reaches it. + #[test] + fn a_signed_aggregate_serialises_the_way_it_arrived() { + use ethlambda_types::beacon::containers::electra::AggregateAndProof; + + let response: VersionedResponse = + serde_json::from_str(LIGHTHOUSE_AGGREGATE).expect("parses"); + let aggregate = Attestation::try_from(&response.data).expect("converts"); + let signed = SignedAggregateAndProof { + message: AggregateAndProof { + aggregator_index: 131, + aggregate, + selection_proof: BlsSignature([7; 96]), + }, + signature: BlsSignature([9; 96]), + }; + + let json = serde_json::to_value([SignedAggregateAndProofOutDto::from(&signed)]) + .expect("serialises"); + assert!(json.is_array(), "the endpoint takes a bare array"); + let message = &json[0]["message"]; + assert_eq!(message["aggregator_index"], "131"); + assert_eq!(message["aggregate"]["aggregation_bits"], "0x3f"); + assert_eq!(message["aggregate"]["committee_bits"], "0x0100000000000000"); + assert_eq!(message["aggregate"]["data"]["slot"], "5"); + assert_eq!(message["aggregate"]["signature"], response.data.signature); + } + + #[test] + fn a_signature_of_the_wrong_length_is_rejected() { + let err = parse_signature("0x1234").expect_err("must reject"); + assert!(matches!(err, Error::Decode(_)), "got {err:?}"); + } + + #[test] + fn a_root_of_the_wrong_length_is_rejected() { + let err = parse_root("0x1234").expect_err("must reject"); + assert!(matches!(err, Error::Decode(_)), "got {err:?}"); + } + + /// A real `GET /eth/v1/beacon/genesis` body (mainnet's values), including + /// `genesis_fork_version`, which the DTO deliberately does not declare. + const GENESIS: &str = r#"{ + "data": { + "genesis_time": "1606824023", + "genesis_validators_root": "0x4b363db94e286120d76eb905340fdd4e54bfe9f06bf33ff6cf5ad27f511bfe9", + "genesis_fork_version": "0x00000000" + } + }"#; + + #[test] + fn parses_genesis_ignoring_the_fork_version_it_does_not_declare() { + let response: DataResponse = serde_json::from_str(GENESIS).expect("parses"); + assert_eq!(response.data.genesis_time, 1_606_824_023); + assert_eq!( + response.data.genesis_validators_root, + "0x4b363db94e286120d76eb905340fdd4e54bfe9f06bf33ff6cf5ad27f511bfe9" + ); + } + + /// A real `GET /eth/v1/node/syncing` body, carrying all five spec fields; + /// only two are declared here. + const SYNCING: &str = r#"{ + "data": { + "head_slot": "12345678", + "sync_distance": "0", + "is_syncing": false, + "is_optimistic": true, + "el_offline": false + } + }"#; + + #[test] + fn parses_syncing_ignoring_the_fields_it_does_not_declare() { + let response: DataResponse = serde_json::from_str(SYNCING).expect("parses"); + assert!(!response.data.is_syncing); + assert_eq!(response.data.is_optimistic, Some(true)); + } + + /// A real `POST /eth/v1/beacon/states/head/validators` body, including + /// `balance`, which the DTO deliberately does not declare. + const VALIDATORS: &str = r#"{ + "data": [{ + "index": "1337", + "balance": "32000000000", + "status": "active_ongoing", + "validator": { + "pubkey": "0x9612d7a727c9d0a22e185a1c768478dfe919cada9266988cb32359c11f2b7b27f4ae4040902382ae2910c15e2b420d07", + "withdrawal_credentials": "0x010000000000000000000000000000000000000000000000000000000000", + "effective_balance": "32000000000", + "slashed": false, + "activation_eligibility_epoch": "0", + "activation_epoch": "0", + "exit_epoch": "18446744073709551615", + "withdrawable_epoch": "18446744073709551615" + } + }] + }"#; + + #[test] + fn parses_a_validator_entry_ignoring_the_balance_it_does_not_declare() { + let response: DataResponse> = + serde_json::from_str(VALIDATORS).expect("parses"); + let entry = &response.data[0]; + assert_eq!(entry.index, 1337); + assert_eq!(entry.status, "active_ongoing"); + parse_pubkey(&entry.validator.pubkey).expect("pubkey parses"); + } + + #[test] + fn a_committee_subscription_quotes_its_integers_but_not_the_flag() { + let dto = CommitteeSubscriptionDto { + validator_index: 1337, + committee_index: 3, + committees_at_slot: 64, + slot: 12_345_678, + is_aggregator: true, + }; + let json = serde_json::to_string(&dto).expect("serialises"); + + assert!(json.contains(r#""validator_index":"1337""#), "got {json}"); + assert!(json.contains(r#""committee_index":"3""#), "got {json}"); + assert!(json.contains(r#""committees_at_slot":"64""#), "got {json}"); + assert!(json.contains(r#""slot":"12345678""#), "got {json}"); + assert!( + json.contains(r#""is_aggregator":true"#), + "is_aggregator must be a bare boolean, got {json}" + ); + } + + #[test] + fn a_spec_response_overrides_only_what_it_names() { + let response = serde_json::json!({ + "SECONDS_PER_SLOT": "12", + "ELECTRA_FORK_EPOCH": "364032", + }); + let config = config_from_spec_response(&response).expect("builds"); + assert_eq!(config.seconds_per_slot, 12); + assert_eq!(config.fork_epoch(ForkName::Electra), 364_032); + // Untouched by the response, so still the mainnet default. + assert_eq!(config.min_genesis_time, Config::mainnet().min_genesis_time); + } + + /// The key the specification actually ships now. Reading only the old one + /// left the slot length at a compiled-in mainnet default, which is right on + /// mainnet and wrong everywhere else. + #[test] + fn the_slot_duration_is_read_from_the_millisecond_key() { + let response = serde_json::json!({ "SLOT_DURATION_MS": "6000" }); + let config = config_from_spec_response(&response).expect("builds"); + assert_eq!(config.slot_duration_ms, 6_000); + assert_eq!(config.seconds_per_slot, 6, "the older field tracks it"); + } + + /// Deployed nodes still send the removed key, so it stays as a fallback. + #[test] + fn a_node_still_sending_only_seconds_per_slot_is_understood() { + let response = serde_json::json!({ "SECONDS_PER_SLOT": "6" }); + let config = config_from_spec_response(&response).expect("builds"); + assert_eq!(config.slot_duration_ms, 6_000); + assert_eq!(config.seconds_per_slot, 6); + } + + /// A node sending both is asked to agree with itself. They describe one + /// slot, so a disagreement means one of them is wrong and there is no way + /// to tell which. + #[test] + fn a_node_contradicting_itself_about_the_slot_length_is_rejected() { + let response = serde_json::json!({ + "SLOT_DURATION_MS": "12000", + "SECONDS_PER_SLOT": "6", + }); + let err = config_from_spec_response(&response).expect_err("must reject"); + assert!(matches!(err, Error::Decode(_)), "got {err:?}"); + } + + #[test] + fn a_node_agreeing_with_itself_about_the_slot_length_is_accepted() { + let response = serde_json::json!({ + "SLOT_DURATION_MS": "6000", + "SECONDS_PER_SLOT": "6", + }); + let config = config_from_spec_response(&response).expect("builds"); + assert_eq!(config.slot_duration_ms, 6_000); + } + + /// The duty offsets follow the network rather than being divided out of the + /// slot, which is what makes a fork that moves them work at all. + #[test] + fn the_duty_offsets_are_read_from_the_response() { + let response = serde_json::json!({ + "ATTESTATION_DUE_BPS": "2500", + "AGGREGATE_DUE_BPS": "5000", + }); + let config = config_from_spec_response(&response).expect("builds"); + assert_eq!(config.attestation_due_bps, 2_500); + assert_eq!(config.aggregate_due_bps, 5_000); + } + + /// A node that has not moved to the basis-point form keeps the defaults, + /// which are the right answer for every network that predates it. + #[test] + fn absent_duty_offsets_keep_the_defaults() { + let config = config_from_spec_response(&serde_json::json!({})).expect("builds"); + assert_eq!(config.attestation_due_bps, 3_333); + assert_eq!(config.aggregate_due_bps, 6_667); + } + + #[test] + fn the_genesis_fork_version_is_read_under_its_own_name() { + // No node ever sends PHASE0_FORK_VERSION; the spec calls it + // GENESIS_FORK_VERSION. This is the regression the review caught: the + // naive per-fork loop could never reach this field. + let response = serde_json::json!({ + "GENESIS_FORK_VERSION": "0x01020304", + }); + let config = config_from_spec_response(&response).expect("builds"); + assert_eq!( + config.fork_version(ForkName::Phase0), + [0x01, 0x02, 0x03, 0x04] + ); + } + + #[test] + fn a_present_but_malformed_value_is_an_error_not_a_silent_default() { + let response = serde_json::json!({ + "GENESIS_FORK_VERSION": "0xzz", + }); + let err = config_from_spec_response(&response).expect_err("must reject"); + assert!(matches!(err, Error::Decode(_)), "got {err:?}"); + } + + #[test] + fn a_zero_slot_duration_is_rejected_rather_than_handed_to_the_clock() { + // A slot clock divides by this value; a beacon node reporting 0 is + // not a network we cannot support, it is a response that cannot be + // true. + let response = serde_json::json!({ + "SECONDS_PER_SLOT": "0", + }); + let err = config_from_spec_response(&response).expect_err("must reject"); + assert!(matches!(err, Error::Decode(_)), "got {err:?}"); + } + + #[test] + fn a_double_prefixed_version_is_rejected_rather_than_silently_stripped() { + // Only one "0x" is ever stripped, matching `parse_root`'s convention; + // a second one must fail to decode as hex rather than be swallowed. + let response = serde_json::json!({ + "GENESIS_FORK_VERSION": "0x0x000102", + }); + let err = config_from_spec_response(&response).expect_err("must reject"); + assert!(matches!(err, Error::Decode(_)), "got {err:?}"); + } +} diff --git a/crates/validator/src/beacon_node/fallback.rs b/crates/validator/src/beacon_node/fallback.rs new file mode 100644 index 000000000..c1d3eb352 --- /dev/null +++ b/crates/validator/src/beacon_node/fallback.rs @@ -0,0 +1,698 @@ +//! Ordered failover across several beacon nodes. +//! +//! First healthy wins, in the order given on the command line. Deliberately not +//! health-scored: Lighthouse ranks its nodes by sync distance and tie-breaks on +//! list order, which is better, and is a refinement this does not need yet. + +use std::future::Future; +use std::pin::Pin; + +use async_trait::async_trait; +use ethlambda_types::beacon::config::Config; +use ethlambda_types::beacon::containers::electra::SignedAggregateAndProof; +use ethlambda_types::beacon::containers::shared::AttestationData; +use ethlambda_types::beacon::fork::ForkName; +use ethlambda_types::beacon::primitives::{BlsPubkey, Epoch, Root, Slot, ValidatorIndex}; +use tracing::{debug, warn}; + +use crate::beacon_node::block_contents::ProducedBlock; +use crate::beacon_node::dto::{ + CommitteeSubscriptionDto, ProposerPreparationDto, SingleAttestationDto, +}; +use crate::beacon_node::{ + AggregateAttestation, AttesterDuties, BeaconNodeApi, BlockRequest, Genesis, ProposerDuties, + Published, ValidatorEntry, +}; +use crate::error::{Error, Result}; + +/// The boxed future `#[async_trait]` desugars a trait method call into, +/// borrowed from the receiver (and, for methods that take borrowed arguments, +/// from those too). Naming it lets [`FallbackBeaconNode::try_each`] state that +/// the future it gets back is tied to one caller-chosen lifetime `'p`, shared +/// by the node reference and whatever else the closure captures, rather than +/// to `Self`. +type BoxFuture<'a, T> = Pin + Send + 'a>>; + +/// A `BeaconNodeApi` backed by an ordered list of others, answering from the +/// first one that succeeds. +pub struct FallbackBeaconNode { + nodes: Vec, +} + +impl FallbackBeaconNode { + pub fn new(nodes: Vec) -> Self { + assert!(!nodes.is_empty(), "at least one beacon node is required"); + Self { nodes } + } + + /// Try each node in order, returning the first success. + /// + /// "Success" means the node returned `Ok`, nothing more. That is the right + /// rule only where an `Ok` is an answer this client can act on, which is + /// why [`BeaconNodeApi::is_syncing`] does not use this helper: there, + /// `Ok(true)` is a node reporting itself unusable, and returning it from + /// the first node would mask every healthy node behind it. + /// + /// A node that is syncing is skipped here in practice, because the calls + /// routed through this helper answer 503 while syncing and 503 is mapped to + /// an error. That is a property of those endpoints, not something this + /// function arranges. + /// + /// `'p` is a single, caller-inferred lifetime rather than a higher-ranked + /// `for<'b>` one: a higher-ranked bound would force the closure's future to + /// be valid for every possible `'b`, including `'static`, which is + /// impossible once the closure also borrows a method argument (e.g. + /// `pubkeys` in `validator_indices`) with its own, shorter lifetime. Tying + /// both `&self` and the future to one named `'p` lets the compiler unify + /// it with whatever the call site's shortest borrow actually is. + async fn try_each<'p, T, F>(&'p self, what: &'static str, call: F) -> Result + where + F: Fn(&'p B) -> BoxFuture<'p, Result>, + { + let mut last = None; + for (position, node) in self.nodes.iter().enumerate() { + match call(node).await { + Ok(value) => return Ok(value), + Err(err) => { + // Debug, not warn: with more than one node configured, an + // outage on one of them fires this every slot for as long + // as it lasts. The event that actually means the client + // missed a duty is every node failing, below. + debug!(%what, position, %err, "Beacon node request failed; trying the next"); + last = Some(err); + } + } + } + let detail = last + .map(|err| err.to_string()) + .unwrap_or_else(|| "no nodes configured".to_string()); + warn!(%what, %detail, "All configured beacon nodes failed; duty likely missed"); + Err(Error::AllBeaconNodesFailed(detail)) + } + + /// Send to **every** node, succeeding if any accepted. + /// + /// The counterpart to [`Self::try_each`], for the calls where first-success + /// is the wrong rule. A query has one right answer and any node can give + /// it; these two calls instead install *state on a node*, and a node that + /// was never told is a node that cannot serve the duty later. + /// + /// That is not hypothetical. With two nodes configured, `try_each` tells + /// node 1 which committees this client attests in and which it will + /// aggregate for, and node 2 hears nothing. When node 1 goes down + /// mid-epoch, every later call fails over to the node least prepared to + /// answer it: it is not holding those attestation subnets open, and it + /// collected none of the votes an aggregate is folded from, so it answers + /// 404 for every committee. The failover node exists for exactly that + /// moment. + /// + /// Succeeding if any node accepted, rather than requiring all, because one + /// unreachable node must not stop the reachable ones being told. A failure + /// is logged per node so a partially-registered client is visible rather + /// than silent. + async fn try_all<'p, F>(&'p self, what: &'static str, call: F) -> Result<()> + where + F: Fn(&'p B) -> BoxFuture<'p, Result<()>>, + { + let mut accepted = 0; + let mut last = None; + for (position, node) in self.nodes.iter().enumerate() { + match call(node).await { + Ok(()) => accepted += 1, + Err(err) => { + warn!(%what, position, %err, "Beacon node did not accept; it will be unprepared for this duty"); + last = Some(err); + } + } + } + if accepted > 0 { + return Ok(()); + } + let detail = last + .map(|err| err.to_string()) + .unwrap_or_else(|| "no nodes configured".to_string()); + warn!(%what, %detail, "No configured beacon node accepted; duty likely missed"); + Err(Error::AllBeaconNodesFailed(detail)) + } +} + +#[async_trait] +impl BeaconNodeApi for FallbackBeaconNode { + async fn genesis(&self) -> Result { + self.try_each("genesis", |node| node.genesis()).await + } + + async fn spec(&self) -> Result { + self.try_each("spec", |node| node.spec()).await + } + + /// Whether *this fallback* has no usable node, rather than whether the + /// first reachable one happens to be syncing or optimistic. + /// + /// Deliberately not a plain `try_each`. A node answering `true` is + /// answering successfully, so `try_each` would return that `Ok(true)` from + /// the first node and never consult the rest, and the caller + /// (`refresh_epoch`) turns `true` into "do not refresh duties". A syncing + /// node at the head of the list would therefore stop duties from being + /// refreshed for as long as it was syncing, with a perfectly healthy node + /// sitting behind it unused, which is the opposite of what configuring a + /// second node is for. + /// + /// So: look for a node that is usable, and report `false` the moment one is + /// found. `true` means every reachable node said it was syncing or + /// optimistic, which is the only situation where the caller's decision to + /// hold off is right. + /// + /// A node that fails outright is skipped like any other failure, and only + /// if none answers at all does this surface an error. + async fn is_optimistic_or_syncing(&self) -> Result { + let mut last = None; + let mut any_answered = false; + for (position, node) in self.nodes.iter().enumerate() { + match node.is_optimistic_or_syncing().await { + Ok(false) => return Ok(false), + Ok(true) => { + any_answered = true; + debug!( + position, + "Beacon node is syncing or optimistic; trying the next" + ); + } + Err(err) => { + debug!(what = "syncing", position, %err, "Beacon node request failed; trying the next"); + last = Some(err); + } + } + } + + if any_answered { + // Every node that answered said it was unusable. That is a real + // answer, not a failure: the caller should hold off, and saying so + // is more useful than an error claiming nothing could be reached. + return Ok(true); + } + + let detail = last + .map(|err| err.to_string()) + .unwrap_or_else(|| "no nodes configured".to_string()); + warn!( + what = "syncing", + %detail, "All configured beacon nodes failed; duty likely missed" + ); + Err(Error::AllBeaconNodesFailed(detail)) + } + + async fn validator_indices(&self, pubkeys: &[BlsPubkey]) -> Result> { + self.try_each("validator_indices", |node| node.validator_indices(pubkeys)) + .await + } + + async fn attester_duties( + &self, + epoch: Epoch, + indices: &[ValidatorIndex], + ) -> Result { + self.try_each("attester_duties", |node| { + node.attester_duties(epoch, indices) + }) + .await + } + + async fn proposer_duties(&self, epoch: Epoch) -> Result { + self.try_each("proposer_duties", |node| node.proposer_duties(epoch)) + .await + } + + async fn attestation_data(&self, slot: Slot) -> Result { + self.try_each("attestation_data", |node| node.attestation_data(slot)) + .await + } + + async fn produce_block(&self, request: &BlockRequest) -> Result { + self.try_each("produce_block", |node| node.produce_block(request)) + .await + } + + /// First node that accepts it wins, rather than a broadcast to every node. + /// + /// Publishing is the one call where that choice is not obvious, because + /// sending to every node would reach more of the network's gossip mesh at + /// once. It stays first-wins for now because a node that accepts a block + /// gossips it, so the second node would receive it over the network in any + /// case, and because a broadcast makes the outcome ambiguous: with three + /// nodes answering 200, 202 and a timeout, there is no single answer to + /// report. + /// + /// A 202 counts as success and stops the walk. The node could not import + /// the block, but it did broadcast it, so the network has it and offering + /// it to another node would not change that. + async fn publish_block(&self, fork: ForkName, body: &[u8]) -> Result { + self.try_each("publish_block", |node| node.publish_block(fork, body)) + .await + } + + async fn submit_attestations( + &self, + attestations: &[SingleAttestationDto], + fork_name: &str, + ) -> Result { + self.try_each("submit_attestations", |node| { + node.submit_attestations(attestations, fork_name) + }) + .await + } + + /// A 404 here is a node with nothing to fold, not a broken node, and it is + /// the reason this is worth failing over: another node may have been on + /// the subnet when the votes arrived. + async fn aggregate_attestation( + &self, + slot: Slot, + attestation_data_root: Root, + committee_index: u64, + ) -> Result { + self.try_each("aggregate_attestation", |node| { + node.aggregate_attestation(slot, attestation_data_root, committee_index) + }) + .await + } + + async fn publish_aggregates( + &self, + fork: ForkName, + aggregates: &[SignedAggregateAndProof], + ) -> Result<()> { + self.try_each("publish_aggregates", |node| { + node.publish_aggregates(fork, aggregates) + }) + .await + } + + /// Every node, not the first that answers. See [`Self::try_all`]: a node + /// that was never told where to pay builds a payload paying somewhere else, + /// and it is the node this client falls over to that needs telling most. + async fn prepare_beacon_proposer(&self, preparations: &[ProposerPreparationDto]) -> Result<()> { + self.try_all("prepare_beacon_proposer", |node| { + node.prepare_beacon_proposer(preparations) + }) + .await + } + + /// Every node, for the same reason, and with more at stake. A subscription + /// is what puts a node on the attestation subnets and what makes it collect + /// the votes an aggregate is folded from, so a node that never received one + /// answers 404 for every committee this client asks about. + async fn subscribe_committees(&self, subscriptions: &[CommitteeSubscriptionDto]) -> Result<()> { + self.try_all("subscribe_committees", |node| { + node.subscribe_committees(subscriptions) + }) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::beacon_node::mock::MockBeaconNode; + use ethlambda_types::beacon::primitives::Root; + + fn healthy() -> MockBeaconNode { + let mut node = MockBeaconNode::new(); + node.genesis = Some(Genesis { + genesis_time: 1_606_824_023, + genesis_validators_root: Root::ZERO, + }); + node + } + + #[tokio::test] + async fn the_first_healthy_node_answers() { + let fallback = FallbackBeaconNode::new(vec![healthy(), MockBeaconNode::failing("down")]); + let genesis = fallback.genesis().await.expect("answers"); + assert_eq!(genesis.genesis_time, 1_606_824_023); + } + + #[tokio::test] + async fn a_failing_first_node_falls_through_to_the_second() { + let fallback = FallbackBeaconNode::new(vec![MockBeaconNode::failing("down"), healthy()]); + let genesis = fallback.genesis().await.expect("answers"); + assert_eq!(genesis.genesis_time, 1_606_824_023); + } + + /// The finding this arrangement exists for: a node that is *up* but stuck + /// on a stale head must be failed over from, not treated as a success. + /// + /// Before the contract moved into the implementations, the slot check ran + /// above `try_each`, so node 1's stale answer was accepted and returned and + /// node 2 was never asked. The validator then missed every attestation + /// while both nodes looked healthy, because nothing was ever `Err`. + #[tokio::test] + async fn a_node_answering_about_a_stale_slot_is_failed_over_from() { + // Node 1 answers about slot 90 whatever it is asked; node 2 about 96. + let stale = MockBeaconNode::new().with_attestation_data(90); + let fresh = MockBeaconNode::new().with_attestation_data(96); + let fallback = FallbackBeaconNode::new(vec![stale, fresh]); + + let data = fallback + .attestation_data(96) + .await + .expect("the second node answers correctly"); + + assert_eq!( + data.slot, 96, + "the stale answer must not be the one returned" + ); + } + + #[tokio::test] + async fn every_node_answering_about_a_stale_slot_is_an_error() { + // With nowhere left to fall through to, this must be a failure rather + // than a stale answer quietly returned. + let fallback = FallbackBeaconNode::new(vec![ + MockBeaconNode::new().with_attestation_data(90), + MockBeaconNode::new().with_attestation_data(91), + ]); + + let err = fallback + .attestation_data(96) + .await + .expect_err("no node answered about the requested slot"); + + assert!(matches!(err, Error::AllBeaconNodesFailed(_)), "got {err:?}"); + } + + /// A syncing node at the head of the list must not stop duties being + /// refreshed when a healthy node sits behind it. + /// + /// `is_syncing` returning `Ok(true)` is a *successful* call, so a plain + /// `try_each` would return the first node's answer and never look further. + /// `refresh_epoch` turns `true` into "do not refresh", so the second node + /// would go unused for as long as the first was syncing. + #[tokio::test] + async fn a_syncing_first_node_does_not_mask_a_healthy_second() { + let syncing = MockBeaconNode { + syncing: true, + ..MockBeaconNode::new() + }; + let fallback = FallbackBeaconNode::new(vec![syncing, healthy()]); + + assert!( + !fallback.is_optimistic_or_syncing().await.expect("answers"), + "a healthy node behind a syncing one must be found" + ); + } + + #[tokio::test] + async fn every_node_syncing_reports_syncing() { + // A real answer, not a failure: the caller should hold off, and saying + // so beats an error claiming nothing could be reached. + let syncing = || MockBeaconNode { + syncing: true, + ..MockBeaconNode::new() + }; + let fallback = FallbackBeaconNode::new(vec![syncing(), syncing()]); + + assert!(fallback.is_optimistic_or_syncing().await.expect("answers")); + } + + #[tokio::test] + async fn a_syncing_node_is_preferred_over_no_answer_at_all() { + // One node down, one syncing: `true` is the honest report, because a + // node did answer and it said it was syncing. + let syncing = MockBeaconNode { + syncing: true, + ..MockBeaconNode::new() + }; + let fallback = FallbackBeaconNode::new(vec![MockBeaconNode::failing("down"), syncing]); + + assert!(fallback.is_optimistic_or_syncing().await.expect("answers")); + } + + #[tokio::test] + async fn no_node_answering_syncing_is_an_error() { + let fallback = FallbackBeaconNode::new(vec![ + MockBeaconNode::failing("first down"), + MockBeaconNode::failing("second down"), + ]); + + let err = fallback + .is_optimistic_or_syncing() + .await + .expect_err("nothing answered"); + assert!(matches!(err, Error::AllBeaconNodesFailed(_)), "got {err:?}"); + } + + #[tokio::test] + async fn every_node_failing_is_reported_as_such() { + let fallback = FallbackBeaconNode::new(vec![ + MockBeaconNode::failing("first down"), + MockBeaconNode::failing("second down"), + ]); + let err = fallback.genesis().await.expect_err("must fail"); + assert!(matches!(err, Error::AllBeaconNodesFailed(_)), "got {err:?}"); + assert!(err.to_string().contains("second down"), "got {err}"); + } + + /// A node that is up but whose duties poll is failing is a different + /// shape from a node that is entirely down: `genesis` must still be + /// served by it, while `attester_duties` must fall through to the next. + #[tokio::test] + async fn a_node_failing_one_call_still_serves_the_others() { + let first = healthy().failing_call("attester_duties", "duties down"); + let second = healthy().with_duties(3, Root::repeat_byte(7), vec![]); + + let fallback = FallbackBeaconNode::new(vec![first, second]); + + let genesis = fallback.genesis().await.expect("first node answers"); + assert_eq!(genesis.genesis_time, 1_606_824_023); + + let duties = fallback + .attester_duties(3, &[]) + .await + .expect("second node answers"); + assert_eq!(duties.dependent_root, Root::repeat_byte(7)); + } + + fn request(slot: Slot, proposer_index: ValidatorIndex) -> BlockRequest { + use ethlambda_types::beacon::primitives::{BlsSignature, Bytes32}; + BlockRequest { + slot, + proposer_index, + randao_reveal: BlsSignature([0; 96]), + graffiti: Bytes32::default(), + } + } + + /// The proposal-shaped version of the stale-head finding above, and the + /// more expensive one to get wrong: a block signed against a stale node's + /// answer is a block for the wrong slot, and it burns the proposal guard's + /// record for that validator on the way out. + #[tokio::test] + async fn a_node_producing_a_block_for_the_wrong_slot_is_failed_over_from() { + let stale = MockBeaconNode::new().with_block(90, 7); + let fresh = MockBeaconNode::new().with_block(96, 7); + let fallback = FallbackBeaconNode::new(vec![stale, fresh]); + + let block = fallback + .produce_block(&request(96, 7)) + .await + .expect("the second node answers correctly"); + assert_eq!(block.block().slot, 96); + } + + /// A node on a different fork computes a different proposer. Signing its + /// block would produce a signature the network discards, while the guard + /// records the slot as proposed and refuses the real duty. + #[tokio::test] + async fn a_node_naming_the_wrong_proposer_is_failed_over_from() { + let wrong = MockBeaconNode::new().with_block(96, 11); + let right = MockBeaconNode::new().with_block(96, 7); + let fallback = FallbackBeaconNode::new(vec![wrong, right]); + + let block = fallback + .produce_block(&request(96, 7)) + .await + .expect("the second node names the expected proposer"); + assert_eq!(block.block().proposer_index, 7); + } + + #[tokio::test] + async fn every_node_naming_the_wrong_proposer_is_an_error() { + let fallback = FallbackBeaconNode::new(vec![ + MockBeaconNode::new().with_block(96, 11), + MockBeaconNode::new().with_block(96, 12), + ]); + + let err = fallback + .produce_block(&request(96, 7)) + .await + .expect_err("no node produced a block for this proposer"); + assert!(matches!(err, Error::AllBeaconNodesFailed(_)), "got {err:?}"); + } + + #[tokio::test] + async fn a_block_is_published_to_the_first_node_that_accepts_it() { + let fallback = + FallbackBeaconNode::new(vec![MockBeaconNode::failing("down"), MockBeaconNode::new()]); + + let outcome = fallback + .publish_block(ForkName::Electra, b"body") + .await + .expect("the second node accepts it"); + assert_eq!(outcome, Published::Imported); + assert_eq!( + fallback.nodes[1].published_blocks(), + vec![(ForkName::Electra, b"body".to_vec())] + ); + assert!( + fallback.nodes[0].published_blocks().is_empty(), + "the failing node recorded nothing" + ); + } + + /// 202 means the node broadcast the block but could not import it. It is a + /// success for the walk, so the second node is never tried, but it must not + /// be reported to the caller as a clean proposal. + #[tokio::test] + async fn a_block_the_first_node_broadcast_but_could_not_import_stops_the_walk() { + let fallback = FallbackBeaconNode::new(vec![ + MockBeaconNode::new().with_publish_outcome(Published::BroadcastNotImported), + MockBeaconNode::new(), + ]); + + let outcome = fallback + .publish_block(ForkName::Electra, b"body") + .await + .expect("202 is not an error"); + assert_eq!(outcome, Published::BroadcastNotImported); + assert!( + fallback.nodes[1].published_blocks().is_empty(), + "the block was already broadcast; the second node must not be asked" + ); + } + + fn subscription(slot: Slot) -> CommitteeSubscriptionDto { + CommitteeSubscriptionDto { + validator_index: 1, + committee_index: 2, + committees_at_slot: 64, + slot, + is_aggregator: true, + } + } + + /// The finding this helper exists for: a subscription is state installed on + /// a node, not a query with one right answer. Under `try_each` only node 1 + /// ever heard about this client's committees, and node 2 — the one failover + /// exists to use — was left unable to serve the duty. + #[tokio::test] + async fn a_subscription_reaches_every_node_not_just_the_first() { + let fallback = FallbackBeaconNode::new(vec![MockBeaconNode::new(), MockBeaconNode::new()]); + fallback + .subscribe_committees(&[subscription(96)]) + .await + .expect("subscribes"); + + assert_eq!(fallback.nodes[0].subscriptions().len(), 1); + assert_eq!( + fallback.nodes[1].subscriptions().len(), + 1, + "the second node must be told too, or it cannot serve a failover" + ); + } + + #[tokio::test] + async fn a_fee_recipient_registration_reaches_every_node() { + let fallback = FallbackBeaconNode::new(vec![MockBeaconNode::new(), MockBeaconNode::new()]); + let preparations = [ProposerPreparationDto { + validator_index: 1, + fee_recipient: "0xab".to_string(), + }]; + fallback + .prepare_beacon_proposer(&preparations) + .await + .expect("registers"); + + assert_eq!(fallback.nodes[0].preparations().len(), 1); + assert_eq!(fallback.nodes[1].preparations().len(), 1); + } + + /// One unreachable node must not stop the reachable ones being told. + #[tokio::test] + async fn one_failing_node_does_not_stop_the_others_being_subscribed() { + let fallback = + FallbackBeaconNode::new(vec![MockBeaconNode::failing("down"), MockBeaconNode::new()]); + fallback + .subscribe_committees(&[subscription(96)]) + .await + .expect("the healthy node accepted"); + + assert!(fallback.nodes[0].subscriptions().is_empty()); + assert_eq!(fallback.nodes[1].subscriptions().len(), 1); + } + + #[tokio::test] + async fn no_node_accepting_a_subscription_is_an_error() { + let fallback = FallbackBeaconNode::new(vec![ + MockBeaconNode::failing("down"), + MockBeaconNode::failing("also down"), + ]); + let err = fallback + .subscribe_committees(&[subscription(96)]) + .await + .expect_err("nothing was registered anywhere"); + assert!(matches!(err, Error::AllBeaconNodesFailed(_)), "got {err:?}"); + } + + /// The other direction, stated so the two rules do not drift: a query still + /// stops at the first node that answers. Asking every node for a block + /// would make every node build one. + #[tokio::test] + async fn a_query_still_stops_at_the_first_node_that_answers() { + let fallback = FallbackBeaconNode::new(vec![ + MockBeaconNode::new().with_block(96, 7), + MockBeaconNode::new().with_block(96, 7), + ]); + fallback + .produce_block(&request(96, 7)) + .await + .expect("the first node answers"); + + assert_eq!(fallback.nodes[0].block_requests().len(), 1); + assert!( + fallback.nodes[1].block_requests().is_empty(), + "the second node must not have been asked to build a block too" + ); + } + + /// The pair a beacon node can genuinely report and this client must still + /// refuse: caught up, but tracking a head its execution client has not + /// validated. The specification makes not signing in that position a MUST, + /// and checking only `is_syncing` would miss it entirely. + #[tokio::test] + async fn a_synced_but_optimistic_node_is_not_usable() { + let mut node = MockBeaconNode::new(); + node.syncing = false; + node.optimistic = true; + let fallback = FallbackBeaconNode::new(vec![node]); + + assert!( + fallback.is_optimistic_or_syncing().await.expect("answers"), + "an optimistic node must be refused even though it is not syncing" + ); + } + + /// And the failover half of the same: an optimistic first node must not + /// mask a healthy second, exactly as a syncing one must not. + #[tokio::test] + async fn an_optimistic_first_node_does_not_mask_a_healthy_second() { + let mut optimistic = MockBeaconNode::new(); + optimistic.optimistic = true; + let fallback = FallbackBeaconNode::new(vec![optimistic, MockBeaconNode::new()]); + + assert!( + !fallback.is_optimistic_or_syncing().await.expect("answers"), + "the healthy second node must be found" + ); + } +} diff --git a/crates/validator/src/beacon_node/http.rs b/crates/validator/src/beacon_node/http.rs new file mode 100644 index 000000000..caa709b6f --- /dev/null +++ b/crates/validator/src/beacon_node/http.rs @@ -0,0 +1,760 @@ +//! The Beacon API over HTTP. +//! +//! # Which error a failure becomes +//! +//! Two of this crate's variants could plausibly claim a bad response, so the +//! split is fixed here rather than decided per call site: +//! +//! - `BeaconNodeFailure::classify` is called **only** on a `send` failure, so +//! it only ever describes transport: a timeout, a refused connection, a body +//! that stopped arriving. That is a property of the node or the network. +//! - A response that arrives whole but does not deserialise is +//! `Error::Decode`. That is a property of what the node said. +//! - A response that deserialises but contradicts what was asked for is +//! `Error::InconsistentResponse`, raised here. It used to be raised by the +//! caller, which was wrong for one specific reason: `FallbackBeaconNode` +//! wraps these methods, so a check above them runs only after this node's +//! answer has already been accepted, and the next node is never tried. See +//! the contract on `BeaconNodeApi::attestation_data`. +//! +//! Keeping `classify` off the `json` path is what stops `reqwest`'s own +//! `is_decode` from competing with `Error::Decode` for the same failure. + +use std::time::Duration; + +use async_trait::async_trait; +use ethlambda_types::beacon::config::Config; +use ethlambda_types::beacon::containers::electra::{Attestation, SignedAggregateAndProof}; +use ethlambda_types::beacon::containers::shared::AttestationData; +use ethlambda_types::beacon::fork::ForkName; +use ethlambda_types::beacon::primitives::{BlsPubkey, Epoch, Root, Slot, ValidatorIndex}; +use reqwest::{Client, StatusCode}; +use serde::Serialize; +use tracing::{debug, warn}; + +use crate::beacon_node::block_contents::ProducedBlock; +use crate::beacon_node::dto::{ + AttestationDataDto, AttestationDto, AttesterDutyDto, CommitteeSubscriptionDto, DataResponse, + DutiesResponse, GenesisDto, IndexedErrorResponse, ProposerDutyDto, ProposerPreparationDto, + SignedAggregateAndProofOutDto, SingleAttestationDto, SyncingDto, ValidatorEntryDto, + VersionedResponse, config_from_spec_response, encode_hex, parse_pubkey, parse_root, +}; +use crate::beacon_node::{ + AggregateAttestation, AttesterDuties, BeaconNodeApi, BlockRequest, Genesis, ProposerDuties, + Published, ValidatorEntry, validate_attestation_data, validate_produced_block, +}; +use crate::error::{BeaconNodeFailure, Error, Result}; + +/// How long any single request may take. +/// +/// A fixed 8 seconds, sized for mainnet-family slot times (12 seconds and up); +/// it is not derived from the network's configured slot duration, which is +/// only known after a successful [`HttpBeaconNode::spec`] call through this +/// same client. A network with shorter slots would need this revisited, since +/// a stalled request could then outlive the slot it was serving. +const REQUEST_TIMEOUT: Duration = Duration::from_secs(8); + +/// A [`BeaconNodeApi`] backed by one beacon node's standard REST Beacon API. +pub struct HttpBeaconNode { + base_url: String, + client: Client, +} + +impl HttpBeaconNode { + /// Builds a client for the node at `base_url`, stripping any trailing + /// slash so callers may pass either form. + pub fn new(base_url: impl Into) -> Result { + let base_url = base_url.into().trim_end_matches('/').to_string(); + let client = Client::builder() + .timeout(REQUEST_TIMEOUT) + .build() + .map_err(|err| Error::BeaconNode { + url: base_url.clone(), + failure: BeaconNodeFailure::classify(&err), + detail: err.to_string(), + })?; + Ok(Self { base_url, client }) + } + + /// The node's base URL, with any trailing slash already stripped. + pub fn base_url(&self) -> &str { + &self.base_url + } + + async fn get(&self, path: &str) -> Result { + let url = format!("{}{path}", self.base_url); + debug!(%url, "Beacon API GET"); + let response = self + .client + .get(&url) + .send() + .await + .map_err(|err| Error::BeaconNode { + url: url.clone(), + failure: BeaconNodeFailure::classify(&err), + detail: err.to_string(), + })?; + Self::decode(response).await + } + + async fn post( + &self, + path: &str, + body: &B, + consensus_version: Option<&str>, + ) -> Result { + let url = format!("{}{path}", self.base_url); + debug!(%url, "Beacon API POST"); + let mut request = self.client.post(&url).json(body); + if let Some(version) = consensus_version { + request = request.header("Eth-Consensus-Version", version); + } + let response = request.send().await.map_err(|err| Error::BeaconNode { + url: url.clone(), + failure: BeaconNodeFailure::classify(&err), + detail: err.to_string(), + })?; + Self::decode(response).await + } + + /// POST where the beacon node answers with an empty body on success. + async fn post_no_content( + &self, + path: &str, + body: &B, + consensus_version: Option<&str>, + ) -> Result<()> { + let url = format!("{}{path}", self.base_url); + let mut request = self.client.post(&url).json(body); + if let Some(version) = consensus_version { + request = request.header("Eth-Consensus-Version", version); + } + let response = request.send().await.map_err(|err| Error::BeaconNode { + url: url.clone(), + failure: BeaconNodeFailure::classify(&err), + detail: err.to_string(), + })?; + let status = response.status(); + if status.is_success() { + return Ok(()); + } + let body = response.text().await.unwrap_or_default(); + Err(Error::BeaconNodeStatus { + status: status.as_u16(), + body, + }) + } + + /// Fetch a body as SSZ, returning the fork the node named alongside it. + /// + /// `Accept` names SSZ alone, deliberately narrower than the specification's + /// own example. + /// + /// That example is `application/octet-stream;q=1.0,application/json;q=0.9`, + /// which says JSON is acceptable at lower preference. It is the right + /// header for a client that can decode both. This one cannot: there is no + /// JSON path for a block, so a node taking the `q=0.9` offer would be + /// answering correctly and losing this client the proposal on every node in + /// the failover list. (Aggregates do not come through here; they are + /// fetched as JSON, because Lighthouse sends JSON for them regardless.) + /// + /// Naming only what can be decoded makes 406 the node's one legal way out, + /// and `decode_status` turns that into an ordinary error failover moves + /// past. The content-type check below stays as the backstop for a node that + /// ignores the header entirely. + /// + /// The fork comes back from `Eth-Consensus-Version` because it cannot come + /// from the bytes: SSZ carries no type tag. A missing or unrecognised + /// header is therefore a hard error, not a default, since guessing the + /// fork means decoding a block into the wrong shape and signing whatever + /// root that produces. The comparison is case-insensitive: the schema's + /// enum is lowercase but nothing in the specification says a client must + /// match it that way. + /// + /// Returns the blinded flag as an `Option`, `None` when the header is + /// absent, because only one endpoint sends it and only that endpoint's + /// caller knows whether its absence is an error. + async fn get_ssz(&self, path: &str) -> Result<(ForkName, Option, Vec)> { + let url = format!("{}{path}", self.base_url); + debug!(%url, "Beacon API GET (ssz)"); + let response = self + .client + .get(&url) + .header(reqwest::header::ACCEPT, "application/octet-stream") + .send() + .await + .map_err(|err| Error::BeaconNode { + url: url.clone(), + failure: BeaconNodeFailure::classify(&err), + detail: err.to_string(), + })?; + + let status = response.status(); + if status == StatusCode::SERVICE_UNAVAILABLE { + return Err(Error::BeaconNodeSyncing); + } + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(Error::BeaconNodeStatus { + status: status.as_u16(), + body, + }); + } + + let header = |name: &str| -> Option { + response + .headers() + .get(name) + .and_then(|value| value.to_str().ok()) + .map(str::to_string) + }; + + // A node that ignored the Accept header and sent JSON would otherwise + // reach the SSZ decoder as bytes that happen not to parse, and be + // reported as a malformed block rather than as the content-type + // mismatch it is. + if let Some(content_type) = header(reqwest::header::CONTENT_TYPE.as_str()) + && !content_type.starts_with("application/octet-stream") + { + return Err(Error::InconsistentResponse(format!( + "asked for SSZ, node answered with {content_type}" + ))); + } + + let version = header("eth-consensus-version").ok_or_else(|| { + Error::InconsistentResponse( + "response carries no Eth-Consensus-Version, so the fork it encodes is unknown" + .to_string(), + ) + })?; + let fork = ForkName::parse(&version.to_ascii_lowercase()).ok_or_else(|| { + Error::InconsistentResponse(format!( + "Eth-Consensus-Version names fork {version}, which this client does not know" + )) + })?; + + // Reported, not judged. Only `produceBlockV3` sends this header, so + // requiring it here would break every other caller of this helper; + // whether its absence matters is the caller's question. + let blinded = match header("eth-execution-payload-blinded") { + Some(value) if value.eq_ignore_ascii_case("true") => Some(true), + Some(value) if value.eq_ignore_ascii_case("false") => Some(false), + Some(value) => { + return Err(Error::InconsistentResponse(format!( + "Eth-Execution-Payload-Blinded is {value}, expected true or false" + ))); + } + None => None, + }; + + let body = response.bytes().await.map_err(|err| Error::BeaconNode { + url, + failure: BeaconNodeFailure::classify(&err), + detail: err.to_string(), + })?; + Ok((fork, blinded, body.to_vec())) + } + + /// The query string for one aggregate. + /// + /// A pure function so it can be asserted on directly. It is built with a + /// string continuation, and Rust strips the newline *and* the following + /// indentation, so a misplaced one would put a space inside a query + /// parameter. The beacon node would then answer about a different + /// committee, or 400, with nothing in this client naming the cause. + fn aggregate_path(slot: Slot, attestation_data_root: Root, committee_index: u64) -> String { + format!( + "/eth/v2/validator/aggregate_attestation?attestation_data_root={}&slot={slot}\ + &committee_index={committee_index}", + encode_hex(&attestation_data_root.0), + ) + } + + /// Turn a non-2xx response from the pool-attestations endpoint into + /// either a partial success or an error. + /// + /// Split out from `submit_attestations` as a pure function of the status + /// and body text, so a batch's partial-failure body can be exercised in a + /// unit test with no real HTTP round trip involved. + /// + /// A 400 whose body parses as [`IndexedErrorResponse`] and names fewer + /// failures than `submitted` is a partial success: the beacon node has + /// already stored and gossiped whichever entries were not in the list, so + /// this returns `Ok` with that count rather than collapsing it into one + /// opaque error the way `post_no_content` would, which would make + /// `inc_attestations_published` never fire for a batch that mostly + /// succeeded. Anything else, a total rejection, an unparsable body, or a + /// different status, is still reported as `Err`, unchanged from before. + fn handle_pool_submission(status: StatusCode, body: String, submitted: usize) -> Result { + if status == StatusCode::BAD_REQUEST + && let Ok(error) = serde_json::from_str::(&body) + { + let failed = error.failures.len(); + if failed > 0 && failed < submitted { + for failure in &error.failures { + warn!( + submission_index = failure.index, + reason = %failure.message, + "One attestation in this slot's batch was rejected" + ); + } + return Ok(submitted - failed); + } + } + + Err(Error::BeaconNodeStatus { + status: status.as_u16(), + body, + }) + } + + async fn decode(response: reqwest::Response) -> Result { + let status = response.status(); + if status == StatusCode::SERVICE_UNAVAILABLE { + return Err(Error::BeaconNodeSyncing); + } + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(Error::BeaconNodeStatus { + status: status.as_u16(), + body, + }); + } + response + .json() + .await + .map_err(|err| Error::Decode(err.to_string())) + } +} + +#[async_trait] +impl BeaconNodeApi for HttpBeaconNode { + /// Fetches the chain's genesis time and validators root. + async fn genesis(&self) -> Result { + let response: DataResponse = self.get("/eth/v1/beacon/genesis").await?; + Ok(Genesis { + genesis_time: response.data.genesis_time, + genesis_validators_root: parse_root(&response.data.genesis_validators_root)?, + }) + } + + /// Fetches the network's fork schedule and slot duration. + /// + /// The spec endpoint returns every configuration value as a quoted + /// string. Only the fork schedule and the slot duration are read here; + /// the rest of `Config` keeps its mainnet defaults, which is correct for + /// every network whose presets this binary was compiled against. + async fn spec(&self) -> Result { + let response: DataResponse = self.get("/eth/v1/config/spec").await?; + config_from_spec_response(&response.data) + } + + async fn is_optimistic_or_syncing(&self) -> Result { + let response: DataResponse = self.get("/eth/v1/node/syncing").await?; + let syncing = response.data.is_syncing; + let optimistic = response.data.is_optimistic.unwrap_or(false); + + // Reported rather than acted on. An execution client that has just gone + // offline leaves the node with the head it validated before that, which + // is still a head worth attesting to; the moment that stops being true + // the node reports it as optimistic instead. + if response.data.el_offline.unwrap_or(false) { + warn!( + url = %self.base_url, + "Beacon node reports its execution client is offline; it will go optimistic if it \ + has not already" + ); + } + if optimistic && !syncing { + warn!( + url = %self.base_url, + "Beacon node has finished syncing but is tracking an unvalidated head; refusing \ + to sign against it" + ); + } + Ok(syncing || optimistic) + } + + /// Resolves each pubkey to its validator index and current status. + async fn validator_indices(&self, pubkeys: &[BlsPubkey]) -> Result> { + // See the contract on the trait: an empty list means "every validator" + // to this endpoint, so asking is the one thing that must not happen. + if pubkeys.is_empty() { + return Ok(Vec::new()); + } + + #[derive(Serialize)] + struct Body { + ids: Vec, + } + let body = Body { + ids: pubkeys.iter().map(|key| encode_hex(&key.0)).collect(), + }; + let response: DataResponse> = self + .post("/eth/v1/beacon/states/head/validators", &body, None) + .await?; + response + .data + .into_iter() + .map(|entry| { + Ok(ValidatorEntry { + index: entry.index, + pubkey: parse_pubkey(&entry.validator.pubkey)?, + status: entry.status, + }) + }) + .collect() + } + + async fn attester_duties( + &self, + epoch: Epoch, + indices: &[ValidatorIndex], + ) -> Result { + let body: Vec = indices.iter().map(|index| index.to_string()).collect(); + let response: DutiesResponse> = self + .post( + &format!("/eth/v1/validator/duties/attester/{epoch}"), + &body, + None, + ) + .await?; + Ok(AttesterDuties { + dependent_root: parse_root(&response.dependent_root)?, + duties: response.data, + }) + } + + /// A GET with no body, unlike the attester equivalent's POST: the endpoint + /// answers for every proposer in the epoch rather than for a submitted + /// list, so there is nothing to send. + async fn proposer_duties(&self, epoch: Epoch) -> Result { + let response: DutiesResponse> = self + .get(&format!("/eth/v1/validator/duties/proposer/{epoch}")) + .await?; + Ok(ProposerDuties { + dependent_root: parse_root(&response.dependent_root)?, + duties: response.data, + }) + } + + async fn attestation_data(&self, slot: Slot) -> Result { + let response: DataResponse = self + .get(&format!( + "/eth/v1/validator/attestation_data?slot={slot}&committee_index=0" + )) + .await?; + let data = AttestationData::try_from(&response.data)?; + // Enforced here rather than at the call site so that failover works: + // see the contract on `BeaconNodeApi::attestation_data`. An `Err` here + // is what lets `FallbackBeaconNode` move to the next node; the same + // check one layer up runs only after this node's answer was already + // accepted. + validate_attestation_data(slot, &data)?; + Ok(data) + } + + /// `builder_boost_factor=0` is how this client says it does not do the + /// builder flow. + /// + /// The parameter is a bid comparison: the node returns the local execution + /// payload when its value is at least `factor / 100` of the builder's, so + /// zero makes the local payload win on value. + /// + /// It is a preference, not a demand, and an earlier version of this comment + /// said otherwise. The specification's words are "prefer the local + /// execution node payload **unless an error makes it unviable**", so a node + /// with a builder configured and a failing execution client may still + /// answer with a blinded block. The one unconditional guarantee is the + /// other half: a node with no builder configured MUST return a full block. + /// + /// A blinded answer is therefore rejected below rather than assumed away. + /// This client cannot publish what it cannot unblind, so signing one would + /// burn the slot's proposal guard entry for a block that can never be + /// sent. + async fn produce_block(&self, request: &BlockRequest) -> Result { + let path = format!( + "/eth/v3/validator/blocks/{}?randao_reveal={}&graffiti={}&builder_boost_factor=0", + request.slot, + encode_hex(&request.randao_reveal.0), + encode_hex(&request.graffiti.0), + ); + let (fork, blinded, body) = self.get_ssz(&path).await?; + // Required on this endpoint, so its absence is an error rather than a + // default. The specification marks it required precisely because it + // selects which container the bytes are, and a blinded body is a + // different shape entirely; guessing "unblinded" would send a blinded + // answer to the decoder and surface it as an opaque malformed block. + let blinded = blinded.ok_or_else(|| { + Error::InconsistentResponse( + "response carries no Eth-Execution-Payload-Blinded, so whether it is a block or \ + a blinded block is unknown" + .to_string(), + ) + })?; + if blinded { + return Err(Error::InconsistentResponse(format!( + "node produced a blinded block for slot {}; this client does not implement the \ + builder flow and cannot publish one", + request.slot + ))); + } + + let block = ProducedBlock::from_ssz(fork, &body)?; + // Enforced here rather than at the call site so failover works: see the + // contract on `BeaconNodeApi::produce_block`. + validate_produced_block(request, &block)?; + Ok(block) + } + + /// 202 is not an error and not a clean success, so it is neither mapped to + /// `Err` nor flattened into `Ok(())`. See [`Published`]. + async fn publish_block(&self, fork: ForkName, body: &[u8]) -> Result { + let path = "/eth/v2/beacon/blocks"; + let url = format!("{}{path}", self.base_url); + let response = self + .client + .post(&url) + .header("Eth-Consensus-Version", fork.as_str()) + .header(reqwest::header::CONTENT_TYPE, "application/octet-stream") + .body(body.to_vec()) + .send() + .await + .map_err(|err| Error::BeaconNode { + url: url.clone(), + failure: BeaconNodeFailure::classify(&err), + detail: err.to_string(), + })?; + + let status = response.status(); + if status == StatusCode::ACCEPTED { + return Ok(Published::BroadcastNotImported); + } + if status.is_success() { + return Ok(Published::Imported); + } + let body = response.text().await.unwrap_or_default(); + Err(Error::BeaconNodeStatus { + status: status.as_u16(), + body, + }) + } + + /// Distinct from `post_no_content`: this endpoint's 400s are not always + /// total failures. See [`Self::handle_pool_submission`]. + async fn submit_attestations( + &self, + attestations: &[SingleAttestationDto], + fork_name: &str, + ) -> Result { + let path = "/eth/v2/beacon/pool/attestations"; + let url = format!("{}{path}", self.base_url); + let response = self + .client + .post(&url) + .header("Eth-Consensus-Version", fork_name) + .json(attestations) + .send() + .await + .map_err(|err| Error::BeaconNode { + url: url.clone(), + failure: BeaconNodeFailure::classify(&err), + detail: err.to_string(), + })?; + + let status = response.status(); + if status.is_success() { + return Ok(attestations.len()); + } + let body = response.text().await.unwrap_or_default(); + Self::handle_pool_submission(status, body, attestations.len()) + } + + /// Three required query parameters, and the third is the one electra added. + /// See the contract on [`BeaconNodeApi::aggregate_attestation`] for why the + /// root alone is no longer enough to name a committee's votes. + async fn aggregate_attestation( + &self, + slot: Slot, + attestation_data_root: Root, + committee_index: u64, + ) -> Result { + let path = Self::aggregate_path(slot, attestation_data_root, committee_index); + // JSON, not SSZ, and not by preference: Lighthouse answers this endpoint + // in JSON whatever `Accept` says. See `AttestationDto` for why that is + // safe here when it would not be for a block. + let response: VersionedResponse = self.get(&path).await?; + let fork = ForkName::parse(&response.version.to_ascii_lowercase()).ok_or_else(|| { + Error::InconsistentResponse(format!( + "aggregate names fork {}, which this client does not know", + response.version + )) + })?; + // Refused by name rather than left to fail as an opaque decode error, + // the same way a pre-electra block is. Electra widened `Attestation` + // with `committee_bits`, so an earlier fork's bytes are a different + // shape and this client has no container for them. + if fork < ForkName::Electra { + return Err(Error::InconsistentResponse(format!( + "node produced a {} aggregate, which this client does not publish; electra is \ + the earliest supported", + fork.as_str() + ))); + } + let attestation = Attestation::try_from(&response.data)?; + // The same contract the other two fetches carry, enforced here so a + // node answering about the wrong slot is failed over from rather than + // wrapped in a signature. `committee_index` is deliberately not checked + // against `committee_bits`: which committees an aggregate covers is the + // node's answer to the question, and electra's gossip rules already + // require exactly one. + if attestation.data.slot != slot { + return Err(Error::InconsistentResponse(format!( + "requested an aggregate for slot {slot}, node answered for slot {}", + attestation.data.slot + ))); + } + Ok(AggregateAttestation { fork, attestation }) + } + + /// JSON, because Lighthouse answers an SSZ body here with 415. See the + /// contract on [`BeaconNodeApi::publish_aggregates`]. + async fn publish_aggregates( + &self, + fork: ForkName, + aggregates: &[SignedAggregateAndProof], + ) -> Result<()> { + let body: Vec = aggregates + .iter() + .map(SignedAggregateAndProofOutDto::from) + .collect(); + self.post_no_content( + "/eth/v2/validator/aggregate_and_proofs", + &body, + Some(fork.as_str()), + ) + .await + } + + async fn prepare_beacon_proposer(&self, preparations: &[ProposerPreparationDto]) -> Result<()> { + self.post_no_content( + "/eth/v1/validator/prepare_beacon_proposer", + &preparations, + None, + ) + .await + } + + async fn subscribe_committees(&self, subscriptions: &[CommitteeSubscriptionDto]) -> Result<()> { + self.post_no_content( + "/eth/v1/validator/beacon_committee_subscriptions", + &subscriptions, + None, + ) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_trims_a_trailing_slash_from_the_base_url() { + let node = HttpBeaconNode::new("http://x/").expect("builds"); + assert_eq!(node.base_url(), "http://x"); + } + + /// An `IndexedErrorMessage` naming fewer failures than the batch size is + /// a partial success: the valid entries are already stored and gossiped, + /// so this must report their count rather than turn the whole submission + /// into an error. + #[test] + fn a_partial_batch_rejection_reports_how_many_succeeded() { + let body = serde_json::json!({ + "code": 400, + "message": "some failed to verify", + "failures": [ + { "index": 1, "message": "invalid signature" } + ] + }) + .to_string(); + + let published = HttpBeaconNode::handle_pool_submission(StatusCode::BAD_REQUEST, body, 3) + .expect("a partial rejection is not an error"); + assert_eq!(published, 2); + } + + /// The opposite edge from the test above: every submitted entry is named + /// as a failure, so nothing actually succeeded and this must still be + /// reported as an error, exactly like the pre-existing behaviour for a + /// bad response with no per-index detail at all. + #[test] + fn a_total_batch_rejection_is_still_an_error() { + let body = serde_json::json!({ + "code": 400, + "message": "all failed to verify", + "failures": [ + { "index": 0, "message": "invalid signature" }, + { "index": 1, "message": "invalid signature" } + ] + }) + .to_string(); + + let err = HttpBeaconNode::handle_pool_submission(StatusCode::BAD_REQUEST, body, 2) + .expect_err("every entry failed; must not be reported as a success"); + assert!(matches!(err, Error::BeaconNodeStatus { .. }), "got {err:?}"); + } + + /// A 400 with no `failures` field at all (or one that fails to parse) + /// must fall back to the old opaque-error behaviour rather than panicking + /// or silently reporting a made-up count. + #[test] + fn an_unparsable_body_falls_back_to_an_opaque_error() { + let err = HttpBeaconNode::handle_pool_submission( + StatusCode::BAD_REQUEST, + "not json".to_string(), + 2, + ) + .expect_err("must fail"); + assert!(matches!(err, Error::BeaconNodeStatus { .. }), "got {err:?}"); + } + + #[test] + fn a_non_400_failure_status_is_still_an_error() { + let err = HttpBeaconNode::handle_pool_submission( + StatusCode::INTERNAL_SERVER_ERROR, + "boom".to_string(), + 2, + ) + .expect_err("must fail"); + assert!(matches!(err, Error::BeaconNodeStatus { .. }), "got {err:?}"); + } + + #[test] + fn the_aggregate_query_has_no_stray_whitespace() { + let path = HttpBeaconNode::aggregate_path(12_345, Root::repeat_byte(0xab), 7); + assert!( + !path.contains(' '), + "a string continuation must not leave a space in the query: {path}" + ); + assert_eq!( + path, + format!( + "/eth/v2/validator/aggregate_attestation?attestation_data_root=0x{}&slot=12345&committee_index=7", + "ab".repeat(32) + ) + ); + } + + /// All three parameters are required by the specification, and the third is + /// the one electra added. Losing it would silently ask about whichever + /// committee the node picked. + #[test] + fn the_aggregate_query_carries_all_three_required_parameters() { + let path = HttpBeaconNode::aggregate_path(1, Root::ZERO, 63); + for parameter in ["attestation_data_root=", "slot=", "committee_index="] { + assert!(path.contains(parameter), "{parameter} missing from {path}"); + } + } +} diff --git a/crates/validator/src/beacon_node/mock.rs b/crates/validator/src/beacon_node/mock.rs new file mode 100644 index 000000000..9e1370304 --- /dev/null +++ b/crates/validator/src/beacon_node/mock.rs @@ -0,0 +1,520 @@ +//! A `BeaconNodeApi` that answers from values a test sets, used by the duty +//! service tests. Compiled only under `cfg(test)`. + +use std::collections::HashMap; +use std::sync::Mutex; + +use async_trait::async_trait; +use ethlambda_types::beacon::config::Config; +use ethlambda_types::beacon::containers::electra::{Attestation, SignedAggregateAndProof}; +use ethlambda_types::beacon::containers::shared::AttestationData; +use ethlambda_types::beacon::containers::shared::Checkpoint; +use ethlambda_types::beacon::fork::ForkName; +use ethlambda_types::beacon::primitives::{BlsPubkey, Epoch, Root, Slot, ValidatorIndex}; + +use crate::beacon_node::block_contents::{Contents, ProducedBlock, empty_block_for}; +use crate::beacon_node::dto::{ + AttesterDutyDto, CommitteeSubscriptionDto, ProposerDutyDto, ProposerPreparationDto, + SingleAttestationDto, +}; +use crate::beacon_node::{ + AggregateAttestation, AttesterDuties, BeaconNodeApi, BlockRequest, Genesis, ProposerDuties, + Published, ValidatorEntry, +}; +use crate::error::{BeaconNodeFailure, Error, Result}; + +/// A `BeaconNodeApi` driven entirely by fields and methods a test controls, +/// rather than by a network or a real beacon node. +#[derive(Default)] +pub struct MockBeaconNode { + /// Failures keyed by method name, plus an optional catch-all. + /// + /// Per-method rather than blanket because the scenarios worth testing are + /// partial: a node that answers `genesis` and `spec` at startup and then + /// fails `attester_duties` every epoch is a real and common shape, and a + /// single flag cannot express it. + pub failures: Mutex>, + /// When set, every method fails with this message, regardless of `failures`. + pub fail_everything: Option, + pub genesis: Option, + pub syncing: bool, + /// Tracking a head its execution client has not validated. Separate from + /// `syncing`, because the pair a real node can report and this client must + /// still refuse is exactly "synced, but optimistic". + pub optimistic: bool, + pub validators: Vec, + pub duties: Mutex>, + pub proposers: Mutex>, + pub attestation_data: Option, + /// The slot and proposer the mock will produce a block for. `None` makes + /// `produce_block` fail, the way an unset `attestation_data` does. + pub produces_block: Option<(Slot, ValidatorIndex)>, + /// What `publish_block` reports. Defaults to `Imported`; a test that cares + /// about the 202 path sets it. + pub publish_outcome: Option, + + /// What the test asserts against. + pub submitted: Mutex>, + /// The `fork_name` argument `submit_attestations` was called with, in + /// call order; parallel to `submitted` batch-for-batch. + pub submitted_fork_names: Mutex>, + pub subscriptions: Mutex>, + pub preparations: Mutex>, + /// Every published block body, with the fork named alongside it. + pub published_blocks: Mutex)>>, + /// Every `produce_block` request, in call order. + pub block_requests: Mutex>, + /// Every `aggregate_attestation` request, as (slot, data root, committee). + pub aggregate_requests: Mutex>, + /// Every published aggregate body, with the fork named alongside it. + pub published_aggregates: Mutex)>>, + /// When set, `aggregate_attestation` answers with an aggregate over this + /// data. `None` makes it fail the way a node with nothing to fold does. + pub aggregate: Option, + pub duties_calls: Mutex, + pub validator_indices_calls: Mutex, + pub proposer_duties_calls: Mutex, + pub attestation_data_calls: Mutex, +} + +impl MockBeaconNode { + pub fn new() -> Self { + Self::default() + } + + /// Fails every call with `message`. For a test that only cares whether the + /// node is up, not which call it made; failover tests use this. + pub fn failing(message: &str) -> Self { + Self { + fail_everything: Some(message.to_string()), + ..Self::default() + } + } + + /// Fails only the named method (its `BeaconNodeApi` name, e.g. + /// `"attester_duties"`) with `message`; every other call succeeds + /// normally. This is what makes "the node is up but its duties poll is + /// failing" expressible. + pub fn failing_call(self, what: &'static str, message: &str) -> Self { + self.failures + .lock() + .expect("lock") + .insert(what, message.to_string()); + self + } + + pub fn with_duties( + self, + epoch: Epoch, + dependent_root: Root, + duties: Vec, + ) -> Self { + self.duties.lock().expect("lock").push(( + epoch, + AttesterDuties { + dependent_root, + duties, + }, + )); + self + } + + /// Replace the duties stored for `epoch`. + /// + /// Distinct from `with_duties`, which appends at construction: a reorg is + /// the same epoch answering differently on a later call, so a test needs + /// to overwrite between calls without knowing how they are stored. + pub fn set_duties(&self, epoch: Epoch, dependent_root: Root, duties: Vec) { + let mut stored = self.duties.lock().expect("lock"); + let entry = AttesterDuties { + dependent_root, + duties, + }; + match stored + .iter_mut() + .find(|(stored_epoch, _)| *stored_epoch == epoch) + { + Some((_, existing)) => *existing = entry, + None => stored.push((epoch, entry)), + } + } + + pub fn with_proposers( + self, + epoch: Epoch, + dependent_root: Root, + duties: Vec, + ) -> Self { + self.proposers.lock().expect("lock").push(( + epoch, + ProposerDuties { + dependent_root, + duties, + }, + )); + self + } + + /// Replace the proposer duties stored for `epoch`, the way `set_duties` + /// replaces the attester ones: a reorg is the same epoch answering + /// differently on a later call. + pub fn set_proposers(&self, epoch: Epoch, dependent_root: Root, duties: Vec) { + let mut stored = self.proposers.lock().expect("lock"); + let entry = ProposerDuties { + dependent_root, + duties, + }; + match stored + .iter_mut() + .find(|(stored_epoch, _)| *stored_epoch == epoch) + { + Some((_, existing)) => *existing = entry, + None => stored.push((epoch, entry)), + } + } + + pub fn with_attestation_data(mut self, slot: Slot) -> Self { + self.attestation_data = Some(AttestationData { + slot, + index: 0, + beacon_block_root: Root::ZERO, + source: Checkpoint { + epoch: 0, + root: Root::ZERO, + }, + target: Checkpoint { + epoch: slot / 32, + root: Root::ZERO, + }, + }); + self + } + + /// Answer `produce_block` with a minimal well-formed electra block for + /// `slot`, proposed by `proposer_index`. + /// + /// The same fixture `block_contents`' own tests decode, rather than a + /// second one: a mock that built its block differently would let a decoding + /// bug pass here and fail in production. + pub fn with_block(mut self, slot: Slot, proposer_index: ValidatorIndex) -> Self { + self.produces_block = Some((slot, proposer_index)); + self + } + + pub fn with_publish_outcome(mut self, outcome: Published) -> Self { + self.publish_outcome = Some(outcome); + self + } + + /// Every block body published so far, with its fork, in publication order. + pub fn published_blocks(&self) -> Vec<(ForkName, Vec)> { + self.published_blocks.lock().expect("lock").clone() + } + + /// Every block this mock was asked to produce, in call order. What a test + /// asserts the randao reveal and graffiti against, since the mock builds + /// its own block rather than echoing the request back. + pub fn block_requests(&self) -> Vec { + self.block_requests.lock().expect("lock").clone() + } + + /// Answer `aggregate_attestation` with an aggregate over `data`. + pub fn with_aggregate(mut self, data: AttestationData) -> Self { + self.aggregate = Some(data); + self + } + + /// Every aggregate this mock was asked for, as (slot, data root, + /// committee index), in call order. + pub fn aggregate_requests(&self) -> Vec<(Slot, Root, u64)> { + self.aggregate_requests.lock().expect("lock").clone() + } + + /// Every aggregate body published so far, with its fork. + pub fn published_aggregates(&self) -> Vec<(ForkName, Vec)> { + self.published_aggregates.lock().expect("lock").clone() + } + + /// Stop failing the named method, so one node can fail a call and then + /// answer it. A test that needs "failed once, worked next time" cannot get + /// it from `failing_call` alone, which is fixed at construction. + pub fn stop_failing(&self, what: &'static str) { + self.failures.lock().expect("lock").remove(what); + } + + /// The attestations submitted so far, in submission order. + pub fn submitted(&self) -> Vec { + self.submitted.lock().expect("lock").clone() + } + + /// The `fork_name` of the most recent `submit_attestations` call. + pub fn last_submitted_fork_name(&self) -> Option { + self.submitted_fork_names + .lock() + .expect("lock") + .last() + .cloned() + } + + /// The committee subscriptions submitted so far, in submission order. + pub fn subscriptions(&self) -> Vec { + self.subscriptions.lock().expect("lock").clone() + } + + /// The proposer preparations submitted so far, in submission order. + pub fn preparations(&self) -> Vec { + self.preparations.lock().expect("lock").clone() + } + + /// How many times `attester_duties` has been called, successes and + /// failures alike. + pub fn duties_call_count(&self) -> usize { + *self.duties_calls.lock().expect("lock") + } + + /// How many times `validator_indices` has been called, successes and + /// failures alike. + pub fn validator_indices_call_count(&self) -> usize { + *self.validator_indices_calls.lock().expect("lock") + } + + /// How many times `proposer_duties` has been called, successes and + /// failures alike. + pub fn proposer_duties_call_count(&self) -> usize { + *self.proposer_duties_calls.lock().expect("lock") + } + + /// How many times `attestation_data` has been called, successes and + /// failures alike. + pub fn attestation_data_call_count(&self) -> usize { + *self.attestation_data_calls.lock().expect("lock") + } + + fn guard(&self, what: &'static str) -> Result<()> { + if let Some(detail) = self.failures.lock().expect("lock").get(what) { + return Err(Error::BeaconNode { + url: "mock".to_string(), + failure: BeaconNodeFailure::Request, + detail: detail.clone(), + }); + } + if let Some(detail) = &self.fail_everything { + return Err(Error::BeaconNode { + url: "mock".to_string(), + failure: BeaconNodeFailure::Request, + detail: detail.clone(), + }); + } + Ok(()) + } +} + +#[async_trait] +impl BeaconNodeApi for MockBeaconNode { + async fn genesis(&self) -> Result { + self.guard("genesis")?; + self.genesis.ok_or_else(|| Error::BeaconNode { + url: "mock".to_string(), + failure: BeaconNodeFailure::Request, + detail: "no genesis set".into(), + }) + } + + async fn spec(&self) -> Result { + self.guard("spec")?; + Ok(Config::mainnet()) + } + + async fn is_optimistic_or_syncing(&self) -> Result { + self.guard("is_syncing")?; + Ok(self.syncing || self.optimistic) + } + + async fn validator_indices(&self, pubkeys: &[BlsPubkey]) -> Result> { + self.guard("validator_indices")?; + *self.validator_indices_calls.lock().expect("lock") += 1; + // Honours the same contract every real implementation does, so a test + // cannot pass here and ask a live node for the whole registry. + if pubkeys.is_empty() { + return Ok(Vec::new()); + } + Ok(self.validators.clone()) + } + + async fn attester_duties( + &self, + epoch: Epoch, + _indices: &[ValidatorIndex], + ) -> Result { + self.guard("attester_duties")?; + *self.duties_calls.lock().expect("lock") += 1; + self.duties + .lock() + .expect("lock") + .iter() + .find(|(stored, _)| *stored == epoch) + .map(|(_, duties)| duties.clone()) + .ok_or_else(|| Error::BeaconNode { + url: "mock".to_string(), + failure: BeaconNodeFailure::Request, + detail: format!("no duties for epoch {epoch}"), + }) + } + + async fn proposer_duties(&self, epoch: Epoch) -> Result { + self.guard("proposer_duties")?; + *self.proposer_duties_calls.lock().expect("lock") += 1; + self.proposers + .lock() + .expect("lock") + .iter() + .find(|(stored, _)| *stored == epoch) + .map(|(_, duties)| duties.clone()) + .ok_or_else(|| Error::BeaconNode { + url: "mock".to_string(), + failure: BeaconNodeFailure::Request, + detail: format!("no proposer duties for epoch {epoch}"), + }) + } + + async fn attestation_data(&self, slot: Slot) -> Result { + self.guard("attestation_data")?; + *self.attestation_data_calls.lock().expect("lock") += 1; + let data = self.attestation_data.ok_or_else(|| Error::BeaconNode { + url: "mock".to_string(), + failure: BeaconNodeFailure::Request, + detail: "no attestation data set".into(), + })?; + // The mock honours the same contract every real implementation does + // (see `BeaconNodeApi::attestation_data`), so a test double configured + // with data for another slot behaves like a node stuck on a stale + // head: it errors, and failover moves past it. Without this the mock + // would be a more permissive node than any real one, and tests built + // on it would not reflect what happens in production. + crate::beacon_node::validate_attestation_data(slot, &data)?; + Ok(data) + } + + /// Honours the same contract every real implementation does (see + /// `BeaconNodeApi::produce_block`), so a mock configured for another slot + /// or another proposer behaves like a node on a stale head: it errors, and + /// failover moves past it. + async fn produce_block(&self, request: &BlockRequest) -> Result { + self.guard("produce_block")?; + self.block_requests + .lock() + .expect("lock") + .push(request.clone()); + let (slot, proposer_index) = self.produces_block.ok_or_else(|| Error::BeaconNode { + url: "mock".to_string(), + failure: BeaconNodeFailure::Request, + detail: "no block set".into(), + })?; + // Electra, matching the block shape `empty_block_for` builds. A test + // that needs a fork mismatch constructs one itself rather than getting + // it by accident here. + let block = ProducedBlock { + fork: ForkName::Electra, + contents: Contents::WithBlobProofs { + block: empty_block_for(slot, proposer_index), + kzg_proofs: Default::default(), + blobs: Default::default(), + }, + }; + crate::beacon_node::validate_produced_block(request, &block)?; + Ok(block) + } + + async fn publish_block(&self, fork: ForkName, body: &[u8]) -> Result { + self.guard("publish_block")?; + self.published_blocks + .lock() + .expect("lock") + .push((fork, body.to_vec())); + Ok(self.publish_outcome.unwrap_or(Published::Imported)) + } + + async fn submit_attestations( + &self, + attestations: &[SingleAttestationDto], + fork_name: &str, + ) -> Result { + self.guard("submit_attestations")?; + self.submitted + .lock() + .expect("lock") + .extend_from_slice(attestations); + self.submitted_fork_names + .lock() + .expect("lock") + .push(fork_name.to_string()); + Ok(attestations.len()) + } + + /// Honours the slot contract the real implementation does, so a mock + /// configured for another slot behaves like a node on a stale head. + async fn aggregate_attestation( + &self, + slot: Slot, + attestation_data_root: Root, + committee_index: u64, + ) -> Result { + self.guard("aggregate_attestation")?; + self.aggregate_requests.lock().expect("lock").push(( + slot, + attestation_data_root, + committee_index, + )); + + let data = self.aggregate.ok_or_else(|| Error::BeaconNodeStatus { + status: 404, + body: "no aggregate available".to_string(), + })?; + if data.slot != slot { + return Err(Error::InconsistentResponse(format!( + "requested an aggregate for slot {slot}, node answered for slot {}", + data.slot + ))); + } + Ok(AggregateAttestation { + fork: ForkName::Electra, + attestation: Attestation { + aggregation_bits: Default::default(), + data, + signature: Default::default(), + committee_bits: Default::default(), + }, + }) + } + + async fn publish_aggregates( + &self, + fork: ForkName, + aggregates: &[SignedAggregateAndProof], + ) -> Result<()> { + self.guard("publish_aggregates")?; + self.published_aggregates + .lock() + .expect("lock") + .push((fork, aggregates.to_vec())); + Ok(()) + } + + async fn prepare_beacon_proposer(&self, preparations: &[ProposerPreparationDto]) -> Result<()> { + self.guard("prepare_beacon_proposer")?; + self.preparations + .lock() + .expect("lock") + .extend(preparations.iter().cloned()); + Ok(()) + } + + async fn subscribe_committees(&self, subscriptions: &[CommitteeSubscriptionDto]) -> Result<()> { + self.guard("subscribe_committees")?; + self.subscriptions + .lock() + .expect("lock") + .extend_from_slice(subscriptions); + Ok(()) + } +} diff --git a/crates/validator/src/beacon_node/mod.rs b/crates/validator/src/beacon_node/mod.rs new file mode 100644 index 000000000..06d68e29a --- /dev/null +++ b/crates/validator/src/beacon_node/mod.rs @@ -0,0 +1,378 @@ +//! Everything this client knows about the chain arrives through here. +//! +//! One trait, so the duty services can be driven by a mock with no network and +//! no beacon node. That is the whole reason it exists: the alternative, calling +//! `reqwest` from inside the duty logic, makes the interesting behaviour +//! (a reorg invalidating duties, a node failing mid-slot) untestable. + +use async_trait::async_trait; +use ethlambda_types::beacon::config::Config; +use ethlambda_types::beacon::containers::shared::AttestationData; +use ethlambda_types::beacon::fork::ForkName; +use ethlambda_types::beacon::primitives::{ + BlsPubkey, BlsSignature, Bytes32, Epoch, Root, Slot, ValidatorIndex, +}; +use ethlambda_types::beacon::signing::compute_epoch_at_slot; + +use crate::beacon_node::block_contents::ProducedBlock; +use crate::beacon_node::dto::{ + AttesterDutyDto, CommitteeSubscriptionDto, ProposerDutyDto, ProposerPreparationDto, + SingleAttestationDto, +}; +use crate::error::Result; +use ethlambda_types::beacon::containers::electra::{Attestation, SignedAggregateAndProof}; + +pub mod block_contents; +pub mod dto; +pub mod fallback; +pub mod http; + +#[cfg(test)] +pub mod mock; + +/// Check that `data` is an answer about `slot`, as the contract on +/// [`BeaconNodeApi::attestation_data`] requires. +/// +/// One function rather than two copies, because it is enforced in two places +/// for two different reasons and they must not drift apart. Each +/// implementation calls it so a wrong answer becomes an `Err` that failover +/// can act on, and [`crate::attestation::AttestationService`] calls it again +/// before signing, since the trait is public and nothing stops an +/// implementation from being wrong. +/// +/// Both fields are checked because both are signable. `slot` is the obvious +/// one. `target.epoch` matters independently: it selects the signing domain +/// (see `SigningContext::attestation_signing_root`), so a node answering with +/// the right slot and a wrong target epoch is a second, distinct way to make +/// this client sign something it should not. +pub fn validate_attestation_data(slot: Slot, data: &AttestationData) -> Result<()> { + if data.slot != slot { + return Err(crate::error::Error::InconsistentResponse(format!( + "requested attestation data for slot {slot}, node answered for slot {}", + data.slot + ))); + } + let expected_target_epoch = compute_epoch_at_slot(slot); + if data.target.epoch != expected_target_epoch { + return Err(crate::error::Error::InconsistentResponse(format!( + "attestation data for slot {slot} carries target epoch {}, expected \ + {expected_target_epoch}", + data.target.epoch + ))); + } + Ok(()) +} + +/// The chain's genesis, as the beacon node reports it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Genesis { + pub genesis_time: u64, + pub genesis_validators_root: Root, +} + +/// One epoch's duties of some kind, with the block root the schedule depends +/// on. +/// +/// One envelope for both duty kinds, because the API uses one: a duties +/// response is `dependent_root` plus `data`, whatever `data` holds. What +/// differs is what the root *means*, and that difference matters enough to +/// state here. +/// +/// For attester duties it is the block at the last slot of `epoch - 2`, so a +/// schedule survives any reorg shallower than two epochs. For proposer duties +/// it is the block at the last slot of `epoch - 1`: the proposer shuffling is +/// fixed a whole epoch later than the committee shuffling, so a proposer +/// schedule is invalidated by far shallower reorgs than an attester one, and a +/// caller must not reason about the two as if they were equally stable. +#[derive(Debug, Clone)] +pub struct Duties { + pub dependent_root: Root, + pub duties: Vec, +} + +/// One epoch's attester duties. See [`Duties`] for what `dependent_root` +/// means here. +pub type AttesterDuties = Duties; + +/// Every proposer for one epoch, not only this client's. The endpoint takes no +/// validator list, so the caller filters. See [`Duties`] for what +/// `dependent_root` means here, and why it is the more fragile of the two. +pub type ProposerDuties = Duties; + +/// Everything `produceBlockV3` needs, and the two facts its answer is checked +/// against. +/// +/// A struct rather than four arguments because two of the fields are only +/// there to be checked, not sent. `proposer_index` is not a query parameter at +/// all: the beacon node derives the proposer from the slot and its own head. +/// It is carried here so the check can live in the implementation, where a +/// wrong answer becomes an `Err` that failover acts on, for exactly the reason +/// spelled out on [`BeaconNodeApi::attestation_data`]. +#[derive(Debug, Clone)] +pub struct BlockRequest { + pub slot: Slot, + /// The validator this client believes proposes `slot`. + pub proposer_index: ValidatorIndex, + /// This proposer's reveal for the slot's epoch, which the node needs + /// before it can build a body. + pub randao_reveal: BlsSignature, + /// Thirty-two bytes consensus never reads. + pub graffiti: Bytes32, +} + +/// What became of a published block. +/// +/// The distinction exists because `publishBlockV2` answers 202 for something +/// that is neither a success nor a failure: the node broadcast the block but +/// could not import it. Collapsing that into `Ok` would report a proposal as +/// clean when the node that made it cannot follow it, which usually means its +/// execution layer is unsynced or the parent is not what this client thought. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Published { + /// 200: broadcast, and in the node's own database. + Imported, + /// 202: broadcast, but the node could not import it. + BroadcastNotImported, +} + +/// Check that `block` is the block that was asked for. +/// +/// The proposal-shaped counterpart to [`validate_attestation_data`], enforced +/// in the implementations for the same reason and with the same consequence +/// if it is not: [`fallback::FallbackBeaconNode`] wraps the call, so a check +/// above it runs only after one node's answer has been accepted, and a node +/// stuck on a stale head is never failed over from. +/// +/// Both fields are checked because both end up signed. The slot selects the +/// signing domain and is the whole of what the proposal guard keys on. The +/// proposer index is not something this client chooses either: a node on a +/// different fork computes a different proposer, and a block naming someone +/// else is one this client's key can only sign uselessly, while still burning +/// the guard's record for that slot. +pub fn validate_produced_block(request: &BlockRequest, block: &ProducedBlock) -> Result<()> { + let block = block.block(); + if block.slot != request.slot { + return Err(crate::error::Error::InconsistentResponse(format!( + "requested a block for slot {}, node produced one for slot {}", + request.slot, block.slot + ))); + } + if block.proposer_index != request.proposer_index { + return Err(crate::error::Error::InconsistentResponse(format!( + "block for slot {} names proposer {}, expected {}", + request.slot, block.proposer_index, request.proposer_index + ))); + } + Ok(()) +} + +/// An aggregate a beacon node folded together, with the fork it named. +/// +/// The fork is carried for the reason [`ProducedBlock`]'s is: publishing has to +/// name the same one back, and with SSZ the response header is the only thing +/// that says which `Attestation` layout the bytes are. +#[derive(Debug, Clone)] +pub struct AggregateAttestation { + pub fork: ForkName, + pub attestation: Attestation, +} + +/// A validator's index and status, as resolved from its public key. +#[derive(Debug, Clone)] +pub struct ValidatorEntry { + pub index: ValidatorIndex, + pub pubkey: BlsPubkey, + pub status: String, +} + +#[async_trait] +pub trait BeaconNodeApi: Send + Sync { + /// The chain's genesis time and validators root, needed to compute slots + /// and signing domains before any duty can be served. + async fn genesis(&self) -> Result; + + /// The network's fork schedule and slot duration, needed to pick the + /// right signing domain for a given epoch and to drive the slot clock. + async fn spec(&self) -> Result; + + /// Whether the node is in a state this client must not sign against. + /// + /// Two states, not one, and the second is the reason this is not called + /// `is_syncing`. A **syncing** node's head is not the network's head, so + /// duties derived from it would be for the wrong chain. An **optimistic** + /// node has a head its execution client has not validated, and the + /// specification is explicit that a validator in that position must not + /// sign: an optimistic validator "MUST NOT produce a block" and "MUST NOT + /// participate in attestation", naming the proposer, attester, selection + /// and aggregate domains. + /// + /// The two are independent. A node can report that it has finished syncing + /// while still tracking an unvalidated head, so checking only the first + /// leaves the second unguarded. + /// + /// Beacon nodes are separately obliged to answer 503 on the duty endpoints + /// while optimistic, and this client maps that to + /// [`crate::error::Error::BeaconNodeSyncing`]. That is a backstop, not the + /// check: it puts correctness entirely in the node's hands for a rule the + /// client is the one bound by. + async fn is_optimistic_or_syncing(&self) -> Result; + + /// Resolves each pubkey to its current validator index and status, so the + /// rest of the client can address validators by index, as the Beacon API + /// does everywhere except this one lookup. + /// + /// # An empty `pubkeys` must not reach the wire + /// + /// The endpoint treats an empty id list as "return **every** validator", + /// which on mainnet is millions of entries. An implementation must answer + /// an empty request with an empty result rather than asking, because the + /// one caller that can pass an empty slice is a client with no keys + /// loaded, and the answer it wants is "none of them", not "all of them". + async fn validator_indices(&self, pubkeys: &[BlsPubkey]) -> Result>; + + /// The attester duties for `indices` in `epoch`, and the block root the + /// schedule was computed against. A reorg past that root invalidates the + /// schedule; the caller is the one that notices, by comparing roots. + async fn attester_duties( + &self, + epoch: Epoch, + indices: &[ValidatorIndex], + ) -> Result; + + /// Every proposer duty in `epoch`, and the block root the schedule was + /// computed against. + /// + /// No validator list, unlike [`Self::attester_duties`]: the endpoint has + /// no request body and answers for the whole epoch, so the caller keeps + /// only the entries naming a validator it holds. + /// + /// The schedule is *not* as durable as an attester one. Its dependent root + /// is only one epoch back rather than two, so a reorg that leaves attester + /// duties untouched can still move a proposer between slots. A caller that + /// treats the two the same way will act on a stale proposer schedule. + async fn proposer_duties(&self, epoch: Epoch) -> Result; + + /// Produce the attestation data for `slot`. + /// + /// No committee index: the specification deprecated that parameter, from + /// Electra on `AttestationData.index` must be zero, and beacon nodes ignore + /// whatever is supplied. Keeping it out means a caller cannot get it wrong. + /// + /// # Contract: the answer is for `slot`, or this is an `Err` + /// + /// An implementation must reject data whose `slot` is not the one asked + /// for, and whose `target.epoch` is not that slot's epoch. Both are + /// signable material, and this client keeps no slashing-protection record, + /// so a node answering about the wrong slot must never reach the signing + /// path. + /// + /// It matters that this is the *implementation's* job rather than the + /// caller's, because of where failover sits. + /// [`fallback::FallbackBeaconNode`] wraps this method: a check performed + /// above it runs after `try_each` has already accepted node 1's answer and + /// returned, so a node stuck on a stale head is seen as a success every + /// slot and the next node is never consulted. Enforced here, a wrong + /// answer is an `Err` that failover treats like any other and moves past. + async fn attestation_data(&self, slot: Slot) -> Result; + + /// Ask the node to build a block for `request.slot`. + /// + /// Returns the block and whatever travelled with it, decoded from SSZ. + /// JSON is not used here: see [`block_contents`] for why a block, alone + /// among everything this client handles, cannot go through the JSON path. + /// + /// # Contract: the answer is for this request, or it is an `Err` + /// + /// An implementation must call [`validate_produced_block`] before + /// returning, so that a node answering about the wrong slot or naming the + /// wrong proposer is failed over from rather than signed for. + /// + /// # Contract: never a blinded block + /// + /// This client does not implement the builder flow, so it cannot publish a + /// blinded block and must not sign one. An implementation must ask for an + /// unblinded block and reject a blinded answer rather than returning + /// something the caller will sign and then be unable to send. + async fn produce_block(&self, request: &BlockRequest) -> Result; + + /// Publish an already-signed block, given the SSZ body and the fork it was + /// produced under. + /// + /// Bytes rather than a container, because the body's shape depends on the + /// fork and the caller has already built the right one; re-deciding that + /// here would mean two places that must agree. + /// + /// Borrowed rather than owned so failover can offer the same body to the + /// next node without copying it. A block with blobs runs to megabytes, and + /// a `Vec` here would be cloned once per configured node on every + /// proposal, whether or not the first one succeeded. + async fn publish_block(&self, fork: ForkName, body: &[u8]) -> Result; + + /// Submits signed attestations to the node's pool, so they reach gossip. + /// `fork_name` names the fork the attestations were produced under, since + /// the endpoint requires it as a header rather than inferring it. + /// + /// Returns how many of `attestations` the node accepted. This can be + /// fewer than `attestations.len()` without the call being an `Err`: the + /// endpoint answers 400 with per-index detail when part of a batch is + /// rejected, and still stores and gossips the rest, so a partial result + /// is success for those entries, not failure for the whole batch. + async fn submit_attestations( + &self, + attestations: &[SingleAttestationDto], + fork_name: &str, + ) -> Result; + + /// The best aggregate the node has for one committee's votes on + /// `attestation_data_root` at `slot`. + /// + /// `committee_index` is separate from the root and not derivable from it. + /// From electra on, `AttestationData.index` is required to be zero, so the + /// root no longer distinguishes one committee's votes from another's in the + /// same slot; the committee moved into the attestation's `committee_bits`. + /// That is exactly why the v1 form of this endpoint was removed rather than + /// deprecated: it had no way to ask the question. + /// + /// A node with nothing to fold answers 404, which the specification makes + /// normative rather than exceptional. That surfaces here as an ordinary + /// error so failover tries the next node, which may have seen the votes + /// this one missed. + async fn aggregate_attestation( + &self, + slot: Slot, + attestation_data_root: Root, + committee_index: u64, + ) -> Result; + + /// Publish signed aggregates produced under `fork`. + /// + /// Typed rather than pre-encoded bytes, unlike [`Self::publish_block`], + /// because the encoding is the implementation's call here: Lighthouse + /// refuses an SSZ body on this endpoint with 415, so the HTTP client sends + /// JSON. A block has no such problem and a block's JSON form is the large + /// hand-written mapping this crate avoids, which is why the two differ. + /// + /// The endpoint takes a list even for one aggregate, so this does too. + async fn publish_aggregates( + &self, + fork: ForkName, + aggregates: &[SignedAggregateAndProof], + ) -> Result<()>; + + /// Tells the node where to pay each validator's execution-layer block + /// rewards, so it has somewhere to send them when it builds a payload. + /// + /// Must be re-sent periodically. A node keeps a preparation for the epoch + /// it arrived in and two more, and forgets all of them when it restarts, so + /// a client that sends this once at startup stops being registered a few + /// minutes later without anything reporting it. + /// + /// The node is not obliged to honour it. The specification says so + /// outright, which is why a produced block's fee recipient is checked + /// before it is signed rather than assumed. + async fn prepare_beacon_proposer(&self, preparations: &[ProposerPreparationDto]) -> Result<()>; + + /// Tells the node which committees this client's validators care about + /// this epoch, so it can manage subnet subscriptions on their behalf. + async fn subscribe_committees(&self, subscriptions: &[CommitteeSubscriptionDto]) -> Result<()>; +} diff --git a/crates/validator/src/duties.rs b/crates/validator/src/duties.rs new file mode 100644 index 000000000..5f05c7ab6 --- /dev/null +++ b/crates/validator/src/duties.rs @@ -0,0 +1,608 @@ +//! What this client's validators are scheduled to do, and when. +//! +//! Holds one epoch's attester duties at a time, plus a lookahead epoch, and +//! discards them when the beacon node reports a different `dependent_root`: a +//! reorg deeper than `MIN_SEED_LOOKAHEAD` changes the committee shuffling, and +//! acting on the old schedule would attest from the wrong committee. +//! +//! Proposer duties are held alongside them, in a separate map and on +//! deliberately different terms. Three differences, all of them consequences of +//! the proposer shuffling being fixed a whole epoch later than the committee +//! shuffling: +//! +//! - **No lookahead.** Asking for the next epoch's proposers returns an answer +//! that the end of this epoch will rewrite, so holding it would be holding a +//! guess. Attester duties for the next epoch are already settled, which is +//! why those *are* prefetched. +//! - **Filtered on arrival.** The endpoint answers for every proposer in the +//! epoch, not for a submitted list, so all but this client's own entries are +//! dropped as they come in rather than at every lookup. +//! - **A change here is not a change for subscriptions.** Subnet subscriptions +//! follow committees, and a proposer has none. + +use std::collections::HashMap; +use std::sync::Arc; + +use ethlambda_types::beacon::constants::GENESIS_SLOT; +use ethlambda_types::beacon::primitives::{Epoch, Root, Slot, ValidatorIndex}; +use tracing::{info, warn}; + +use crate::beacon_node::BeaconNodeApi; +use crate::beacon_node::dto::{AttesterDutyDto, ProposerDutyDto}; +use crate::error::Result; + +/// One epoch's schedule of some kind, and the block root it is derived from. +#[derive(Debug, Clone)] +struct EpochDuties { + dependent_root: Root, + duties: Vec, +} + +pub struct DutiesService { + beacon_node: Arc, + /// Validator indices this client signs for, resolved from public keys. + indices: Vec, + by_epoch: HashMap>, + /// Proposer duties, already narrowed to `indices`. See the module doc for + /// why this is a second map rather than another field on the first. + proposers: HashMap>, + /// Whether the "no validator indices resolved" warning has already fired. + /// Without this, an idle client would repeat it every epoch forever; with + /// it, the operator still gets exactly one signal that duties are not + /// being fetched, rather than silent, permanent success. + warned_no_indices: bool, +} + +impl DutiesService { + pub fn new(beacon_node: Arc, indices: Vec) -> Self { + Self { + beacon_node, + indices, + by_epoch: HashMap::new(), + proposers: HashMap::new(), + warned_no_indices: false, + } + } + + pub fn set_indices(&mut self, indices: Vec) { + // Re-arm the warning: if indices are cleared again later, that is a + // fresh regression worth reporting, not a continuation of the first. + if !indices.is_empty() { + self.warned_no_indices = false; + } + self.indices = indices; + } + + pub fn indices(&self) -> &[ValidatorIndex] { + &self.indices + } + + /// Fetch `epoch`'s duties, replacing anything held for it if the beacon + /// node's `dependent_root` has changed. + /// + /// Returns whether the schedule changed, which is what tells the caller to + /// re-send subnet subscriptions. + pub async fn refresh(&mut self, epoch: Epoch) -> Result { + if self.indices.is_empty() { + // Otherwise indistinguishable from a correctly idle node: without + // this, a client that never resolved any validators attests + // nothing, forever, and reports success the whole time. + if !self.warned_no_indices { + warn!("No validator indices resolved; attester duties will not be fetched"); + self.warned_no_indices = true; + } + return Ok(false); + } + + let fetched = self + .beacon_node + .attester_duties(epoch, &self.indices) + .await?; + + let changed = match self.by_epoch.get(&epoch) { + Some(held) if held.dependent_root == fetched.dependent_root => false, + Some(held) => { + warn!( + %epoch, + held = %hex::encode(&held.dependent_root.0[..4]), + fetched = %hex::encode(&fetched.dependent_root.0[..4]), + "Attester duties invalidated by a reorg; replacing the schedule" + ); + true + } + None => true, + }; + + if changed { + info!(%epoch, count = fetched.duties.len(), "Attester duties updated"); + self.by_epoch.insert( + epoch, + EpochDuties { + dependent_root: fetched.dependent_root, + duties: fetched.duties, + }, + ); + } + + Ok(changed) + } + + /// Fetch `epoch`'s proposer duties and keep only this client's, replacing + /// anything held for that epoch if the `dependent_root` has changed. + /// + /// Returns nothing a caller acts on, unlike [`Self::refresh`]: a proposer + /// schedule changing has no subscription consequence, because subnet + /// subscriptions follow committees and a proposer has none. + /// + /// The filter is the reason the fetched list is not stored as it arrives. + /// A mainnet epoch names thirty-two proposers and this client typically + /// holds none of them, so keeping the whole list would mean storing + /// thirty-two entries an epoch to answer "not us" with, and re-deciding + /// that at every slot lookup instead of once here. + pub async fn refresh_proposers(&mut self, epoch: Epoch) -> Result<()> { + if self.indices.is_empty() { + // Silent, unlike the attester path's one-shot warning: that + // warning already fired for the same cause, and saying it twice + // per epoch would make the log read as two separate faults. + return Ok(()); + } + + let fetched = self.beacon_node.proposer_duties(epoch).await?; + + let unchanged = self + .proposers + .get(&epoch) + .is_some_and(|held| held.dependent_root == fetched.dependent_root); + if unchanged { + return Ok(()); + } + + // The genesis slot is dropped along with other validators' entries. + // The endpoint lists a proposer for every slot of the epoch, slot 0 + // included, but slot 0 holds the genesis block and nobody proposes + // there: asking a beacon node for a block at it is refused outright + // (Lighthouse answers `SlotOutOfBounds`). It only ever comes up once in + // a chain's life, which is exactly why it is easy to miss. + let mine: Vec = fetched + .duties + .into_iter() + .filter(|duty| duty.slot != GENESIS_SLOT) + .filter(|duty| self.indices.contains(&duty.validator_index)) + .collect(); + + // Logged at info only when there is something to do, and at debug + // otherwise. A client with a handful of validators proposes a few times + // a day, so an info line every epoch saying "none of ours" would bury + // the one that says otherwise. + if mine.is_empty() { + tracing::debug!(%epoch, "No proposer duties this epoch"); + } else { + for duty in &mine { + info!( + slot = duty.slot, + validator = duty.validator_index, + %epoch, + "Proposer duty scheduled" + ); + } + } + + self.proposers.insert( + epoch, + EpochDuties { + dependent_root: fetched.dependent_root, + duties: mine, + }, + ); + Ok(()) + } + + /// This client's proposer duty for `slot`, if it has one. + /// + /// At most one, and not because of any filtering here: a slot has exactly + /// one proposer, so two entries would mean the beacon node contradicted + /// itself. + pub fn proposer_at_slot(&self, slot: Slot, epoch: Epoch) -> Option<&ProposerDutyDto> { + self.proposers + .get(&epoch)? + .duties + .iter() + .find(|duty| duty.slot == slot) + } + + /// Refresh the current epoch and the one after it, and forget anything + /// older. The lookahead is what lets subnet subscriptions be sent before + /// the duty slot arrives. + /// + /// A failed lookahead fetch is logged and shrugged off rather than + /// propagated: it is speculative, so losing it must never mask a genuine + /// change reported for the current epoch, which is the one that actually + /// has a duty due. Pruning with `>= current_epoch` never evicts an epoch + /// above the current one, so a clock stepping backwards costs at most a + /// re-fetch, never the loss of an already-held future schedule. Each + /// `refresh` call fetches fully before mutating `by_epoch`, so a failure + /// never leaves a partial schedule behind either: this module is + /// recoverable by construction, not by explicit retry logic, and the next + /// call simply tries again. + pub async fn refresh_around(&mut self, current_epoch: Epoch) -> Result { + let mut changed = self.refresh(current_epoch).await?; + + match self.refresh(current_epoch + 1).await { + Ok(lookahead_changed) => changed |= lookahead_changed, + Err(err) => warn!( + epoch = current_epoch + 1, + %err, + "Lookahead duties fetch failed; keeping the current epoch's result" + ), + } + + // The current epoch only, and never the lookahead: next epoch's + // proposers are not decided until this one ends, so an answer now is a + // guess that would be overwritten anyway. + // + // Shrugged off like the lookahead rather than propagated, and for a + // sharper reason: the attester schedule for this epoch has already been + // fetched successfully by the time this runs, and returning an error + // here would throw that away and leave the caller holding the previous + // epoch's attester duties. Losing the proposer schedule costs at most a + // block; losing the attester one costs every validator's vote, every + // slot, until the next refresh succeeds. + if let Err(err) = self.refresh_proposers(current_epoch).await { + warn!( + epoch = current_epoch, + %err, + "Proposer duties fetch failed; this epoch's blocks will not be proposed" + ); + } + + self.by_epoch.retain(|epoch, _| *epoch >= current_epoch); + self.proposers.retain(|epoch, _| *epoch >= current_epoch); + Ok(changed) + } + + /// The duties to perform at `slot`. + pub fn at_slot(&self, slot: Slot, epoch: Epoch) -> Vec { + self.by_epoch + .get(&epoch) + .map(|held| { + held.duties + .iter() + .filter(|duty| duty.slot == slot) + .cloned() + .collect() + }) + .unwrap_or_default() + } + + /// Every duty held, across the epochs currently loaded. + pub fn all(&self) -> Vec { + self.by_epoch + .values() + .flat_map(|held| held.duties.iter().cloned()) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::beacon_node::mock::MockBeaconNode; + + fn duty(validator_index: ValidatorIndex, slot: Slot, committee_index: u64) -> AttesterDutyDto { + AttesterDutyDto { + pubkey: "0x00".to_string(), + validator_index, + committee_index, + committee_length: 128, + committees_at_slot: 64, + validator_committee_index: 7, + slot, + } + } + + fn root(byte: u8) -> Root { + Root::repeat_byte(byte) + } + + fn proposer(validator_index: ValidatorIndex, slot: Slot) -> ProposerDutyDto { + ProposerDutyDto { + pubkey: "0x00".to_string(), + validator_index, + slot, + } + } + + #[tokio::test] + async fn duties_are_fetched_and_indexed_by_slot() { + let node = MockBeaconNode::new().with_duties(3, root(1), vec![duty(1337, 96, 2)]); + let mut service = DutiesService::new(Arc::new(node), vec![1337]); + + assert!(service.refresh(3).await.expect("refreshes")); + let at_96 = service.at_slot(96, 3); + assert_eq!(at_96.len(), 1); + assert_eq!(at_96[0].validator_index, 1337); + assert!(service.at_slot(97, 3).is_empty()); + } + + #[tokio::test] + async fn an_unchanged_dependent_root_is_not_a_change() { + let node = MockBeaconNode::new().with_duties(3, root(1), vec![duty(1337, 96, 2)]); + let mut service = DutiesService::new(Arc::new(node), vec![1337]); + + assert!( + service.refresh(3).await.expect("first"), + "first fetch is a change" + ); + assert!( + !service.refresh(3).await.expect("second"), + "the same dependent_root must not count as a change" + ); + } + + #[tokio::test] + async fn a_changed_dependent_root_replaces_the_schedule() { + let node = Arc::new(MockBeaconNode::new().with_duties(3, root(1), vec![duty(1337, 96, 2)])); + let mut service = DutiesService::new(node.clone(), vec![1337]); + service.refresh(3).await.expect("first"); + + // The beacon node now reports a different dependent root and a + // different committee, as it would after a deep reorg. `set_duties` + // replaces rather than appends, which is what makes the same epoch + // answer differently on the second call. + node.set_duties(3, root(2), vec![duty(1337, 96, 55)]); + + assert!( + service.refresh(3).await.expect("second"), + "must report a change" + ); + assert_eq!(service.at_slot(96, 3)[0].committee_index, 55); + } + + #[tokio::test] + async fn with_no_validators_nothing_is_fetched() { + let node = Arc::new(MockBeaconNode::new()); + let mut service = DutiesService::new(node.clone(), vec![]); + assert!(!service.refresh(3).await.expect("no-op")); + assert_eq!(node.duties_call_count(), 0); + } + + #[tokio::test] + async fn refreshing_around_an_epoch_forgets_the_previous_one() { + let node = MockBeaconNode::new() + .with_duties(3, root(1), vec![duty(1337, 96, 2)]) + .with_duties(4, root(2), vec![duty(1337, 130, 2)]) + .with_duties(5, root(3), vec![duty(1337, 165, 2)]); + let mut service = DutiesService::new(Arc::new(node), vec![1337]); + + service.refresh_around(3).await.expect("epoch 3"); + assert!(!service.at_slot(96, 3).is_empty()); + + service.refresh_around(4).await.expect("epoch 4"); + assert!( + service.at_slot(96, 3).is_empty(), + "epoch 3 must have been dropped" + ); + assert!(!service.at_slot(130, 4).is_empty()); + } + + #[tokio::test] + async fn refresh_around_prefetches_the_lookahead_epoch() { + let node = MockBeaconNode::new() + .with_duties(3, root(1), vec![duty(1337, 96, 2)]) + .with_duties(4, root(2), vec![duty(1337, 130, 2)]); + let mut service = DutiesService::new(Arc::new(node), vec![1337]); + + service.refresh_around(3).await.expect("epoch 3"); + + // Epoch 4 must already be held before it is ever the current epoch: + // that is the entire point of the lookahead, to have the schedule in + // hand before the duty slot arrives. + assert!( + !service.at_slot(130, 4).is_empty(), + "the lookahead epoch must have been fetched alongside the current one" + ); + } + + #[tokio::test] + async fn at_slot_is_scoped_to_the_requested_epoch() { + // Two epochs holding a duty at the same slot number, for different + // validators: `at_slot` must not blur the epoch argument away and + // search every held epoch for a match. + let node = MockBeaconNode::new() + .with_duties(3, root(1), vec![duty(1337, 96, 2)]) + .with_duties(4, root(2), vec![duty(7, 96, 9)]); + let mut service = DutiesService::new(Arc::new(node), vec![1337, 7]); + + service.refresh(3).await.expect("epoch 3"); + service.refresh(4).await.expect("epoch 4"); + + let at_epoch_3 = service.at_slot(96, 3); + assert_eq!(at_epoch_3.len(), 1); + assert_eq!(at_epoch_3[0].validator_index, 1337); + + let at_epoch_4 = service.at_slot(96, 4); + assert_eq!(at_epoch_4.len(), 1); + assert_eq!(at_epoch_4[0].validator_index, 7); + } + + /// The endpoint answers for every proposer in the epoch, so the filter is + /// the whole point: a mainnet epoch names thirty-two and this client holds + /// none of them on a typical day. + #[tokio::test] + async fn proposer_duties_are_narrowed_to_this_clients_validators() { + let node = MockBeaconNode::new().with_proposers( + 3, + root(1), + vec![proposer(1337, 96), proposer(42, 97), proposer(1337, 98)], + ); + let mut service = DutiesService::new(Arc::new(node), vec![1337]); + service.refresh_proposers(3).await.expect("refreshes"); + + assert_eq!( + service.proposer_at_slot(96, 3).map(|d| d.validator_index), + Some(1337) + ); + assert!( + service.proposer_at_slot(97, 3).is_none(), + "another validator's slot must not be kept" + ); + assert_eq!( + service.proposer_at_slot(98, 3).map(|d| d.validator_index), + Some(1337) + ); + } + + #[tokio::test] + async fn a_slot_with_no_proposer_duty_of_ours_answers_none() { + let node = MockBeaconNode::new().with_proposers(3, root(1), vec![proposer(1337, 96)]); + let mut service = DutiesService::new(Arc::new(node), vec![1337]); + service.refresh_proposers(3).await.expect("refreshes"); + + assert!(service.proposer_at_slot(97, 3).is_none()); + // Right slot, wrong epoch: the lookup must be scoped, not blurred. + assert!(service.proposer_at_slot(96, 4).is_none()); + } + + #[tokio::test] + async fn an_unchanged_proposer_dependent_root_keeps_the_held_schedule() { + let node = + Arc::new(MockBeaconNode::new().with_proposers(3, root(1), vec![proposer(1337, 96)])); + let mut service = DutiesService::new(node.clone(), vec![1337]); + service.refresh_proposers(3).await.expect("first"); + + // Same dependent root, different content. A node that answers this way + // is contradicting itself, and the held schedule must win: the root is + // what the schedule is keyed on. + node.set_proposers(3, root(1), vec![proposer(1337, 99)]); + service.refresh_proposers(3).await.expect("second"); + + assert!(service.proposer_at_slot(96, 3).is_some()); + assert!(service.proposer_at_slot(99, 3).is_none()); + } + + #[tokio::test] + async fn a_changed_proposer_dependent_root_replaces_the_schedule() { + let node = + Arc::new(MockBeaconNode::new().with_proposers(3, root(1), vec![proposer(1337, 96)])); + let mut service = DutiesService::new(node.clone(), vec![1337]); + service.refresh_proposers(3).await.expect("first"); + + // A reorg past the last slot of epoch 2 re-shuffles this epoch's + // proposers, which is a far shallower reorg than the one that would + // move an attester. + node.set_proposers(3, root(2), vec![proposer(1337, 99)]); + service.refresh_proposers(3).await.expect("second"); + + assert!( + service.proposer_at_slot(96, 3).is_none(), + "the old slot must be gone" + ); + assert!(service.proposer_at_slot(99, 3).is_some()); + } + + /// The reason the proposer fetch is shrugged off inside `refresh_around` + /// rather than propagated. By the time it runs, this epoch's attester + /// duties are already in hand; returning an error would discard them and + /// leave the caller attesting on the previous epoch's schedule. A lost + /// block is cheaper than every validator's vote, every slot, until the next + /// refresh succeeds. + #[tokio::test] + async fn a_failed_proposer_fetch_does_not_cost_the_attester_schedule() { + let node = MockBeaconNode::new() + .with_duties(3, root(1), vec![duty(1337, 96, 2)]) + .with_duties(4, root(2), vec![duty(1337, 130, 2)]) + .failing_call("proposer_duties", "node is unhappy"); + let mut service = DutiesService::new(Arc::new(node), vec![1337]); + + service + .refresh_around(3) + .await + .expect("a failed proposer fetch must not fail the refresh"); + assert!( + !service.at_slot(96, 3).is_empty(), + "the attester schedule must have survived" + ); + assert!(service.proposer_at_slot(96, 3).is_none()); + } + + /// Proposer duties for the next epoch are not decided until this one ends, + /// so prefetching them would hold a guess that the epoch boundary + /// overwrites. Attester duties *are* prefetched, which is why this has to + /// be asserted rather than assumed. + #[tokio::test] + async fn proposer_duties_are_not_prefetched_for_the_lookahead_epoch() { + let node = Arc::new( + MockBeaconNode::new() + .with_duties(3, root(1), vec![duty(1337, 96, 2)]) + .with_duties(4, root(2), vec![duty(1337, 130, 2)]) + .with_proposers(3, root(1), vec![proposer(1337, 96)]) + .with_proposers(4, root(2), vec![proposer(1337, 130)]), + ); + let mut service = DutiesService::new(node.clone(), vec![1337]); + service.refresh_around(3).await.expect("epoch 3"); + + assert_eq!( + node.proposer_duties_call_count(), + 1, + "exactly one proposer fetch, for the current epoch" + ); + assert!(service.proposer_at_slot(96, 3).is_some()); + assert!( + service.proposer_at_slot(130, 4).is_none(), + "the lookahead epoch's proposers must not have been fetched" + ); + } + + #[tokio::test] + async fn refreshing_around_an_epoch_forgets_the_previous_ones_proposers() { + let node = MockBeaconNode::new() + .with_duties(3, root(1), vec![duty(1337, 96, 2)]) + .with_duties(4, root(2), vec![duty(1337, 130, 2)]) + .with_duties(5, root(3), vec![duty(1337, 165, 2)]) + .with_proposers(3, root(1), vec![proposer(1337, 96)]) + .with_proposers(4, root(2), vec![proposer(1337, 130)]); + let mut service = DutiesService::new(Arc::new(node), vec![1337]); + + service.refresh_around(3).await.expect("epoch 3"); + assert!(service.proposer_at_slot(96, 3).is_some()); + + service.refresh_around(4).await.expect("epoch 4"); + assert!( + service.proposer_at_slot(96, 3).is_none(), + "epoch 3's proposers must have been pruned" + ); + assert!(service.proposer_at_slot(130, 4).is_some()); + } + + /// Found on a devnet where this client held every validator: the duties + /// list named one of them for slot 0, the client asked for a block there, + /// and the beacon node refused it. Slot 0 is the genesis block; there is + /// no proposal to make. + #[tokio::test] + async fn a_duty_at_the_genesis_slot_is_not_scheduled() { + let node = MockBeaconNode::new().with_proposers( + 0, + root(1), + vec![proposer(1337, 0), proposer(1337, 3)], + ); + let mut service = DutiesService::new(Arc::new(node), vec![1337]); + service.refresh_proposers(0).await.expect("refreshes"); + + assert!( + service.proposer_at_slot(0, 0).is_none(), + "nobody proposes at the genesis slot" + ); + assert!( + service.proposer_at_slot(3, 0).is_some(), + "the rest of epoch 0 is still scheduled" + ); + } + + #[tokio::test] + async fn with_no_validators_no_proposer_duties_are_fetched() { + let node = Arc::new(MockBeaconNode::new()); + let mut service = DutiesService::new(node.clone(), vec![]); + service.refresh_proposers(3).await.expect("no-op"); + assert_eq!(node.proposer_duties_call_count(), 0); + } +} diff --git a/crates/validator/src/error.rs b/crates/validator/src/error.rs new file mode 100644 index 000000000..55b82a0ff --- /dev/null +++ b/crates/validator/src/error.rs @@ -0,0 +1,144 @@ +//! One error type for the crate. +//! +//! Variants are grouped by which layer raised them, because the recovery is +//! decided per layer: a beacon-node error means try the next node or skip the +//! duty, a keystore error at startup is fatal, and a signing error affects one +//! validator and never the rest. `Decode` sits with the beacon-node variants: +//! a response that fails to parse, or parses but makes no sense, is a symptom +//! of the node that sent it, not a separate layer. `Io` is deliberately +//! cross-cutting, since reading a keystore, a validator definitions file, or +//! any other config can fail this way; it carries the path so the caller does +//! not have to guess which file it was. + +use ethlambda_types::beacon::primitives::BlsPubkey; + +/// The EIP-2335 keystore schema version this client understands. +pub const EIP2335_KEYSTORE_VERSION: u64 = 4; + +/// Why a beacon node request failed, classified at construction. +/// +/// Failover and the duty loop need to tell a node that is down from one that +/// is merely slow. The concrete `reqwest::Error` is not carried in [`Error`] +/// because a test mock implementing `BeaconNodeApi` has to be able to +/// construct the same variant without ever holding a real HTTP error. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BeaconNodeFailure { + /// The request outlived its timeout. + Timeout, + /// The connection could not be established. + Connect, + /// The request could not be built or sent. + Request, + /// The response body could not be read. + Body, +} + +impl BeaconNodeFailure { + /// Classify a `reqwest` failure. + pub fn classify(error: &reqwest::Error) -> Self { + if error.is_timeout() { + Self::Timeout + } else if error.is_connect() { + Self::Connect + } else if error.is_body() || error.is_decode() { + Self::Body + } else { + Self::Request + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("beacon node {url} failed ({failure:?}): {detail}")] + BeaconNode { + url: String, + failure: BeaconNodeFailure, + detail: String, + }, + #[error("every configured beacon node failed; last error: {0}")] + AllBeaconNodesFailed(String), + #[error("beacon node returned {status}: {body}")] + BeaconNodeStatus { status: u16, body: String }, + #[error("beacon node is syncing")] + BeaconNodeSyncing, + /// The slot's attestation work ran past the end of the slot and was + /// abandoned. + /// + /// Distinct from the beacon-node failures above even though a hung node is + /// the usual cause: those describe one request, this describes the duty as + /// a whole giving up. Retryable, in the sense that the next slot's duty + /// should still be attempted, which is all `is_retryable` governs; the + /// abandoned attestation itself is never retried, by design. + #[error("attestation duty for slot {slot} ran past the end of its slot")] + AttestationDeadline { slot: u64 }, + /// The proposal guard refused to sign a block for this slot. + /// + /// Not a beacon-node failure and not retryable: the whole point of a + /// refusal is that trying again produces the same answer. It is an error + /// rather than a quiet `Ok` because a refusal means the duty loop asked for + /// something it should not have, which is worth surfacing even though the + /// block was correctly suppressed. + #[error("refused to propose a block for slot {slot}: {reason}")] + ProposalRefused { slot: u64, reason: String }, + #[error("beacon node returned an inconsistent response: {0}")] + InconsistentResponse(String), + #[error("malformed response: {0}")] + Decode(String), + + #[error("keystore {path}: {reason}")] + Keystore { path: String, reason: String }, + #[error("unsupported keystore version {0}, expected {EIP2335_KEYSTORE_VERSION}")] + KeystoreVersion(u64), + #[error("keystore password did not match the checksum")] + KeystoreBadPassword, + + #[error("no signing key for validator {0:?}")] + UnknownValidator(BlsPubkey), + /// Not constructed today: the only `SigningMethod` is `LocalKeystore`, + /// whose signing call cannot fail once a key has been resolved. Kept for + /// the remote-signer variant `keys::store`'s module doc already commits + /// to supporting, whose call to an external signer can fail in ways a + /// local scalar multiplication cannot; remove this only if that plan is + /// dropped. + #[error("signing failed for {pubkey:?}: {reason}")] + Signing { pubkey: BlsPubkey, reason: String }, + + #[error("{path}: {source}")] + Io { + path: String, + #[source] + source: std::io::Error, + }, +} + +impl Error { + /// Whether the duty loop should try again rather than give up. + /// + /// The distinction the loop needs every slot: a beacon node that is down, + /// syncing or answering badly will likely answer correctly later, so the + /// duty is skipped and retried. A bad keystore or an unknown validator will + /// not fix itself, and retrying only hides it. + pub fn is_retryable(&self) -> bool { + match self { + Self::BeaconNode { .. } + | Self::AllBeaconNodesFailed(_) + | Self::BeaconNodeStatus { .. } + | Self::BeaconNodeSyncing + | Self::InconsistentResponse(_) + | Self::AttestationDeadline { .. } + | Self::Decode(_) => true, + // A refusal is deterministic: the guard will refuse the same slot + // again, so retrying only repeats it. + Self::ProposalRefused { .. } + | Self::Keystore { .. } + | Self::KeystoreVersion(_) + | Self::KeystoreBadPassword + | Self::UnknownValidator(_) + | Self::Signing { .. } + | Self::Io { .. } => false, + } + } +} + +pub type Result = std::result::Result; diff --git a/crates/validator/src/http_api/keystores.rs b/crates/validator/src/http_api/keystores.rs new file mode 100644 index 000000000..75a6deeb7 --- /dev/null +++ b/crates/validator/src/http_api/keystores.rs @@ -0,0 +1,1267 @@ +//! `GET`, `POST` and `DELETE /eth/v1/keystores`. +//! +//! # Deviation +//! +//! This client keeps no slashing-protection record, so the interchange data the +//! specification requires cannot be produced. `DELETE` declares +//! `slashing_protection` a required response field, so an empty but well-formed +//! EIP-3076 interchange is returned; `POST` accepts the optional +//! `slashing_protection` field and ignores it. A caller migrating keys between +//! clients must not rely on this client to carry that history. +//! +//! The empty interchange [`DELETE`] returns is deliberately unusable as one: +//! its `genesis_validators_root` is all-zero rather than the chain's real +//! value. A real root would make the file look like legitimate, if empty, +//! history, which is worse than what it actually is (no history at all, +//! because this client never recorded any). The all-zero root is chosen so a +//! conformant importer rejects the file outright instead of trusting it, on +//! the theory that a loud, obvious failure at import time beats a tool +//! silently believing a validator has never signed. + +use std::path::PathBuf; +use std::sync::Arc; + +use axum::{Json, extract::State}; +use ethlambda_types::beacon::primitives::BlsPubkey; +use serde::{Deserialize, Serialize}; +use tokio::sync::RwLock; +use tracing::{info, warn}; +use zeroize::Zeroizing; + +use crate::Error; +use crate::beacon_node::dto::{encode_hex, parse_pubkey}; +use crate::http_api::KeymanagerContext; +use crate::keys::keystore::Keystore; +use crate::keys::{ValidatorDefinition, ValidatorDefinitions, ValidatorStore}; +use crate::secure_fs; + +/// The EIP-3076 interchange version this client reports when it has no history. +const INTERCHANGE_VERSION: &str = "5"; + +/// The most keystores accepted in a single import request. +/// +/// Each one costs a deliberately slow EIP-2335 key derivation (scrypt at the +/// parameters staking tools commonly emit runs on the order of 100-300ms), +/// run synchronously inside this handler while `definitions_lock` is held. +/// With no cap, one request could tie up that lock, and the worker thread +/// running it, for as long as the caller likes. 100 keys is far beyond a +/// normal runtime import (adding or rotating a handful of validators) while +/// keeping a single request's worst case in the tens of seconds rather than +/// unbounded. +const MAX_KEYSTORES_PER_IMPORT: usize = 100; + +pub type SharedStore = Arc>; + +#[derive(Debug, Serialize)] +pub struct ListResponse { + pub data: Vec, +} + +#[derive(Debug, Serialize)] +pub struct ListedKeystore { + pub validating_pubkey: String, + pub derivation_path: Option, + pub readonly: bool, +} + +#[derive(Deserialize)] +pub struct ImportRequest { + pub keystores: Vec, + /// Zeroized on drop, the same guarantee `Keystore::decrypt`'s output + /// already gets: this holds every plaintext password in the request in + /// memory until decryption runs, and a plain `Vec` would leave + /// those bytes sitting in the allocator's freed memory afterwards. + pub passwords: Zeroizing>, + /// Accepted and ignored: this client keeps no slashing-protection record. + #[serde(default)] + pub slashing_protection: Option, +} + +/// Hand-written rather than derived: `passwords` holds every plaintext +/// password in this request, and a derived `Debug` would print them verbatim +/// into any log line that ever dumps this struct. The standing rule in this +/// crate is that nothing carrying secrets is printable; see `keys::store`. +impl std::fmt::Debug for ImportRequest { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ImportRequest") + .field("keystores", &self.keystores.len()) + .field("passwords", &"") + .field("slashing_protection", &self.slashing_protection.is_some()) + .finish() + } +} + +#[derive(Debug, Serialize)] +pub struct StatusResponse { + pub data: Vec, +} + +#[derive(Debug, Serialize)] +pub struct KeyStatus { + pub status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, +} + +impl KeyStatus { + fn error(message: impl std::fmt::Display) -> Self { + Self { + status: "error".to_string(), + message: Some(message.to_string()), + } + } +} + +#[derive(Debug, Deserialize)] +pub struct DeleteRequest { + pub pubkeys: Vec, +} + +#[derive(Debug, Serialize)] +pub struct DeleteResponse { + pub data: Vec, + /// Required by the specification even though this client has no history to + /// report. See the module documentation. + pub slashing_protection: String, +} + +pub async fn list(State(context): State) -> Json { + let store = context.store.read().await; + Json(ListResponse { + data: store + .pubkeys() + .into_iter() + .map(|pubkey| ListedKeystore { + validating_pubkey: encode_hex(&pubkey.0), + derivation_path: None, + readonly: false, + }) + .collect(), + }) +} + +pub async fn import( + State(context): State, + Json(request): Json, +) -> Json { + if request.slashing_protection.is_some() { + warn!("Ignoring slashing_protection on import: this client keeps no signing history"); + } + + if request.keystores.len() > MAX_KEYSTORES_PER_IMPORT { + let message = format!( + "request carries {} keystores, more than the limit of {MAX_KEYSTORES_PER_IMPORT}", + request.keystores.len() + ); + warn!( + count = request.keystores.len(), + "Rejecting oversized import request" + ); + let data = request + .keystores + .iter() + .map(|_| KeyStatus::error(&message)) + .collect(); + return Json(StatusResponse { data }); + } + + // Held across this whole function: the entire open-mutate-save cycle + // over the definitions file, including the slow decrypt-and-persist loop + // below. This is what actually serializes concurrent import/delete + // requests; see `KeymanagerContext::definitions_lock`. Taken before + // `store`'s write lock, never the other way around, so the two locks + // never deadlock against each other. + let _definitions_guard = context.definitions_lock.lock().await; + + // Read only now, after taking the guard above: a snapshot taken before + // it could already be stale by the time this request's own writes land, + // which is exactly the race the guard exists to close. Every key that + // persists successfully appends to this and re-saves it, so later keys + // in the same request see earlier ones. A failure here (a definitions + // file that exists but is corrupt) is not something any individual key's + // error can fix, so it fails the whole batch rather than pretending a + // per-key retry would help. + let mut definitions = match ValidatorDefinitions::open(&context.validators_dir) { + Ok(definitions) => definitions, + Err(err) => { + let message = err.to_string(); + let data = request + .keystores + .iter() + .map(|_| KeyStatus::error(&message)) + .collect(); + return Json(StatusResponse { data }); + } + }; + + // Decrypt (deliberately slow: EIP-2335 key derivation) and persist every + // key to disk before ever touching the store's write lock. The duty + // loop's `attest` only ever holds a read lock across a short, synchronous + // signing loop (see `attestation.rs`'s doc comment on that block), but + // `tokio::sync::RwLock` is write-preferring: a write lock queued here + // while this loop ran would jump that queue and stall every signature + // due for as long as the whole batch takes, rather than just for the + // instant the inserts below actually need. `definitions_lock` above has + // no such reader/writer to starve: only import and delete ever take it. + let mut prepared = Vec::with_capacity(request.keystores.len()); + for (position, json) in request.keystores.iter().enumerate() { + let password = request.passwords.get(position).map(String::as_str); + prepared.push(prepare_import(&context, &mut definitions, json, password)); + } + + // The definitions file is fully written by this point; nothing below + // touches it, so the guard can be released before the store lock is + // taken rather than held across it too. + drop(_definitions_guard); + + let mut store = context.store.write().await; + let data = prepared + .into_iter() + .map(|result| match result { + Ok(secret) => finish_import(&mut store, &secret), + Err(status) => status, + }) + .collect(); + drop(store); + + Json(StatusResponse { data }) +} + +/// Decrypt one keystore and persist it to disk: everything an import can do +/// before it needs the store's write lock. +fn prepare_import( + context: &KeymanagerContext, + definitions: &mut ValidatorDefinitions, + json: &str, + password: Option<&str>, +) -> std::result::Result, KeyStatus> { + let Some(password) = password else { + return Err(KeyStatus::error("no password supplied for this keystore")); + }; + + let keystore = Keystore::from_json(json).map_err(KeyStatus::error)?; + let secret = keystore.decrypt(password).map_err(KeyStatus::error)?; + let pubkey = ValidatorStore::derive_pubkey(&secret).map_err(KeyStatus::error)?; + + // Persisted before the key is reachable through the store: a validator + // this process is willing to sign with must already be durable, or a + // crash right after "imported" is reported silently loses it, with + // nothing about a live, signing validator hinting that it is about to + // vanish on the next restart. + persist_import(context, definitions, &pubkey, json, password).map_err(KeyStatus::error)?; + + Ok(secret) +} + +/// Add an already-decrypted, already-persisted key to the live store. The +/// only step of an import that needs the write lock: a cheap, synchronous +/// scalar validation and hashmap insert, never an await. +fn finish_import(store: &mut ValidatorStore, secret: &[u8; 32]) -> KeyStatus { + match store.insert_secret("keymanager import", secret) { + Ok(pubkey) => { + info!(pubkey = %encode_hex(&pubkey.0), "Imported validator key"); + KeyStatus { + status: "imported".to_string(), + message: None, + } + } + Err(err) => KeyStatus::error(err), + } +} + +/// Write an imported keystore and password to disk and record them in the +/// definitions file, so the key survives a restart. +fn persist_import( + context: &KeymanagerContext, + definitions: &mut ValidatorDefinitions, + pubkey: &BlsPubkey, + keystore_json: &str, + password: &str, +) -> crate::Result<()> { + // Named after the pubkey, the convention staking tools use, so an + // operator can tell which file backs which validator without opening it. + // That predictability is exactly why the writes below refuse to follow a + // symlink already sitting at either path: an attacker who can guess a + // validator's pubkey (public by definition) can guess these paths too. + let base = encode_hex(&pubkey.0); + + // `ValidatorStore::load` resolves a relative definition path against the + // validators directory only (see `keys::store::resolve`), which would + // silently mis-locate a password file that actually lives under a + // separate secrets directory. Absolutizing both here sidesteps that: + // `resolve` passes an absolute path through unchanged regardless of + // which directory it names. + let keystore_path = absolute(context.validators_dir.join(format!("{base}.json")))?; + let password_path = absolute(context.secrets_dir.join(&base))?; + + // `write_private_no_symlink` still overwrites an existing *regular* + // file, so re-importing the same key (rotating its password, or simply + // retrying) behaves exactly as a plain `std::fs::write` would; it only + // refuses to follow a symlink planted at the path ahead of time. + secure_fs::write_private_no_symlink(&keystore_path, keystore_json).map_err(|source| { + Error::Io { + path: keystore_path.display().to_string(), + source, + } + })?; + secure_fs::write_private_no_symlink(&password_path, password).map_err(|source| Error::Io { + path: password_path.display().to_string(), + source, + })?; + + let definition = ValidatorDefinition { + enabled: true, + voting_public_key: base.clone(), + voting_keystore_path: keystore_path, + voting_keystore_password_path: password_path, + }; + // How to put `definitions` back if the save below fails. + // + // One vector is shared by every key in the batch, and each key mutates it + // and re-saves it. Leaving a failed key's mutation in place would let the + // *next* key's successful save write it to disk, so a key this request + // reports as `error` would be enabled on the next restart and start + // signing. With no slashing-protection record, a validator the operator + // believes was never imported is exactly the kind that ends up running in + // two places. + enum Undo { + Appended, + Replaced { + index: usize, + previous: ValidatorDefinition, + }, + } + + // Re-importing an already-known key updates its entry in place instead + // of appending a duplicate: `voting_public_key` is this file's natural + // key, and a duplicate would make `ValidatorStore::load` process the + // same keystore twice on the next restart. + let undo = match definitions + .0 + .iter() + .position(|existing| existing.voting_public_key == base) + { + Some(index) => Undo::Replaced { + index, + previous: std::mem::replace(&mut definitions.0[index], definition), + }, + None => { + definitions.0.push(definition); + Undo::Appended + } + }; + + if let Err(err) = definitions.save(&context.validators_dir) { + // `Appended` pops rather than removing by index because this function + // pushes and saves with nothing in between, so the entry it added is + // still the last one. + match undo { + Undo::Appended => { + definitions.0.pop(); + } + Undo::Replaced { index, previous } => definitions.0[index] = previous, + } + return Err(err); + } + Ok(()) +} + +fn absolute(path: PathBuf) -> crate::Result { + std::path::absolute(&path).map_err(|source| Error::Io { + path: path.display().to_string(), + source, + }) +} + +pub async fn delete( + State(context): State, + Json(request): Json, +) -> Json { + // Taken before `store`'s write lock below, the same order `import` uses, + // so the two handlers never deadlock against each other. See + // `KeymanagerContext::definitions_lock`: this is what makes `persist_delete` + // below and a concurrent import's own open-mutate-save cycle mutually + // exclusive, rather than racing to overwrite the definitions file. + let _definitions_guard = context.definitions_lock.lock().await; + + let mut store = context.store.write().await; + let data = request + .pubkeys + .iter() + .map(|text| match parse_pubkey(text) { + Ok(pubkey) => delete_one(&context, &mut store, &pubkey), + Err(err) => KeyStatus::error(err), + }) + .collect(); + drop(store); + + // This interchange cannot be used to migrate the key(s) just deleted to + // another client: see the module doc for why `genesis_validators_root` + // is deliberately wrong rather than merely absent. Logged on every call, + // not just a failure, since a successful response is exactly the one a + // caller might mistake for "safe to import elsewhere". + warn!( + "DELETE /eth/v1/keystores returns an empty EIP-3076 interchange with no real signing \ + history; it cannot be used to migrate these keys to another client without slashing risk" + ); + + Json(DeleteResponse { + data, + slashing_protection: empty_interchange(), + }) +} + +fn delete_one( + context: &KeymanagerContext, + store: &mut ValidatorStore, + pubkey: &BlsPubkey, +) -> KeyStatus { + // Whether this key is on disk, read before the in-memory removal makes the + // store an unreliable witness. `not_found` has to mean "this client has no + // definitions entry for it", not "it is not in memory right now": a key + // removed from memory by an earlier failed attempt is still very much + // present on disk, and reporting `not_found` for it tells an operator the + // key is gone when a restart will bring it back signing. + let on_disk = match definitions_contain(context, pubkey) { + Ok(present) => present, + // Cannot tell. Report the failure rather than guessing either way: a + // wrong `not_found` here is the dangerous direction. + Err(err) => return KeyStatus::error(err), + }; + let in_memory = store.remove(pubkey); + + if !on_disk && !in_memory { + return KeyStatus { + status: "not_found".to_string(), + message: None, + }; + } + + // The in-memory store has already stopped signing with this key, which is + // the safety-relevant effect and happens first, the opposite order from + // import. If persisting the removal below fails, this process will not + // sign with the key again this run, but the definitions file still lists + // it, so a restart would reactivate it. + // + // That is why the error is returned rather than swallowed, and why + // `not_found` above is gated on the file rather than on memory. An + // operator who retries after this error must get the same error again, not + // a `not_found` that reads as "already gone": acting on that, by importing + // the key elsewhere, is what turns a failed delete into two hosts signing + // for one validator. + if let Err(err) = persist_delete(context, pubkey) { + return KeyStatus::error(err); + } + + info!(pubkey = %encode_hex(&pubkey.0), "Deleted validator key"); + KeyStatus { + status: "deleted".to_string(), + message: None, + } +} + +/// Whether the definitions file currently holds an entry for `pubkey`. +/// +/// Read from disk rather than from the in-memory store, because the two can +/// disagree and it is the file that decides what the next restart loads. A +/// disabled entry counts as present: it is still there, `delete` is still what +/// removes it, and reporting `not_found` for one would leave the operator +/// believing a key is gone while its entry waits to be re-enabled. +/// +/// An unparseable `voting_public_key` cannot match, matching the filter in +/// [`persist_delete`]: if that entry will not be removed, this must not claim +/// it was found, or a delete would report success having removed nothing. +fn definitions_contain(context: &KeymanagerContext, pubkey: &BlsPubkey) -> crate::Result { + let definitions = ValidatorDefinitions::open(&context.validators_dir)?; + Ok(definitions.0.iter().any(|definition| { + parse_pubkey(&definition.voting_public_key) + .map(|parsed| parsed == *pubkey) + .unwrap_or(false) + })) +} + +/// Drop a deleted key's entry from the definitions file. +/// +/// Deliberately leaves the keystore and password files on disk: `deleted` +/// means this client has stopped using the key, not that it destroys operator +/// key material on an HTTP call. A definitions file with no entry for them is +/// enough to keep them from being loaded again. +fn persist_delete(context: &KeymanagerContext, pubkey: &BlsPubkey) -> crate::Result<()> { + let mut definitions = ValidatorDefinitions::open(&context.validators_dir)?; + definitions.0.retain(|definition| { + parse_pubkey(&definition.voting_public_key) + .map(|parsed| parsed != *pubkey) + .unwrap_or(true) + }); + definitions.save(&context.validators_dir) +} + +/// A well-formed EIP-3076 interchange holding no history. +/// +/// `genesis_validators_root` is deliberately all-zero rather than the +/// chain's real value; see the module doc for why. Do not "fix" this to +/// carry the real root: that would make an empty history look legitimate +/// instead of getting it rejected. +fn empty_interchange() -> String { + serde_json::json!({ + "metadata": { + "interchange_format_version": INTERCHANGE_VERSION, + "genesis_validators_root": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + "data": [] + }) + .to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::http_api::router; + use axum::body::Body; + use axum::http::{Request, StatusCode}; + use http_body_util::BodyExt as _; + use tower::ServiceExt as _; + + const TOKEN: &str = "test-token"; + + /// The scrypt keystore from the EIP-2335 vectors, as a single-line string. + fn keystore_json() -> String { + serde_json::json!({ + "crypto": { + "kdf": { + "function": "scrypt", + "params": { "dklen": 32, "n": 262144, "p": 1, "r": 8, + "salt": "d4e56740f876aef8c010b86a40d5f56745a118d0906a34e69aec8c0db1cb8fa3" }, + "message": "" + }, + "checksum": { "function": "sha256", "params": {}, + "message": "d2217fe5f3e9a1e34581ef8a78f7c9928e436d36dacc5e846690a5581e8ea484" }, + "cipher": { "function": "aes-128-ctr", + "params": { "iv": "264daa3f303d7259501c93d997d84fe6" }, + "message": "06ae90d55fe0a6e9c5c3bc5b170827b2e5cce3929ed3f116c2811e6366dfe20f" } + }, + "pubkey": "9612d7a727c9d0a22e185a1c768478dfe919cada9266988cb32359c11f2b7b27f4ae4040902382ae2910c15e2b420d07", + "path": "m/12381/60/3141592653/589793238", + "uuid": "1d85ae20-35c5-4611-98e8-aa14a633906f", + "version": 4 + }) + .to_string() + } + + const PASSWORD: &str = "\u{1d531}\u{1d522}\u{1d530}\u{1d531}\u{1d52d}\u{1d51e}\u{1d530}\u{1d530}\u{1d534}\u{1d52c}\u{1d52f}\u{1d521}\u{1f511}"; + + fn context_with_dirs(store: SharedStore, dir: &std::path::Path) -> KeymanagerContext { + KeymanagerContext { + store, + validators_dir: dir.to_path_buf(), + secrets_dir: dir.to_path_buf(), + definitions_lock: Arc::new(tokio::sync::Mutex::new(())), + } + } + + /// A context whose directories are never created. Fine for tests that + /// never reach a successful import or delete, since `ValidatorDefinitions` + /// treats a missing directory the same as a missing file: an empty set. + fn app() -> axum::Router { + let placeholder = std::env::temp_dir().join("ethlambda-validator-keymanager-tests-absent"); + router( + context_with_dirs(Arc::new(RwLock::new(ValidatorStore::new())), &placeholder), + TOKEN.to_string(), + ) + } + + async fn send(app: axum::Router, request: Request) -> (StatusCode, serde_json::Value) { + let response = app.oneshot(request).await.expect("responds"); + let status = response.status(); + let bytes = response + .into_body() + .collect() + .await + .expect("body") + .to_bytes(); + let json = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null); + (status, json) + } + + fn authed(method: &str, uri: &str, body: serde_json::Value) -> Request { + Request::builder() + .method(method) + .uri(uri) + .header("authorization", format!("Bearer {TOKEN}")) + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .expect("request") + } + + #[tokio::test] + async fn a_request_without_a_token_is_rejected() { + let request = Request::builder() + .uri("/eth/v1/keystores") + .body(Body::empty()) + .expect("request"); + let (status, _) = send(app(), request).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn a_request_with_the_wrong_token_is_rejected() { + let request = Request::builder() + .uri("/eth/v1/keystores") + .header("authorization", "Bearer wrong") + .body(Body::empty()) + .expect("request"); + let (status, _) = send(app(), request).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + } + + /// Defence in depth for the empty-token bypass. + /// + /// `load_or_create_token` now refuses to return an empty token, so this + /// router should be unreachable in practice. But `router` takes the token + /// from its caller, so the comparison must refuse an empty one on its own: + /// `subtle`'s `ct_eq` reports two empty slices as equal, and + /// `Authorization: Bearer ` with a trailing space presents exactly that. + #[tokio::test] + async fn an_empty_configured_token_authenticates_nobody() { + let placeholder = std::env::temp_dir().join("ethlambda-validator-keymanager-tests-absent"); + let app = router( + context_with_dirs(Arc::new(RwLock::new(ValidatorStore::new())), &placeholder), + String::new(), + ); + + // The exact header that made empty match empty: "Bearer " strips to "". + let request = Request::builder() + .uri("/eth/v1/keystores") + .header("authorization", "Bearer ") + .body(Body::empty()) + .expect("request"); + + let (status, _) = send(app, request).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + } + + /// A key whose persist fails must not be left in the shared definitions + /// vector for the next key in the batch to write out. + /// + /// One `ValidatorDefinitions` is threaded through every key in an import, + /// and each mutates it and re-saves it. Without a rollback, key 7's failed + /// save leaves its entry in the vector and key 8's successful save commits + /// it: key 7 is reported `error` to the caller and is enabled on disk. A + /// validator the operator believes was never imported is exactly the kind + /// that ends up running in two places. + /// + /// The save is made to fail by putting a *directory* where the definitions + /// file goes: the keystore and password writes still succeed, so the + /// mutation is reached, and only the final rename fails. + #[test] + fn a_failed_persist_leaves_the_definitions_untouched() { + let dir = tempfile::tempdir().expect("temp dir"); + std::fs::create_dir(dir.path().join(crate::keys::definitions::DEFINITIONS_FILE)) + .expect("occupies the definitions path with a directory"); + + let context = context_with_dirs(Arc::new(RwLock::new(ValidatorStore::new())), dir.path()); + let mut definitions = ValidatorDefinitions::default(); + + let secret = Keystore::from_json(&keystore_json()) + .expect("valid keystore") + .decrypt(PASSWORD) + .expect("decrypts"); + let pubkey = ValidatorStore::derive_pubkey(&secret).expect("derives"); + + let result = persist_import( + &context, + &mut definitions, + &pubkey, + &keystore_json(), + PASSWORD, + ); + + assert!( + result.is_err(), + "the save must fail for this test to mean anything" + ); + assert!( + definitions.0.is_empty(), + "a failed save must not leave the entry behind for the next key to commit" + ); + } + + /// The same rollback for the replace path: re-importing a known key whose + /// save then fails must leave the *previous* entry intact, not a + /// half-applied update. + #[test] + fn a_failed_persist_restores_a_replaced_entry() { + let dir = tempfile::tempdir().expect("temp dir"); + std::fs::create_dir(dir.path().join(crate::keys::definitions::DEFINITIONS_FILE)) + .expect("occupies the definitions path with a directory"); + + let context = context_with_dirs(Arc::new(RwLock::new(ValidatorStore::new())), dir.path()); + + let secret = Keystore::from_json(&keystore_json()) + .expect("valid keystore") + .decrypt(PASSWORD) + .expect("decrypts"); + let pubkey = ValidatorStore::derive_pubkey(&secret).expect("derives"); + + // An existing entry for the same key, marked disabled so the restored + // value is distinguishable from what the import would have written. + let existing = ValidatorDefinition { + enabled: false, + voting_public_key: encode_hex(&pubkey.0), + voting_keystore_path: PathBuf::from("old.json"), + voting_keystore_password_path: PathBuf::from("old.txt"), + }; + let mut definitions = ValidatorDefinitions(vec![existing.clone()]); + + let result = persist_import( + &context, + &mut definitions, + &pubkey, + &keystore_json(), + PASSWORD, + ); + + assert!(result.is_err()); + assert_eq!( + definitions.0, + vec![existing], + "a failed save must restore the entry it replaced" + ); + } + + /// A key whose definitions entry is still on disk must not be reported + /// `not_found` just because it is absent from memory. + /// + /// This is the state a failed delete leaves behind: the in-memory removal + /// already happened, the persist failed, and the entry survives. An + /// operator who retries and reads `not_found` concludes the key is gone + /// and imports it on another host; this one brings it back on its next + /// restart, and two hosts sign for one validator. + #[tokio::test] + async fn a_key_still_on_disk_is_not_reported_not_found() { + let dir = tempfile::tempdir().expect("temp dir"); + let store = Arc::new(RwLock::new(ValidatorStore::new())); + let app = router( + context_with_dirs(store.clone(), dir.path()), + TOKEN.to_string(), + ); + + // Import so the definitions entry exists on disk. + let (status, body) = send( + app.clone(), + authed( + "POST", + "/eth/v1/keystores", + serde_json::json!({ + "keystores": [keystore_json()], + "passwords": [PASSWORD], + }), + ), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["data"][0]["status"], "imported", "got {body}"); + + let (_, listed) = send( + app.clone(), + Request::builder() + .uri("/eth/v1/keystores") + .header("authorization", format!("Bearer {TOKEN}")) + .body(Body::empty()) + .expect("request"), + ) + .await; + let pubkey = listed["data"][0]["validating_pubkey"] + .as_str() + .expect("string") + .to_string(); + + // Now reproduce the post-failed-delete state: drop it from memory + // only, leaving the definitions entry in place. + { + let parsed = parse_pubkey(&pubkey).expect("valid pubkey"); + assert!(store.write().await.remove(&parsed), "was in memory"); + } + + let (_, body) = send( + app, + authed( + "DELETE", + "/eth/v1/keystores", + serde_json::json!({ "pubkeys": [pubkey] }), + ), + ) + .await; + + assert_ne!( + body["data"][0]["status"], "not_found", + "a key whose definitions entry is still on disk is not gone: {body}" + ); + assert_eq!(body["data"][0]["status"], "deleted", "got {body}"); + } + + #[tokio::test] + async fn import_list_delete_round_trip() { + let dir = tempfile::tempdir().expect("temp dir"); + let store = Arc::new(RwLock::new(ValidatorStore::new())); + let app = router( + context_with_dirs(store.clone(), dir.path()), + TOKEN.to_string(), + ); + + let (status, body) = send( + app.clone(), + authed( + "POST", + "/eth/v1/keystores", + serde_json::json!({ + "keystores": [keystore_json()], + "passwords": [PASSWORD], + }), + ), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["data"][0]["status"], "imported", "got {body}"); + + let (_, body) = send( + app.clone(), + Request::builder() + .uri("/eth/v1/keystores") + .header("authorization", format!("Bearer {TOKEN}")) + .body(Body::empty()) + .expect("request"), + ) + .await; + let listed = body["data"].as_array().expect("array"); + assert_eq!(listed.len(), 1); + let pubkey = listed[0]["validating_pubkey"] + .as_str() + .expect("string") + .to_string(); + + let (_, body) = send( + app, + authed( + "DELETE", + "/eth/v1/keystores", + serde_json::json!({ "pubkeys": [pubkey] }), + ), + ) + .await; + assert_eq!(body["data"][0]["status"], "deleted", "got {body}"); + assert!( + body["slashing_protection"].is_string(), + "the field is required even when empty" + ); + assert!(store.read().await.is_empty()); + } + + /// The regression test for the gap that let the keymanager mutate only + /// the in-memory store: a fresh `ValidatorStore::load` from the same + /// directory, independent of the one the API mutated, is the only way to + /// prove an import reached disk rather than just the running process. + #[tokio::test] + async fn an_imported_key_survives_a_fresh_load_from_disk() { + let dir = tempfile::tempdir().expect("temp dir"); + let app = router( + context_with_dirs(Arc::new(RwLock::new(ValidatorStore::new())), dir.path()), + TOKEN.to_string(), + ); + + let (status, body) = send( + app, + authed( + "POST", + "/eth/v1/keystores", + serde_json::json!({ + "keystores": [keystore_json()], + "passwords": [PASSWORD], + }), + ), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["data"][0]["status"], "imported", "got {body}"); + + let reloaded = ValidatorStore::load(dir.path()).expect("loads"); + assert_eq!(reloaded.len(), 1); + assert_eq!( + hex::encode(reloaded.pubkeys()[0].0), + "9612d7a727c9d0a22e185a1c768478dfe919cada9266988cb32359c11f2b7b27f4ae4040902382ae2910c15e2b420d07" + ); + } + + #[tokio::test] + async fn deleting_an_unknown_key_reports_not_found() { + let (_, body) = send( + app(), + authed( + "DELETE", + "/eth/v1/keystores", + serde_json::json!({ "pubkeys": [encode_hex(&[0x11; 48])] }), + ), + ) + .await; + assert_eq!(body["data"][0]["status"], "not_found", "got {body}"); + } + + #[tokio::test] + async fn a_wrong_password_is_reported_per_key_not_as_a_failure() { + let (status, body) = send( + app(), + authed( + "POST", + "/eth/v1/keystores", + serde_json::json!({ + "keystores": [keystore_json()], + "passwords": ["wrong"], + }), + ), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["data"][0]["status"], "error", "got {body}"); + } + + #[test] + fn import_request_debug_redacts_passwords() { + let request = ImportRequest { + keystores: vec!["a".to_string(), "b".to_string(), "c".to_string()], + passwords: Zeroizing::new(vec!["super-secret".to_string()]), + slashing_protection: None, + }; + let rendered = format!("{request:?}"); + assert!(rendered.contains("keystores: 3"), "got {rendered}"); + assert!(rendered.contains(""), "got {rendered}"); + assert!( + !rendered.contains("super-secret"), + "password leaked into Debug output: {rendered}" + ); + } + + /// Finding 1: every file this handler writes must land at `0600`, not + /// whatever the platform default (typically `0644`) gives it. Checked + /// through the real HTTP path rather than by calling `secure_fs` + /// directly, so a future refactor that swapped back to a bare + /// `std::fs::write` at a call site would be caught here too. + #[cfg(unix)] + #[tokio::test] + async fn imported_keystore_and_password_files_are_mode_0600() { + use std::os::unix::fs::PermissionsExt as _; + + let dir = tempfile::tempdir().expect("temp dir"); + let app = router( + context_with_dirs(Arc::new(RwLock::new(ValidatorStore::new())), dir.path()), + TOKEN.to_string(), + ); + + let (status, body) = send( + app, + authed( + "POST", + "/eth/v1/keystores", + serde_json::json!({ + "keystores": [keystore_json()], + "passwords": [PASSWORD], + }), + ), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["data"][0]["status"], "imported", "got {body}"); + + // The definitions file names on-disk files after `encode_hex`'s + // output, which is `0x`-prefixed (see `persist_import`'s `base`). + let pubkey = "0x9612d7a727c9d0a22e185a1c768478dfe919cada9266988cb32359c11f2b7b27f4ae4040902382ae2910c15e2b420d07"; + for path in [ + dir.path().join(format!("{pubkey}.json")), + dir.path().join(pubkey), + ] { + let mode = std::fs::metadata(&path) + .unwrap_or_else(|err| panic!("metadata for {}: {err}", path.display())) + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o600, "{} got {mode:o}", path.display()); + } + } + + /// Finding 4: a batch larger than the limit is rejected outright, per + /// key, rather than run through the slow KDF at all. + #[tokio::test] + async fn an_oversized_import_batch_is_rejected() { + let oversized = MAX_KEYSTORES_PER_IMPORT + 1; + let (status, body) = send( + app(), + authed( + "POST", + "/eth/v1/keystores", + serde_json::json!({ + "keystores": vec![keystore_json(); oversized], + "passwords": vec![PASSWORD; oversized], + }), + ), + ) + .await; + assert_eq!(status, StatusCode::OK); + let data = body["data"].as_array().expect("array"); + assert_eq!(data.len(), oversized); + assert!( + data.iter().all(|status| status["status"] == "error"), + "got {body}" + ); + } + + /// Finding 5: a symlink planted at the predictable, pubkey-derived + /// keystore path ahead of an import must not be followed. The write is + /// reported as a per-key error, not a crash, and the symlink's target is + /// left untouched. + #[cfg(unix)] + #[tokio::test] + async fn import_refuses_to_follow_a_symlink_at_the_keystore_path() { + let dir = tempfile::tempdir().expect("temp dir"); + let target = dir.path().join("attacker-target"); + std::fs::write(&target, "untouched").expect("writes target"); + + // `0x`-prefixed to match `encode_hex`'s output, which is what + // `persist_import` actually names files after. + let pubkey = "0x9612d7a727c9d0a22e185a1c768478dfe919cada9266988cb32359c11f2b7b27f4ae4040902382ae2910c15e2b420d07"; + let keystore_link = dir.path().join(format!("{pubkey}.json")); + std::os::unix::fs::symlink(&target, &keystore_link).expect("symlinks"); + + let app = router( + context_with_dirs(Arc::new(RwLock::new(ValidatorStore::new())), dir.path()), + TOKEN.to_string(), + ); + let (status, body) = send( + app, + authed( + "POST", + "/eth/v1/keystores", + serde_json::json!({ + "keystores": [keystore_json()], + "passwords": [PASSWORD], + }), + ), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["data"][0]["status"], "error", "got {body}"); + assert_eq!( + std::fs::read_to_string(&target).expect("reads"), + "untouched", + "the symlink target must not be overwritten" + ); + } + + /// Finding 2's fallback test: two sequential requests, so this does not + /// by itself exercise the race (the bug needed a concurrent import whose + /// definitions snapshot was taken *before* a delete's save landed; two + /// requests run one after the other each open the file fresh regardless + /// of any locking). What it does check, deterministically: the dedup fix + /// in `persist_import` and a plain delete-then-import sequence do not + /// themselves reintroduce a deleted key by any other means, e.g. an + /// import that matched on the wrong key or clobbered the whole file + /// instead of one entry. `import_waits_for_an_in_flight_holder_of_the_definitions_lock` + /// below is the actual proof that concurrent requests cannot interleave; + /// a genuine two-request race is not reachable through the public API + /// once that guard is real, which makes it untestable without adding a + /// test-only delay inside production code, which did not seem worth it + /// for this one assertion. + #[tokio::test] + async fn a_delete_survives_a_subsequent_import_of_a_different_key() { + let dir = tempfile::tempdir().expect("temp dir"); + let app = router( + context_with_dirs(Arc::new(RwLock::new(ValidatorStore::new())), dir.path()), + TOKEN.to_string(), + ); + + let (_, body) = send( + app.clone(), + authed( + "POST", + "/eth/v1/keystores", + serde_json::json!({ + "keystores": [keystore_json()], + "passwords": [PASSWORD], + }), + ), + ) + .await; + assert_eq!(body["data"][0]["status"], "imported", "got {body}"); + // `0x`-prefixed to match how `voting_public_key` is actually stored + // (see `persist_import`'s `base`), since the assertion below compares + // against it directly rather than going through `parse_pubkey`. + let pubkey = "0x9612d7a727c9d0a22e185a1c768478dfe919cada9266988cb32359c11f2b7b27f4ae4040902382ae2910c15e2b420d07"; + + let (_, body) = send( + app.clone(), + authed( + "DELETE", + "/eth/v1/keystores", + serde_json::json!({ "pubkeys": [pubkey] }), + ), + ) + .await; + assert_eq!(body["data"][0]["status"], "deleted", "got {body}"); + + // A second, unrelated key. Its import must not resurrect the one + // just deleted by re-saving a stale definitions snapshot. + let other_secret = { + let mut bytes = [0u8; 32]; + bytes[31] = 7; + bytes + }; + let other_password = "other-password"; + let other_keystore = build_test_keystore_json(&other_secret, other_password); + + let (_, body) = send( + app, + authed( + "POST", + "/eth/v1/keystores", + serde_json::json!({ + "keystores": [other_keystore], + "passwords": [other_password], + }), + ), + ) + .await; + assert_eq!(body["data"][0]["status"], "imported", "got {body}"); + + let definitions = + ValidatorDefinitions::open(dir.path()).expect("opens the definitions file"); + assert!( + definitions + .0 + .iter() + .all(|definition| definition.voting_public_key != pubkey), + "the deleted key must not reappear: {definitions:?}" + ); + let reloaded = ValidatorStore::load(dir.path()).expect("loads"); + assert_eq!( + reloaded.len(), + 1, + "only the second import's key should be active" + ); + } + + /// Proves `definitions_lock` is real, not decorative: a second request + /// that needs it must actually wait for an in-flight one to release it, + /// rather than run its own open-mutate-save cycle concurrently. This is + /// the mechanism the test above relies on to be deterministic. + #[tokio::test] + async fn import_waits_for_an_in_flight_holder_of_the_definitions_lock() { + let dir = tempfile::tempdir().expect("temp dir"); + let context = context_with_dirs(Arc::new(RwLock::new(ValidatorStore::new())), dir.path()); + + let guard = context.definitions_lock.clone().lock_owned().await; + + let request = ImportRequest { + keystores: vec![keystore_json()], + passwords: Zeroizing::new(vec![PASSWORD.to_string()]), + slashing_protection: None, + }; + let handle = tokio::spawn(import(State(context.clone()), Json(request))); + + // Give the spawned task every chance to run up to the point where it + // blocks on the lock. It has no other await point before that: if it + // were not actually waiting on `definitions_lock`, it would complete + // well within this many yields. + for _ in 0..64 { + tokio::task::yield_now().await; + } + assert!( + !handle.is_finished(), + "import must block while definitions_lock is held elsewhere" + ); + + drop(guard); + let Json(response) = handle.await.expect("import task did not panic"); + assert_eq!(response.data[0].status, "imported", "got {response:?}"); + } + + /// Build a keystore JSON string (not written to disk; `import` is what + /// writes it) that decrypts `secret` under `password`, so a test can + /// hand `import` a second, distinct key without hardcoding another + /// EIP-2335 vector. + /// + /// Kept simple rather than general: this crate has no keystore *encoder* + /// (only `Keystore::decrypt`), so this reaches into the cipher directly + /// with fixed, already-tested-elsewhere parameters. + fn build_test_keystore_json(secret: &[u8; 32], password: &str) -> String { + use aes::cipher::{KeyIvInit as _, StreamCipher as _}; + use sha2::Digest as _; + + let salt = [0x11u8; 32]; + let iv = [0x22u8; 16]; + let mut derived = [0u8; 32]; + scrypt::scrypt( + password.as_bytes(), + &salt, + &scrypt::Params::new(14, 8, 1, 32).expect("valid params"), + &mut derived, + ) + .expect("scrypt"); + + let mut cipher_message = *secret; + type Aes128Ctr = ctr::Ctr128BE; + let mut cipher = Aes128Ctr::new_from_slices(&derived[..16], &iv).expect("valid key/iv"); + cipher.apply_keystream(&mut cipher_message); + + let mut hasher = sha2::Sha256::new(); + hasher.update(&derived[16..32]); + hasher.update(cipher_message); + let checksum = hasher.finalize(); + + serde_json::json!({ + "crypto": { + "kdf": { + "function": "scrypt", + "params": { "dklen": 32, "n": 16384, "p": 1, "r": 8, "salt": hex::encode(salt) }, + "message": "" + }, + "checksum": { "function": "sha256", "params": {}, "message": hex::encode(checksum) }, + "cipher": { "function": "aes-128-ctr", + "params": { "iv": hex::encode(iv) }, + "message": hex::encode(cipher_message) } + }, + "pubkey": encode_hex(&ValidatorStore::derive_pubkey(secret).expect("derives").0), + "path": "m/12381/60/0/0", + "uuid": "00000000-0000-0000-0000-000000000000", + "version": 4 + }) + .to_string() + } + + /// Finding 6: the interchange's `genesis_validators_root` must stay + /// all-zero. See the module doc: a real root would make an empty history + /// look legitimate, which is worse than the deliberately unusable file + /// this client returns instead. + #[test] + fn the_empty_interchange_root_stays_all_zero() { + let interchange: serde_json::Value = + serde_json::from_str(&empty_interchange()).expect("valid json"); + assert_eq!( + interchange["metadata"]["genesis_validators_root"], + "0x0000000000000000000000000000000000000000000000000000000000000000", + ); + } + + /// Finding 3: `require_bearer` must reject a wrong token of a different + /// length too, not just one that fails a byte comparison partway + /// through. Not a timing assertion (this crate has no timing harness), + /// but it does cover the length-mismatch branch of `ConstantTimeEq`'s + /// slice impl, which returns early on differing lengths before ever + /// comparing bytes. + #[tokio::test] + async fn a_token_of_a_different_length_is_rejected() { + let request = Request::builder() + .uri("/eth/v1/keystores") + .header("authorization", "Bearer short") + .body(Body::empty()) + .expect("request"); + let (status, _) = send(app(), request).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + } +} diff --git a/crates/validator/src/http_api/mod.rs b/crates/validator/src/http_api/mod.rs new file mode 100644 index 000000000..24382a986 --- /dev/null +++ b/crates/validator/src/http_api/mod.rs @@ -0,0 +1,277 @@ +//! The keymanager API. +//! +//! Bearer-token authenticated and bound to localhost by default. The +//! specification requires TLS; a deployment that exposes this beyond the +//! loopback interface must front it with a TLS terminator, which is the same +//! position Lighthouse and Prysm take. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use axum::extract::Request; +use axum::http::StatusCode; +use axum::middleware::{self, Next}; +use axum::response::Response; +use axum::routing::get; +use axum::{Extension, Router}; +use subtle::ConstantTimeEq as _; +use tokio::sync::Mutex; +use tracing::info; + +use crate::Error; +use crate::secure_fs; + +pub mod keystores; + +pub use keystores::SharedStore; + +/// Everything the keymanager handlers need: the validator store, plus the +/// directories an import or delete must write through so the change survives +/// a restart. Held as the axum state rather than three separate `Extension`s, +/// since every route needs all three together. +#[derive(Clone)] +pub struct KeymanagerContext { + pub store: SharedStore, + pub validators_dir: PathBuf, + pub secrets_dir: PathBuf, + /// Serializes the open-mutate-save cycle over the validator definitions + /// file across concurrent import/delete requests. + /// + /// `store`'s `RwLock` does not close this on its own: an import or delete + /// takes a fresh [`crate::keys::ValidatorDefinitions`] snapshot from disk + /// outside of any lock, mutates it, and saves it back, so two requests + /// each doing that independently can interleave, and one write clobbers + /// the other. Concretely: a delete removes a key and saves; a concurrent + /// import that snapshotted the file before that save lands afterwards and + /// writes the deleted key back as `enabled: true`. This mutex, held by + /// each handler across its entire read-modify-write cycle (not just + /// `store`), is what actually orders those cycles. + /// + /// Guards the file only, never `store`: holding it across the slow + /// EIP-2335 key derivation an import performs must not stall the duty + /// loop, which only ever waits on `store`. Where a handler needs both, + /// it takes this one first and `store` second, consistently, so the two + /// never deadlock against each other. + pub definitions_lock: Arc>, +} + +/// The file the generated token is written to, inside the validators directory. +pub const API_TOKEN_FILE: &str = "api-token.txt"; + +/// The shortest token this will accept from an existing file. +/// +/// Generated tokens are a 32-character UUID, so this rejects nothing this +/// client writes. It exists for what it refuses: a file that is empty or +/// nearly so, which cannot be a token anyone chose. +/// +/// The value matters less than the floor being above zero. See +/// [`load_or_create_token`] for why zero was not safe. +const MIN_TOKEN_LEN: usize = 16; + +/// Read the API token, generating one if the file does not exist. +/// +/// # An empty file is refused, not accepted +/// +/// Returning the trimmed contents of any existing file was an authentication +/// bypass. Two facts combined: this returned `Ok("")` for an empty file, and +/// the comparison in [`require_bearer_token`] uses `subtle`'s `ct_eq`, which +/// returns *true* for two empty slices (its accumulator starts at 1 and the +/// fold body never runs). A request carrying `Authorization: Bearer ` with a +/// trailing space strips to `Some("")`, and empty matched empty. +/// +/// It was reachable without anyone doing anything unusual: +/// [`secure_fs::write_private`] opens with `truncate(true)` and then writes, +/// so a first boot that failed between those two steps left a zero-byte file +/// that every later boot accepted. +/// +/// Refusing is the right failure here rather than regenerating: a token file +/// that exists but is unusable means something went wrong that an operator +/// should see, and silently minting a new one would change the credential +/// their tooling already holds. +pub fn load_or_create_token(validators_dir: &Path) -> crate::Result { + let path = validators_dir.join(API_TOKEN_FILE); + match std::fs::read_to_string(&path) { + Ok(token) => { + let token = token.trim().to_string(); + if token.len() < MIN_TOKEN_LEN { + return Err(Error::Keystore { + path: path.display().to_string(), + reason: format!( + "the API token file holds {} characters, which is below the {MIN_TOKEN_LEN} \ + required; delete it to have a new token generated", + token.len() + ), + }); + } + Ok(token) + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + let token = uuid::Uuid::new_v4().simple().to_string(); + secure_fs::write_private(&path, &token).map_err(|source| Error::Io { + path: path.display().to_string(), + source, + })?; + info!(path = %path.display(), "Generated a keymanager API token"); + Ok(token) + } + Err(source) => Err(Error::Io { + path: path.display().to_string(), + source, + }), + } +} + +pub fn router(context: KeymanagerContext, token: String) -> Router { + Router::new() + .route( + "/eth/v1/keystores", + get(keystores::list) + .post(keystores::import) + .delete(keystores::delete), + ) + .with_state(context) + .layer(middleware::from_fn(require_bearer)) + .layer(Extension(ApiToken(token))) +} + +#[derive(Clone)] +struct ApiToken(String); + +/// Reject anything without the exact bearer token. +/// +/// Deliberately a blanket layer rather than a per-route guard: a route added +/// later is authenticated by default rather than by remembering to add it. +async fn require_bearer(request: Request, next: Next) -> Response { + let expected = request + .extensions() + .get::() + .map(|token| token.0.clone()); + + let presented = request + .headers() + .get(axum::http::header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Bearer ")) + .map(str::to_string); + + // `==` on `String` short-circuits at the first mismatched byte, which + // leaks timing information about how much of the token a caller + // guessed right. This service is localhost-bound by default, but + // `--http-address` is operator-configurable with nothing enforcing that, + // so the comparison has to hold even off loopback. + let matches = match (&expected, &presented) { + // An empty expected token must never match, independently of + // `load_or_create_token` refusing to produce one. `ct_eq` returns true + // for two empty slices, so without this an empty configured token plus + // a header of `Bearer ` (trailing space) authenticates. `router` takes + // the token from its caller, so this cannot rely on the loader being + // the only source. Checking the length first is not a timing leak: the + // emptiness of the *configured* token is not a secret, and no + // comparison against the presented value has happened yet. + (Some(expected), _) if expected.is_empty() => false, + (Some(expected), Some(presented)) => { + bool::from(expected.as_bytes().ct_eq(presented.as_bytes())) + } + _ => false, + }; + + if matches { + next.run(request).await + } else { + Response::builder() + .status(StatusCode::UNAUTHORIZED) + .body(axum::body::Body::empty()) + .expect("static response") + } +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use std::os::unix::fs::PermissionsExt as _; + + #[test] + fn the_generated_api_token_file_is_mode_0600() { + let dir = tempfile::tempdir().expect("temp dir"); + load_or_create_token(dir.path()).expect("generates a token"); + + let mode = std::fs::metadata(dir.path().join(API_TOKEN_FILE)) + .expect("metadata") + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o600, "got {mode:o}"); + } + + #[test] + fn a_generated_token_is_long_enough_to_be_accepted_on_the_next_boot() { + // The two halves of `load_or_create_token` must agree: a token it + // writes must be one it will read back. A `MIN_TOKEN_LEN` raised above + // the generated length would lock the client out of its own file on + // restart, and nothing else would catch that. + let dir = tempfile::tempdir().expect("temp dir"); + let generated = load_or_create_token(dir.path()).expect("generates a token"); + + assert!(generated.len() >= MIN_TOKEN_LEN); + let reloaded = load_or_create_token(dir.path()).expect("reads it back"); + assert_eq!(generated, reloaded); + } + + /// The authentication bypass this check exists for. + /// + /// `secure_fs::write_private` truncates before it writes, so a first boot + /// that dies between those steps leaves a zero-byte file. Accepting it + /// produced an empty expected token, and `ct_eq` matches two empty slices, + /// so `Authorization: Bearer ` with a trailing space authenticated. + #[test] + fn an_empty_token_file_is_refused_rather_than_accepted() { + let dir = tempfile::tempdir().expect("temp dir"); + std::fs::write(dir.path().join(API_TOKEN_FILE), "").expect("writes an empty file"); + + let err = load_or_create_token(dir.path()).expect_err("an empty token must be refused"); + + assert!( + err.to_string().contains("below the"), + "the error should say why: {err}" + ); + } + + #[test] + fn a_whitespace_only_token_file_is_refused() { + // Trimming turns this into the empty case, so it must fail the same + // way rather than slipping past on its untrimmed length. + let dir = tempfile::tempdir().expect("temp dir"); + std::fs::write(dir.path().join(API_TOKEN_FILE), " \n\t \n").expect("writes whitespace"); + + assert!(load_or_create_token(dir.path()).is_err()); + } + + #[test] + fn a_short_token_file_is_refused() { + let dir = tempfile::tempdir().expect("temp dir"); + std::fs::write(dir.path().join(API_TOKEN_FILE), "abc").expect("writes a short token"); + + assert!(load_or_create_token(dir.path()).is_err()); + } + + #[test] + fn a_plausible_operator_supplied_token_is_accepted() { + // The check must not reject a token an operator chose themselves, + // which is a supported way to run this: only implausibly short ones. + let dir = tempfile::tempdir().expect("temp dir"); + let chosen = "a-token-an-operator-picked"; + std::fs::write(dir.path().join(API_TOKEN_FILE), chosen).expect("writes it"); + + assert_eq!(load_or_create_token(dir.path()).expect("accepted"), chosen); + } + + /// `ct_eq`'s behaviour on empty slices, pinned here because the + /// authentication check's correctness depends on it and it is surprising. + #[test] + fn subtle_reports_two_empty_slices_as_equal() { + assert!( + bool::from(b"".ct_eq(b"")), + "if this ever becomes false, the empty-token guard is still correct \ + but its stated reason is not" + ); + } +} diff --git a/crates/validator/src/keys/definitions.rs b/crates/validator/src/keys/definitions.rs new file mode 100644 index 000000000..f1c990807 --- /dev/null +++ b/crates/validator/src/keys/definitions.rs @@ -0,0 +1,160 @@ +//! The validator definitions file: which validators this client signs for. +//! +//! One YAML-free, JSON-Lines-free plain list, deliberately separate from the +//! keystores themselves. It is what the keymanager API mutates, so a key +//! imported at runtime is still there after a restart. + +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::error::{Error, Result}; +use crate::secure_fs; + +/// The file's name inside the validators directory. +pub const DEFINITIONS_FILE: &str = "validator_definitions.yml"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ValidatorDefinition { + /// Whether this validator should perform duties. A disabled entry keeps its + /// keystore on disk and is simply not loaded. + pub enabled: bool, + /// The validator's BLS public key, `0x`-prefixed hex. + pub voting_public_key: String, + /// Path to the EIP-2335 keystore. + pub voting_keystore_path: PathBuf, + /// Path to the file holding that keystore's password. + pub voting_keystore_password_path: PathBuf, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(transparent)] +pub struct ValidatorDefinitions(pub Vec); + +impl ValidatorDefinitions { + /// Read the definitions file from a validators directory, returning an empty + /// set if it does not exist yet. + pub fn open(validators_dir: &Path) -> Result { + let path = validators_dir.join(DEFINITIONS_FILE); + match std::fs::read_to_string(&path) { + Ok(contents) => serde_yaml_ng::from_str(&contents).map_err(|err| Error::Keystore { + path: path.display().to_string(), + reason: err.to_string(), + }), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()), + Err(err) => Err(Error::Io { + path: path.display().to_string(), + source: err, + }), + } + } + + /// Write the definitions back, replacing the file. + /// + /// Writes to a sibling temporary file and renames it over the target + /// rather than truncating in place. The keymanager API calls this while + /// validators are signing, and a crash partway through an in-place write + /// would leave a truncated file that stops the client booting, losing every + /// validator rather than the one being changed. `rename` within one + /// directory is atomic, so a reader sees the old file or the new one. + pub fn save(&self, validators_dir: &Path) -> Result<()> { + let path = validators_dir.join(DEFINITIONS_FILE); + let contents = serde_yaml_ng::to_string(self).map_err(|err| Error::Keystore { + path: path.display().to_string(), + reason: err.to_string(), + })?; + + // Mode set on the temporary file, not the final path: `rename` + // preserves it, and setting it here means the file is never + // world-readable even for the instant between creation and rename. + let temporary = validators_dir.join(format!("{DEFINITIONS_FILE}.tmp")); + secure_fs::write_private(&temporary, contents).map_err(|source| Error::Io { + path: temporary.display().to_string(), + source, + })?; + std::fs::rename(&temporary, &path).map_err(|source| Error::Io { + path: path.display().to_string(), + source, + }) + } + + /// The entries that should be loaded and signed with. + pub fn enabled(&self) -> impl Iterator { + self.0.iter().filter(|definition| definition.enabled) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn definition(pubkey: &str, enabled: bool) -> ValidatorDefinition { + ValidatorDefinition { + enabled, + voting_public_key: pubkey.to_string(), + voting_keystore_path: PathBuf::from("keystore.json"), + voting_keystore_password_path: PathBuf::from("password.txt"), + } + } + + #[test] + fn a_missing_file_is_an_empty_set_not_an_error() { + let dir = tempfile::tempdir().expect("temp dir"); + let definitions = ValidatorDefinitions::open(dir.path()).expect("opens"); + assert!(definitions.0.is_empty()); + } + + #[test] + fn round_trips_through_the_file() { + let dir = tempfile::tempdir().expect("temp dir"); + + let written = + ValidatorDefinitions(vec![definition("0xaa", true), definition("0xbb", false)]); + written.save(dir.path()).expect("saves"); + + let read = ValidatorDefinitions::open(dir.path()).expect("opens"); + assert_eq!(read.0, written.0); + } + + #[test] + fn saving_over_an_existing_file_exercises_the_rename_path() { + let dir = tempfile::tempdir().expect("temp dir"); + + let first = ValidatorDefinitions(vec![definition("0xaa", true)]); + first.save(dir.path()).expect("saves"); + + let second = ValidatorDefinitions(vec![definition("0xaa", true), definition("0xbb", true)]); + second + .save(dir.path()) + .expect("saves again, renaming over the existing file"); + + let read = ValidatorDefinitions::open(dir.path()).expect("opens"); + assert_eq!(read.0, second.0); + } + + #[test] + fn only_enabled_entries_are_loaded() { + let definitions = + ValidatorDefinitions(vec![definition("0xaa", true), definition("0xbb", false)]); + let enabled: Vec<_> = definitions.enabled().collect(); + assert_eq!(enabled.len(), 1); + assert_eq!(enabled[0].voting_public_key, "0xaa"); + } + + #[cfg(unix)] + #[test] + fn the_saved_file_is_mode_0600() { + use std::os::unix::fs::PermissionsExt as _; + + let dir = tempfile::tempdir().expect("temp dir"); + ValidatorDefinitions(vec![definition("0xaa", true)]) + .save(dir.path()) + .expect("saves"); + + let mode = std::fs::metadata(dir.path().join(DEFINITIONS_FILE)) + .expect("metadata") + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o600, "got {mode:o}"); + } +} diff --git a/crates/validator/src/keys/keystore.rs b/crates/validator/src/keys/keystore.rs new file mode 100644 index 000000000..80a9121aa --- /dev/null +++ b/crates/validator/src/keys/keystore.rs @@ -0,0 +1,422 @@ +//! EIP-2335 keystore decryption. +//! +//! The format every staking tool emits: a JSON document holding a key-derivation +//! function (scrypt or pbkdf2), a checksum that verifies the password before any +//! decryption is attempted, and the secret under aes-128-ctr. + +use serde::Deserialize; +use zeroize::Zeroizing; + +use crate::error::{EIP2335_KEYSTORE_VERSION, Error, Result}; + +#[derive(Debug, Deserialize)] +pub struct Keystore { + /// The key-derivation, checksum and cipher parameters, plus the + /// encrypted secret. + pub crypto: Crypto, + /// The validator's BLS public key, hex-encoded. + pub pubkey: Option, + /// The keystore's HD derivation path (e.g. `m/12381/60/0/0`). + /// + /// ERC-2335 marks this required, but `decrypt` never reads it: refusing + /// a keystore that would otherwise decrypt correctly would reject real + /// operator key material over metadata this client has no use for. + pub path: Option, + /// A UUID identifying this keystore. + /// + /// ERC-2335 marks this required too, but `decrypt` never reads it + /// either, for the same reason `path` stays optional. + pub uuid: Option, + /// The ERC-2335 schema version; only [`EIP2335_KEYSTORE_VERSION`] decrypts. + pub version: u64, + /// Where this keystore's JSON came from, used only to build error + /// messages. Not part of the ERC-2335 schema: set by the constructor, + /// never deserialized. + #[serde(skip)] + source: String, +} + +#[derive(Debug, Deserialize)] +pub struct Crypto { + /// The key-derivation function and its parameters. + pub kdf: Kdf, + /// The checksum that verifies the password before decryption runs. + pub checksum: Checksum, + /// The cipher that encrypts the secret, and its parameters. + pub cipher: Cipher, +} + +#[derive(Debug, Deserialize)] +pub struct Kdf { + /// The key-derivation function's name: `scrypt` or `pbkdf2`. + pub function: String, + /// The function's parameters, shaped differently depending on `function`. + pub params: serde_json::Value, +} + +#[derive(Debug, Deserialize)] +pub struct Checksum { + /// The checksum function; only `sha256` is supported. + pub function: String, + /// The expected checksum, hex-encoded. + pub message: String, +} + +#[derive(Debug, Deserialize)] +pub struct Cipher { + /// The cipher function; only `aes-128-ctr` is supported. + pub function: String, + /// The cipher's parameters. + pub params: CipherParams, + /// The encrypted secret, hex-encoded. + pub message: String, +} + +#[derive(Debug, Deserialize)] +pub struct CipherParams { + /// The initialization vector, hex-encoded. + pub iv: String, +} + +impl Keystore { + /// Parse a keystore from its JSON form. + pub fn from_json(json: &str) -> Result { + let mut keystore: Self = serde_json::from_str(json).map_err(|err| Error::Keystore { + path: "".to_string(), + reason: err.to_string(), + })?; + keystore.source = "".to_string(); + Ok(keystore) + } + + /// Build a keystore error pointing at wherever this keystore's JSON came + /// from, so a later file-loading constructor only has to change what it + /// sets `source` to, not every call site that raises an error. + fn keystore_error(&self, reason: impl Into) -> Error { + Error::Keystore { + path: self.source.clone(), + reason: reason.into(), + } + } + + /// Recover the 32-byte secret key, verifying the password first. + /// + /// The password check is deliberately a separate step ahead of decryption: + /// the checksum tells us the password is right without the cipher ever + /// running, which is what lets a wrong password be reported as such rather + /// than as 32 bytes of garbage that only fail later at signing time. + /// + /// The result is wrapped in [`Zeroizing`] so the secret is scrubbed from + /// memory on drop rather than lingering for the rest of the process. + pub fn decrypt(&self, password: &str) -> Result> { + if self.version != EIP2335_KEYSTORE_VERSION { + return Err(Error::KeystoreVersion(self.version)); + } + + let password = normalize_password(password); + let derived = self.derive_key(&password)?; + + let cipher_message = self.decode_hex(&self.crypto.cipher.message)?; + if !self.checksum_matches(&derived, &cipher_message)? { + return Err(Error::KeystoreBadPassword); + } + + if self.crypto.cipher.function != "aes-128-ctr" { + return Err(self.keystore_error(format!( + "unsupported cipher {}", + self.crypto.cipher.function + ))); + } + + let iv = self.decode_hex(&self.crypto.cipher.params.iv)?; + // Wrapped as soon as it holds the plaintext (post-decryption), so the + // buffer is scrubbed on drop rather than left as an ordinary `Vec`. + let mut secret = Zeroizing::new(cipher_message); + self.apply_aes_128_ctr(&derived[..16], &iv, &mut secret)?; + + if secret.len() != 32 { + return Err( + self.keystore_error(format!("secret is {} bytes, expected 32", secret.len())) + ); + } + // Constructed before the copy rather than after: the secret is never + // briefly held in a bare, unwrapped `[u8; 32]` on the stack between + // being copied out of `secret` and getting a `Zeroizing` guarantee of + // its own. + let mut out = Zeroizing::new([0u8; 32]); + out.copy_from_slice(&secret); + Ok(out) + } + + /// Run the keystore's KDF over the password, producing the 32-byte + /// decryption key whose halves serve two different purposes: the first for + /// the cipher, the second for the checksum. + fn derive_key(&self, password: &[u8]) -> Result> { + let params = &self.crypto.kdf.params; + let salt = self.decode_hex(self.string_param(params, "salt")?)?; + let dklen = self.u64_param(params, "dklen")? as usize; + if dklen != 32 { + return Err(self.keystore_error(format!("dklen is {dklen}, expected 32"))); + } + + let mut out = Zeroizing::new([0u8; 32]); + match self.crypto.kdf.function.as_str() { + "scrypt" => { + let n = self.u64_param(params, "n")?; + // RFC 7914 (which ERC-2335 references normatively) requires N + // to be a power of two: it is scrypt's cost parameter, + // expressed to the cipher as log2(N). A value that is not an + // exact power of two would silently truncate through + // `trailing_zeros`, deriving the wrong key and surfacing as a + // bad-password error instead of the corrupt file it is. + if n < 2 || !n.is_power_of_two() { + return Err(self + .keystore_error(format!("scrypt n must be a power of two >= 2, got {n}"))); + } + let r = self.u64_param(params, "r")?; + let r = u32::try_from(r) + .map_err(|_| self.keystore_error(format!("scrypt r={r} out of range")))?; + let p = self.u64_param(params, "p")?; + let p = u32::try_from(p) + .map_err(|_| self.keystore_error(format!("scrypt p={p} out of range")))?; + // n was just verified to be an exact power of two, so this + // recovers its exponent rather than truncating it. + let log_n = n.trailing_zeros() as u8; + let scrypt_params = scrypt::Params::new(log_n, r, p, 32) + .map_err(|err| self.keystore_error(format!("bad scrypt params: {err}")))?; + scrypt::scrypt(password, &salt, &scrypt_params, out.as_mut()) + .map_err(|err| self.keystore_error(format!("scrypt failed: {err}")))?; + } + "pbkdf2" => { + let c = self.u64_param(params, "c")?; + let c = u32::try_from(c) + .map_err(|_| self.keystore_error(format!("pbkdf2 c={c} out of range")))?; + let prf = self.string_param(params, "prf")?; + if prf != "hmac-sha256" { + return Err(self.keystore_error(format!("unsupported prf {prf}"))); + } + pbkdf2::pbkdf2::>(password, &salt, c, out.as_mut()) + .map_err(|err| self.keystore_error(format!("pbkdf2 failed: {err}")))?; + } + other => { + return Err(self.keystore_error(format!("unsupported kdf {other}"))); + } + } + Ok(out) + } + + /// `sha256(derived_key[16..32] | cipher_message) == checksum.message`. + fn checksum_matches(&self, derived: &[u8; 32], cipher_message: &[u8]) -> Result { + if self.crypto.checksum.function != "sha256" { + return Err(self.keystore_error(format!( + "unsupported checksum {}", + self.crypto.checksum.function + ))); + } + use sha2::Digest as _; + let mut hasher = sha2::Sha256::new(); + hasher.update(&derived[16..32]); + hasher.update(cipher_message); + let expected = self.decode_hex(&self.crypto.checksum.message)?; + Ok(hasher.finalize().as_slice() == expected.as_slice()) + } + + fn apply_aes_128_ctr(&self, key: &[u8], iv: &[u8], data: &mut [u8]) -> Result<()> { + use aes::cipher::{KeyIvInit as _, StreamCipher as _}; + type Aes128Ctr = ctr::Ctr128BE; + let mut cipher = Aes128Ctr::new_from_slices(key, iv) + .map_err(|err| self.keystore_error(format!("bad aes key or iv: {err}")))?; + cipher.apply_keystream(data); + Ok(()) + } + + fn decode_hex(&self, value: &str) -> Result> { + hex::decode(value.trim_start_matches("0x")) + .map_err(|err| self.keystore_error(format!("bad hex: {err}"))) + } + + fn string_param<'a>(&self, params: &'a serde_json::Value, name: &str) -> Result<&'a str> { + params + .get(name) + .and_then(serde_json::Value::as_str) + .ok_or_else(|| self.keystore_error(format!("missing kdf param {name}"))) + } + + fn u64_param(&self, params: &serde_json::Value, name: &str) -> Result { + params + .get(name) + .and_then(serde_json::Value::as_u64) + .ok_or_else(|| self.keystore_error(format!("missing kdf param {name}"))) + } +} + +/// EIP-2335 requires NFKD normalization, then stripping the C0/C1 control +/// codes, then UTF-8 encoding, in that order. The control-code strip must +/// happen on Unicode scalar values, not on already-encoded bytes: a C1 code +/// point (U+0080-U+009F) is two bytes in UTF-8, and so is every other +/// character above U+007F, so a byte in that same numeric range can be an +/// unrelated character's continuation byte. Filtering post-encoding would +/// corrupt any such character instead of leaving it alone. +/// +/// Wrapped in [`Zeroizing`] since this buffer holds the operator's password +/// in a form directly usable by the KDF. +fn normalize_password(password: &str) -> Zeroizing> { + use unicode_normalization::UnicodeNormalization as _; + Zeroizing::new( + password + .nfkd() + .filter(|c| !(*c <= '\u{1f}' || ('\u{7f}'..='\u{9f}').contains(c))) + .collect::() + .into_bytes(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The password from the EIP-2335 test vectors. Both keystores use it. + const PASSWORD: &str = "\u{1d531}\u{1d522}\u{1d530}\u{1d531}\u{1d52d}\u{1d51e}\u{1d530}\u{1d530}\u{1d534}\u{1d52c}\u{1d52f}\u{1d521}\u{1f511}"; + + /// The secret both test keystores encrypt. + const SECRET: &str = "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f"; + + const SCRYPT: &str = r#"{ + "crypto": { + "kdf": { + "function": "scrypt", + "params": { + "dklen": 32, "n": 262144, "p": 1, "r": 8, + "salt": "d4e56740f876aef8c010b86a40d5f56745a118d0906a34e69aec8c0db1cb8fa3" + }, + "message": "" + }, + "checksum": { + "function": "sha256", "params": {}, + "message": "d2217fe5f3e9a1e34581ef8a78f7c9928e436d36dacc5e846690a5581e8ea484" + }, + "cipher": { + "function": "aes-128-ctr", + "params": { "iv": "264daa3f303d7259501c93d997d84fe6" }, + "message": "06ae90d55fe0a6e9c5c3bc5b170827b2e5cce3929ed3f116c2811e6366dfe20f" + } + }, + "description": "This is a test keystore that uses scrypt to secure the secret.", + "pubkey": "9612d7a727c9d0a22e185a1c768478dfe919cada9266988cb32359c11f2b7b27f4ae4040902382ae2910c15e2b420d07", + "path": "m/12381/60/3141592653/589793238", + "uuid": "1d85ae20-35c5-4611-98e8-aa14a633906f", + "version": 4 + }"#; + + const PBKDF2: &str = r#"{ + "crypto": { + "kdf": { + "function": "pbkdf2", + "params": { + "dklen": 32, "c": 262144, "prf": "hmac-sha256", + "salt": "d4e56740f876aef8c010b86a40d5f56745a118d0906a34e69aec8c0db1cb8fa3" + }, + "message": "" + }, + "checksum": { + "function": "sha256", "params": {}, + "message": "8a9f5d9912ed7e75ea794bc5a89bca5f193721d30868ade6f73043c6ea6febf1" + }, + "cipher": { + "function": "aes-128-ctr", + "params": { "iv": "264daa3f303d7259501c93d997d84fe6" }, + "message": "cee03fde2af33149775b7223e7845e4fb2c8ae1792e5f99fe9ecf474cc8c16ad" + } + }, + "description": "This is a test keystore that uses PBKDF2 to secure the secret.", + "pubkey": "9612d7a727c9d0a22e185a1c768478dfe919cada9266988cb32359c11f2b7b27f4ae4040902382ae2910c15e2b420d07", + "path": "m/12381/60/0/0", + "uuid": "64625def-3331-4eea-ab6f-782f3ed16a83", + "version": 4 + }"#; + + #[test] + fn decrypts_the_scrypt_vector() { + let keystore = Keystore::from_json(SCRYPT).expect("parses"); + let secret = keystore.decrypt(PASSWORD).expect("decrypts"); + assert_eq!(hex::encode(*secret), SECRET); + } + + #[test] + fn decrypts_the_pbkdf2_vector() { + let keystore = Keystore::from_json(PBKDF2).expect("parses"); + let secret = keystore.decrypt(PASSWORD).expect("decrypts"); + assert_eq!(hex::encode(*secret), SECRET); + } + + #[test] + fn rejects_a_wrong_password_before_decrypting() { + let keystore = Keystore::from_json(SCRYPT).expect("parses"); + let err = keystore + .decrypt("not the password") + .expect_err("must reject"); + assert!(matches!(err, Error::KeystoreBadPassword), "got {err:?}"); + } + + #[test] + fn rejects_an_unsupported_version() { + let json = SCRYPT.replace("\"version\": 4", "\"version\": 3"); + let keystore = Keystore::from_json(&json).expect("parses"); + let err = keystore.decrypt(PASSWORD).expect_err("must reject"); + assert!(matches!(err, Error::KeystoreVersion(3)), "got {err:?}"); + } + + #[test] + fn rejects_a_non_power_of_two_scrypt_n() { + let json = SCRYPT.replace("\"n\": 262144", "\"n\": 100000"); + let keystore = Keystore::from_json(&json).expect("parses"); + let err = keystore.decrypt(PASSWORD).expect_err("must reject"); + match &err { + Error::Keystore { reason, .. } => { + assert!(reason.contains("power of two"), "got {err:?}"); + } + _ => panic!("got {err:?}"), + } + } + + #[test] + fn rejects_an_unsupported_kdf_function() { + let json = SCRYPT.replace("\"function\": \"scrypt\"", "\"function\": \"argon2\""); + let keystore = Keystore::from_json(&json).expect("parses"); + let err = keystore.decrypt(PASSWORD).expect_err("must reject"); + match &err { + Error::Keystore { reason, .. } => assert!(reason.contains("argon2"), "got {err:?}"), + _ => panic!("got {err:?}"), + } + } + + #[test] + fn rejects_an_unsupported_cipher_function() { + let json = SCRYPT.replace( + "\"function\": \"aes-128-ctr\"", + "\"function\": \"aes-256-cbc\"", + ); + let keystore = Keystore::from_json(&json).expect("parses"); + let err = keystore.decrypt(PASSWORD).expect_err("must reject"); + match &err { + Error::Keystore { reason, .. } => { + assert!(reason.contains("aes-256-cbc"), "got {err:?}") + } + _ => panic!("got {err:?}"), + } + } + + #[test] + fn rejects_invalid_hex_in_salt() { + let json = SCRYPT.replace( + "\"salt\": \"d4e56740f876aef8c010b86a40d5f56745a118d0906a34e69aec8c0db1cb8fa3\"", + "\"salt\": \"not-hex\"", + ); + let keystore = Keystore::from_json(&json).expect("parses"); + let err = keystore.decrypt(PASSWORD).expect_err("must reject"); + match &err { + Error::Keystore { reason, .. } => assert!(reason.contains("bad hex"), "got {err:?}"), + _ => panic!("got {err:?}"), + } + } +} diff --git a/crates/validator/src/keys/mod.rs b/crates/validator/src/keys/mod.rs new file mode 100644 index 000000000..ac89a776a --- /dev/null +++ b/crates/validator/src/keys/mod.rs @@ -0,0 +1,8 @@ +//! Validator key material: what exists, and how each one signs. + +pub mod definitions; +pub mod keystore; +pub mod store; + +pub use definitions::{ValidatorDefinition, ValidatorDefinitions}; +pub use store::{SigningMethod, ValidatorStore}; diff --git a/crates/validator/src/keys/store.rs b/crates/validator/src/keys/store.rs new file mode 100644 index 000000000..216855253 --- /dev/null +++ b/crates/validator/src/keys/store.rs @@ -0,0 +1,393 @@ +//! The set of validators this client can sign for, and how each one signs. + +use std::collections::HashMap; +use std::path::Path; + +use blst::min_pk::SecretKey; +use ethlambda_types::beacon::primitives::{BLS_PUBKEY_SIZE, BlsPubkey}; +use tracing::info; + +use crate::beacon_node::dto::{encode_hex, parse_pubkey}; +use crate::error::{Error, Result}; +use crate::keys::definitions::ValidatorDefinitions; +use crate::keys::keystore::Keystore; + +/// How one validator produces a signature. +/// +/// One variant today. It is an enum from the start so that adding a remote +/// signer later is a new arm rather than a reshaping of every call site. +/// +/// **Never derive `Debug` on this type, or on anything holding it.** +/// `blst::min_pk::SecretKey` zeroizes on drop but still derives a plain +/// `Debug` that prints the raw scalar, so a derived `Debug` anywhere up the +/// chain would put a validator's signing key into a log line. `Zeroizing` +/// is no protection either: it forwards `Debug` to the type it wraps, so it +/// controls the key's lifetime in memory, not whether it can be printed. +pub enum SigningMethod { + /// The secret key is held in this process, decrypted from a local keystore. + LocalKeystore { secret_key: Box }, +} + +/// Every validator this client signs for, keyed by public key. +pub struct ValidatorStore { + validators: HashMap, +} + +impl ValidatorStore { + pub fn new() -> Self { + Self { + validators: HashMap::new(), + } + } + + /// Load every enabled definition from a validators directory. + /// + /// A keystore that fails to decrypt aborts startup rather than being + /// skipped: a validator that silently does not attest looks exactly like a + /// healthy one from inside this process, and the operator finds out from + /// missed-attestation penalties days later. + pub fn load(validators_dir: &Path) -> Result { + let definitions = ValidatorDefinitions::open(validators_dir)?; + let mut store = Self::new(); + + for definition in definitions.enabled() { + let keystore_path = resolve(validators_dir, &definition.voting_keystore_path); + let password_path = resolve(validators_dir, &definition.voting_keystore_password_path); + + let json = std::fs::read_to_string(&keystore_path).map_err(|source| Error::Io { + path: keystore_path.display().to_string(), + source, + })?; + let keystore = Keystore::from_json(&json).map_err(|err| Error::Keystore { + path: keystore_path.display().to_string(), + reason: err.to_string(), + })?; + let password = std::fs::read_to_string(&password_path).map_err(|source| Error::Io { + path: password_path.display().to_string(), + source, + })?; + // `normalize_password` inside `decrypt` already strips every C0 + // control code, newlines included, so this trim is redundant + // today. It stays anyway: trimming at the file-reading boundary + // is defensible on its own and does not depend on a deeper + // implementation detail continuing to hold. Do not delete it as + // dead code, and do not treat it as the only thing standing + // between a saved password file and a working decrypt. + // + // `decrypt` yields a `Zeroizing<[u8; 32]>`, scrubbed on drop. + // Deref it at the call below rather than copying it out. + let secret = keystore + .decrypt(password.trim_end_matches(['\n', '\r'])) + .map_err(|err| Error::Keystore { + path: keystore_path.display().to_string(), + reason: err.to_string(), + })?; + + let derived = store.insert_secret(&keystore_path.display().to_string(), &secret)?; + + // The definitions file's `voting_public_key` is a claim about what + // is inside the keystore, and until here nothing checked it. Every + // signing path uses `derived`, so a mismatch cannot produce a wrong + // signature; what it does produce is a validator this client signs + // for under one key while the definitions file names another. + // + // The keymanager's delete is where that becomes dangerous. It + // removes from the store by the derived key but removes from the + // definitions file by the *declared* one + // (`http_api::keystores::persist_delete`), so a mismatch makes the + // file retain the entry while the response reports `deleted`. The + // next restart loads the keystore again and the validator signs + // again, which is exactly the shape that produces a double vote + // when the operator deleted the key because they moved it + // elsewhere. Refusing here makes that path's assumption true by + // construction rather than by hope. + // + // Fatal rather than skipped, consistent with every other failure in + // this loop: a definitions file that does not describe its own + // keystores is a configuration error to fix, not one to run half of. + let declared = + parse_pubkey(&definition.voting_public_key).map_err(|err| Error::Keystore { + path: keystore_path.display().to_string(), + reason: format!( + "the definitions entry's voting_public_key is unreadable: {err}" + ), + })?; + if declared != derived { + return Err(Error::Keystore { + path: keystore_path.display().to_string(), + reason: format!( + "the definitions entry declares {} but the keystore holds {}; \ + correct the definitions file before starting", + encode_hex(&declared.0), + encode_hex(&derived.0) + ), + }); + } + } + + info!(count = store.len(), "Loaded validator keys"); + Ok(store) + } + + /// Add one secret key, deriving its public key. + /// + /// `origin` names where the key came from, a keystore path or the + /// keymanager API, purely so a rejection can say which one was bad. The + /// failure is reported as a keystore error rather than a signing one: + /// `Error::Signing` carries the validator's public key, and here the bytes + /// were rejected before a public key could be derived from them. + pub fn insert_secret(&mut self, origin: &str, secret: &[u8; 32]) -> Result { + let secret_key = parse_secret_key(origin, secret)?; + let pubkey = pubkey_of(&secret_key); + self.validators.insert( + pubkey, + SigningMethod::LocalKeystore { + secret_key: Box::new(secret_key), + }, + ); + Ok(pubkey) + } + + /// Derive a validator's public key from its raw secret, without adding it + /// to the store. + /// + /// The keymanager API needs the pubkey to name an import's on-disk files + /// before deciding whether to activate it, so this exists to let a + /// handler learn that without a `&mut self` it does not have yet. + /// Duplicating the cheap scalar validation `insert_secret` also does is + /// simpler than splitting that method's contract in two. + pub fn derive_pubkey(secret: &[u8; 32]) -> Result { + let secret_key = parse_secret_key("keymanager import", secret)?; + Ok(pubkey_of(&secret_key)) + } + + pub fn remove(&mut self, pubkey: &BlsPubkey) -> bool { + self.validators.remove(pubkey).is_some() + } + + pub fn contains(&self, pubkey: &BlsPubkey) -> bool { + self.validators.contains_key(pubkey) + } + + pub fn get(&self, pubkey: &BlsPubkey) -> Option<&SigningMethod> { + self.validators.get(pubkey) + } + + pub fn pubkeys(&self) -> Vec { + self.validators.keys().copied().collect() + } + + pub fn len(&self) -> usize { + self.validators.len() + } + + pub fn is_empty(&self) -> bool { + self.validators.is_empty() + } +} + +impl Default for ValidatorStore { + fn default() -> Self { + Self::new() + } +} + +fn resolve(base: &Path, path: &Path) -> std::path::PathBuf { + if path.is_absolute() { + path.to_path_buf() + } else { + base.join(path) + } +} + +/// Validate a raw secret and parse it into a signing key. Shared by +/// `insert_secret` and `derive_pubkey` so the error message and the +/// `origin`-carrying `Error::Keystore` it produces stay in one place. +fn parse_secret_key(origin: &str, secret: &[u8; 32]) -> Result { + SecretKey::from_bytes(secret).map_err(|err| Error::Keystore { + path: origin.to_string(), + reason: format!("invalid secret key: {err:?}"), + }) +} + +fn pubkey_of(secret_key: &SecretKey) -> BlsPubkey { + let pubkey_bytes: [u8; BLS_PUBKEY_SIZE] = secret_key.sk_to_pk().to_bytes(); + BlsPubkey(pubkey_bytes) +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + use crate::keys::definitions::{ValidatorDefinition, ValidatorDefinitions}; + + /// The secret from the EIP-2335 test vectors. + fn test_secret() -> [u8; 32] { + let bytes = hex::decode("000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f") + .expect("valid hex"); + bytes.try_into().expect("32 bytes") + } + + /// The password from the EIP-2335 test vectors, copied from + /// `keys::keystore`'s tests since its constants are private to that module. + const PASSWORD: &str = "\u{1d531}\u{1d522}\u{1d530}\u{1d531}\u{1d52d}\u{1d51e}\u{1d530}\u{1d530}\u{1d534}\u{1d52c}\u{1d52f}\u{1d521}\u{1f511}"; + + /// The PBKDF2 keystore from the EIP-2335 test vectors, copied from + /// `keys::keystore`'s tests for the same reason. + const PBKDF2_KEYSTORE: &str = r#"{ + "crypto": { + "kdf": { + "function": "pbkdf2", + "params": { + "dklen": 32, "c": 262144, "prf": "hmac-sha256", + "salt": "d4e56740f876aef8c010b86a40d5f56745a118d0906a34e69aec8c0db1cb8fa3" + }, + "message": "" + }, + "checksum": { + "function": "sha256", "params": {}, + "message": "8a9f5d9912ed7e75ea794bc5a89bca5f193721d30868ade6f73043c6ea6febf1" + }, + "cipher": { + "function": "aes-128-ctr", + "params": { "iv": "264daa3f303d7259501c93d997d84fe6" }, + "message": "cee03fde2af33149775b7223e7845e4fb2c8ae1792e5f99fe9ecf474cc8c16ad" + } + }, + "description": "This is a test keystore that uses PBKDF2 to secure the secret.", + "pubkey": "9612d7a727c9d0a22e185a1c768478dfe919cada9266988cb32359c11f2b7b27f4ae4040902382ae2910c15e2b420d07", + "path": "m/12381/60/0/0", + "uuid": "64625def-3331-4eea-ab6f-782f3ed16a83", + "version": 4 + }"#; + + /// A password file saved with a trailing newline, the way an editor or a + /// shell redirect commonly leaves one, must not stop the keystore behind + /// it from decrypting. This is the failure mode the task called + /// "miserable to debug": the password itself is right, but a stray + /// newline byte makes the checksum comparison fail with no indication why. + #[test] + fn load_tolerates_a_trailing_newline_in_the_password_file() { + let dir = tempfile::tempdir().expect("temp dir"); + + std::fs::write(dir.path().join("keystore.json"), PBKDF2_KEYSTORE).expect("writes keystore"); + std::fs::write(dir.path().join("password.txt"), format!("{PASSWORD}\n")) + .expect("writes password"); + + let definitions = ValidatorDefinitions(vec![ValidatorDefinition { + enabled: true, + voting_public_key: "0x9612d7a727c9d0a22e185a1c768478dfe919cada9266988cb32359c11f2b7b27f4ae4040902382ae2910c15e2b420d07".to_string(), + voting_keystore_path: PathBuf::from("keystore.json"), + voting_keystore_password_path: PathBuf::from("password.txt"), + }]); + definitions.save(dir.path()).expect("saves definitions"); + + let store = ValidatorStore::load(dir.path()).expect("loads"); + assert_eq!(store.len(), 1); + let pubkey = store.pubkeys()[0]; + assert_eq!( + hex::encode(pubkey.0), + "9612d7a727c9d0a22e185a1c768478dfe919cada9266988cb32359c11f2b7b27f4ae4040902382ae2910c15e2b420d07" + ); + } + + /// The real key behind `PBKDF2_KEYSTORE`, as the EIP-2335 vectors record it. + const VECTOR_PUBKEY: &str = "0x9612d7a727c9d0a22e185a1c768478dfe919cada9266988cb32359c11f2b7b27f4ae4040902382ae2910c15e2b420d07"; + + /// Write the vector keystore and password into `dir`, with a definitions + /// file declaring `voting_public_key`. Declaring the wrong one is the + /// point of the tests below, so it is a parameter rather than fixed. + fn write_definitions(dir: &std::path::Path, voting_public_key: &str) { + std::fs::write(dir.join("keystore.json"), PBKDF2_KEYSTORE).expect("writes keystore"); + std::fs::write(dir.join("password.txt"), PASSWORD).expect("writes password"); + ValidatorDefinitions(vec![ValidatorDefinition { + enabled: true, + voting_public_key: voting_public_key.to_string(), + voting_keystore_path: PathBuf::from("keystore.json"), + voting_keystore_password_path: PathBuf::from("password.txt"), + }]) + .save(dir) + .expect("saves definitions"); + } + + /// A definitions entry declaring a key its keystore does not hold must not + /// load. + /// + /// Left unchecked, this is what makes the keymanager's delete report + /// `deleted` while removing nothing: the store is keyed by the derived key + /// and the definitions file is filtered by the declared one, so the entry + /// survives and the next restart brings the validator back signing. + #[test] + fn a_definitions_entry_declaring_the_wrong_pubkey_is_refused() { + let dir = tempfile::tempdir().expect("temp dir"); + // A well-formed pubkey of the right length that is simply not this + // keystore's: the check must be about the value, not the shape. + let wrong = format!("0x{}", "ab".repeat(BLS_PUBKEY_SIZE)); + write_definitions(dir.path(), &wrong); + + // `let else` rather than `expect_err`: the latter needs `Debug` on the + // `Ok` type, and `ValidatorStore` deliberately has none because it + // holds secret keys. + let Err(err) = ValidatorStore::load(dir.path()) else { + panic!("a definitions entry declaring the wrong pubkey must not load"); + }; + + let rendered = err.to_string(); + assert!( + rendered.contains("declares") && rendered.contains("keystore holds"), + "the error should name both keys: {rendered}" + ); + } + + #[test] + fn a_definitions_entry_with_an_unreadable_pubkey_is_refused() { + let dir = tempfile::tempdir().expect("temp dir"); + write_definitions(dir.path(), "0xnot-hex"); + + assert!(ValidatorStore::load(dir.path()).is_err()); + } + + #[test] + fn a_matching_definitions_entry_loads() { + // The other side of the check: the correct declaration must still + // load, or the guard would lock out every well-formed configuration. + let dir = tempfile::tempdir().expect("temp dir"); + write_definitions(dir.path(), VECTOR_PUBKEY); + + let store = ValidatorStore::load(dir.path()).expect("loads"); + assert_eq!(store.len(), 1); + } + + #[test] + fn inserting_a_secret_derives_the_expected_pubkey() { + let mut store = ValidatorStore::new(); + let pubkey = store + .insert_secret("test", &test_secret()) + .expect("inserts"); + // The pubkey the EIP-2335 vectors record for this secret. + assert_eq!( + hex::encode(pubkey.0), + "9612d7a727c9d0a22e185a1c768478dfe919cada9266988cb32359c11f2b7b27f4ae4040902382ae2910c15e2b420d07" + ); + assert!(store.contains(&pubkey)); + assert_eq!(store.len(), 1); + } + + #[test] + fn removing_a_validator_takes_it_out_of_the_set() { + let mut store = ValidatorStore::new(); + let pubkey = store + .insert_secret("test", &test_secret()) + .expect("inserts"); + assert!(store.remove(&pubkey)); + assert!(!store.contains(&pubkey)); + assert!(store.is_empty()); + } + + #[test] + fn an_unknown_pubkey_has_no_signing_method() { + let store = ValidatorStore::new(); + assert!(store.get(&BlsPubkey([7; BLS_PUBKEY_SIZE])).is_none()); + } +} diff --git a/crates/validator/src/lib.rs b/crates/validator/src/lib.rs new file mode 100644 index 000000000..9eca256e0 --- /dev/null +++ b/crates/validator/src/lib.rs @@ -0,0 +1,735 @@ +//! A beacon-chain validator client. +//! +//! It holds BLS keys, learns its duties from a beacon node over the standard +//! REST Beacon API, and signs and submits what those duties call for: +//! attestations, the blocks its validators are scheduled to propose, and the +//! aggregates they are selected to publish. It is beacon-node-agnostic: +//! everything it knows about the chain arrives through the `BeaconNodeApi` +//! trait, so it runs against any conformant node. +//! +//! # Deviations from the specifications +//! +//! This client implements **no slashing protection**. It keeps no durable +//! record of what it has signed, so a restart, or a second instance sharing the +//! same keystores, can double-vote or propose twice for one slot. On a live +//! network either is a slashable offence. This is a deliberate, recorded scope decision, not an oversight; +//! see [`docs/spec_deviations.md`] for the full entry, including what is and is +//! not covered. +//! +//! [`crate::attestation_guard`] and [`crate::proposal_guard`] close the part of +//! this that is reachable within a single run: a backward wall-clock step, or a +//! duty schedule replaced mid-epoch. Both are in-memory only and neither is a +//! substitute for the real thing. +//! +//! [`docs/spec_deviations.md`]: https://github.com/lambdaclass/ethlambda/blob/main/docs/spec_deviations.md +//! +//! The keymanager API is affected by the same decision. Its specification makes +//! `slashing_protection` a required field of the `DELETE /eth/v1/keystores` +//! response, so this implementation returns a well-formed but empty EIP-3076 +//! interchange, and accepts and ignores the optional `slashing_protection` +//! field on import. + +pub mod aggregation; +pub mod aggregation_selection; +pub mod attestation; +pub mod attestation_guard; +pub mod beacon_node; +pub mod duties; +pub mod error; +pub mod http_api; +pub mod keys; +pub mod metrics; +pub mod proposal; +pub mod proposal_guard; +pub(crate) mod secure_fs; +pub mod signing; +pub mod slot_clock; +pub mod subscriptions; + +pub use error::{Error, Result}; + +use std::path::PathBuf; +use std::sync::Arc; +use std::time::SystemTime; + +use ethlambda_types::beacon::primitives::{BlsPubkey, Bytes32, ExecutionAddress, ValidatorIndex}; +use tokio::sync::{Mutex, RwLock}; +use tracing::{error, info, warn}; + +use crate::aggregation::AggregationService; +use crate::attestation::AttestationService; +use crate::beacon_node::BeaconNodeApi; +use crate::beacon_node::dto::ProposerPreparationDto; +use crate::beacon_node::fallback::FallbackBeaconNode; +use crate::beacon_node::http::HttpBeaconNode; +use crate::duties::DutiesService; +use crate::keys::ValidatorStore; +use crate::proposal::ProposalService; +use crate::signing::SigningContext; +use crate::slot_clock::SlotClock; + +/// Everything the client is configured with at startup. +#[derive(Debug, Clone)] +pub struct ValidatorConfig { + pub beacon_nodes: Vec, + pub validators_dir: PathBuf, + pub secrets_dir: PathBuf, + pub metrics: std::net::SocketAddr, + /// Thirty-two bytes put in every block this client proposes. Consensus + /// never reads them, so an empty default costs nothing and avoids + /// announcing which client built a block to anyone who did not ask. + pub graffiti: Bytes32, + /// Where execution-layer block rewards should be paid. + /// + /// `None` when the operator named no address, in which case the beacon node + /// picks one, and it will not be the operator's. + pub suggested_fee_recipient: Option, + /// Bind address for the keymanager API. `None` when `--enable-keymanager` + /// was not passed, in which case it is never spawned. + pub keymanager: Option, +} + +/// Run the validator client until the process is stopped. +pub async fn run(config: ValidatorConfig) -> Result<()> { + metrics::init(); + + // Said once, at startup, before anything is loaded or signed. + // + // Until now this client's most consequential property was recorded only in + // a rustdoc comment on this module, which nobody running a binary reads. + // An operator can build this, point it at mainnet keys, and never meet the + // warning. `warn!` rather than `info!` because the consequence is losing + // stake, and it is unconditional rather than behind a flag because a + // warning an operator can silence is one they will. + warn!( + "This validator client keeps NO slashing-protection record. It cannot detect that a \ + previous run, or another client holding these keys, already signed. Running the same \ + keys here and anywhere else, or restarting into a state where the chain has moved, can \ + produce a slashable double vote or a double block proposal. Do not use these keys in \ + any other client while this one runs. See docs/spec_deviations.md." + ); + + // Said at startup for the same reason the warning above is: an operator + // who never sets this proposes blocks that pay their execution-layer + // rewards to whatever address their beacon node was configured with, which + // on a default configuration is nobody useful. It is silent money, and + // nothing later in a normal run mentions it again. + if config.suggested_fee_recipient.is_none() { + warn!( + "No --suggested-fee-recipient was set. Any block this client proposes will pay its \ + execution-layer rewards to an address chosen by the beacon node, which is very \ + unlikely to be yours." + ); + } + + let store = ValidatorStore::load(&config.validators_dir)?; + if store.is_empty() { + warn!("No validators loaded; the client will idle"); + } + metrics::set_validators_loaded(store.len() as u64); + // The keymanager mutates this at runtime while the duty loop reads it, so + // it is shared rather than owned outright from here on. + let store = Arc::new(RwLock::new(store)); + + // Bound before any beacon-node network call, deliberately: a validator + // that cannot reach its beacon node still exits (via the `?`s below) if + // it never gets one, but until then it must expose `/health` and + // `/metrics`, so an operator can tell "still starting, node unreachable" + // apart from "crashed with no HTTP surface at all". + let listener = tokio::net::TcpListener::bind(config.metrics) + .await + .map_err(|source| Error::Io { + path: config.metrics.to_string(), + source, + })?; + info!(address = %config.metrics, "Metrics listening"); + tokio::spawn(async move { + if let Err(err) = axum::serve(listener, metrics::router()).await { + error!(%err, "Metrics server stopped"); + } + }); + + let nodes = config + .beacon_nodes + .iter() + .map(HttpBeaconNode::new) + .collect::>>()?; + let beacon_node = Arc::new(FallbackBeaconNode::new(nodes)); + + let genesis = beacon_node.genesis().await?; + let spec = beacon_node.spec().await?; + info!( + genesis_time = genesis.genesis_time, + slot_duration_ms = spec.slot_duration_ms, + attestation_due_bps = spec.attestation_due_bps, + aggregate_due_bps = spec.aggregate_due_bps, + validators = store.read().await.len(), + "Validator client starting" + ); + + let clock = SlotClock::new( + genesis.genesis_time, + spec.slot_duration_ms, + spec.attestation_due_bps, + spec.aggregate_due_bps, + ); + let context = Arc::new(SigningContext { + config: spec, + genesis_validators_root: genesis.genesis_validators_root, + }); + + let mut duties = DutiesService::new(beacon_node.clone(), Vec::new()); + let attestation = AttestationService::new(beacon_node.clone(), context.clone()); + let aggregation = AggregationService::new(beacon_node.clone(), context.clone()); + let proposal = ProposalService::new( + beacon_node.clone(), + context.clone(), + config.graffiti, + config.suggested_fee_recipient, + ); + + if let Some(address) = config.keymanager { + let token = http_api::load_or_create_token(&config.validators_dir)?; + let context = http_api::KeymanagerContext { + store: store.clone(), + validators_dir: config.validators_dir.clone(), + secrets_dir: config.secrets_dir.clone(), + definitions_lock: Arc::new(Mutex::new(())), + }; + let router = http_api::router(context, token); + let listener = tokio::net::TcpListener::bind(address) + .await + .map_err(|source| Error::Io { + path: address.to_string(), + source, + })?; + info!(%address, "Keymanager API listening"); + tokio::spawn(async move { + if let Err(err) = axum::serve(listener, router).await { + error!(%err, "Keymanager API stopped"); + } + }); + } + + let mut last_epoch: Option = None; + // The slot whose duties were last attempted, so an overrunning slot does + // not cost the next one as well. See `SlotClock::next_slot_to_serve`. + let mut served: Option = None; + + loop { + // One wake per slot, at the slot boundary, rather than one at the + // attester offset. + // + // A slot has more than one thing due in it and they are due at + // different points: a proposer publishes at the boundary, every + // attester votes one third in. A loop woken only at the attester + // offset can never propose, because by the time it runs the slot it + // would be proposing for is already a third gone. + // + // Waking at the boundary and sleeping further in reaches both, and + // gives the epoch refresh below a third of a slot of headroom it did + // not have when it ran at the attester offset itself, where every + // second it took came straight out of the attestation's lateness. + let (slot, delay) = clock.next_slot_to_serve(served, SystemTime::now()); + tokio::time::sleep(delay).await; + served = Some(slot); + + let epoch = clock.epoch_of(slot); + + if last_epoch != Some(epoch) { + // Snapshot the pubkeys and let the guard drop right here, before + // `refresh_epoch`'s awaits (a sync check, two duties fetches, a + // validator lookup, a subscription POST). Passing the guard + // itself in used to extend it across that whole call, because + // `&*store.read().await` as a match scrutinee has its temporary + // lifetime extended to the entire match. Holding a read lock + // that long lets the keymanager's write lock + // (`http_api::keystores::import`, held across slow EIP-2335 + // derivation) queue ahead of it, and since `tokio::sync::RwLock` + // is write-preferring, every read after that point, including + // this one next epoch, would starve until the import finished. + let pubkeys = store.read().await.pubkeys(); + let refreshed = refresh_epoch( + &beacon_node, + &mut duties, + &pubkeys, + epoch, + config.suggested_fee_recipient, + &store, + &context, + ); + match refreshed.await { + Ok(()) => last_epoch = Some(epoch), + // A beacon node that is down, syncing or answering badly will + // likely answer next epoch, so keep the schedule already held + // and try again. Anything else will not fix itself, and looping + // on it would hide the cause behind a warning every slot. + Err(err) if err.is_retryable() => { + warn!(%epoch, %err, "Failed to refresh duties; keeping the previous schedule"); + metrics::set_beacon_node_available(false); + } + // Unreachable today: every error `refresh_epoch` can produce + // comes from a `BeaconNodeApi` call, and every variant those + // raise is classified retryable (see `Error::is_retryable`). + // Kept rather than deleted, since it is the only thing that + // would stop the process if a future non-retryable failure + // (a local config or key problem, say) ever reached this + // point; do not remove it as unreachable dead code. + Err(err) => { + error!(%epoch, %err, "Cannot refresh duties; stopping"); + return Err(err); + } + } + } + + // A block is due now, at the boundary this iteration woke on. + // + // Cloned out of `duties` so the borrow ends before the await: the duty + // is three small fields, and holding a borrow of the schedule across + // the proposal would stop the next epoch's refresh from replacing it. + if let Some(duty) = duties.proposer_at_slot(slot, epoch).cloned() { + propose(&proposal, &clock, slot, &duty, &store).await; + } + + // The rest of the way to the attester offset, one third into the slot. + // Zero if the refresh or the proposal above already ran past it, in + // which case this slot's attestation is late rather than skipped. + tokio::time::sleep(clock.until_attestation(slot, SystemTime::now())).await; + + let slot_duties = duties.at_slot(slot, epoch); + if slot_duties.is_empty() { + continue; + } + + // Bound the slot's work by what is left of the slot. + // + // Nothing else does. The per-request timeout in `HttpBeaconNode` is 8 + // seconds, failover tries each node in turn, and `attest` makes two + // calls, so one hung node can carry a 12-second slot's duty well past + // the slot itself and into the next one, whose duty is then late in + // turn. An attestation that misses its slot is worth little; one that + // also delays the next slot's is worth less than nothing. + // + // Dropping the future mid-flight is safe here specifically because of + // `AttestationGuard`: it records at signing time, so a duty abandoned + // between signing and submission cannot be re-signed next slot under + // the same target. That is the intended outcome and matches `attest`'s + // own "do not retry within a slot" rule; the attestation is simply + // lost. + let budget = clock.remaining_in(slot, SystemTime::now()); + let attempt = tokio::time::timeout(budget, attestation.attest(slot, &slot_duties, &store)); + match attempt.await.unwrap_or_else(|_| { + warn!( + %slot, + budget_ms = budget.as_millis() as u64, + "Attestation duty ran past the end of its slot and was abandoned" + ); + metrics::inc_attestation_deadline_missed(); + Err(Error::AttestationDeadline { slot }) + }) { + // `attest` scopes its own read guard away from every await (see + // its doc comment), so passing the shared `store` straight + // through here holds nothing across this call. + Ok(attested) => { + if attested.published > 0 { + let elapsed = SystemTime::now() + .duration_since(clock.start_of(slot)) + .unwrap_or_default(); + metrics::observe_publication_delay(elapsed.as_secs_f64()); + } + + // Aggregation, two thirds in, for whichever of this slot's + // duties this client was selected for. + // + // Reached only when the attestation duty produced data, since + // that data is what names the aggregate to ask for. It is not + // gated on anything having been *published*, though: an + // aggregator collects the whole committee's votes, so the duty + // is still owed when this client's own signatures were refused. + if let Some(data) = attested.data { + let delay = clock.until_aggregation(slot, SystemTime::now()); + tokio::time::sleep(delay).await; + aggregate(&aggregation, &clock, slot, &data, &slot_duties, &store).await; + } + } + Err(err) => { + error!(%slot, %err, "Failed to publish attestations for this slot"); + metrics::inc_attestation_failures(); + // Every error `attest` can return originates from the beacon + // node (a failed fetch, a bad or mismatched response, a + // failed submission), so a failure here is exactly the signal + // this gauge exists for: `refresh_epoch` only runs once an + // epoch and would otherwise leave it reporting stale + // availability for up to that long. + metrics::set_beacon_node_available(false); + } + } + } +} + +/// Run one slot's aggregation, bounded by what is left of the slot. +/// +/// # The budget is the end of the slot, not the attester offset +/// +/// Looser than the proposal's, because the thing it competes with is different. +/// A proposal that overruns eats into this client's own attestations, which are +/// due for every validator it holds. An aggregation is already the last duty in +/// its slot, so the only thing past its deadline is the next slot's work, and +/// the same budget the attestation gets is the right one. +/// +/// Abandoning it is cheap and safe. Nothing here is slashable (see +/// [`crate::aggregation`]), so a dropped future costs one aggregate and +/// nothing else, and other aggregators were selected for the same committee. +/// +/// Failures are logged and counted rather than propagated, like the proposal's: +/// one slot's aggregate must not stop the client attesting for the rest of the +/// epoch. +async fn aggregate( + aggregation: &AggregationService, + clock: &SlotClock, + slot: u64, + data: ðlambda_types::beacon::containers::shared::AttestationData, + duties: &[crate::beacon_node::dto::AttesterDutyDto], + store: &RwLock, +) { + let budget = clock.remaining_in(slot, SystemTime::now()); + let attempt = tokio::time::timeout(budget, aggregation.aggregate(slot, data, duties, store)); + match attempt.await { + Ok(Ok(_)) => {} + Ok(Err(err)) => { + error!(%slot, %err, "Failed to publish this slot's aggregates"); + metrics::inc_aggregation_failures(); + } + Err(_) => { + warn!( + %slot, + budget_ms = budget.as_millis() as u64, + "Aggregation ran past the end of its slot and was abandoned" + ); + metrics::inc_aggregation_failures(); + } + } +} + +/// Run one slot's proposal, bounded by what is left before the attestation is +/// due. +/// +/// Split out of the loop so the deadline's reasoning has somewhere to live, and +/// so the loop body stays readable with two duties in it. +/// +/// # The budget is the attester offset, not the end of the slot +/// +/// Deliberately tighter than the attestation's own budget. A block published +/// after the attester offset has already lost most of its value, because that +/// is the point at which attesters stop waiting for it and vote for the +/// previous head; the specification defines no block-production deadline of its +/// own, and this is the nearest thing to one that it does define. Meanwhile +/// every second spent here past that point comes straight out of this client's +/// own attestations, which are due for every validator it holds rather than for +/// the one proposing. +/// +/// So: a proposal that overruns is abandoned, and the slot's attesters still +/// vote. Dropping the future mid-flight is safe for the same reason it is on +/// the attestation path, and only for that reason: `ProposalService` records +/// the slot in its guard at signing time, so a block abandoned between signing +/// and publication cannot be signed a second time. +/// +/// Failures are logged and counted rather than propagated. A proposal is one +/// slot's work; losing it must not stop the client attesting for the rest of +/// the epoch. +async fn propose( + proposal: &ProposalService, + clock: &SlotClock, + slot: u64, + duty: &crate::beacon_node::dto::ProposerDutyDto, + store: &RwLock, +) { + let budget = clock.until_attestation(slot, SystemTime::now()); + let attempt = tokio::time::timeout(budget, proposal.propose(slot, duty, store)); + match attempt.await { + Ok(Ok(_)) => { + let elapsed = SystemTime::now() + .duration_since(clock.start_of(slot)) + .unwrap_or_default(); + metrics::observe_block_publication_delay(elapsed.as_secs_f64()); + } + Ok(Err(err)) => { + error!(%slot, validator = duty.validator_index, %err, "Failed to propose this slot's block"); + metrics::inc_block_proposal_failures(); + } + Err(_) => { + warn!( + %slot, + validator = duty.validator_index, + budget_ms = budget.as_millis() as u64, + "Block proposal ran past the point attesters stop waiting and was abandoned" + ); + metrics::inc_block_proposal_failures(); + } + } +} + +/// Resolve any newly activated validators, refresh the duty schedule, and +/// re-send subnet subscriptions if it moved. +async fn refresh_epoch( + beacon_node: &Arc, + duties: &mut DutiesService, + pubkeys: &[BlsPubkey], + epoch: u64, + fee_recipient: Option, + store: &RwLock, + context: &SigningContext, +) -> Result<()> { + // Nothing to resolve, nothing to schedule, nothing to register. Returning + // here also keeps an empty pubkey list away from `validator_indices`, whose + // endpoint reads one as "every validator on the chain". + if pubkeys.is_empty() { + return Ok(()); + } + + if beacon_node.is_optimistic_or_syncing().await? { + return Err(Error::BeaconNodeSyncing); + } + + // Re-resolved every epoch, not once at startup: a validator can be + // deposited but not yet activated, in which case it has no index to ask + // duties for until it is. + let entries = beacon_node.validator_indices(pubkeys).await?; + let indices: Vec = entries.iter().map(|entry| entry.index).collect(); + if indices.len() != pubkeys.len() { + info!( + resolved = indices.len(), + loaded = pubkeys.len(), + "Some validators have no index yet; they are not active on chain" + ); + } + // Kept as a continuously updated gauge, not just the one-shot warning in + // `DutiesService::refresh` (which only fires once per empty-to-nonempty + // transition): an operator needs to see "no validator indices resolved" + // stay visible in monitoring for as long as it is true, not read it off + // a single log line from whenever it first happened. + metrics::set_validators_resolved(indices.len() as u64); + duties.set_indices(indices); + + // Re-sent every epoch, not once at startup, because the node forgets. + // + // A preparation is kept for the epoch it arrived in and two more, and is + // lost entirely when the node restarts. A client that sent this once would + // stop being registered a few minutes later with nothing reporting it, and + // would find out only by proposing a block that paid someone else. + // + // A failure is logged rather than propagated. The consequence is a payload + // built for the wrong address, which the check before signing catches; the + // consequence of returning here would be losing this epoch's attester + // duties, which is worse and unrelated. + if let Some(fee_recipient) = fee_recipient { + let preparations: Vec = duties + .indices() + .iter() + .map(|index| ProposerPreparationDto { + validator_index: *index, + fee_recipient: crate::beacon_node::dto::encode_hex(&fee_recipient.0), + }) + .collect(); + if !preparations.is_empty() + && let Err(err) = beacon_node.prepare_beacon_proposer(&preparations).await + { + warn!( + %epoch, + %err, + "Failed to register this client's fee recipient; blocks proposed this epoch may \ + pay somewhere else" + ); + } + } + + let changed = duties.refresh_around(epoch).await?; + metrics::set_duties_held(duties.all().len() as u64); + if changed { + subscriptions::subscribe(beacon_node, &duties.all(), store, context).await?; + } + metrics::set_beacon_node_available(true); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::beacon_node::ValidatorEntry; + use crate::beacon_node::mock::MockBeaconNode; + use ethlambda_types::beacon::primitives::{H160, Root}; + + fn pubkey(byte: u8) -> BlsPubkey { + BlsPubkey([byte; 48]) + } + + /// A node that resolves two validators and has duties for epoch 3, so + /// `refresh_epoch` gets all the way through to the preparation. + fn node() -> MockBeaconNode { + let mut node = MockBeaconNode::new().with_duties(3, Root::repeat_byte(1), Vec::new()); + node = node.with_duties(4, Root::repeat_byte(2), Vec::new()); + node = node.with_proposers(3, Root::repeat_byte(1), Vec::new()); + node.validators = vec![ + ValidatorEntry { + index: 11, + pubkey: pubkey(1), + status: "active_ongoing".to_string(), + }, + ValidatorEntry { + index: 22, + pubkey: pubkey(2), + status: "active_ongoing".to_string(), + }, + ]; + node + } + + fn context() -> SigningContext { + SigningContext { + config: ethlambda_types::beacon::config::Config::mainnet(), + genesis_validators_root: Root::ZERO, + } + } + + fn empty_store() -> RwLock { + RwLock::new(ValidatorStore::new()) + } + + async fn refresh(node: Arc, fee_recipient: Option) { + let mut duties = DutiesService::new(node.clone(), Vec::new()); + let keys = [pubkey(1), pubkey(2)]; + refresh_epoch( + &node, + &mut duties, + &keys, + 3, + fee_recipient, + &empty_store(), + &context(), + ) + .await + .expect("refreshes"); + } + + /// One preparation per resolved validator, every epoch. The node keeps a + /// preparation for three epochs and forgets all of them on restart, so a + /// client that sent this once at startup would quietly stop being + /// registered. + #[tokio::test] + async fn every_resolved_validator_is_registered_for_its_fee_recipient() { + let node = Arc::new(node()); + refresh(node.clone(), Some(H160([0xab; 20]))).await; + + let sent = node.preparations(); + assert_eq!(sent.len(), 2); + let indices: Vec = sent.iter().map(|entry| entry.validator_index).collect(); + assert_eq!(indices, vec![11, 22]); + assert!( + sent.iter() + .all(|entry| entry.fee_recipient == format!("0x{}", "ab".repeat(20))), + "got {sent:?}" + ); + } + + /// Re-sent, not sent once. Two refreshes must produce two registrations. + #[tokio::test] + async fn the_registration_is_repeated_on_every_refresh() { + let node = Arc::new(node()); + refresh(node.clone(), Some(H160([0xab; 20]))).await; + refresh(node.clone(), Some(H160([0xab; 20]))).await; + assert_eq!(node.preparations().len(), 4); + } + + #[tokio::test] + async fn no_configured_address_sends_no_registration() { + let node = Arc::new(node()); + refresh(node.clone(), None).await; + assert!(node.preparations().is_empty()); + } + + /// A failed registration must not cost this epoch's duties. The + /// consequence of the failure is a payload built for the wrong address, + /// which the check before signing catches; the consequence of propagating + /// it would be attesting on last epoch's schedule, which is worse and + /// unrelated. + #[tokio::test] + async fn a_failed_registration_does_not_fail_the_refresh() { + let node = Arc::new(node().failing_call("prepare_beacon_proposer", "node is unhappy")); + let mut duties = DutiesService::new(node.clone(), Vec::new()); + let keys = [pubkey(1), pubkey(2)]; + + refresh_epoch( + &node, + &mut duties, + &keys, + 3, + Some(H160([0xab; 20])), + &empty_store(), + &context(), + ) + .await + .expect("the refresh must survive a failed registration"); + assert_eq!(duties.indices(), &[11, 22]); + } + + /// A client with no keys must not ask the beacon node anything. + /// + /// The validators endpoint reads an empty id list as "return every + /// validator", which on mainnet is millions of entries, fetched every + /// epoch, and then fed straight into the attester-duties request. A keyless + /// client is a supported state, so this is reachable by simply starting one + /// with an empty validators directory. + #[tokio::test] + async fn a_client_with_no_keys_asks_the_node_for_nothing() { + let node = Arc::new(node()); + let mut duties = DutiesService::new(node.clone(), Vec::new()); + + refresh_epoch( + &node, + &mut duties, + &[], + 3, + Some(H160([0xab; 20])), + &empty_store(), + &context(), + ) + .await + .expect("a keyless client idles rather than failing"); + + assert_eq!( + node.validator_indices_call_count(), + 0, + "an empty id list means every validator on the chain; it must never be sent" + ); + assert_eq!(node.duties_call_count(), 0); + assert!(node.preparations().is_empty()); + } + + /// And the implementations answer an empty request themselves, so a caller + /// that reaches them directly cannot make the same mistake. + #[tokio::test] + async fn an_empty_pubkey_list_resolves_to_no_validators() { + let node = node(); + let resolved = node + .validator_indices(&[]) + .await + .expect("an empty request is not an error"); + assert!( + resolved.is_empty(), + "the answer to 'resolve nothing' is nothing, not everything" + ); + } + + /// With nothing resolved there is nobody to register, and an empty array + /// must not be posted: it is a request that can only fail or do nothing. + #[tokio::test] + async fn nothing_is_registered_when_no_validator_resolved() { + let mut bare = MockBeaconNode::new().with_duties(3, Root::repeat_byte(1), Vec::new()); + bare = bare.with_duties(4, Root::repeat_byte(2), Vec::new()); + bare = bare.with_proposers(3, Root::repeat_byte(1), Vec::new()); + let node = Arc::new(bare); + + refresh(node.clone(), Some(H160([0xab; 20]))).await; + assert!(node.preparations().is_empty()); + } +} diff --git a/crates/validator/src/metrics.rs b/crates/validator/src/metrics.rs new file mode 100644 index 000000000..d8455d95c --- /dev/null +++ b/crates/validator/src/metrics.rs @@ -0,0 +1,373 @@ +//! Prometheus series for the validator client. +//! +//! Named `ethlambda_validator_*` rather than with this repo's usual `lean_` +//! prefix: this process follows the beacon chain, not the lean chain, and a +//! `lean_` series here would be misleading in a shared dashboard. + +use std::sync::LazyLock; + +use ethlambda_metrics::{ + Histogram, IntCounter, IntGauge, TimingGuard, register_histogram, register_int_counter, + register_int_gauge, +}; + +static VALIDATORS_LOADED: LazyLock = LazyLock::new(|| { + register_int_gauge!( + "ethlambda_validator_validators_loaded", + "Validator keys loaded by this client" + ) + .unwrap() +}); + +static VALIDATORS_RESOLVED: LazyLock = LazyLock::new(|| { + register_int_gauge!( + "ethlambda_validator_validators_resolved", + "Loaded validator keys with an index on chain, out of validators_loaded" + ) + .unwrap() +}); + +static DUTIES_HELD: LazyLock = LazyLock::new(|| { + register_int_gauge!( + "ethlambda_validator_duties_held", + "Attester duties currently scheduled" + ) + .unwrap() +}); + +static ATTESTATIONS_PUBLISHED_TOTAL: LazyLock = LazyLock::new(|| { + register_int_counter!( + "ethlambda_validator_attestations_published_total", + "Attestations accepted by a beacon node" + ) + .unwrap() +}); + +static ATTESTATION_FAILURES_TOTAL: LazyLock = LazyLock::new(|| { + register_int_counter!( + "ethlambda_validator_attestation_failures_total", + "Slots where publishing attestations failed" + ) + .unwrap() +}); + +static SIGNING_FAILURES_TOTAL: LazyLock = LazyLock::new(|| { + register_int_counter!( + "ethlambda_validator_signing_failures_total", + "Attestations that could not be signed" + ) + .unwrap() +}); + +/// Slots whose attestation work was abandoned for running past the end of the +/// slot. +/// +/// Distinct from `attestation_failures_total`, which counts a duty that failed +/// and returned. This counts one that never returned in time, which points at a +/// slow or hung beacon node rather than at a rejected attestation, and is the +/// series to watch when publication delay starts climbing. +static ATTESTATION_DEADLINE_MISSED_TOTAL: LazyLock = LazyLock::new(|| { + register_int_counter!( + "ethlambda_validator_attestation_deadline_missed_total", + "Slots whose attestation work was abandoned for overrunning the slot" + ) + .unwrap() +}); + +/// Attestations this process declined to sign because it had already signed a +/// conflicting one for that validator in this run. +/// +/// Separate from `signing_failures_total`, which counts signatures that were +/// attempted and failed. This counts signatures deliberately not attempted, and +/// it should normally read zero: a non-zero value means the duty loop tried to +/// sign something the guard judged a double vote, which is worth investigating +/// even though the attestation was correctly suppressed. See +/// `crate::attestation_guard`. +static ATTESTATIONS_REFUSED_TOTAL: LazyLock = LazyLock::new(|| { + register_int_counter!( + "ethlambda_validator_attestations_refused_total", + "Attestations refused because this process already signed a conflicting one" + ) + .unwrap() +}); + +static BLOCKS_PROPOSED_TOTAL: LazyLock = LazyLock::new(|| { + register_int_counter!( + "ethlambda_validator_blocks_proposed_total", + "Blocks signed and accepted by a beacon node" + ) + .unwrap() +}); + +/// Blocks a beacon node broadcast but could not import into its own database, +/// which is what `publishBlockV2` answers 202 for. +/// +/// A subset of `blocks_proposed_total` rather than a failure counter: the block +/// did reach the network. A non-zero rate here points at the beacon node, not +/// at this client, and usually means its execution layer is unsynced. +static BLOCKS_BROADCAST_NOT_IMPORTED_TOTAL: LazyLock = LazyLock::new(|| { + register_int_counter!( + "ethlambda_validator_blocks_broadcast_not_imported_total", + "Blocks broadcast by a beacon node that could not import them" + ) + .unwrap() +}); + +/// Blocks this process declined to sign because it had already proposed that +/// slot for that validator in this run. +/// +/// The proposal-shaped counterpart to `attestations_refused_total`, and it +/// should read zero for the same reason: a non-zero value means the duty loop +/// tried to propose a slot the guard judged already proposed, which is worth +/// investigating even though the block was correctly suppressed. See +/// `crate::proposal_guard`. +static BLOCKS_REFUSED_TOTAL: LazyLock = LazyLock::new(|| { + register_int_counter!( + "ethlambda_validator_blocks_refused_total", + "Blocks refused because this process already proposed that slot" + ) + .unwrap() +}); + +/// Slots where a proposal duty was held and failed to produce a published +/// block, for any reason. +static BLOCK_PROPOSAL_FAILURES_TOTAL: LazyLock = LazyLock::new(|| { + register_int_counter!( + "ethlambda_validator_block_proposal_failures_total", + "Proposal duties that did not result in a published block" + ) + .unwrap() +}); + +/// End to end, from the slot's start to the node accepting the block. +/// +/// The buckets are tighter at the low end than the attestation histogram's, +/// because the interesting question is different. An attestation is due a third +/// of the way into the slot; a block is due at the start, and the deadlines it +/// actually faces are the proposer re-org cutoff and the point attesters stop +/// waiting for it. +static BLOCK_PUBLICATION_DELAY_SECONDS: LazyLock = LazyLock::new(|| { + register_histogram!( + "ethlambda_validator_block_publication_delay_seconds", + "Time from slot start to this slot's block being accepted by a beacon node", + vec![0.25, 0.5, 0.75, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0, 8.0] + ) + .unwrap() +}); + +/// Blocks whose execution-layer fee recipient was not the address this client +/// asked the beacon node to use. +/// +/// Should read zero forever. A non-zero value means every block this validator +/// proposes is paying its execution rewards somewhere else, which is a beacon +/// node misconfiguration and not something this client can fix by refusing: +/// see `crate::proposal::ProposalService::check_fee_recipient` for why it signs +/// anyway. +static FEE_RECIPIENT_MISMATCHES_TOTAL: LazyLock = LazyLock::new(|| { + register_int_counter!( + "ethlambda_validator_fee_recipient_mismatches_total", + "Blocks paying execution rewards to an address this client did not request" + ) + .unwrap() +}); + +/// Aggregates this client signed and a beacon node accepted. +/// +/// Expected to be small and bursty rather than steady: a validator is selected +/// to aggregate a few times a day, so a flat zero over hours is normal for a +/// small deployment and only meaningful against `aggregator_duties_total`. +static AGGREGATES_PUBLISHED_TOTAL: LazyLock = LazyLock::new(|| { + register_int_counter!( + "ethlambda_validator_aggregates_published_total", + "Aggregates accepted by a beacon node" + ) + .unwrap() +}); + +/// Slots where an aggregation duty was held and no aggregate was published. +static AGGREGATION_FAILURES_TOTAL: LazyLock = LazyLock::new(|| { + register_int_counter!( + "ethlambda_validator_aggregation_failures_total", + "Aggregation duties that did not result in a published aggregate" + ) + .unwrap() +}); + +static BEACON_NODE_AVAILABLE: LazyLock = LazyLock::new(|| { + register_int_gauge!( + "ethlambda_validator_beacon_node_available", + "1 when a beacon node answered the last duty refresh or attestation" + ) + .unwrap() +}); + +static SIGNING_DURATION_SECONDS: LazyLock = LazyLock::new(|| { + register_histogram!( + "ethlambda_validator_signing_duration_seconds", + "Time spent signing one slot's batch of attestations, read lock included", + vec![ + 0.0005, 0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5 + ] + ) + .unwrap() +}); + +static PUBLICATION_DELAY_SECONDS: LazyLock = LazyLock::new(|| { + register_histogram!( + "ethlambda_validator_publication_delay_seconds", + "Time from slot start to this slot's attestations being accepted by a beacon node", + vec![0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 6.0, 8.0, 12.0] + ) + .unwrap() +}); + +/// Register every series with the Prometheus registry so `/metrics` lists them +/// at zero from startup, rather than only after whatever first touches them. +/// +/// Without this, a function-scoped `LazyLock` only registers on first call, so +/// (for example) `ethlambda_validator_attestations_published_total` is absent +/// rather than zero until the first successful publish. An alert on +/// "attestations stopped" cannot fire on a series that does not exist, and +/// absent-versus-zero is exactly the distinction an operator needs. +pub fn init() { + LazyLock::force(&VALIDATORS_LOADED); + LazyLock::force(&VALIDATORS_RESOLVED); + LazyLock::force(&DUTIES_HELD); + LazyLock::force(&ATTESTATIONS_PUBLISHED_TOTAL); + LazyLock::force(&ATTESTATION_FAILURES_TOTAL); + LazyLock::force(&SIGNING_FAILURES_TOTAL); + LazyLock::force(&ATTESTATIONS_REFUSED_TOTAL); + LazyLock::force(&ATTESTATION_DEADLINE_MISSED_TOTAL); + LazyLock::force(&BLOCKS_PROPOSED_TOTAL); + LazyLock::force(&BLOCKS_BROADCAST_NOT_IMPORTED_TOTAL); + LazyLock::force(&BLOCKS_REFUSED_TOTAL); + LazyLock::force(&BLOCK_PROPOSAL_FAILURES_TOTAL); + LazyLock::force(&BLOCK_PUBLICATION_DELAY_SECONDS); + LazyLock::force(&AGGREGATES_PUBLISHED_TOTAL); + LazyLock::force(&AGGREGATION_FAILURES_TOTAL); + LazyLock::force(&FEE_RECIPIENT_MISMATCHES_TOTAL); + LazyLock::force(&BEACON_NODE_AVAILABLE); + LazyLock::force(&SIGNING_DURATION_SECONDS); + LazyLock::force(&PUBLICATION_DELAY_SECONDS); +} + +pub fn set_validators_loaded(count: u64) { + VALIDATORS_LOADED.set(count as i64); +} + +pub fn set_validators_resolved(count: u64) { + VALIDATORS_RESOLVED.set(count as i64); +} + +pub fn set_duties_held(count: u64) { + DUTIES_HELD.set(count as i64); +} + +pub fn inc_attestations_published(count: u64) { + ATTESTATIONS_PUBLISHED_TOTAL.inc_by(count); +} + +pub fn inc_attestation_failures() { + ATTESTATION_FAILURES_TOTAL.inc(); +} + +pub fn inc_signing_failures() { + SIGNING_FAILURES_TOTAL.inc(); +} + +pub fn inc_attestations_refused() { + ATTESTATIONS_REFUSED_TOTAL.inc(); +} + +pub fn inc_attestation_deadline_missed() { + ATTESTATION_DEADLINE_MISSED_TOTAL.inc(); +} + +pub fn inc_blocks_proposed() { + BLOCKS_PROPOSED_TOTAL.inc(); +} + +pub fn inc_blocks_broadcast_not_imported() { + BLOCKS_BROADCAST_NOT_IMPORTED_TOTAL.inc(); +} + +pub fn inc_blocks_refused() { + BLOCKS_REFUSED_TOTAL.inc(); +} + +pub fn inc_block_proposal_failures() { + BLOCK_PROPOSAL_FAILURES_TOTAL.inc(); +} + +/// Record how long after a slot's start its block was accepted. +pub fn observe_block_publication_delay(seconds: f64) { + BLOCK_PUBLICATION_DELAY_SECONDS.observe(seconds); +} + +pub fn inc_aggregates_published(count: u64) { + AGGREGATES_PUBLISHED_TOTAL.inc_by(count); +} + +pub fn inc_aggregation_failures() { + AGGREGATION_FAILURES_TOTAL.inc(); +} + +pub fn inc_fee_recipient_mismatches() { + FEE_RECIPIENT_MISMATCHES_TOTAL.inc(); +} + +pub fn set_beacon_node_available(available: bool) { + BEACON_NODE_AVAILABLE.set(i64::from(available)); +} + +/// Time one slot's signing loop. The returned guard records to +/// `ethlambda_validator_signing_duration_seconds` when dropped; hold it only +/// across the synchronous signing loop, never across an await, or the sample +/// stops meaning "how long the signing took" and starts meaning "how long +/// the signing took plus whatever else shared the guard's scope". +pub fn time_signing() -> TimingGuard { + TimingGuard::new(&SIGNING_DURATION_SECONDS) +} + +/// Record how long after a slot's start its attestations were accepted. +pub fn observe_publication_delay(seconds: f64) { + PUBLICATION_DELAY_SECONDS.observe(seconds); +} + +/// A Prometheus endpoint for this process. +/// +/// Deliberately not `ethlambda-rpc`'s router: reaching that would put the +/// blockchain and storage crates, and RocksDB with them, on a process that +/// only signs attestations. +pub fn router() -> axum::Router { + use axum::response::IntoResponse; + use axum::routing::get; + + async fn serve() -> impl IntoResponse { + match ethlambda_metrics::gather_default_metrics() { + Ok(body) => ( + [( + axum::http::header::CONTENT_TYPE, + "text/plain; version=0.0.4", + )], + body, + ) + .into_response(), + Err(err) => { + tracing::warn!(%err, "Failed to gather metrics"); + axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + } + } + + async fn health() -> impl IntoResponse { + ( + [(axum::http::header::CONTENT_TYPE, "application/json")], + r#"{"status":"healthy","service":"ethlambda-validator"}"#, + ) + } + + axum::Router::new() + .route("/metrics", get(serve)) + .route("/health", get(health)) +} diff --git a/crates/validator/src/proposal.rs b/crates/validator/src/proposal.rs new file mode 100644 index 000000000..91993f7ce --- /dev/null +++ b/crates/validator/src/proposal.rs @@ -0,0 +1,643 @@ +//! Producing, signing and publishing this slot's block. +//! +//! The attestation path's counterpart, and deliberately shaped like it. What +//! differs is the cost of each step and therefore where the checks sit. +//! +//! # Three signatures, one slot +//! +//! A proposal needs two signatures from this client and gets a third thing +//! back from the beacon node in between: +//! +//! 1. The **RANDAO reveal** for the slot's epoch, signed first because the node +//! cannot build a body without it. It is not slashable and is not guarded. +//! 2. The **block**, built by the beacon node around that reveal. This client +//! never chooses a block's contents; it asks for one and checks that what +//! came back is the one it asked for. +//! 3. The **block signature**, which is slashable and is guarded. +//! +//! # Why the guard is consulted twice +//! +//! Once before asking for a block, once before signing it. +//! +//! The early check is not about safety, it is about cost. Producing a block +//! makes the beacon node drive an execution-layer payload build, which is the +//! most expensive thing this client can ask of it. Discovering only afterwards +//! that the slot was already proposed wastes that for nothing. +//! +//! The check before signing is the one that matters, and it records. Recording +//! at signing time rather than after publication is what makes the duty loop's +//! deadline safe: a proposal abandoned between signing and publishing cannot be +//! re-signed, because the guard already counts that slot as proposed. +//! +//! # Do not retry this call within a slot +//! +//! For the same reason [`crate::attestation::AttestationService::attest`] must +//! not be: a second call asks the node for a second block, which will differ +//! from the first because the node has packed whatever arrived in between, and +//! two distinct blocks for one slot from one validator is a slashable proposer +//! offence. The guard refuses it, so a retry is merely useless rather than +//! dangerous, but the caller should not be relying on the guard for that. + +use std::sync::Arc; + +use ethlambda_types::beacon::fork::ForkName; +use ethlambda_types::beacon::primitives::{Bytes32, ExecutionAddress, HashTreeRoot as _, Slot}; +use ethlambda_types::beacon::signing::compute_epoch_at_slot; +use tokio::sync::RwLock; +use tracing::{info, warn}; + +use crate::beacon_node::dto::{ProposerDutyDto, parse_pubkey}; +use crate::beacon_node::{BeaconNodeApi, BlockRequest, Published, validate_produced_block}; +use crate::error::{Error, Result}; +use crate::keys::ValidatorStore; +use crate::proposal_guard::ProposalGuard; +use crate::signing::SigningContext; + +pub struct ProposalService { + beacon_node: Arc, + context: Arc, + /// Thirty-two bytes put in every block this client proposes. Consensus + /// never reads them. + graffiti: Bytes32, + /// Where this client asked for its execution-layer block rewards to be + /// paid, if the operator named an address. + /// + /// Held only to check what comes back. The beacon node is the one that + /// builds the payload, and the specification is explicit that it need not + /// honour the preparation it was sent. + fee_recipient: Option, + /// What this process has already proposed, per validator. + /// + /// A `std::sync::Mutex` for the reason [`crate::attestation`]'s is: the + /// critical section is a lookup and an insert with no await inside it, so + /// an async mutex would buy nothing and cost a scheduling point. + /// + /// Not slashing protection. See [`ProposalGuard`]. + guard: std::sync::Mutex, +} + +impl ProposalService { + pub fn new( + beacon_node: Arc, + context: Arc, + graffiti: Bytes32, + fee_recipient: Option, + ) -> Self { + Self { + beacon_node, + context, + graffiti, + fee_recipient, + guard: std::sync::Mutex::new(ProposalGuard::new()), + } + } + + /// Compare the produced block's fee recipient against what was asked for, + /// and complain loudly if they differ. + /// + /// # Why this warns instead of refusing + /// + /// The specification requires the check and leaves the response open: a + /// client "should confirm that it finds the fee recipient within the block + /// acceptable before signing it". Both answers cost the operator money, and + /// they are not the same amount. + /// + /// Refusing loses the consensus-layer reward *and* the execution-layer one, + /// and costs the network a slot. Signing loses only the execution-layer + /// reward, which was already going elsewhere the moment the node built the + /// payload. So signing is the cheaper of the two for the operator and + /// strictly better for the network, and the thing that actually fixes it is + /// the operator noticing. + /// + /// Hence `error!` rather than `warn!`, and a counter beside it: this should + /// never happen, and when it does it is a misconfigured or untrustworthy + /// beacon node paying someone else, every time this validator proposes. + /// + /// With no configured address there is nothing to compare against and this + /// says nothing, which is the same silence as a matching one; the startup + /// warning is where that case is reported. + fn check_fee_recipient( + &self, + produced: &crate::beacon_node::block_contents::ProducedBlock, + slot: Slot, + ) { + let Some(expected) = self.fee_recipient else { + return; + }; + let actual = produced.block().body.execution_payload.fee_recipient; + if actual != expected { + tracing::error!( + %slot, + expected = %crate::beacon_node::dto::encode_hex(&expected.0), + actual = %crate::beacon_node::dto::encode_hex(&actual.0), + "Block pays its execution-layer rewards to an address this client did not ask \ + for; signing it anyway, since refusing would also forfeit the consensus reward \ + and cost the network a slot. Check this beacon node's proposer preparation." + ); + crate::metrics::inc_fee_recipient_mismatches(); + } + } + + /// Produce, sign and publish the block for `slot`. + /// + /// Returns what became of it, which is not always a clean success: see + /// [`Published`]. + pub async fn propose( + &self, + slot: Slot, + duty: &ProposerDutyDto, + store: &RwLock, + ) -> Result { + let pubkey = parse_pubkey(&duty.pubkey)?; + let epoch = compute_epoch_at_slot(slot); + + // Before paying for a block, not after. See the module doc. + // + // A poisoned lock is treated as a refusal rather than unwrapped: the + // lock is poisoned only by a panic while it is held, nothing inside it + // can panic, and a crash in the duty path would be a worse outcome than + // a skipped proposal. + match self.guard.lock() { + Ok(guard) => { + if let Err(refusal) = guard.check(&pubkey, slot) { + warn!( + %slot, + validator = duty.validator_index, + %refusal, + "Refusing to propose: this process already proposed this slot" + ); + crate::metrics::inc_blocks_refused(); + return Err(Error::ProposalRefused { + slot, + reason: refusal.to_string(), + }); + } + } + Err(err) => { + warn!(%slot, %err, "Proposal guard is poisoned; refusing to propose"); + crate::metrics::inc_blocks_refused(); + return Err(Error::ProposalRefused { + slot, + reason: "the proposal guard is poisoned".to_string(), + }); + } + } + + // Scoped away from every await, the way the attestation path's read + // guard is, and for the reason spelled out there: a write-preferring + // lock lets one queued keymanager writer block every reader behind it, + // so a guard held across the block production round trip below would + // put the whole duty path behind it. + let randao_reveal = { + let store = store.read().await; + self.context.sign_randao(&store, &pubkey, epoch)? + }; + + let request = BlockRequest { + slot, + proposer_index: duty.validator_index, + randao_reveal, + graffiti: self.graffiti, + }; + let produced = self.beacon_node.produce_block(&request).await?; + + // Checked again here, having already been checked by the + // implementation this call went through. Not redundant, and the + // attestation path does the same for the same reason: the trait is + // public, so this is the last point at which a wrong answer from an + // implementation that failed to honour its contract can be stopped + // before it becomes a signature. + // + // It matters more here than it reads. The guard records the *requested* + // slot while the signature covers the *produced* header's slot. If + // those ever diverged and both fell in one epoch, the domain would be + // identical and the result would be a valid, unguarded second block for + // a slot already proposed. + validate_produced_block(&request, &produced)?; + + // The node's fork and this client's must agree, or the signature is + // computed under a fork version the network does not accept. + // + // The two are separate facts. The block was decoded, and will be + // published, under the fork the node named in its response header. The + // signing domain comes from this client's own fork schedule, fetched + // from `/config/spec` at startup. They normally agree because they came + // from the same place; when they do not, one of them is wrong about + // where a fork boundary sits, and signing anyway produces a block that + // is rejected for a reason nothing in the logs would explain. + let expected = self.context.config.fork_at_epoch(epoch); + if produced.fork != expected { + return Err(Error::InconsistentResponse(format!( + "node produced a {} block for slot {slot}, but this client's fork schedule puts \ + epoch {epoch} in {}; signing would use the wrong fork version", + produced.fork.as_str(), + expected.as_str() + ))); + } + + self.check_fee_recipient(&produced, slot); + + let fork = produced.fork; + info!( + %slot, + validator = duty.validator_index, + fork = fork.as_str(), + blobs = produced.blob_count(), + "Block produced; signing" + ); + + // The root is taken from the decoded container, never from anything + // reassembled here, and it is taken once: the same value is what the + // guard's slot is recorded against and what the signature covers. + let block_root = produced.block().hash_tree_root(); + + let signature = { + let store = store.read().await; + + // The check that matters, and the one that records. Everything + // after this point may be abandoned without risk, because the + // guard already counts this slot as proposed. + match self.guard.lock() { + Ok(mut guard) => guard.check_and_record(&pubkey, slot).map_err(|refusal| { + crate::metrics::inc_blocks_refused(); + Error::ProposalRefused { + slot, + reason: refusal.to_string(), + } + })?, + Err(err) => { + warn!(%slot, %err, "Proposal guard is poisoned; refusing to sign"); + crate::metrics::inc_blocks_refused(); + return Err(Error::ProposalRefused { + slot, + reason: "the proposal guard is poisoned".to_string(), + }); + } + } + + self.context.sign_block(&store, &pubkey, block_root, slot)? + }; + + let body = produced.into_signed_ssz(signature); + let published = self + .publish(fork, &body, slot, duty.validator_index) + .await?; + crate::metrics::inc_blocks_proposed(); + Ok(published) + } + + /// Send the signed body and report what the node made of it. + /// + /// Split out so the 202 case has one place to be explained rather than + /// being an arm of an already long function. + async fn publish( + &self, + fork: ForkName, + body: &[u8], + slot: Slot, + validator: u64, + ) -> Result { + let published = self.beacon_node.publish_block(fork, body).await?; + match published { + Published::Imported => { + info!(%slot, validator, bytes = body.len(), "Block published"); + } + // Not an error, and not silence either. The block reached the + // network, so the proposal may well have worked; what failed is the + // node's own import, which usually means its execution layer is + // unsynced or the parent is not what this client thought. An + // operator seeing this repeatedly has a beacon node problem, not a + // validator one. + Published::BroadcastNotImported => { + warn!( + %slot, + validator, + "Block was broadcast but the beacon node could not import it; \ + check that node's execution layer" + ); + crate::metrics::inc_blocks_broadcast_not_imported(); + } + } + Ok(published) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::beacon_node::block_contents::SignedBlockContents; + use crate::beacon_node::mock::MockBeaconNode; + use ethlambda_types::beacon::config::Config; + use ethlambda_types::beacon::primitives::{BlsPubkey, H160, Root}; + use libssz::SszDecode as _; + + fn secret() -> [u8; 32] { + hex::decode("000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f") + .expect("valid hex") + .try_into() + .expect("32 bytes") + } + + fn context() -> Arc { + Arc::new(SigningContext { + config: Config::mainnet(), + genesis_validators_root: Root::ZERO, + }) + } + + /// A store holding one key, and the pubkey it resolves to. + fn store() -> (RwLock, BlsPubkey) { + let mut store = ValidatorStore::new(); + let pubkey = store.insert_secret("test", &secret()).expect("inserts"); + (RwLock::new(store), pubkey) + } + + fn duty(pubkey: &BlsPubkey, validator_index: u64, slot: Slot) -> ProposerDutyDto { + ProposerDutyDto { + pubkey: crate::beacon_node::dto::encode_hex(&pubkey.0), + validator_index, + slot, + } + } + + fn service(node: Arc) -> ProposalService { + ProposalService::new(node, context(), Bytes32::repeat_byte(0xab), None) + } + + fn service_expecting( + node: Arc, + fee_recipient: ExecutionAddress, + ) -> ProposalService { + ProposalService::new( + node, + context(), + Bytes32::repeat_byte(0xab), + Some(fee_recipient), + ) + } + + /// A slot inside mainnet's electra era. + /// + /// Not an arbitrary small number: the mock produces an electra-shaped + /// block and names electra, and `propose` refuses to sign when the node's + /// fork and this client's schedule disagree. Slot 96 is phase0 on mainnet, + /// so it would be refused, which is the behaviour these tests want + /// everywhere except the one that asserts it. + fn slot() -> Slot { + use ethlambda_types::beacon::preset::SLOTS_PER_EPOCH; + Config::mainnet().electra_fork_epoch * SLOTS_PER_EPOCH + 96 + } + + #[tokio::test] + async fn a_block_is_produced_signed_and_published() { + let (store, pubkey) = store(); + let node = Arc::new(MockBeaconNode::new().with_block(slot(), 7)); + let service = service(node.clone()); + + let published = service + .propose(slot(), &duty(&pubkey, 7, slot()), &store) + .await + .expect("proposes"); + assert_eq!(published, Published::Imported); + + let sent = node.published_blocks(); + assert_eq!(sent.len(), 1); + assert_eq!(sent[0].0, ForkName::Electra); + } + + /// The property the whole path exists to get right: the signature must + /// cover the block the node produced, and the block must reach the wire + /// unchanged. + #[tokio::test] + async fn the_published_body_carries_the_produced_block_and_a_matching_signature() { + use blst::min_pk::{PublicKey, Signature}; + + let (store, pubkey) = store(); + let node = Arc::new(MockBeaconNode::new().with_block(slot(), 7)); + let service = service(node.clone()); + + service + .propose(slot(), &duty(&pubkey, 7, slot()), &store) + .await + .expect("proposes"); + + let (_, body) = node.published_blocks().remove(0); + let decoded = SignedBlockContents::from_ssz_bytes(&body).expect("decodes"); + assert_eq!(decoded.signed_block.message.slot, slot()); + assert_eq!(decoded.signed_block.message.proposer_index, 7); + + let root = + context().block_signing_root(decoded.signed_block.message.hash_tree_root(), slot()); + let pk = PublicKey::from_bytes(&pubkey.0).expect("valid pubkey"); + let sig = + Signature::from_bytes(&decoded.signed_block.signature.0).expect("valid signature"); + assert_eq!( + sig.verify( + true, + root.as_slice(), + b"BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_", + &[], + &pk, + true + ), + blst::BLST_ERROR::BLST_SUCCESS, + "the published signature must verify over the published block" + ); + } + + #[tokio::test] + async fn the_configured_graffiti_reaches_the_block_request() { + let (store, pubkey) = store(); + let node = Arc::new(MockBeaconNode::new().with_block(slot(), 7)); + service(node.clone()) + .propose(slot(), &duty(&pubkey, 7, slot()), &store) + .await + .expect("proposes"); + + // The mock builds its own block rather than echoing the request, so + // the graffiti is asserted where it is actually carried: on the + // request the node received. + let seen = node.block_requests(); + assert_eq!(seen.len(), 1); + assert_eq!(seen[0].graffiti, Bytes32::repeat_byte(0xab)); + } + + /// The reveal is over the slot's epoch, and it is what the node is given + /// to build a body around. A client that signed the wrong epoch would get + /// a block back and only find out when the network rejected it. + #[tokio::test] + async fn the_randao_reveal_is_signed_over_the_slots_epoch() { + use blst::min_pk::{PublicKey, Signature}; + + let (store, pubkey) = store(); + let slot = slot(); + let node = Arc::new(MockBeaconNode::new().with_block(slot, 7)); + service(node.clone()) + .propose(slot, &duty(&pubkey, 7, slot), &store) + .await + .expect("proposes"); + + let reveal = node.block_requests().remove(0).randao_reveal; + let root = context().randao_signing_root(compute_epoch_at_slot(slot)); + let pk = PublicKey::from_bytes(&pubkey.0).expect("valid pubkey"); + let sig = Signature::from_bytes(&reveal.0).expect("valid signature"); + assert_eq!( + sig.verify( + true, + root.as_slice(), + b"BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_", + &[], + &pk, + true + ), + blst::BLST_ERROR::BLST_SUCCESS + ); + } + + /// The guard is consulted before the block is asked for, not after, so a + /// slot already proposed costs the beacon node nothing. + #[tokio::test] + async fn a_repeated_slot_is_refused_without_asking_for_a_block() { + let (store, pubkey) = store(); + let node = Arc::new(MockBeaconNode::new().with_block(slot(), 7)); + let service = service(node.clone()); + + service + .propose(slot(), &duty(&pubkey, 7, slot()), &store) + .await + .expect("first"); + let err = service + .propose(slot(), &duty(&pubkey, 7, slot()), &store) + .await + .expect_err("a second block for one slot must be refused"); + + assert!(matches!(err, Error::ProposalRefused { .. }), "got {err:?}"); + assert_eq!( + node.block_requests().len(), + 1, + "the refused attempt must not have asked the node for a block" + ); + assert_eq!(node.published_blocks().len(), 1); + } + + #[tokio::test] + async fn a_node_that_cannot_import_the_block_is_reported_rather_than_hidden() { + let (store, pubkey) = store(); + let node = Arc::new( + MockBeaconNode::new() + .with_block(slot(), 7) + .with_publish_outcome(Published::BroadcastNotImported), + ); + + let published = service(node) + .propose(slot(), &duty(&pubkey, 7, slot()), &store) + .await + .expect("202 is not an error"); + assert_eq!(published, Published::BroadcastNotImported); + } + + /// A duty naming a key this client does not hold must fail before anything + /// is asked of the beacon node. + #[tokio::test] + async fn a_duty_for_an_unknown_validator_is_an_error() { + let store = RwLock::new(ValidatorStore::new()); + let node = Arc::new(MockBeaconNode::new().with_block(slot(), 7)); + + let err = service(node.clone()) + .propose(slot(), &duty(&BlsPubkey([9; 48]), 7, slot()), &store) + .await + .expect_err("must fail"); + assert!(matches!(err, Error::UnknownValidator(_)), "got {err:?}"); + assert!(node.block_requests().is_empty()); + } + + /// The mock's block carries a zeroed fee recipient, so an expectation of + /// anything else is a mismatch. The block must still be signed and + /// published: refusing would forfeit the consensus reward as well as the + /// execution one and cost the network a slot, while the execution reward + /// was already going elsewhere the moment the node built the payload. + #[tokio::test] + async fn a_block_paying_the_wrong_address_is_still_published() { + let (store, pubkey) = store(); + let node = Arc::new(MockBeaconNode::new().with_block(slot(), 7)); + + let published = service_expecting(node.clone(), H160([0xfe; 20])) + .propose(slot(), &duty(&pubkey, 7, slot()), &store) + .await + .expect("a wrong fee recipient must not stop the proposal"); + + assert_eq!(published, Published::Imported); + assert_eq!(node.published_blocks().len(), 1); + } + + #[tokio::test] + async fn a_block_paying_the_expected_address_is_published_too() { + let (store, pubkey) = store(); + let node = Arc::new(MockBeaconNode::new().with_block(slot(), 7)); + + // The fixture block's payload is zeroed, so the zero address is the + // one it actually pays. + service_expecting(node.clone(), H160([0; 20])) + .propose(slot(), &duty(&pubkey, 7, slot()), &store) + .await + .expect("proposes"); + assert_eq!(node.published_blocks().len(), 1); + } + + /// The two forks in play must agree before anything is signed. + /// + /// The block is decoded and published under the fork the node named; the + /// signing domain comes from this client's own schedule. A disagreement + /// means one of them is wrong about where a fork boundary sits, and signing + /// anyway yields a block rejected for a reason nothing in the logs would + /// explain. + /// + /// The fixture makes them disagree the only way a test can: the mock always + /// names electra, and slot 96 is phase0 on mainnet's schedule. + #[tokio::test] + async fn a_fork_the_client_does_not_expect_is_refused_before_signing() { + let (store, pubkey) = store(); + let node = Arc::new(MockBeaconNode::new().with_block(96, 7)); + + let err = service(node.clone()) + .propose(96, &duty(&pubkey, 7, 96), &store) + .await + .expect_err("a fork mismatch must not be signed"); + + assert!(matches!(err, Error::InconsistentResponse(_)), "got {err:?}"); + assert!( + node.published_blocks().is_empty(), + "nothing may be published when the forks disagree" + ); + } + + /// A failed production must leave the guard untouched, so the slot can + /// still be proposed if a later attempt succeeds. Recording on the early + /// check rather than at signing time would have burned it. + /// + /// The same service and the same node throughout, deliberately: a second + /// service would carry a second, empty guard and the test would pass + /// whether or not the first one recorded. + #[tokio::test] + async fn a_failed_production_does_not_burn_the_slot() { + let (store, pubkey) = store(); + let node = Arc::new( + MockBeaconNode::new() + .with_block(slot(), 7) + .failing_call("produce_block", "node is unhappy"), + ); + let service = service(node.clone()); + + service + .propose(slot(), &duty(&pubkey, 7, slot()), &store) + .await + .expect_err("production failed"); + + node.stop_failing("produce_block"); + service + .propose(slot(), &duty(&pubkey, 7, slot()), &store) + .await + .expect("the slot was never recorded, so it can still be proposed"); + assert_eq!(node.published_blocks().len(), 1); + } +} diff --git a/crates/validator/src/proposal_guard.rs b/crates/validator/src/proposal_guard.rs new file mode 100644 index 000000000..3d87c03a5 --- /dev/null +++ b/crates/validator/src/proposal_guard.rs @@ -0,0 +1,271 @@ +//! An in-process guard against signing two blocks for one slot. +//! +//! The proposal-shaped counterpart to [`crate::attestation_guard`], and the +//! same disclaimer applies in full: **this is not slashing protection.** It +//! holds nothing on disk, so it knows nothing about a previous run of this +//! process or about another process holding the same keys. A restart empties +//! it. +//! +//! # Why it is a separate guard rather than a field on the other one +//! +//! The two answer different questions and would answer them badly if merged. +//! An attestation is judged on its source and target *epochs*; a block is +//! judged on its *slot*, and a validator can legitimately propose in an epoch +//! it also attests in. Keying both off one record would either refuse a legal +//! proposal or admit an illegal one, depending on which field won. +//! +//! # The rule +//! +//! EIP-3076's minimal variant for blocks, per validator: remember the highest +//! slot proposed, and refuse anything that does not strictly advance it. +//! +//! One record per validator, like the attestation guard, and for the same +//! reason: the full condition ("never two distinct blocks for one slot") needs +//! the whole history to evaluate, while "the slot must strictly advance" needs +//! one number and is strictly more conservative. An honest proposer's blocks +//! already advance this way, since a validator is assigned at most one slot per +//! epoch and epochs only move forward. +//! +//! # What it actually closes +//! +//! The same two shapes the attestation guard closes, reached the same ways: +//! +//! - **A backward wall-clock step.** The duty loop takes its slot from +//! `SystemTime::now()`, which is not monotonic. An NTP correction can +//! re-enter a slot already proposed, and the block produced the second time +//! will differ from the first, because the beacon node has since seen more +//! attestations and a different head. Two distinct blocks for one slot from +//! one validator is a slashable proposer offence, and unlike a double vote it +//! needs no second validator to be caught: the two signed headers are the +//! whole evidence. +//! - **A proposer schedule replaced mid-epoch.** Proposer duties are only +//! final once the epoch's randao is fixed, so a refresh that lands late can +//! move a validator between slots within the epoch. Acting on both is two +//! blocks from one key. +//! +//! Refusing is always safe here, and cheaper than it is for an attestation: a +//! skipped proposal costs one block reward, and a proposer slashing is the most +//! expensive thing a validator can do. + +use std::collections::HashMap; + +use ethlambda_types::beacon::primitives::{BlsPubkey, Slot}; + +/// Why a proposal was refused. +/// +/// One variant, unlike [`crate::attestation_guard::Refusal`]'s two, because +/// there is only one rule to break. It stays an enum rather than a struct so +/// that the call site's `match`/`Display` shape matches the attestation path's, +/// and so a second rule can be added without changing the signature. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Refusal { + /// The slot is at or below one already proposed for this validator. + /// + /// Equality is refused as well as regression. The same slot proposed twice + /// is a slashable offence unless the two blocks are byte-identical, and + /// this guard deliberately does not keep enough to tell those apart. Nor + /// would it help: a block re-produced for the same slot is almost never + /// identical, since the beacon node packs whatever attestations have + /// arrived since. + SlotNotAdvanced { signed: Slot, proposed: Slot }, +} + +impl std::fmt::Display for Refusal { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::SlotNotAdvanced { signed, proposed } => write!( + f, + "slot {proposed} does not advance past {signed}, already proposed this run" + ), + } + } +} + +/// Per-validator record of the highest slot proposed in this run. +/// +/// Not `Clone`, for the reason [`crate::attestation_guard::AttestationGuard`] +/// is not: one guard per process is the point, and a copy would be a second +/// opinion about what has been signed. +#[derive(Debug, Default)] +pub struct ProposalGuard { + proposed: HashMap, +} + +impl ProposalGuard { + pub fn new() -> Self { + Self::default() + } + + /// Whether a block for `slot` may be signed for `pubkey`, recording + /// nothing. + /// + /// Separate from [`Self::record`] so a caller can decide before paying for + /// a block production round trip, which is far more expensive than the + /// attestation equivalent: it costs the beacon node an execution-layer + /// payload build. + pub fn check(&self, pubkey: &BlsPubkey, slot: Slot) -> Result<(), Refusal> { + let Some(&signed) = self.proposed.get(pubkey) else { + // Nothing proposed for this validator this run. As with the + // attestation guard, that says nothing about previous runs; the + // gap is the crate-level scope decision, not an oversight here. + return Ok(()); + }; + + if slot <= signed { + return Err(Refusal::SlotNotAdvanced { + signed, + proposed: slot, + }); + } + Ok(()) + } + + /// Record that a block for `slot` was signed for `pubkey`. + /// + /// Takes the maximum rather than overwriting, so a caller recording out of + /// order cannot lower the bar. Nothing does that today; making it + /// order-independent is one `max` and removes a class of bug that would + /// only ever appear under a clock step, which is the exact situation the + /// guard exists for. + pub fn record(&mut self, pubkey: BlsPubkey, slot: Slot) { + let entry = self.proposed.entry(pubkey).or_insert(slot); + *entry = (*entry).max(slot); + } + + /// Check and record in one step, for the common call site. + /// + /// Recording only on success is what makes a refusal idempotent: a refused + /// proposal must not move the bar it was measured against. + pub fn check_and_record(&mut self, pubkey: &BlsPubkey, slot: Slot) -> Result<(), Refusal> { + self.check(pubkey, slot)?; + self.record(*pubkey, slot); + Ok(()) + } + + /// How many validators have proposed something this run. For metrics. + pub fn len(&self) -> usize { + self.proposed.len() + } + + pub fn is_empty(&self) -> bool { + self.proposed.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pubkey(byte: u8) -> BlsPubkey { + BlsPubkey([byte; 48]) + } + + #[test] + fn a_first_proposal_is_allowed() { + let mut guard = ProposalGuard::new(); + assert!(guard.check_and_record(&pubkey(1), 96).is_ok()); + } + + #[test] + fn advancing_the_slot_is_allowed() { + let mut guard = ProposalGuard::new(); + guard.check_and_record(&pubkey(1), 96).expect("first"); + assert!(guard.check_and_record(&pubkey(1), 128).is_ok()); + } + + /// The backward-clock-step case: the same slot re-entered after an NTP + /// correction. The block produced the second time is a different block, + /// because the beacon node has packed whatever arrived in between, so this + /// is the proposer slashing the guard exists to stop. + #[test] + fn proposing_the_same_slot_twice_is_refused() { + let mut guard = ProposalGuard::new(); + guard.check_and_record(&pubkey(1), 96).expect("first"); + + let err = guard + .check_and_record(&pubkey(1), 96) + .expect_err("a second block for one slot must be refused"); + + assert_eq!( + err, + Refusal::SlotNotAdvanced { + signed: 96, + proposed: 96 + } + ); + } + + #[test] + fn a_regressed_slot_is_refused() { + let mut guard = ProposalGuard::new(); + guard.check_and_record(&pubkey(1), 128).expect("first"); + assert!(guard.check_and_record(&pubkey(1), 96).is_err()); + } + + /// The property that makes this per-validator rather than global: two of + /// this client's validators can be assigned different slots in one epoch, + /// and the second must not be blocked by the first. + #[test] + fn one_validator_does_not_block_another() { + let mut guard = ProposalGuard::new(); + guard + .check_and_record(&pubkey(1), 128) + .expect("first validator"); + + assert!( + guard.check_and_record(&pubkey(2), 96).is_ok(), + "a second validator proposing an earlier slot must be allowed" + ); + } + + #[test] + fn a_refusal_records_nothing() { + let mut guard = ProposalGuard::new(); + guard.check_and_record(&pubkey(1), 128).expect("first"); + guard + .check_and_record(&pubkey(1), 96) + .expect_err("regression refused"); + + // Still measured against 128, not against the refused 96: a proposal + // at 100 must still be refused. + assert!( + guard.check_and_record(&pubkey(1), 100).is_err(), + "the refused attempt must not have lowered the recorded slot" + ); + } + + #[test] + fn check_alone_does_not_record() { + let guard = ProposalGuard::new(); + guard.check(&pubkey(1), 96).expect("allowed"); + guard + .check(&pubkey(1), 96) + .expect("still allowed, because check records nothing"); + } + + #[test] + fn recording_out_of_order_does_not_lower_the_bar() { + let mut guard = ProposalGuard::new(); + guard.record(pubkey(1), 128); + guard.record(pubkey(1), 96); + + assert!( + guard.check(&pubkey(1), 100).is_err(), + "the earlier record must not have displaced the later one" + ); + } + + /// Slot 0 is a real slot, and `HashMap`'s absent-versus-zero distinction is + /// exactly the kind of thing a `unwrap_or_default()` would quietly erase. + /// A validator that proposed slot 0 must not be allowed to propose it + /// again. + #[test] + fn slot_zero_is_recorded_like_any_other() { + let mut guard = ProposalGuard::new(); + guard.check_and_record(&pubkey(1), 0).expect("first"); + assert!( + guard.check_and_record(&pubkey(1), 0).is_err(), + "slot 0 proposed twice must be refused, not treated as never proposed" + ); + } +} diff --git a/crates/validator/src/secure_fs.rs b/crates/validator/src/secure_fs.rs new file mode 100644 index 000000000..574056e9f --- /dev/null +++ b/crates/validator/src/secure_fs.rs @@ -0,0 +1,140 @@ +//! Helpers for writing files that hold secrets or decide what a validator +//! signs: the API token, imported keystores and their passwords, and the +//! validator definitions file. +//! +//! `std::fs::write` and `File::create` land at whatever mode the platform +//! default gives them, `0644` under a standard umask on unix, which makes +//! every one of those files readable by any other local account. Lighthouse's +//! convention here is `0600`; everything in this crate that writes such a +//! file must go through one of these two functions instead, to actually +//! follow that convention rather than only claim to. + +use std::path::Path; + +/// Write `contents`, creating the file if it does not exist and truncating it +/// if it does, at mode `0600` on unix. +/// +/// For files this crate is always the sole, trusted writer of and is willing +/// to overwrite outright: the API token, and the validator definitions file's +/// `.tmp` sibling (`ValidatorDefinitions::save` renames it into place, which +/// preserves the mode set here). Neither is named from attacker-influenced +/// input, so there is no symlink concern for these; see +/// [`write_private_no_symlink`] for the ones that are. +#[cfg(unix)] +pub(crate) fn write_private(path: &Path, contents: impl AsRef<[u8]>) -> std::io::Result<()> { + use std::io::Write as _; + use std::os::unix::fs::OpenOptionsExt as _; + let mut file = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(path)?; + file.write_all(contents.as_ref()) +} + +#[cfg(not(unix))] +pub(crate) fn write_private(path: &Path, contents: impl AsRef<[u8]>) -> std::io::Result<()> { + std::fs::write(path, contents) +} + +/// Write `contents` at mode `0600`, refusing to follow an existing symlink at +/// `path`. +/// +/// For the imported keystore and password files, whose names are derived +/// from the public key and are therefore predictable: an attacker able to +/// place a symlink at one of these paths ahead of a legitimate import must +/// not be able to redirect the write to an arbitrary target the operator can +/// write to. `O_NOFOLLOW` refuses to open the path at all when its final +/// component is a symlink, but still opens and truncates an existing regular +/// file, so re-importing the same key (the common case: rotating its +/// password, or simply retrying) overwrites its files exactly as before +/// rather than failing. +#[cfg(unix)] +pub(crate) fn write_private_no_symlink( + path: &Path, + contents: impl AsRef<[u8]>, +) -> std::io::Result<()> { + use std::io::Write as _; + use std::os::unix::fs::OpenOptionsExt as _; + let mut file = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW) + .open(path)?; + file.write_all(contents.as_ref()) +} + +#[cfg(not(unix))] +pub(crate) fn write_private_no_symlink( + path: &Path, + contents: impl AsRef<[u8]>, +) -> std::io::Result<()> { + std::fs::write(path, contents) +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use std::os::unix::fs::PermissionsExt as _; + + #[test] + fn write_private_creates_a_mode_0600_file() { + let dir = tempfile::tempdir().expect("temp dir"); + let path = dir.path().join("secret"); + write_private(&path, "shh").expect("writes"); + + let mode = std::fs::metadata(&path) + .expect("metadata") + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o600, "got {mode:o}"); + assert_eq!(std::fs::read_to_string(&path).expect("reads"), "shh"); + } + + #[test] + fn write_private_no_symlink_creates_a_mode_0600_file() { + let dir = tempfile::tempdir().expect("temp dir"); + let path = dir.path().join("secret"); + write_private_no_symlink(&path, "shh").expect("writes"); + + let mode = std::fs::metadata(&path) + .expect("metadata") + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o600, "got {mode:o}"); + } + + #[test] + fn write_private_no_symlink_overwrites_an_existing_regular_file() { + let dir = tempfile::tempdir().expect("temp dir"); + let path = dir.path().join("secret"); + write_private_no_symlink(&path, "first").expect("writes"); + write_private_no_symlink(&path, "second").expect("overwrites"); + + assert_eq!(std::fs::read_to_string(&path).expect("reads"), "second"); + } + + #[test] + fn write_private_no_symlink_refuses_to_follow_a_symlink() { + let dir = tempfile::tempdir().expect("temp dir"); + let target = dir.path().join("target"); + std::fs::write(&target, "untouched").expect("writes target"); + let link = dir.path().join("link"); + std::os::unix::fs::symlink(&target, &link).expect("symlinks"); + + let err = write_private_no_symlink(&link, "attacker-controlled") + .expect_err("must refuse to follow the symlink"); + // `ErrorKind::FilesystemLoop` (the natural match for `O_NOFOLLOW`'s + // `ELOOP`) is still unstable as of this toolchain, so match the raw + // OS error instead. + assert_eq!(err.raw_os_error(), Some(libc::ELOOP)); + assert_eq!( + std::fs::read_to_string(&target).expect("reads"), + "untouched", + "the symlink target must be untouched" + ); + } +} diff --git a/crates/validator/src/signing.rs b/crates/validator/src/signing.rs new file mode 100644 index 000000000..533b55473 --- /dev/null +++ b/crates/validator/src/signing.rs @@ -0,0 +1,535 @@ +//! Producing the signatures a validator owes: attestations, the RANDAO reveal +//! a block carries, the block itself, and the two an aggregator needs. +//! +//! The signing root always comes from the SSZ container, never from the JSON a +//! beacon node sent: the wire representation is a transport detail, and the +//! chain only ever agrees about the merkle root. + +use ethlambda_types::beacon::config::Config; +use ethlambda_types::beacon::constants::{ + DOMAIN_AGGREGATE_AND_PROOF, DOMAIN_BEACON_ATTESTER, DOMAIN_BEACON_PROPOSER, DOMAIN_RANDAO, + DOMAIN_SELECTION_PROOF, +}; +use ethlambda_types::beacon::containers::shared::AttestationData; +use ethlambda_types::beacon::primitives::{ + BLS_SIGNATURE_SIZE, BlsPubkey, BlsSignature, Domain, DomainType, Epoch, HashTreeRoot as _, + Root, Slot, +}; +// `AttestationData`, `Checkpoint`, `Fork` and `SigningData` all live in +// `containers::shared`, verified against `containers/shared.rs:158,120,97,220`. +use ethlambda_types::beacon::signing::{ + compute_domain, compute_epoch_at_slot, compute_signing_root, +}; + +use crate::error::{Error, Result}; +use crate::keys::{SigningMethod, ValidatorStore}; + +/// The ciphersuite the consensus layer pins BLS signatures to. It must match +/// `ethlambda_state_transition::beacon::bls`'s own constant, or nothing this +/// client signs will ever verify. +const DST: &[u8] = b"BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_"; + +/// The chain-wide half of a signing domain, fixed for the life of the process. +/// +/// The fork version deliberately does not live here. It is a function of the +/// epoch being signed for, and for an attestation that is the **target** epoch, +/// not the current one. Caching it would make every signature near a fork +/// boundary silently invalid. +pub struct SigningContext { + /// The fork schedule signatures are resolved against. + pub config: Config, + /// The chain's genesis validators root, mixed into every domain so a + /// signature from one chain never verifies on another running the same + /// fork schedule. + pub genesis_validators_root: Root, +} + +impl SigningContext { + /// The domain for `domain_type` as of `epoch`. + pub fn domain(&self, domain_type: DomainType, epoch: Epoch) -> Domain { + let fork = self.config.fork_at_epoch(epoch); + compute_domain( + domain_type, + self.config.fork_version(fork), + self.genesis_validators_root, + ) + } + + /// The root an attestation's signature is computed over. + pub fn attestation_signing_root(&self, data: &AttestationData) -> Root { + let domain = self.domain(DOMAIN_BEACON_ATTESTER, data.target.epoch); + compute_signing_root(data.hash_tree_root(), domain) + } + + /// The root a block's RANDAO reveal is computed over. + /// + /// The message is the epoch itself, not anything about the block. That is + /// what makes the reveal unforgeable *and* unchooseable: the proposer has + /// exactly one valid signature to offer for its slot's epoch, so it cannot + /// grind the randomness by trying alternatives. + /// + /// The `hash_tree_root` of a `uint64` is its eight little-endian bytes + /// zero-padded to thirty-two, which is why this is not simply the epoch's + /// bytes: it is a merkle root that happens to look like one. + pub fn randao_signing_root(&self, epoch: Epoch) -> Root { + let domain = self.domain(DOMAIN_RANDAO, epoch); + compute_signing_root(epoch.hash_tree_root(), domain) + } + + /// The root a block's own signature is computed over. + /// + /// Takes the block's already-computed `hash_tree_root` rather than the + /// block, so that this stays independent of which fork's block shape the + /// beacon node produced. The caller decodes the block, this signs its + /// root. + /// + /// `slot` must be the block's own slot: unlike an attestation, whose + /// domain comes from its *target* epoch, a block's comes from the epoch + /// containing the slot it is proposed for. + pub fn block_signing_root(&self, block_root: Root, slot: Slot) -> Root { + let domain = self.domain(DOMAIN_BEACON_PROPOSER, compute_epoch_at_slot(slot)); + compute_signing_root(block_root, domain) + } + + /// The root an aggregator's selection proof is computed over. + /// + /// The message is the slot, the same shape the RANDAO reveal uses for an + /// epoch, and for a related reason: the signature has to be a function of + /// the slot alone so that a validator gets exactly one answer per slot and + /// cannot search for one that makes it an aggregator. See + /// [`crate::aggregation_selection`]. + /// + /// A different domain from the reveal, which is what stops one being + /// replayed as the other. Both sign a bare `uint64` merkle root, so without + /// the domain a reveal for epoch N would be a valid selection proof for + /// slot N. + pub fn selection_proof_signing_root(&self, slot: Slot) -> Root { + let domain = self.domain(DOMAIN_SELECTION_PROOF, compute_epoch_at_slot(slot)); + compute_signing_root(slot.hash_tree_root(), domain) + } + + /// The root a signed aggregate is computed over. + /// + /// Takes the `AggregateAndProof`'s already-computed root rather than the + /// container, for the reason [`Self::block_signing_root`] does: this stays + /// independent of which fork's attestation shape the aggregate carries, + /// and electra widened that shape. + /// + /// Note what is signed. Not the aggregate attestation, whose signature is + /// the attesters' own and was produced by somebody else; this covers the + /// whole `AggregateAndProof`, binding the aggregator's index and its + /// selection proof to the aggregate it is publishing. + pub fn aggregate_and_proof_signing_root(&self, root: Root, slot: Slot) -> Root { + let domain = self.domain(DOMAIN_AGGREGATE_AND_PROOF, compute_epoch_at_slot(slot)); + compute_signing_root(root, domain) + } + + /// Sign an already-computed signing root on behalf of `pubkey`. + /// + /// Every public signing method funnels through here, so there is one place + /// a signature is actually produced and one place the remote-signer + /// variant will need to be added. Deliberately private: a caller that can + /// hand in an arbitrary root can make this client sign anything at all, + /// and the domain separation that keeps an attestation from being read as + /// a block lives in the callers above. + fn sign_root( + &self, + store: &ValidatorStore, + pubkey: &BlsPubkey, + root: Root, + ) -> Result { + let method = store.get(pubkey).ok_or(Error::UnknownValidator(*pubkey))?; + match method { + SigningMethod::LocalKeystore { secret_key } => { + let signature = secret_key.sign(root.as_slice(), DST, &[]); + let bytes: [u8; BLS_SIGNATURE_SIZE] = signature.to_bytes(); + Ok(BlsSignature(bytes)) + } + } + } + + /// Sign `data` on behalf of `pubkey`. + pub fn sign_attestation( + &self, + store: &ValidatorStore, + pubkey: &BlsPubkey, + data: &AttestationData, + ) -> Result { + self.sign_root(store, pubkey, self.attestation_signing_root(data)) + } + + /// Sign the RANDAO reveal for `epoch` on behalf of `pubkey`. + /// + /// Unlike the other two, this signature is not itself a slashable message: + /// it commits to an epoch, not to a chain position, and producing one for + /// an epoch a validator is not proposing in reveals nothing and risks + /// nothing. It is therefore not guarded, and does not need to be. + pub fn sign_randao( + &self, + store: &ValidatorStore, + pubkey: &BlsPubkey, + epoch: Epoch, + ) -> Result { + self.sign_root(store, pubkey, self.randao_signing_root(epoch)) + } + + /// Sign the selection proof for `slot` on behalf of `pubkey`. + /// + /// Not slashable, and not guarded. A selection proof commits to a slot, not + /// to a chain position, so producing one twice is producing the same bytes + /// twice: BLS signatures are deterministic, which is the property the + /// selection rule rests on. + pub fn sign_selection_proof( + &self, + store: &ValidatorStore, + pubkey: &BlsPubkey, + slot: Slot, + ) -> Result { + self.sign_root(store, pubkey, self.selection_proof_signing_root(slot)) + } + + /// Sign the aggregate whose `AggregateAndProof` root is `root`, for `slot`, + /// on behalf of `pubkey`. + /// + /// Not slashable either, which is worth stating because it is the only + /// signature here that covers an attestation and is not. The slashing + /// conditions are about a validator's *own* vote; an aggregator is + /// republishing other validators' votes with a wrapper saying who + /// collected them, and publishing two different aggregates for one slot is + /// wasteful rather than punishable. + pub fn sign_aggregate_and_proof( + &self, + store: &ValidatorStore, + pubkey: &BlsPubkey, + root: Root, + slot: Slot, + ) -> Result { + self.sign_root( + store, + pubkey, + self.aggregate_and_proof_signing_root(root, slot), + ) + } + + /// Sign the block whose root is `block_root`, proposed for `slot`, on + /// behalf of `pubkey`. + /// + /// This one *is* slashable, and is the most expensive signature this + /// client can get wrong: two distinct blocks for one slot need no second + /// validator to be caught, since the two signed headers are the whole + /// evidence. Callers must pass it through + /// [`crate::proposal_guard::ProposalGuard`] first. + pub fn sign_block( + &self, + store: &ValidatorStore, + pubkey: &BlsPubkey, + block_root: Root, + slot: Slot, + ) -> Result { + self.sign_root(store, pubkey, self.block_signing_root(block_root, slot)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ethlambda_types::beacon::containers::shared::Checkpoint; + use ethlambda_types::beacon::preset; + + fn secret() -> [u8; 32] { + hex::decode("000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f") + .expect("valid hex") + .try_into() + .expect("32 bytes") + } + + fn context() -> SigningContext { + SigningContext { + config: Config::mainnet(), + genesis_validators_root: Root::ZERO, + } + } + + fn attestation_data(target_epoch: Epoch) -> AttestationData { + AttestationData { + slot: target_epoch * 32, + index: 0, + beacon_block_root: Root::ZERO, + source: Checkpoint { + epoch: target_epoch.saturating_sub(1), + root: Root::ZERO, + }, + target: Checkpoint { + epoch: target_epoch, + root: Root::ZERO, + }, + } + } + + #[test] + fn a_signature_verifies_under_the_signing_root() { + use blst::min_pk::{PublicKey, Signature}; + + let mut store = ValidatorStore::new(); + let pubkey = store.insert_secret("test", &secret()).expect("inserts"); + let context = context(); + let data = attestation_data(100); + + let signature = context + .sign_attestation(&store, &pubkey, &data) + .expect("signs"); + + let root = context.attestation_signing_root(&data); + let pk = PublicKey::from_bytes(&pubkey.0).expect("valid pubkey"); + let sig = Signature::from_bytes(&signature.0).expect("valid signature"); + assert_eq!( + sig.verify(true, root.as_slice(), DST, &[], &pk, true), + blst::BLST_ERROR::BLST_SUCCESS + ); + } + + #[test] + fn the_domain_changes_with_the_epoch() { + let context = context(); + let early = context.domain(DOMAIN_BEACON_ATTESTER, 0); + let late = context.domain(DOMAIN_BEACON_ATTESTER, 1_000_000); + assert_ne!( + early, late, + "epochs on either side of a fork must give different domains" + ); + } + + /// The domain must come from the attestation's target epoch, never from + /// its slot. Real attestations have the two agree, which is exactly why a + /// regression swapping them would go unnoticed: this fixture pulls them + /// apart on purpose so the assertion has something to catch. + #[test] + fn the_signing_root_uses_the_target_epoch_not_the_slot_epoch() { + let context = context(); + + // Target lands after altair activates; the slot lands in phase0. The + // gap has to cross an actual fork boundary, not just be a large + // number, since `domain()` only changes with the epoch insofar as the + // epoch selects a different fork version. + let mut data = attestation_data(context.config.altair_fork_epoch + 1); + data.slot = 3 * preset::SLOTS_PER_EPOCH; + + let from_target = compute_signing_root( + data.hash_tree_root(), + context.domain(DOMAIN_BEACON_ATTESTER, data.target.epoch), + ); + let from_slot = compute_signing_root( + data.hash_tree_root(), + context.domain(DOMAIN_BEACON_ATTESTER, compute_epoch_at_slot(data.slot)), + ); + assert_ne!( + from_target, from_slot, + "the fixture must actually distinguish the two, or this test proves nothing" + ); + assert_eq!(context.attestation_signing_root(&data), from_target); + } + + #[test] + fn signing_for_an_unknown_validator_is_an_error() { + let store = ValidatorStore::new(); + let err = context() + .sign_attestation(&store, &BlsPubkey::default(), &attestation_data(1)) + .expect_err("must fail"); + assert!(matches!(err, Error::UnknownValidator(_)), "got {err:?}"); + } + + /// Verifies a signature produced by one of the three signing methods + /// against the root that method says it signed. Shared so each method's + /// test asserts the same property and cannot drift into asserting a + /// weaker one. + fn verify(pubkey: &BlsPubkey, signature: &BlsSignature, root: Root) -> bool { + use blst::min_pk::{PublicKey, Signature}; + + let pk = PublicKey::from_bytes(&pubkey.0).expect("valid pubkey"); + let sig = Signature::from_bytes(&signature.0).expect("valid signature"); + sig.verify(true, root.as_slice(), DST, &[], &pk, true) == blst::BLST_ERROR::BLST_SUCCESS + } + + fn store_with_key() -> (ValidatorStore, BlsPubkey) { + let mut store = ValidatorStore::new(); + let pubkey = store.insert_secret("test", &secret()).expect("inserts"); + (store, pubkey) + } + + #[test] + fn a_randao_reveal_verifies_under_the_randao_signing_root() { + let (store, pubkey) = store_with_key(); + let context = context(); + + let signature = context.sign_randao(&store, &pubkey, 100).expect("signs"); + assert!(verify( + &pubkey, + &signature, + context.randao_signing_root(100) + )); + } + + #[test] + fn a_block_signature_verifies_under_the_block_signing_root() { + let (store, pubkey) = store_with_key(); + let context = context(); + let block_root = Root::repeat_byte(7); + + let signature = context + .sign_block(&store, &pubkey, block_root, 3200) + .expect("signs"); + assert!(verify( + &pubkey, + &signature, + context.block_signing_root(block_root, 3200) + )); + } + + /// What the RANDAO reveal actually commits to: the epoch's merkle root, + /// which for a `uint64` is its eight little-endian bytes zero-padded to + /// thirty-two. A client that signed the epoch's big-endian bytes, or its + /// raw eight bytes unpadded, would produce a reveal the network rejects + /// and would have no way to tell why. + #[test] + fn the_randao_message_is_the_epochs_little_endian_merkle_root() { + let epoch: Epoch = 0x0102_0304_0506_0708; + let mut expected = [0u8; 32]; + expected[..8].copy_from_slice(&epoch.to_le_bytes()); + assert_eq!(epoch.hash_tree_root().0, expected); + + let context = context(); + assert_eq!( + context.randao_signing_root(epoch), + compute_signing_root(Root::from(expected), context.domain(DOMAIN_RANDAO, epoch)) + ); + } + + /// Domain separation, stated as the property that matters rather than as + /// three constants being different. One 32-byte object root signed under + /// the three domains this client uses must give three distinct signing + /// roots, so a signature obtained for one purpose can never be replayed as + /// another. + /// + /// This is not hypothetical for the RANDAO reveal specifically: its + /// message is a bare `uint64` merkle root, which is also a perfectly + /// well-formed block root. Without the domain in the mix, a reveal for + /// epoch N would be a valid proposer signature for a block whose root + /// happened to be N's merkle root. + #[test] + fn one_object_root_signs_differently_under_each_domain() { + let context = context(); + let epoch: Epoch = 100; + let object = epoch.hash_tree_root(); + let slot = epoch * preset::SLOTS_PER_EPOCH; + + let roots = [ + compute_signing_root(object, context.domain(DOMAIN_BEACON_ATTESTER, epoch)), + context.randao_signing_root(epoch), + context.block_signing_root(object, slot), + context.selection_proof_signing_root(slot), + context.aggregate_and_proof_signing_root(object, slot), + ]; + + for (first, left) in roots.iter().enumerate() { + for right in &roots[first + 1..] { + assert_ne!( + left, right, + "every domain must give a distinct signing root for one object" + ); + } + } + } + + /// The sharpest case of the property above, stated on its own because the + /// two messages are genuinely interchangeable without it. A RANDAO reveal + /// signs an epoch's merkle root and a selection proof signs a slot's; both + /// are bare `uint64` roots, so for epoch N and slot N the object is byte + /// for byte the same, and only the domain tells them apart. + #[test] + fn a_randao_reveal_cannot_be_replayed_as_a_selection_proof() { + let context = context(); + let n: u64 = 100; + assert_eq!( + Epoch::hash_tree_root(&n), + Slot::hash_tree_root(&n), + "the fixture must actually collide, or this test proves nothing" + ); + assert_ne!( + context.randao_signing_root(n), + context.selection_proof_signing_root(n) + ); + } + + /// A block's domain comes from the epoch containing its own slot. The + /// fixture crosses a real fork boundary, because `domain()` only changes + /// with the epoch insofar as the epoch selects a different fork version; + /// two large epochs in the same fork would prove nothing. + #[test] + fn a_blocks_domain_comes_from_its_own_slots_epoch() { + let context = context(); + let after = context.config.altair_fork_epoch * preset::SLOTS_PER_EPOCH; + let before = after - 1; + + assert_ne!( + context.block_signing_root(Root::ZERO, before), + context.block_signing_root(Root::ZERO, after), + "slots on either side of a fork boundary must sign under different domains" + ); + assert_eq!( + context.block_signing_root(Root::ZERO, after), + compute_signing_root( + Root::ZERO, + context.domain(DOMAIN_BEACON_PROPOSER, compute_epoch_at_slot(after)) + ) + ); + } + + #[test] + fn signing_a_block_for_an_unknown_validator_is_an_error() { + let store = ValidatorStore::new(); + let err = context() + .sign_block(&store, &BlsPubkey::default(), Root::ZERO, 1) + .expect_err("must fail"); + assert!(matches!(err, Error::UnknownValidator(_)), "got {err:?}"); + } + + #[test] + fn a_selection_proof_verifies_under_its_own_root() { + let (store, pubkey) = store_with_key(); + let context = context(); + + let signature = context + .sign_selection_proof(&store, &pubkey, 3200) + .expect("signs"); + assert!(verify( + &pubkey, + &signature, + context.selection_proof_signing_root(3200) + )); + } + + #[test] + fn an_aggregate_signature_verifies_under_its_own_root() { + let (store, pubkey) = store_with_key(); + let context = context(); + let root = Root::repeat_byte(4); + + let signature = context + .sign_aggregate_and_proof(&store, &pubkey, root, 3200) + .expect("signs"); + assert!(verify( + &pubkey, + &signature, + context.aggregate_and_proof_signing_root(root, 3200) + )); + } + + #[test] + fn signing_a_randao_reveal_for_an_unknown_validator_is_an_error() { + let store = ValidatorStore::new(); + let err = context() + .sign_randao(&store, &BlsPubkey::default(), 1) + .expect_err("must fail"); + assert!(matches!(err, Error::UnknownValidator(_)), "got {err:?}"); + } +} diff --git a/crates/validator/src/slot_clock.rs b/crates/validator/src/slot_clock.rs new file mode 100644 index 000000000..dc5bf6adf --- /dev/null +++ b/crates/validator/src/slot_clock.rs @@ -0,0 +1,532 @@ +//! The validator's own clock. +//! +//! Seeded once from the beacon node's genesis, then independent of it. It is +//! deliberately not driven by the node's event stream: that stream is +//! best-effort by contract, dropping events for a slow subscriber, and a +//! dropped event must not mean a missed duty. + +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use ethlambda_types::beacon::preset::SLOTS_PER_EPOCH; +use ethlambda_types::beacon::primitives::{Epoch, Slot}; + +/// Basis points in a whole, which is what the duty offsets are expressed in. +const BASIS_POINTS: u64 = 10_000; + +/// Converts wall-clock time to slots and back, for a chain whose genesis, slot +/// length and duty offsets are fixed for the life of the clock. +/// +/// # Milliseconds and basis points, because the specification moved +/// +/// The honest-validator guide used to name the duty offsets as fractions of a +/// `SECONDS_PER_SLOT`. Both halves of that are gone. The slot length is +/// `SLOT_DURATION_MS`, and the offsets are basis points of it: +/// `ATTESTATION_DUE_BPS` and `AGGREGATE_DUE_BPS`, evaluated with +/// `bps * SLOT_DURATION_MS // BASIS_POINTS`. +/// +/// It is not a cosmetic change. On mainnet the attester offset is 3999 ms, not +/// the 4000 that a third of a 12-second slot gives, and the gloas fork moves +/// both offsets to 2500 and 5000 basis points, which no fixed fraction +/// expresses. Holding the basis points means this clock follows the network it +/// was told about rather than the one it was compiled for. +#[derive(Debug, Clone, Copy)] +pub struct SlotClock { + genesis_time: u64, + slot_duration_ms: u64, + attestation_due_bps: u64, + aggregate_due_bps: u64, +} + +impl SlotClock { + /// `genesis_time` is Unix seconds; the rest come from the beacon node's + /// `/eth/v1/config/spec`. + /// + /// `slot_duration_ms` must be nonzero, since every slot computation divides + /// by it. Callers taking it from a beacon node's response must reject a + /// zero there, at the boundary where it can be reported as malformed input; + /// this precondition is the backstop for any caller that does not. + pub fn new( + genesis_time: u64, + slot_duration_ms: u64, + attestation_due_bps: u64, + aggregate_due_bps: u64, + ) -> Self { + assert!(slot_duration_ms > 0, "slot_duration_ms must be nonzero"); + Self { + genesis_time, + slot_duration_ms, + attestation_due_bps, + aggregate_due_bps, + } + } + + /// The slot containing `now`, or `None` before genesis. + /// + /// Divides in milliseconds, not seconds, so a chain whose slot is not a + /// whole number of seconds lands in the right slot rather than a rounded + /// one. + pub fn slot_at(&self, now: SystemTime) -> Option { + let since_epoch = now.duration_since(UNIX_EPOCH).ok()?; + let since_genesis = since_epoch.checked_sub(Duration::from_secs(self.genesis_time))?; + let elapsed_ms = u64::try_from(since_genesis.as_millis()).unwrap_or(u64::MAX); + Some(elapsed_ms / self.slot_duration_ms) + } + + /// The current slot, or `None` before genesis. + pub fn now(&self) -> Option { + self.slot_at(SystemTime::now()) + } + + /// The epoch containing `slot`. + pub fn epoch_of(&self, slot: Slot) -> Epoch { + slot / SLOTS_PER_EPOCH + } + + /// When `slot` begins. + pub fn start_of(&self, slot: Slot) -> SystemTime { + // Saturating rather than wrapping: a slot number large enough to + // overflow this product is centuries away at any real slot duration, + // and a clock that wrapped would put it in the past. + UNIX_EPOCH + + Duration::from_secs(self.genesis_time) + + Duration::from_millis(slot.saturating_mul(self.slot_duration_ms)) + } + + /// When the attester duty for `slot` should run. + pub fn attestation_time(&self, slot: Slot) -> SystemTime { + self.offset_into(slot, self.attestation_due_bps) + } + + /// When the aggregation duty for `slot` should run. + /// + /// After the attester offset on every network the specification ships, + /// necessarily: an aggregator folds together votes its beacon node has + /// collected, and before the attesters have voted there is nothing to fold. + pub fn aggregation_time(&self, slot: Slot) -> SystemTime { + self.offset_into(slot, self.aggregate_due_bps) + } + + /// `bps` basis points of the way into `slot`. + /// + /// The specification's own `get_slot_component_duration_ms`, which is + /// `bps * SLOT_DURATION_MS // BASIS_POINTS`: integer arithmetic on + /// milliseconds, multiplying before dividing. Doing it in `Duration` + /// instead would keep nanoseconds and land 0.4 ms past the spec's answer on + /// mainnet, which is wrong in the direction that matters for a deadline. + fn offset_into(&self, slot: Slot, bps: u64) -> SystemTime { + let offset_ms = bps.saturating_mul(self.slot_duration_ms) / BASIS_POINTS; + self.start_of(slot) + Duration::from_millis(offset_ms) + } + + /// When `slot` ends, which is when the next one begins. + pub fn end_of(&self, slot: Slot) -> SystemTime { + self.start_of(slot + 1) + } + + /// How much of `slot` is left at `now`, or zero once it has passed. + /// + /// This is the budget a slot's duty gets. An attestation is for one slot, + /// and the next slot's duty is due the moment this one ends, so work still + /// running past that point is not merely late, it is competing with the + /// duty that replaced it. + /// + /// Zero rather than an error for a slot already gone, so a caller can pass + /// the result straight to a timeout: a duty that is already too late gets + /// no budget and fails immediately, which is the correct outcome and not a + /// case worth branching on. + pub fn remaining_in(&self, slot: Slot, now: SystemTime) -> Duration { + self.end_of(slot) + .duration_since(now) + .unwrap_or(Duration::ZERO) + } + + /// How long from `now` until the attester duty for `slot`, or zero once + /// that instant has passed. + /// + /// Two jobs, which is why it is one function. It is how long the duty loop + /// sleeps between a slot's proposal work and its attestation work, and it + /// is the budget the proposal work gets: a proposer that is still waiting + /// on its beacon node when the attestation is due has already lost the + /// block, and must not also cost this client's attesters their votes. + /// + /// Zero rather than an error once the instant is past, so an overrunning + /// slot attests immediately and late rather than not at all. + pub fn until_attestation(&self, slot: Slot, now: SystemTime) -> Duration { + self.attestation_time(slot) + .duration_since(now) + .unwrap_or(Duration::ZERO) + } + + /// How long from `now` until the aggregation duty for `slot`, or zero once + /// that instant has passed. + /// + /// The aggregation counterpart to [`Self::until_attestation`], and the + /// budget aggregation gets: an aggregate that arrives after its slot ends + /// is competing with the next slot's duties, and the votes it carries have + /// already had a whole slot to reach a block by another route. + pub fn until_aggregation(&self, slot: Slot, now: SystemTime) -> Duration { + self.aggregation_time(slot) + .duration_since(now) + .unwrap_or(Duration::ZERO) + } + + /// The next slot to serve, given the last one served, and how long until + /// it begins. + /// + /// This is what drives the duty loop: one wake per slot, at the boundary, + /// from which the slot's own offsets are reached by sleeping further in. + /// A proposer publishes at the boundary and an attester one third in, so a + /// loop that woke only at the attester offset could never propose. + /// + /// # Why it takes the slot already served + /// + /// Because "the next slot after now" is the wrong answer when the previous + /// slot's work ran long. A slot's duties are bounded by that slot's end, so + /// a hung beacon node can return the loop to this function at, or just + /// after, the following slot's boundary. Answering "the slot after the one + /// we are standing in" would then sleep straight past a slot whose + /// attestation was still seconds away, and one hung request would cost two + /// slots of duties rather than one, which is the cascade the per-slot + /// budget exists to prevent. + /// + /// So when the clock has already entered a slot later than the one served, + /// that slot is returned with no delay. Its own offsets degrade to zero on + /// their own, so its attestation goes out late rather than not at all. + /// + /// Otherwise the answer is the slot after the one served, which is also + /// what keeps the loop from spinning: finishing inside the slot just served + /// must not return that slot again. + /// + /// `served` is `None` only on the first call. The client is then somewhere + /// inside a slot it is too late to propose for, and has no duties yet + /// anyway, so it waits for the next boundary. + /// + /// Before genesis this returns slot 0 and the wait until it. A client + /// started early sleeps once, exactly until genesis, instead of waking + /// every slot-length to ask again. + pub fn next_slot_to_serve(&self, served: Option, now: SystemTime) -> (Slot, Duration) { + let Some(current) = self.slot_at(now) else { + let delay = self + .start_of(0) + .duration_since(now) + .unwrap_or(Duration::ZERO); + return (0, delay); + }; + + let next = match served { + // The previous slot's work overran into this one, or past it. + // Serve where the clock actually is. + Some(served) if current > served => current, + // Finished inside the slot served, or the wall clock stepped + // backwards. Either way, move on rather than serve it twice: the + // guards would refuse the duties anyway, and repeating a slot is + // how a loop like this spins. + Some(served) => served + 1, + None => current + 1, + }; + let delay = self + .start_of(next) + .duration_since(now) + .unwrap_or(Duration::ZERO); + (next, delay) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const GENESIS: u64 = 1_000_000; + const SECONDS_PER_SLOT: u64 = 12; + /// Mainnet's values, so the numbers in these tests are the real ones. + const ATTESTATION_DUE_BPS: u64 = 3_333; + const AGGREGATE_DUE_BPS: u64 = 6_667; + + fn clock() -> SlotClock { + SlotClock::new( + GENESIS, + SECONDS_PER_SLOT * 1_000, + ATTESTATION_DUE_BPS, + AGGREGATE_DUE_BPS, + ) + } + + fn at(offset_secs: u64) -> SystemTime { + UNIX_EPOCH + Duration::from_secs(GENESIS + offset_secs) + } + + #[test] + fn a_slot_ends_where_the_next_begins() { + let clock = clock(); + assert_eq!(clock.end_of(0), clock.start_of(1)); + assert_eq!(clock.end_of(5), at(6 * SECONDS_PER_SLOT)); + } + + #[test] + fn the_budget_is_what_is_left_of_the_slot() { + let clock = clock(); + // Two seconds into slot 3, ten of its twelve seconds remain. + let now = at(3 * SECONDS_PER_SLOT + 2); + assert_eq!(clock.remaining_in(3, now), Duration::from_secs(10)); + } + + #[test] + fn the_budget_at_a_slot_boundary_is_a_whole_slot() { + let clock = clock(); + assert_eq!( + clock.remaining_in(3, clock.start_of(3)), + Duration::from_secs(SECONDS_PER_SLOT) + ); + } + + /// A duty already past its slot gets no budget rather than an error or a + /// wrapped-around one, so the caller can pass this straight to a timeout + /// and have a hopeless duty fail at once. + #[test] + fn a_slot_already_gone_has_no_budget_left() { + let clock = clock(); + let well_past = at(10 * SECONDS_PER_SLOT); + assert_eq!(clock.remaining_in(3, well_past), Duration::ZERO); + } + + #[test] + fn slot_zero_starts_at_genesis() { + assert_eq!(clock().slot_at(at(0)), Some(0)); + assert_eq!(clock().slot_at(at(11)), Some(0)); + assert_eq!(clock().slot_at(at(12)), Some(1)); + } + + #[test] + fn before_genesis_there_is_no_slot() { + let before = UNIX_EPOCH + Duration::from_secs(GENESIS - 1); + assert_eq!(clock().slot_at(before), None); + } + + #[test] + fn an_epoch_is_slots_per_epoch_slots() { + let clock = clock(); + assert_eq!(clock.epoch_of(0), 0); + assert_eq!(clock.epoch_of(SLOTS_PER_EPOCH - 1), 0); + assert_eq!(clock.epoch_of(SLOTS_PER_EPOCH), 1); + } + + /// 3333 basis points of a 12-second slot is 3999 ms, not the 4000 a third + /// would give. The one-millisecond difference is what the integer floor in + /// the specification's own formula produces, and getting it by dividing + /// into thirds instead is how this was wrong before. + #[test] + fn the_attester_duty_runs_at_the_specifications_basis_points() { + let clock = clock(); + let duty = clock.attestation_time(10); + let start = clock.start_of(10); + assert_eq!( + duty.duration_since(start).expect("after the start"), + Duration::from_millis(3_999) + ); + } + + /// 6667 basis points of a 12-second slot is exactly 8000 ms, which happens + /// to equal two thirds. It is asserted against the basis points rather than + /// the fraction, because the two only coincide here. + #[test] + fn the_aggregation_duty_runs_at_the_specifications_basis_points() { + let clock = clock(); + let duty = clock.aggregation_time(10); + let start = clock.start_of(10); + assert_eq!( + duty.duration_since(start).expect("after the start"), + Duration::from_millis(8_000) + ); + } + + /// A gloas-era configuration moves both offsets, which no fixed fraction of + /// a slot expresses. This is what holding the basis points buys. + #[test] + fn a_different_basis_point_configuration_moves_both_offsets() { + let clock = SlotClock::new(GENESIS, 12_000, 2_500, 5_000); + let start = clock.start_of(10); + assert_eq!( + clock + .attestation_time(10) + .duration_since(start) + .expect("after the start"), + Duration::from_millis(3_000) + ); + assert_eq!( + clock + .aggregation_time(10) + .duration_since(start) + .expect("after the start"), + Duration::from_millis(6_000) + ); + } + + /// A slot length that is not a whole number of seconds has to work at all, + /// which the old whole-second clock could not express. + #[test] + fn a_sub_second_slot_length_lands_in_the_right_slot() { + let clock = SlotClock::new(GENESIS, 1_500, 3_333, 6_667); + let at_ms = |ms: u64| UNIX_EPOCH + Duration::from_millis(GENESIS * 1_000 + ms); + + assert_eq!(clock.slot_at(at_ms(0)), Some(0)); + assert_eq!(clock.slot_at(at_ms(1_499)), Some(0)); + assert_eq!(clock.slot_at(at_ms(1_500)), Some(1)); + assert_eq!(clock.slot_at(at_ms(3_000)), Some(2)); + assert_eq!(clock.start_of(2), at_ms(3_000)); + } + + /// Aggregation must come after attestation. An aggregator folds votes its + /// beacon node has collected, and before the attesters have voted there is + /// nothing to fold. + #[test] + fn aggregation_comes_after_attestation_in_every_slot() { + let clock = clock(); + for slot in [0, 1, 10, 1000] { + assert!(clock.aggregation_time(slot) > clock.attestation_time(slot)); + assert!(clock.aggregation_time(slot) < clock.end_of(slot)); + } + } + + #[test] + fn the_wait_until_aggregation_is_the_rest_of_the_offset() { + let clock = clock(); + // Five seconds into slot 5, whose aggregation runs eight seconds in. + let now = at(5 * SECONDS_PER_SLOT + 5); + assert_eq!(clock.until_aggregation(5, now), Duration::from_secs(3)); + } + + #[test] + fn an_overrun_slot_leaves_no_time_to_aggregate() { + let clock = clock(); + assert_eq!( + clock.until_aggregation(5, at(5 * SECONDS_PER_SLOT + 11)), + Duration::ZERO + ); + } + + #[test] + fn the_next_slot_to_serve_is_the_one_after_the_one_served() { + // Two seconds into slot 5, having just served it. + let now = at(5 * SECONDS_PER_SLOT + 2); + let (slot, delay) = clock().next_slot_to_serve(Some(5), now); + assert_eq!(slot, 6); + assert_eq!(delay, Duration::from_secs(10)); + } + + /// Finishing exactly on the following boundary must not skip that slot. + /// + /// This is the shape a hung beacon node produces: a slot's duties are + /// bounded by that slot's end, so the timeout fires precisely here. + /// Answering "the slot after the one we are standing in" would sleep past + /// slot 6 entirely, and its attestation was still a third of a slot away. + #[test] + fn work_that_overran_into_the_next_slot_serves_that_slot_at_once() { + let clock = clock(); + let (slot, delay) = clock.next_slot_to_serve(Some(5), clock.start_of(6)); + assert_eq!(slot, 6, "the overrun must not cost slot 6 its duties"); + assert_eq!(delay, Duration::ZERO); + } + + /// The same, a nanosecond later, which is where a `>` versus `>=` slip + /// would show up. + #[test] + fn work_that_overran_by_a_nanosecond_still_serves_that_slot() { + let clock = clock(); + let now = clock.start_of(6) + Duration::from_nanos(1); + let (slot, delay) = clock.next_slot_to_serve(Some(5), now); + assert_eq!(slot, 6); + assert_eq!(delay, Duration::ZERO); + } + + /// A badly overrunning slot lands several slots later. Serve where the + /// clock is, not where the sequence says it should be. + #[test] + fn work_that_overran_by_several_slots_serves_the_current_one() { + let clock = clock(); + let (slot, delay) = clock.next_slot_to_serve(Some(5), at(9 * SECONDS_PER_SLOT + 1)); + assert_eq!(slot, 9); + assert_eq!(delay, Duration::ZERO); + } + + /// The property that keeps the loop from spinning: finishing inside the + /// slot just served must move on to the next one, not offer it again. + #[test] + fn finishing_inside_the_slot_served_does_not_serve_it_twice() { + let clock = clock(); + let (slot, delay) = clock.next_slot_to_serve(Some(5), clock.start_of(5)); + assert_eq!(slot, 6); + assert_eq!(delay, Duration::from_secs(SECONDS_PER_SLOT)); + } + + /// A backwards wall-clock step must move forward too, for the same reason. + /// + /// The client then idles until the stepped-back clock reaches slot 10 + /// again, which is the right outcome: slots 5 through 9 have already been + /// served, and the duties for a slot that has not happened yet cannot be + /// fetched. This is the case the guards exist for, and here the clock never + /// even offers them the chance. + #[test] + fn a_backwards_clock_step_does_not_serve_an_old_slot_again() { + let clock = clock(); + let now = at(5 * SECONDS_PER_SLOT); + let (slot, delay) = clock.next_slot_to_serve(Some(9), now); + assert_eq!(slot, 10, "never go back over a slot already served"); + assert_eq!( + now + delay, + clock.start_of(10), + "the wait must reach slot 10, not expire early" + ); + } + + /// On the first call the client is mid-slot with no duties yet, so it + /// waits for the next boundary rather than serving the slot it is in. + #[test] + fn the_first_slot_served_is_the_next_boundary() { + let now = at(5 * SECONDS_PER_SLOT + 2); + let (slot, delay) = clock().next_slot_to_serve(None, now); + assert_eq!(slot, 6); + assert_eq!(delay, Duration::from_secs(10)); + } + + /// Before genesis the client sleeps once, exactly until slot 0, rather + /// than waking every slot-length to ask whether the chain has started. + #[test] + fn before_genesis_the_next_slot_is_zero_and_the_wait_reaches_it() { + let clock = clock(); + let now = UNIX_EPOCH + Duration::from_secs(GENESIS - 30); + let (slot, delay) = clock.next_slot_to_serve(None, now); + assert_eq!(slot, 0); + assert_eq!(delay, Duration::from_secs(30)); + assert_eq!(now + delay, clock.start_of(0)); + } + + #[test] + fn the_wait_until_the_attester_duty_is_the_rest_of_the_offset() { + let clock = clock(); + // One second into slot 5, whose duty runs 3999 ms in. + let now = at(5 * SECONDS_PER_SLOT + 1); + assert_eq!( + clock.until_attestation(5, now), + Duration::from_millis(2_999) + ); + } + + #[test] + fn a_whole_slot_boundary_leaves_the_full_offset_to_propose_in() { + let clock = clock(); + assert_eq!( + clock.until_attestation(5, clock.start_of(5)), + Duration::from_millis(3_999) + ); + } + + /// A proposal that overran its budget must leave zero, not an error and + /// not a wrapped-around wait: the caller sleeps on this value, so a + /// wrapped one would stall the loop for years. + #[test] + fn an_overrun_proposal_leaves_no_time_before_the_attestation() { + let clock = clock(); + let past_the_offset = at(5 * SECONDS_PER_SLOT + 9); + assert_eq!(clock.until_attestation(5, past_the_offset), Duration::ZERO); + } +} diff --git a/crates/validator/src/subscriptions.rs b/crates/validator/src/subscriptions.rs new file mode 100644 index 000000000..b1819adbe --- /dev/null +++ b/crates/validator/src/subscriptions.rs @@ -0,0 +1,239 @@ +//! Telling the beacon node which attestation subnets to find peers on. +//! +//! Without this the beacon node has no reason to be on the subnet an +//! attestation is published to, and the attestation reaches nobody. The +//! specification also makes this the signal by which a node learns which +//! validators are attached to it, which is why one entry is sent per validator +//! per duty even when several share a committee. + +use std::sync::Arc; + +use tokio::sync::RwLock; +use tracing::{info, warn}; + +use crate::aggregation_selection::selection_for; +use crate::beacon_node::BeaconNodeApi; +use crate::beacon_node::dto::{AttesterDutyDto, CommitteeSubscriptionDto}; +use crate::error::Result; +use crate::keys::ValidatorStore; +use crate::signing::SigningContext; + +/// Send one subscription per held duty, claiming the aggregator role for the +/// duties this client was selected for. +/// +/// `is_aggregator` is not a preference. It is the answer +/// [`crate::aggregation_selection`] computes from a signature over the slot, +/// and the beacon node checks the same thing when the aggregate arrives, so +/// there is nothing here to decide. What the flag does is tell the node to keep +/// the subnet subscribed for the whole slot and collect the votes this client +/// will ask it to fold, which is why it has to be sent ahead of time rather +/// than discovered when the aggregation duty runs. +/// +/// A duty whose selection cannot be computed is sent with the flag clear rather +/// than dropped. The subscription is what puts the beacon node on the subnet at +/// all, so losing it would cost that validator its plain attestation as well as +/// its aggregate. +pub async fn subscribe( + beacon_node: &Arc, + duties: &[AttesterDutyDto], + store: &RwLock, + context: &SigningContext, +) -> Result<()> { + if duties.is_empty() { + return Ok(()); + } + + // The read guard is scoped to this block and never held across the await + // below, for the reason spelled out in `crate::attestation`: the lock is + // write-preferring, so one queued keymanager writer blocks every reader + // behind it. + let subscriptions: Vec = { + let store = store.read().await; + duties + .iter() + .map(|duty| { + let is_aggregator = match selection_for(context, &store, duty) { + Ok(selection) => selection.is_some(), + Err(err) => { + warn!( + slot = duty.slot, + validator = duty.validator_index, + %err, + "Could not compute aggregator selection; subscribing without the role" + ); + false + } + }; + CommitteeSubscriptionDto { + validator_index: duty.validator_index, + committee_index: duty.committee_index, + committees_at_slot: duty.committees_at_slot, + slot: duty.slot, + is_aggregator, + } + }) + .collect() + }; + + let aggregating = subscriptions + .iter() + .filter(|entry| entry.is_aggregator) + .count(); + info!( + count = subscriptions.len(), + aggregating, "Subscribing to attestation subnets" + ); + beacon_node.subscribe_committees(&subscriptions).await +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::aggregation_selection::is_aggregator; + use crate::beacon_node::dto::encode_hex; + use crate::beacon_node::mock::MockBeaconNode; + use ethlambda_types::beacon::config::Config; + use ethlambda_types::beacon::primitives::{BlsPubkey, Root}; + + fn secret() -> [u8; 32] { + hex::decode("000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f") + .expect("valid hex") + .try_into() + .expect("32 bytes") + } + + fn context() -> SigningContext { + SigningContext { + config: Config::mainnet(), + genesis_validators_root: Root::ZERO, + } + } + + fn store() -> (RwLock, BlsPubkey) { + let mut store = ValidatorStore::new(); + let pubkey = store.insert_secret("test", &secret()).expect("inserts"); + (RwLock::new(store), pubkey) + } + + fn duty( + pubkey: &BlsPubkey, + validator_index: u64, + slot: u64, + committee_index: u64, + ) -> AttesterDutyDto { + AttesterDutyDto { + pubkey: encode_hex(&pubkey.0), + validator_index, + committee_index, + committee_length: 128, + committees_at_slot: 64, + validator_committee_index: 7, + slot, + } + } + + #[tokio::test] + async fn one_subscription_is_sent_per_duty() { + let node = Arc::new(MockBeaconNode::new()); + let (store, pubkey) = store(); + let duties = vec![ + duty(&pubkey, 1, 96, 2), + duty(&pubkey, 2, 96, 2), + duty(&pubkey, 3, 97, 5), + ]; + subscribe(&node, &duties, &store, &context()) + .await + .expect("subscribes"); + + let sent = node.subscriptions(); + assert_eq!( + sent.len(), + 3, + "validators sharing a committee must not be deduplicated" + ); + assert_eq!(sent[0].validator_index, 1); + assert_eq!(sent[2].slot, 97); + } + + #[tokio::test] + async fn nothing_is_sent_when_there_are_no_duties() { + let node = Arc::new(MockBeaconNode::new()); + let (store, _) = store(); + subscribe(&node, &[], &store, &context()) + .await + .expect("no-op"); + assert!(node.subscriptions().is_empty()); + } + + /// The flag must be the selection rule's answer, not a constant either way. + /// Asserted against the rule itself rather than against a hardcoded + /// expectation, since which slots select this key is a property of SHA-256 + /// that would be meaningless to write down. + #[tokio::test] + async fn the_aggregator_flag_is_the_selection_rules_answer() { + let node = Arc::new(MockBeaconNode::new()); + let (store, pubkey) = store(); + let context = context(); + + let duties: Vec = + (96..128).map(|slot| duty(&pubkey, 1, slot, 2)).collect(); + subscribe(&node, &duties, &store, &context) + .await + .expect("subscribes"); + + let guard = store.read().await; + for (duty, sent) in duties.iter().zip(node.subscriptions()) { + let proof = context + .sign_selection_proof(&guard, &pubkey, duty.slot) + .expect("signs"); + assert_eq!( + sent.is_aggregator, + is_aggregator(duty.committee_length, &proof), + "slot {} disagreed with the selection rule", + duty.slot + ); + } + } + + /// Over a full epoch of slots the flag must be set for some and clear for + /// others. Without this, a bug that always answered one way would satisfy + /// the test above, which only checks agreement with the same computation. + #[tokio::test] + async fn the_role_is_claimed_for_some_slots_and_not_others() { + let node = Arc::new(MockBeaconNode::new()); + let (store, pubkey) = store(); + + let duties: Vec = (0..64).map(|slot| duty(&pubkey, 1, slot, 2)).collect(); + subscribe(&node, &duties, &store, &context()) + .await + .expect("subscribes"); + + let claimed = node + .subscriptions() + .iter() + .filter(|entry| entry.is_aggregator) + .count(); + assert!( + claimed > 0 && claimed < duties.len(), + "expected a mix over 64 slots, got {claimed}" + ); + } + + /// A duty this client cannot sign for still has to be subscribed. The + /// subscription is what puts the beacon node on the subnet, so dropping it + /// would cost that validator its plain attestation too. + #[tokio::test] + async fn a_duty_for_an_unknown_validator_is_still_subscribed_without_the_role() { + let node = Arc::new(MockBeaconNode::new()); + let (store, _) = store(); + let stranger = BlsPubkey([9; 48]); + + subscribe(&node, &[duty(&stranger, 1, 96, 2)], &store, &context()) + .await + .expect("subscribes"); + + let sent = node.subscriptions(); + assert_eq!(sent.len(), 1, "the subscription must still be sent"); + assert!(!sent[0].is_aggregator); + } +} diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 43d4ccde5..97cf4f901 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -12,8 +12,15 @@ - [3SF-mini: Justification & Finalization](./3sf_mini.md) - [LMD-GHOST Fork Choice](./lmd_ghost.md) +# Beacon Chain + +- [Beacon Chain State Transition](./beacon_stf.md) +- [The mainnet wire](./beacon_wire.md) +- [The execution layer pairing](./beacon_engine.md) + # Operations +- [Command line](./cli.md) - [HTTP API](./rpc.md) - [Metrics](./metrics.md) - [Checkpoint Sync](./checkpoint_sync.md) diff --git a/docs/beacon_engine.md b/docs/beacon_engine.md new file mode 100644 index 000000000..78ff476ac --- /dev/null +++ b/docs/beacon_engine.md @@ -0,0 +1,305 @@ +# The execution layer pairing + +`ethlambda beacon` follows the Ethereum Beacon Chain. A beacon block carries an +execution payload that consensus cannot validate on its own: only an execution +client can say whether the transactions in it are valid and whether the state +root they produce matches. This page describes how the follower asks, what it +does with each answer, and what it does not yet do. + +The consensus rules implemented here are +[`specs/bellatrix/optimistic-sync.md`](https://github.com/ethereum/consensus-specs/blob/master/specs/bellatrix/optimistic-sync.md) +and the `verify_and_notify_new_payload` half of each fork's `process_execution_payload`. +The wire is +[`execution-apis/src/engine`](https://github.com/ethereum/execution-apis/tree/main/src/engine). + +## Which methods, and why so few + +Four: + +| Method | Introduced | Why | +|---|---|---| +| `engine_newPayloadV4` | prague | Validate one block's payload | +| `engine_forkchoiceUpdatedV3` | cancun | Say where the head, safe and finalized blocks are | +| `engine_exchangeCapabilities` | common | Startup handshake | +| `engine_getClientVersionV1` | identification | Log what the execution client is | + +Osaka introduces **no new `newPayload`**. Its own document adds only +`engine_getPayloadV5` and `engine_getBlobsV2`/`V3`; `engine_newPayloadV5` belongs +to Amsterdam. So the Osaka-current call for a payload is prague's V4, and the +Osaka-current fork choice notification is cancun's V3. + +Three method families a full client would have are deliberately absent: + +- **`engine_getPayload*` and `PayloadAttributesV3`.** This node is a follower and + never proposes, so it never asks an execution client to start building a block. + That also keeps `forkchoiceUpdated` outside the fork-scheduling rules attached + to `payloadAttributes.timestamp`: the second parameter is always `null`. +- **`engine_getBlobs*`.** There is no blob-pool fetch path here; data columns come + from peers over the network. +- **`engine_getPayloadBodies*`.** Nothing consumes them. + +Blocks before electra are not asked about at all. This node checkpoint-syncs onto +a chain far past bellatrix, capella and deneb and never imports one of their +blocks, and `engine_newPayloadV4` would refuse their payloads as an unsupported +fork anyway. Supporting them would mean carrying V1 through V3 as well, for +chains this follower cannot reach. + +## Where the call sits + +``` +process_block (beacon arm) + │ + ├─ data availability gate ────────────► held, if a custody column is missing + │ + ├─ engine_newPayloadV4 ◄── this page + │ + └─ fork_choice::on_block(…, payload_validity) + │ + ├─ state_transition, with the engine derived from the verdict + └─ record the block's execution hash and its optimistic status +``` + +The order is the specification's own: `is_data_available` runs before +`state_transition`, so a block about to be held for its custody columns is never +one this node asks an execution client about. + +Every field of the request is a pure function of the block: +`parent_beacon_block_root` is `state.latest_block_header.parent_root`, which +after `process_block_header` is the block's own `parent_root`, and the other +three are body fields. That is what lets the round trip happen *outside* the +state transition, so the state transition itself stays synchronous while the +import cascade above it is `async`. + +The verdict then reaches `state_transition` as an `ExecutionEngine`: an +`INVALIDATED` answer makes `verify_and_notify_new_payload` return false and the +transition fail from inside `process_execution_payload`, which is where the +specification puts that failure. The transition is run even when the verdict is +already `INVALIDATED`, which costs one merkleization on a path that should never +run and buys the failure arriving where a reviewer checks this code against the +specification. + +## The verdicts + +`optimistic-sync.md` groups the five wire statuses into three outcomes: + +| Wire status | Alias | What happens | +|---|---|---| +| `VALID` | | Imported. The block and every optimistic ancestor leave `optimistic_roots` | +| `SYNCING`, `ACCEPTED` | `NOT_VALIDATED` | Imported *if* `is_optimistic_candidate_block` allows it, and joins `optimistic_roots` | +| `INVALID`, `INVALID_BLOCK_HASH` | `INVALIDATED` | Not imported. `latestValidHash` decides how much of the branch dies with it | + +A block in `optimistic_roots` carries normal fork-choice weight and can be the +head. That is the point of optimistic sync, and the `sync/optimistic` fixture +suite proves it: its one case, `from_syncing_to_invalid`, has a `SYNCING` branch +take the head from a `VALID` one on attestation weight, and then requires the +head to fall back when the `SYNCING` branch turns out to be invalid. + +An invalidated block leaves fork choice by having its `LiveChain` index row +deleted. That is sufficient because `Store::block_index()` is the only source +`filter_block_tree`, `compute_weights` and `get_head` read: a root with no row +contributes no weight to any ancestor and can never be walked to. The block and +its state stay in their own tables, so an operator can still inspect what was +rejected. + +That an `INVALIDATED` block is never imported is enforced on the verdict itself, +in `on_block`, not on the state transition having failed. The transition still +runs first, so the failure arrives from inside `process_execution_payload` where +the specification puts it and where the `sync/optimistic` fixture exercises it. +But it only fails there for forks that consult the `ExecutionEngine` at all: +bellatrix gates that step on `is_execution_enabled`, and phase0 and altair have +no such step, so on those a condemned block would otherwise transition cleanly. + +### Optimistic roots + +A block imported on `NOT_VALIDATED` is recorded in `optimistic_roots` against +its slot. An entry leaves on a later `VALID` (which clears the whole optimistic +prefix, per `optimistic-sync.md`'s "all *ancestors* of the block MUST also +transition") or `INVALIDATED` verdict, and, for the ones that get neither, on +finality: an execution client doing a long state sync answers `NOT_VALIDATED` to +every block, so without that bound the set would take one root per import for +the life of the process. + +Nothing outside `mark_validated`'s own ancestor walk reads the set yet. The +readers it is waiting for are the ones that need to answer "is my head +optimistic?": the Beacon API's `execution_optimistic` response field, and a sync +status that separates a head this node has vouched for from one it has merely +imported. + +### `is_optimistic_candidate_block` + +A `NOT_VALIDATED` verdict is not enough on its own. The block must also either + +1. have a parent this node has already recorded an execution hash for, or +2. be at least `--safe-slots-to-import-optimistically` behind the wall clock. + +Condition 1 is `store.beacon_el_block_hash(parent_root).is_some()`, and that hash +is written when a block is *imported*. So it asks whether this node imported the +parent and saw its payload, not whether the parent is post-merge. + +The horizon guards against a poisoned *merge transition* block, whose parent hash +names an execution block nobody can produce. Once a node is importing a +post-merge chain every block's parent carries a recorded hash, so condition 1 +carries each one and condition 2 stops mattering. + +The exception is the first block after a checkpoint-sync anchor. The anchor is +never imported and never asked about (see [The anchor is assumed +valid](#the-anchor-is-assumed-valid)), so no execution hash is recorded for it +and its child fails condition 1. Paired with an execution client that is itself +still syncing, and so answers `SYNCING` to every `newPayload`, that child has +only condition 2 left and the follower's head sits at the anchor until the +horizon admits it. + +The wait is `--safe-slots-to-import-optimistically` minus however far the anchor +already trails the wall clock, which for a finalized anchor is most of it: +measured against mainnet on 2026-09-15, an anchor 85 slots back cleared in about +9 minutes rather than the full 128 slots. It costs that once per sync. Importing +that block records its hash, so every descendant takes condition 1 and the +backlog cascades. + +### `latestValidHash` + +An `INVALID` answer names the last execution block that was valid. Resolving +which beacon block that condemns is a walk up the **rejected block's own +ancestry**, not a lookup in a global index, because the specification scopes it +that way: "the *child* of a block with +`body.execution_payload.block_hash == latestValidHash` **in the chain containing +the block with payload in question**". Two branches can share a parent whose +payload is the last valid one, and only the branch that was rejected may die. + +| `latestValidHash` | Condemned | +|---|---| +| An execution hash found on this chain | The child of the block carrying it | +| All zeroes | The deepest indexed ancestor carrying a payload | +| `null`, or a hash not on this chain | The rejected block itself | + +The last row is the specification's own instruction, not a convenience: "when +`latestValidHash` is a meaningful execution block hash but consensus engine +cannot find a block satisfying +`body.execution_payload.block_hash == latestValidHash`, consensus engine SHOULD +behave the same as if `latestValidHash` was `null`". A checkpoint-synced follower +meets this whenever the named block is below its anchor. + +### The finality floor + +A condemned root at or below the finalized checkpoint is refused: the log says +so and nothing is removed. Obeying it would delete every `LiveChain` row from +finality upward, after which `get_head` fails its "block_root in store.blocks" +check on every call and the node can only report that it cannot compute a head +until its database is rebuilt. + +The all-zeroes row above is what reaches this without anyone naming a finalized +block: the walk it describes stops where the execution hash cache does, and that +cache is bounded by finality, so its deepest entry is the finalized block +itself. An execution client condemning finalized history means it and this node +disagree about what is final, which is an operator emergency rather than +something to resolve by emptying fork choice. + +## `forkchoiceUpdated` + +Sent once after every head recompute, which is once per import cascade and once +per tick, not once per block. It is sent even when nothing moved: an execution +client doing state sync needs to keep being fed a recent head or its sync cannot +converge. + +Its three hashes come from the cached execution hash of the head, the justified +checkpoint's root and the finalized checkpoint's root. A root with no cached hash +contributes the zero hash, which is a meaningful value here rather than an +absence: EIP-3675 requires `finalized_block_hash` to be zero before a +post-transition block is finalized, and the specification's own +`get_safe_execution_block_hash` returns zero when no payload is justified yet. A +follower whose *head* has no cached hash is still sitting on its checkpoint +anchor and sends nothing at all. + +The cache is pruned to the unfinalized window on every tick and every import, +with the finalized checkpoint's own root exempted by name. The slot bound alone +does not reach it: a checkpoint is stored as its epoch's start slot, while its +root is the last block at *or before* that boundary, so a missed proposal at an +epoch boundary leaves the finalized block below the bound. Dropping its hash +would make every later call carry `finalized_block_hash = 0x00..0` and stop the +execution client advancing its own finalized block for the life of the process. + +The response carries a `PayloadStatusV1` of its own, and that is the channel by +which a block imported on `SYNCING` later becomes `VALID` or is found to be +`INVALID`. An invalidation reaching the follower this way can remove the head +itself, unlike one reaching it through `newPayload`, because the block in +question has already been imported and *is* in the index. When it does, fork +choice is re-run and the head rewritten before the response handler returns: +`KEY_HEAD` and `Table::BlockRoots` still name the removed roots otherwise, and +the req/resp handlers read both, so a peer would be advertised and served a +block this node has just refused. Only the head is rewritten there, not +announced: telling the execution client from inside its own response would +re-enter the call being answered, so the new head goes out on the next cascade +or tick. + +## Configuration + +``` +--execution-endpoint e.g. http://127.0.0.1:8551 +--execution-jwt-secret file holding 32 bytes of hex +--safe-slots-to-import-optimistically default: the spec's own value +``` + +The first two must be given **together**: an Engine API endpoint always requires +authentication, so an endpoint without a secret is refused at startup, before +anything binds or connects. Clap cannot express "both or neither" across two +optional flags, so the pairing is checked once, in `From`. + +With **neither** flag, the follower contacts no execution client and every block +gets `PayloadValidity::NotRequired`, which is exactly what it did before any of +this existed. + +Authentication is HMAC-SHA256 over a JOSE header and a claim set whose only +member is `iat`. Execution clients accept about a minute of skew either way; a +fresh token is minted per request rather than cached, because minting is two +hashes and a cache would need a clock of its own. + +The startup handshake warns rather than refuses when the execution client does +not advertise a method this node needs: an execution client that under-reports +its capabilities still works, and refusing to start over a handshake would turn a +cosmetic mismatch into an outage. + +## Limitations + +### The retry ladder gives up, and nothing re-drives it + +Each call is attempted `ENGINE_MAX_ATTEMPTS` times, with a per-attempt timeout of +`ENGINE_TIMEOUT` and a backoff starting at `ENGINE_INITIAL_BACKOFF` and doubling. +An RPC *error* is not retried: that is the execution client answering, with a +refusal but an answer, so asking again would get the same refusal and burn the +ladder for nothing. + +After the last attempt the block is **dropped**. `optimistic-sync.md` requires +exactly that much ("a consensus engine MUST NOT import the block and MUST NOT +apply it to the fork choice store"), but nothing here re-drives the call +afterwards, so a persistently unreachable execution client parks the follower's +head behind the first block it could not ask about, permanently, until a +restart. + +This was accepted knowingly for this change. The intended fix is a tick-driven +re-drive of the set of blocks that got no verdict. `lean_engine_no_verdict_total` is the +metric that says it is happening: a non-zero rate means the follower has stopped +following rather than merely slowed down. + +### `optimistic_roots` does not survive a restart + +The optimistic set lives in memory alongside the rest of the beacon fork-choice +scratch and is not persisted. After a restart, blocks that were imported +optimistically are no longer marked as such. + +This is safe because the set is advisory. It records which blocks have not yet +been vouched for, so that a later `VALID` can clear them and a later `INVALID` +can be attributed; it never gates whether a block stays in fork choice. On +restart the execution client is asked again about everything the follower +imports from that point on, and `forkchoiceUpdated` re-establishes the head's +status on the first tick. + +### The anchor is assumed valid + +A checkpoint-synced follower takes its anchor state and block on trust, without +asking an execution client about the anchor's own payload. That is inherent to +checkpoint sync, not specific to this change. + +### The merge transition is untouched + +`validate_merge_block` and the terminal-PoW checks are bellatrix's, are reached +from one place, and were removed outright by capella. Nothing here changes them. diff --git a/docs/beacon_stf.md b/docs/beacon_stf.md new file mode 100644 index 000000000..17ebe686d --- /dev/null +++ b/docs/beacon_stf.md @@ -0,0 +1,539 @@ +# Beacon Chain state transition + +The `beacon` module of `ethlambda-state-transition` +(`crates/blockchain/state_transition/src/beacon/`) implements the Ethereum +**Beacon Chain** consensus specification, +[`ethereum/consensus-specs`][specs], phase0 through fulu. + +This is not the Lean consensus protocol the rest of the crate implements. The two +sit in one crate for the reason their types sit in one crate: a caller +dispatching on a state's fork can then reach either chain's rules without the two +living in separate dependency trees. They share nothing else. Nothing above +`beacon` in this crate reads anything inside it, and nothing inside it reads +lean's own modules. + +The containers, presets, configuration and primitives this module transitions are +in `ethlambda-types`, under its own `beacon` namespace, because +`ethlambda-storage` and the networking crates need those types and must not +depend on `blst` and `c-kzg`. What lives here is the behavior: the state +transition, the fork choice store, the helpers, and the two cryptography modules. + +`blst`, `c-kzg` and `num-bigint` are therefore on the lean binary's dependency +path, since `ethlambda-blockchain`, `ethlambda-rpc` and `ethlambda-test-fixtures` +all depend on this crate. The module is not feature-gated; that is the accepted +cost of holding both chains' rules in one place. + +The types are re-exported at the paths they had when they were defined here, so +`crate::beacon::containers`, `crate::beacon::preset` and `crate::beacon::config` +still resolve, and +`ethlambda_state_transition::beacon::containers::X` and +`ethlambda_types::beacon::containers::X` +are one type by one name. Two definitions travel in the other direction, for the +same reason they moved: `fork_choice` re-exports `LatestMessage` and `PowBlock`, +which `ethlambda-storage` persists, and `helpers::misc` re-exports +`compute_fork_data_root`, which the networking crate's fork digest is built on. + +One consequence reaches every match in the module. `ethlambda-types` gives +`BeaconState` and `ForkName` a `Lean` variant, so that one state type can carry +either chain, and every match on either needs an arm for it. No lean value can +reach this module: a fixture case's fork is parsed from a directory name and +`ForkName::ALL` has no lean entry, and a beacon block or a deposit set produces a +beacon state. So those arms panic and name themselves, through +`lean_state_unreachable`/`lean_fork_unreachable` (`src/beacon/lean_boundary.rs`) and +`lean_is_not_a_fixture_fork` in the spec tests, rather than widening signatures to +a `Result` no correct caller could ever see. They are named arms rather than a +catch-all `_`, so a real fork added to the enum still breaks every match that has +to grow an arm for it. + +Correctness is defined by the released spec test fixtures, pinned in the +`Makefile`. + +## Running the tests + +```bash +make consensus-spec-tests # download the fixture tarballs (about 2.2 GB) +make test-beacon # build and test once per preset +``` + +`make test-beacon` is the two per-preset targets, `test-beacon-mainnet` and +`test-beacon-minimal`, run one after the other. Either can be run on its own, +which is what CI does: the presets get a job each, so the two build and run +concurrently instead of end to end. + +A run reads its own preset's tree plus `general`, the preset-independent BLS and +KZG vectors, and opens nothing else. `CONSENSUS_SPEC_TESTS_CONFIGS` narrows the +download to that, and CI sets it, which takes roughly 1.25 GB of tarballs down to +0.8 GB for mainnet and 0.6 GB for minimal. Locally the default fetches all three, +so both presets can be run without re-downloading. + +The download is stamped with the release it came from and the configs it holds +(`consensus-spec-tests/.version--`), so changing either wipes +the tree and fetches afresh. Without the version in that name a bump would be a +silent no-op: the extracted directories already exist, so `make` would consider +them current and both presets would report green against the old fixtures. +Without the configs, a narrowed download would mark a tree missing one preset as +complete, and that preset's next run would fail on a missing directory rather +than fetching what it needs. The wipe matters as much as the re-download, since +the tarballs unpack side by side into one directory, and layering a new release +over an old one would keep cases the new one deleted. + +`make test` covers the whole workspace, in two halves, and still needs no +fixture download. The halves are all that `test-node`'s `--exclude` flags do; +nothing is dropped to keep the beacon suite out. Two gates keep it that way, +both tied to the `beacon-spec-tests` feature that `make test-beacon` turns on: + +- The `beacon_spec_tests` target declares `required-features = + ["beacon-spec-tests"]`, so `cargo test` skips building it entirely. +- The BLS and KZG fixture vectors are unit tests inside the module, which no + target gate reaches, so each carries + `#[cfg_attr(not(feature = "beacon-spec-tests"), ignore = ...)]`. They report as + ignored rather than disappearing, which is why `make test-beacon` also passes + `--lib`: 15 tests would otherwise never run. + +Neither gate touches the module itself, which always compiles. What they stand +for is the fixture tree, not the code. + +## Layout + +| Module | Holds | +|--------|-------| +| `preset` | Compile-time constants, mainnet or minimal | +| `config` | Runtime configuration: fork schedule, churn limits, blob schedule | +| `constants` | Values the spec fixes outright: domain types, flag weights, sentinels | +| `fork` | `ForkName`, ordered oldest to newest | +| `primitives` | Scalar aliases, `Root`, and the fixed-length byte strings | +| `bls` | BLS12-381 via `blst` | +| `kzg` | KZG via `c-kzg`, plus the two challenge functions c-kzg does not export | +| `containers` | The `BeaconState` enum, fork-invariant containers, per-fork containers | +| `helpers` | The spec's helper functions | +| `stf` | The state transition: slots, blocks, operations, epoch processing | +| `genesis` | Building a genesis state from Eth1 deposits | +| `upgrade` | Fork upgrades between per-fork state shapes | +| `fork_choice` | The LMD GHOST store | +| `lean_boundary` | The two `#[track_caller]` panics for the `Lean` arm of the shared enums | + +The first five come from `ethlambda_types::beacon` and are re-exported by +`beacon/mod.rs`; the rest are defined here. + +## Three kinds of parameter + +The specification distinguishes constants, presets, and configuration, and so +does this module, because they have genuinely different lifetimes. + +**Presets** (`preset`) set container sizes, so they must be compile-time +constants: `SszVector`. The preset +therefore cannot be a runtime value or a type parameter. Stable Rust cannot take a +const-generic argument from a trait's associated const, so the preset is selected +by Cargo feature: mainnet by default, minimal with `preset-minimal`. The test +target builds the crate twice and each run walks only its own fixture tree. + +**Configuration** (`config`) is runtime, because the `transition` fixture suite +moves a fork's activation epoch per test case. That is the concrete reason fork +scheduling is not a preset. + +**Constants** (`constants`) are fixed by the specification and vary by neither. + +## Retuning a value without redefining a function + +The specification expresses a retuned constant as a fresh name per fork +(`MIN_SLASHING_PENALTY_QUOTIENT`, then `..._ALTAIR`, then `..._BELLATRIX`) and +redefines the function that reads it, so each fork carries its own copy of +that function differing in one identifier. `preset::retuned` +(`crates/common/types/src/beacon/preset.rs`, reached here as `crate::preset`) +selects the value by fork instead, in four functions, so `slash_validator` and its neighbors stay a single copy each +rather than one per fork. + +This is the one place in the crate where a preset value is chosen at runtime. +It is sound because none of these values bound a container: they are divisors +and multipliers in balance arithmetic, not container shape. + +The minimal preset is **not** a uniformly scaled mainnet: it overrides only +*phase0's* retuned values and inherits every later fork's from mainnet +unchanged. So a value can move one way across a fork boundary under mainnet +and the other way under minimal: `INACTIVITY_PENALTY_QUOTIENT` falls from +phase0 to altair under mainnet and rises under minimal. A test asserting that +slashing gets uniformly harsher across the forks holds under mainnet and is +false under minimal, so `preset::retuned`'s own tests pin the fork-to-constant +mapping directly, which holds under both presets, rather than any numeric +relationship, which does not. + +## How forks are represented + +Containers that change between forks are defined once per fork as plain structs +that derive their SSZ encoding, decoding, and merkleization, and an enum wraps +them: + +```rust +pub enum BeaconState { + Phase0(phase0::BeaconState), + // one variant per fork that changes the state's shape +} +``` + +Deriving the SSZ traits is the whole reason for that shape. Container +serialization and merkleization is the highest-risk code in the crate, and the +per-fork field lists are not a growing tail: + +- `previous_epoch_attestations` and `current_epoch_attestations` exist **only** in + phase0. From altair on they are absent from both the encoding and the merkle + tree, replaced in position by `previous_epoch_participation` and + `current_epoch_participation`, which have a different type. +- `latest_execution_payload_header` keeps its name from bellatrix on, but is a + different container only in bellatrix, capella, and deneb; electra and fulu + reuse deneb's shape unchanged. +- The field count crosses a power of two at electra, so the state's merkle tree is + five levels deep through deneb and six from electra on. The same logical field + has a different generalized index in different forks. +- `SignedBeaconBlock::Fulu` wraps `electra::SignedBeaconBlock` rather than a + `fulu` type of its own, since fulu changes no field of a block. It still + needs to be its own variant: fulu changes how a block is *processed*, since + the blob commitment limit becomes epoch-dependent, so code that dispatches + on fork still has to tell a fulu block from an electra one even though both + carry the identical payload. + +| Fork | State fields | HTR leaves | Depth | +|------|--------------|------------|-------| +| phase0 | 21 | 32 | 5 | +| altair | 24 | 32 | 5 | +| bellatrix | 25 | 32 | 5 | +| capella, deneb | 28 | 32 | 5 | +| electra | 37 | 64 | 6 | +| fulu | 38 | 64 | 6 | + +A single container with fork-conditional serialization would have to reproduce all +of that by hand. Derived codecs get it from the struct definition, which is +checked field by field against the spec text and then verified by `ssz_static`. + +Since SSZ carries no type tag, the fork cannot be recovered from the bytes, so +decoding takes it from context: `BeaconState::from_ssz(fork, bytes)`. + +### Not duplicating the state transition seven times + +The cost of per-fork structs is that a naive implementation would copy every +function once per fork. Two things prevent that: + +1. **Accessors for the fork-invariant fields.** About twenty of the state's fields + are identical in every fork. One `macro_rules!` in `containers/mod.rs` + generates their read and write accessors from a single list, and that list + doubles as the crate's statement of which fields are fork-invariant: a fork + that changes one moves it out of the list and gains an explicit match at each + use site. + +2. **Matching only where the spec diverges.** Functions take the enum and use + accessors, matching on the fork only where the specification itself changes + behavior, so a match arm can be reviewed against the spec's own diff. + +### No block-body enum + +`BeaconState` and `SignedBeaconBlock` are the only enums; there is no +`BeaconBlockBody` enum or a body trait. Such a type would have to grow a +method or match arm per fork-specific field or operation list, which defeats +the point of dispatching once: a caller of `body.attester_slashings()` would +still have to know which fork it is dealing with to make sense of what comes +back, since electra's attester slashings are not phase0's. What actually lets +one function serve every fork is narrower and cheaper: `process_block_header`, +`process_randao`, and `process_eth1_data` each take the handful of fields they +read directly, rather than a whole body, so validating a header does not care +whether the body it came from also carries a sync aggregate or an execution +payload. A shared step earns its genericity by needing less, not by being +handed a bigger abstraction to see through. See `stf/mod.rs`'s module doc for +the full reasoning. + +### Crossing a fork boundary + +`process_slots` performs the fork-boundary state upgrade itself, inside its +slot loop, at the first slot of the activation epoch, looping again in case a +single configuration activates two forks at the same epoch. `state_transition` +checks the block's fork against the state's own only *after* calling +`process_slots`, not before: a block at the first slot of an activation epoch +is legitimately post-fork shaped while the state arriving there is still +pre-fork, which is exactly what a fork transition consists of. Checking first +would reject every legitimate fork-boundary block and make crossing a fork +impossible. + +## Registry and balances + +`validators` and `balances` hold one entry per validator, about 2.4M on +mainnet, so they dominate both the cost of a state root and the memory of every +cached state. They are `ethlambda_ssz_tree::List`s rather than `SszList`s: +persistent Merkle trees in the shape of the SSZ one, modeled on the `milhouse` +lists lighthouse keeps its state in. + +- **Nodes cache their hash, and states share nodes.** A state derived from + another shares every subtree the block did not touch through `Arc`, and its + root rehashes only the touched paths. A state decoded from storage is rebased + onto a cached relative, so it shares memory with it too. +- **Leaves and inner nodes are page-sized.** A leaf holds a contiguous run of + elements (32 validators, 512 balances), and an inner node up to 512 child + pointers standing for nine binary levels at once. A lookup therefore crosses + a handful of nodes rather than one per level of the registry's depth, and + iteration walks each leaf as a slice. A composite leaf also keeps each + element's root once hashed, and a rebuilt leaf carries over the roots of the + elements it did not change, so one changed validator costs one validator + hash plus a fold of the cached roots. +- **Writes are buffered.** `get_mut`, `IndexMut` and `push` record the new + value; `BeaconState::apply_pending_mutations` folds everything buffered into + the trees in one pass. The state transition calls it at the top of + `process_slot`, after `process_block`, and at the end of `process_slots` (epoch + processing runs after the loop's last `process_slot`). A root taken with + writes pending is still correct but computed on a throwaway copy, and the + store flushes a state before caching it, since a shared `Arc` cannot be + flushed later. + +The access pattern matters. `state.validator(i)` and `balances()[i]` are tree +descents, cheap next to a hash but far from an array index, and they add up +when a helper calls them once per validator: +`get_total_active_balance` builds the active-index `Vec` and then reads every +index back, and runs several times per block (once per attestation through +`get_base_reward_per_increment`, once per execution request through the churn +limits). In the 2026-09-28 import profile, those per-index reads and the +repeated whole-registry scans were the largest cost left after hashing. A loop +over the registry should walk `validators().iter()`, zipped with +`balances().iter()` where it needs both. The total active balance is the obvious +candidate for computing once per epoch rather than per call, once it is shown +that no block operation changes it mid-epoch. + +## Macros and traits + +Two `macro_rules!` in the whole crate, both local, both replacing boilerplate that +would otherwise run to hundreds of near-identical lines: the fixed-length byte +strings in `primitives`, and the state accessors in `containers`. No procedural +macros beyond the SSZ derives, and no trait abstracting over BLS or KZG backends. + +## Cryptography + +`bls` wraps `blst`, and `kzg` wraps `c-kzg` with the `eip-7594` cell and column +functions fulu needs, both matching the versions ethrex uses. + +Two details worth knowing: + +- Every BLS function re-validates its inputs on each call rather than trusting a + wrapper validated once. `BlsPubkey` is deliberately unvalidated on construction, + because deposit processing has to be able to hold a key that never validates, so + nothing upstream guarantees a key is a subgroup-correct point. +- `compute_challenge` and `compute_verify_cell_kzg_proof_batch_challenge` are + implemented directly from the spec rather than called, because c-kzg keeps them + as private steps of its own proof routines yet both have their own fixture + handlers. + +## Fixture suites + +`consensus-spec-tests/tests///////`, +where `` is `general` for the configuration-independent suites and the +preset name otherwise. Container files are `.ssz_snappy`: SSZ compressed with +*raw* snappy, not the framed format. + +Runners discover their cases from disk rather than listing them, so a fixture +release that adds cases needs no code change. Two properties are deliberate: a +suite that matches no case **fails** rather than reporting green, and a container +or fork pair with no implementation yet is counted and printed rather than passed +over silently, so the output never implies more coverage than exists. + +`value.yaml` goes unread in `ssz_static`. Using it would need a serde +implementation for every container and would pin down nothing that the serialized +bytes and expected root do not already. + +## Performance + +The mainnet spec suite went from 1576s to about 106s. Two causes: + +1. `sha2`'s `asm` feature selects the CPU's SHA-256 instructions. Without it, + `sha2` compiles the portable scalar backend on aarch64 regardless of what + the CPU supports, and merkleization is almost entirely SHA-256 + compressions, so a spec fixture case running two whole-state + merkleizations feels the difference more than anything else does. +2. Every fixture case is its own test, so the harness runs them concurrently at + one case per work item. That is finer-grained than a suite-per-test layout + could balance, where the slowest suite alone set the wall clock. + +CPU time and wall clock separate the two cleanly, since running work in +parallel cannot reduce the total CPU time it takes: + +| Measure | Before | After | Factor | +| --- | --- | --- | --- | +| CPU time | 3041s | 928s | 3.3x, the hardware SHA-256 | +| Wall clock | 1576s | 106s | 14.9x, both causes together | + +So parallelism accounts for the remaining 4.9x, taking the run from two of +eleven cores busy to about eight. The 3.3x understates the hashing change, +because the later run does strictly more work: `transition` went from failing +immediately to running every case. + +## One test per fixture case + +The suites were once one test apiece, each looping over its own cases and +aggregating outcomes. That made every failure a failure of the whole suite: the +name in the output was the suite's, and a single bad case marked thousands of +passing ones as part of one failed test. + +A fixture case is not known until the fixture tree is walked, and `#[test]` +needs its tests at compile time, so the spec binary supplies its own harness +(`harness = false`, with `libtest_mimic`) and builds its test list at run time. +Each case is then named, counted, filtered, and attributed on its own, and +`--test-threads`, `--ignored`, `--list`, and substring filters all keep working. +A filter selects a whole suite as readily as one case, since a test's name is +its runner followed by `Case::id`: + +```text +operations/electra/attester_slashing/pyspec_tests/basic_double +``` + +Two things that arrangement has to be careful about: + +- A case whose fork this module does not implement becomes an **ignored** test + rather than a missing one. The harness counts and names ignored tests, which + says more than the tally the old aggregate printed. +- A suite that matches no case at all would otherwise contribute no tests, and a + run of nothing passes. The aggregate used to assert it had matched something; + that check survives as one `/matched_fixture_cases` test per suite, so + a stale runner or handler name still fails loudly. + +## Status + +All seven forks, phase0 through fulu, have containers, fork upgrades, state +transitions, and epoch processing. Every fixture case passes on both presets: + +| Preset | Fixture cases | Ignored | Lib tests | +|--------|---------------|---------|-----------| +| mainnet | 5705, all green | 152 | 195 | +| minimal | 40009, all green | 3692 | 196 | + +The lib counts were 244 and 245 while the containers, presets, configuration and +primitives were defined here. Their 48 unit tests moved with them and run in +`ethlambda-types`; the fixture counts, which are what defines correctness here, +are unchanged. + +Minimal runs more cases because the release ships more fixtures for it, and it +runs two runners mainnet does not: `genesis`'s `initialization` and `validity`. +The release ships no mainnet `genesis` fixtures, so that whole runner is gated +behind the `preset-minimal` feature (`tests/spec/genesis.rs`). + +Nothing is ignored for being unimplemented. Every ignored case is one of two +deliberate exclusions: + +- `LightClient*` containers under `ssz_static`, 5 container types across altair + through fulu. The light-client sync protocol is a different layer from the + state transition and fork choice, and is not in this module's scope. +- The `gloas` and `eip7805` fixture trees, one ignored entry each. See + "Accounting for every fork directory" below. + +## Accounting for every fork directory + +`collect` identifies a fork by parsing the directory name into a `ForkName`, and +a name that does not parse is skipped. That skip is silent in a way the +`HIGHEST_IMPLEMENTED_FORK` gate is not: the cases never become tests, so they are +not counted as ignored either, and nothing in the output says they exist. + +The release does ship two such trees. `gloas` is the fork after fulu, and +`eip7805` is not a fork in the sequence at all, being one of the per-EIP trees +generated against a variant of some fork's rules. Between them they hold 1898 +mainnet and 17539 minimal cases, all of which were previously dropped without a +trace, which is the opposite of what this harness promises. + +So `UNMODELED_FORKS` names them, each reports as one ignored test, and +`fixture_forks/every_directory_is_accounted_for` fails if the tree holds a fork +directory that is neither parseable nor listed. A release that adds a fork now +forces a decision instead of quietly widening the gap. + +| Suite | Covers | +|-------|--------| +| `general/bls` | Cryptography, fork- and preset-independent | +| `general/kzg` | Cryptography, fork- and preset-independent | +| `ssz_static` | Every fork's containers | +| `shuffling` | Committee helpers | +| `operations`, `epoch_processing` | Every fork's operation and epoch sub-function | +| `sanity/blocks`, `sanity/slots`, `finality`, `random`, `rewards` | Whole blocks and slots, end to end | +| `fork`, `transition` | Fork upgrades, standalone and mid-chain | +| `genesis` | Genesis initialization (minimal only, see above) | +| `fork_choice` | The `Store`; see below | + +### Fork choice is fixture-verified + +150 mainnet `fork_choice` cases pass, covering bellatrix's `on_merge_block`/ +terminal-PoW validation, `should_override_forkchoice_update`, deneb's blob +data availability, and fulu's column data availability. + +The release still ships no phase0 `fork_choice` suite: the earliest is +altair's, built from altair-shaped states even though altair changes nothing +about fork choice itself (`Store` accepts a block from any fork this module +implements; see `fork_choice.rs`'s own module doc). Landing altair's state +transition is what let this suite start running at all. + +### A note on earlier case counts + +Counts reported before the runners landed were too high by roughly sevenfold. +`collect_all_handlers` walked the fork directories and then delegated to +`collect`, which walks them again, so every case was emitted once per fork +shipping that runner. The cases were always being *run*; they were counted many +times over. Both collectors now share one suite walk. + +## A recurring bug: projecting when an accessor was needed + +Reach for a concrete per-fork projection, such as `altair_state(state)`, only +when the return type must be that fork's own. Reach through a `BeaconState` +accessor when the fields involved are shared. The trap is that a projection +like `altair_state(state)` matches only `BeaconState::Altair`, so it compiles +cleanly, passes the fork that introduced the field, and returns +`UnsupportedForFork` for every later fork that shares the exact same field +through a different variant. + +This caused five separate bugs during implementation: + +- Capella's withdrawal sweep, projected to capella's own state. +- Deneb reusing that same sweep, still projected to capella's state. +- Altair's participation fields, projected to altair's state, so every one of + the five later forks that also carries participation failed. +- Deneb's `process_attestation`, projected to deneb's state. +- `slash_validator` calling phase0's `initiate_validator_exit` at electra, + which also left electra's EIP-7251 churn cursor unadvanced, mispricing every + later exit processed in the same epoch. + +## Deliberate simplifications + +- Light client containers and suites are out of scope. +- `ssz_generic` exercises the SSZ library rather than the beacon containers, so + it is not a gate. +- The state transition mutates in place, as the specification does, so a state + passed to `state_transition` is left partly modified when a block turns out to + be invalid. Callers that need the pre-state clone it first, which is what the + fixture runners do. +- `ExecutionEngine` (`stf::mod`) stands in for a real execution client: just + `execution_valid: bool`, read straight from a fixture's `execution.yaml` + rather than a payload actually validated. + +## What the fixture format asserts by omission + +A case with a `post` state must succeed and land exactly on it. A case *without* +one must be **rejected**. The second half is what keeps the suites honest: an +implementation that accepted everything would otherwise pass every case that +ships a post-state. That rule lives in one place, `check_transition`, and every +state-comparing runner goes through it. + +Two consequences worth knowing: + +- Roughly a quarter of the phase0 `operations` cases are rejection cases, so the + invalid path gets as much coverage as the valid one. +- The `rewards` suite is the exception to state comparison: it compares the five + per-component delta vectors directly. That is sharper, because the components + are summed into balances, so a sign error in one component and a compensating + error in another would produce correct balances from incorrect deltas. + +## Where the specification's Python does more than it appears to + +Two places where a faithful-looking transcription is wrong, both found by the +fixtures rather than by reading: + +- `get_matching_target_attestations` evaluates `get_block_root` **inside** a list + comprehension, so it never runs when there are no attestations. That call has + its own range assertion, which fails for the epoch a state sits at the start of. + Hoisting it out of the loop rejects states the specification accepts. +- `get_attesting_indices` returns an unordered set, and `get_indexed_attestation` + sorts it. A committee is a *shuffled* slice of the registry, so filtering it in + position order yields attesters in shuffle order, which is almost never + ascending. `is_valid_indexed_attestation` requires sorted indices, so skipping + the sort rejects every valid attestation. + +Both are cases where the Python reads as if order or evaluation point does not +matter, and both change the result. + +[specs]: https://github.com/ethereum/consensus-specs diff --git a/docs/beacon_wire.md b/docs/beacon_wire.md new file mode 100644 index 000000000..599128347 --- /dev/null +++ b/docs/beacon_wire.md @@ -0,0 +1,677 @@ +# The mainnet wire + +`ethlambda beacon` follows the Ethereum Beacon Chain's gossip. This page +describes what it puts on the wire; [`discovery.md`](./discovery.md) covers the +discv5 stack it shares with lean, and [`cli.md`](./cli.md) the flags and the +startup order. + +It follows, from its checkpoint anchor to the tip: a chain actor imports what +gossip announces and what range sync fetches, and the two block protocols serve +other peers from the same store. Fork choice learns its votes from block bodies +and from the aggregate topic, which is how it sees votes for the *current* head +rather than only ones at least a block old; see [Aggregate +attestations](#aggregate-attestations). It publishes nothing. It also custodies +and serves a slice of the fulu data column matrix, and backbones a slice of the +attestation subnets, both sized and selected by its own node id; see [Data +column sidecars](#data-column-sidecars). + +## Running it + +```bash +ethlambda beacon \ + --node-key ./node-key \ + --gossipsub-port 9001 +``` + +No flag is required, but that is only true for a built-in network: +`ethlambda beacon` on its own follows mainnet, since `--network` defaults to +`mainnet`. Sepolia and Hoodi are built in too, and any other network is a +`--network` naming a directory of published files (`config.yaml` and +`genesis.ssz`, see [`cli.md`](./cli.md)): + +```bash +ethlambda beacon --network hoodi --checkpoint-sync-url https://checkpoint-sync.hoodi.ethpandaops.io +ethlambda beacon --network ./my-network --node-key ./node-key +``` + +`genesis_time` and `genesis_validators_root`, and therefore the fork digest, +are derived from the resolved network: a built-in network (`mainnet`, +`sepolia`, `hoodi`) carries the two values as constants beside its +`eth-clients` `config.yaml` and bootnode list, and no genesis state; for a +loaded network they are read off that directory's own `genesis.ssz`. Nothing +about startup touches the network to get there. discv5 is forced on and needs no flag either: published mainnet +bootnodes are largely seed-only, so a crawl is how a peer is reached. + +## The fork digest + +Computed once at startup from the resolved network's genesis state, never +hardcoded as a digest: + +``` +epoch = (now - genesis_time) / (SECONDS_PER_SLOT * SLOTS_PER_EPOCH) +fork_version = the resolved network's fork schedule at epoch +base = compute_fork_data_root(fork_version, genesis_validators_root) +digest = base[..4] if epoch < FULU_FORK_EPOCH + = xor(base, sha256(le64(bp.epoch) ++ + le64(bp.max_blobs)))[..4] if epoch >= FULU_FORK_EPOCH +``` + +`bp` is the latest blob-schedule entry at or before `epoch`, falling back to +`(ELECTRA_FORK_EPOCH, MAX_BLOBS_PER_BLOCK_ELECTRA)`. The fulu branch is +EIP-7892's, which is why mainnet's digest is `8c9f62fe` rather than fulu's bare +`82fae541`. + +Startup logs the next boundary's epoch and wall-clock time. The digest is +computed once, so crossing one strands the node on topic names nobody publishes +to; restart it to pick up the new digest. + +## Gossip + +Seven global topics, `/eth2/{digest}/{name}/ssz_snappy`, plus two subnet +families this node's own node id selects a narrow slice of: the data column +subnets it custodies and the attestation subnets it backbones, both described +below. + +| Topic | Decoded as | +| --- | --- | +| `beacon_block` | `SignedBeaconBlock`, fork chosen by the block's slot | +| `beacon_aggregate_and_proof` | `SignedAggregateAndProof`, phase0 or electra | +| `attester_slashing` | `AttesterSlashing`, phase0 or electra | +| `voluntary_exit` | `SignedVoluntaryExit` | +| `proposer_slashing` | `ProposerSlashing` | +| `bls_to_execution_change` | `SignedBLSToExecutionChange` | +| `sync_committee_contribution_and_proof` | `SignedContributionAndProof` | +| `beacon_attestation_{subnet_id}` | `Attestation`, phase0 or electra | + +`beacon_attestation_{0..63}` is no longer wholly unsubscribed. This node holds +`SUBNETS_PER_NODE` (2 on mainnet) long-lived subscriptions from that family, +chosen by `compute_subscribed_subnets(node_id, epoch)`, a public function of +this node's own discv5 node id in the same way `custody_columns` is, so any +peer can compute the set without asking. `p2p-interface.md` asks every beacon +node to hold them whether or not it runs validators: phase 0 has no shard +committees, so nothing else gives these subnets a stable membership for +validators to publish into. The subscription is therefore owed to the network +rather than to this node's head: this node verifies and relays what arrives on +it (see [Gossip validation](#gossip) below) but never applies it to fork +choice. A lighthouse node with no validators behaves the same way, subscribing +to its own node-id backbone and verifying what arrives on it while +`should_process_attestation` keeps it out of fork choice unless a local +aggregator duty or `--import-all-attestations` says otherwise. + +One deliberate shortfall: the set is computed once at startup and kept for the +process's lifetime rather than rotating every `EPOCHS_PER_SUBNET_SUBSCRIPTION` +epochs. Lighthouse does the same, and reads that constant nowhere. + +`sync_committee_{0..3}` stays unsubscribed and arrives with the work that reads +it. `blob_sidecar_{subnet_id}` stays absent permanently: it is deneb's format +for blobs, deprecated at fulu in favor of the column matrix below. + +`data_column_sidecar_{0..127}` is no longer in that absent list. This node +subscribes to `sampling_size(CUSTODY_REQUIREMENT)` of them — the sampling size +floored by `SAMPLES_PER_SLOT` above `CUSTODY_REQUIREMENT` itself — chosen by +`custody_columns(node_id, …)`, a public function of this node's own discv5 +node id (`das-core.md`), so any peer can compute the same set without asking. +`NUMBER_OF_CUSTODY_GROUPS` and `DATA_COLUMN_SIDECAR_SUBNET_COUNT` are equal +today, so a column is its own subnet with no reduction. That makes this +node's total subscription count the seven global topics plus its sampling size +plus `SUBNETS_PER_NODE`, still far short of a full subscription to every +attestation, sync-committee and data-column subnet, and narrower still than +`NUMBER_OF_CUSTODY_GROUPS` columns of custody, which is what a supernode +would carry alone. A sidecar decodes as +`fulu::DataColumnSidecar`; the checks it passes before this node keeps or +forwards it are described just below. How a kept sidecar is later served back +out over req/resp is under [Data column sidecars](#data-column-sidecars). + +Every beacon message is held by gossipsub until it has a verdict +(`validate_messages()` is on for this wire only). Blocks, data column +sidecars, aggregates and subnet attestations are all validated by fulu's +gossip rules (`ethlambda_state_transition::beacon::gossip`, one module per +topic family): the checks that need no state run inline in the p2p actor, the +rest on a bounded `spawn_blocking` task whose verdict comes back to the actor +(`crate::beacon::verdict`). Two permit pools bound how many of these run at +once, so a burst on one family cannot starve another: +`gossip_validation_permits` for blocks and columns, +`attestation_validation_permits` for aggregates and subnet attestations. A +mainnet slot carries up to `MAX_COMMITTEES_PER_SLOT * +TARGET_AGGREGATORS_PER_COMMITTEE` aggregates alone, arriving every slot rather +than only during a range sync, which is why that traffic needs a pool of its +own rather than sharing the block and column one. Accept propagates the +message; a message whose dependency is not ready yet is IGNOREd. See +[Aggregate attestations](#aggregate-attestations) for the two topics with +their own section. + +Blocks and data column sidecars are, either way, handed to the chain actor, +which parks what it cannot import yet and imports the rest immediately, such +as a sidecar whose slot merely falls outside its parent state's proposer +lookahead. An aggregate reaches the chain actor only on `Accept`, carrying the +attesting indices gossip validation resolved. A subnet attestation never +reaches it at all, on any outcome: verifying and relaying it is the whole of +what this node owes the topic (see above), so there is nothing further for the +chain actor to do with one. + +The remaining five global topics are decoded, logged at `debug`, and IGNOREd, +since nothing consumes them; an undecodable payload on any topic is REJECTed. +Nothing is published on any topic, columns included: nothing this node can +produce today would be signature-valid. + +## Aggregate attestations + +`beacon_aggregate_and_proof` reaches fork choice. It is how a follower learns +votes for the *current* head rather than only the votes a block body carries, +which are always at least one block old. `beacon_attestation_{subnet_id}`, +covered in the same section below, never does: this node relays its backbone +subnets without ever applying what arrives on them. + +Both topics are validated the same way blocks and columns are, in +`ethlambda_state_transition::beacon::gossip::{aggregate,attestation}`: cheap +conditions (seen cache, propagation window, `data.index == 0`, exactly one +committee named) run inline in the p2p actor; the rest run on a blocking +thread, in this order: + +1. The vote's block is known (`Store::has_block`); if not, IGNORE. +2. **Committees, signatures and ancestry all resolve against the vote block's + own cached post-state** + (`store.cached_state(CacheKey::BlockState(beacon_block_root))`), not the + specification's head state and not the target checkpoint's state either. + Three reasons converge: it is the attested chain's own state, so its + shuffling is the one the attesters were actually assigned, where the head + (or a checkpoint reached by replaying a different branch) can name the + wrong one; it is an `O(1)` cache read rather than a lookup or a replay; and + its own `block_roots` answers both ancestry questions without a + `Store::block_index` / `LiveChain` scan. +3. The signatures that need only a pubkey, before any committee derivation: an + aggregate's selection proof and aggregator signature, an attestation's own + signature. An unknown validator index REJECTs here rather than paying for a + committee lookup first, since a forged message must not be able to reach a + shuffling derivation. +4. The committees, through the `Store`-held cache both actors share; then + `is_aggregator` (rewritten to take a committee length rather than a + pre-fetched cache), committee membership, and, for a subnet attestation, the + subnet match (`compute_subnet_for_attestation`). +5. An aggregate's own signature, over the indexed attestation built from that + committee. The indices it verifies travel to the chain actor; it never + rebuilds them. +6. Ancestry against the vote state's `block_roots`: the target is the vote + block's ancestor at the target epoch (REJECT), and the finalized checkpoint + is an ancestor of the vote block (IGNORE). + +The seen caches (`SeenAggregates`, keyed both by `(target_epoch, +aggregator_index)` and by `(hash_tree_root(data), committee_index)`; +`SeenAttestations`, by `(target_epoch, attester_index)`) live in p2p now, +recorded only on `Accept` by `verdict::settle`, and are bounded by capacity +(an LRU, like the block and column seen caches) rather than pruned on +finality: a peer must not get to grow either one just by outlasting +finalization. Recording only after the signatures verify is still what keeps a +garbage aggregate from being able to censor a genuine one for the rest of the +epoch by claiming its `(epoch, aggregator)` pair first; lighthouse splits the +same way, reading its observed-sets in `verify_early_checks` and writing them +in `verify_late_checks`. + +Only an aggregate that gossip `Accept`s reaches the chain actor, carrying the +attesting indices already resolved; the actor never re-derives a committee or +checks a signature for this topic. What is left for it: + +- **A lighter, actor-local applied-bits gate**, a running union of aggregation + bits already applied per `(target_epoch, hash_tree_root(data), + committee_index)`. Not a spec seen-set (p2p owns that one now); it exists so + a committee's other aggregators, each individually accepted by gossip + because each is a first-seen, valid message, do not all pay for + `apply_verified_aggregate` when the first one already covered their bits. + Pruned by the store's own clock to the current and previous epoch, not by + finality, so a stalled chain cannot make this grow without bound either. +- **The deferral queue.** `validate_on_attestation` requires + `get_current_slot(store) >= data.slot + 1`, and aggregates are published two + thirds of the way through the slot they vote for, so every one arrives too + early. Without the queue this topic would apply approximately nothing. It + drains once per beacon tick, between the clock advancing and the head being + recomputed, so released votes are in fork choice before that tick's head is + chosen. The specification licenses this directly ("consider scheduling it for + later processing in such case") and lighthouse has the same queue. Held + entries now carry their gossip-resolved attesting indices alongside them, so + a drain applies them without recomputing anything. + +A subnet attestation is fully verified by this same pipeline and then simply +dropped: nothing forwards it to the chain actor, on any outcome, matching a +lighthouse follower with no validators, which verifies and relays its own +backbone subnets while `should_process_attestation` keeps them out of its fork +choice. + +## Request/response + +| Protocol | Direction | +| --- | --- | +| `status/1`, `status/2` | both | +| `ping/1` | both | +| `metadata/1`, `metadata/2`, `metadata/3` | both | +| `goodbye/1` | inbound; the reason code is logged and the stream closed | +| `beacon_blocks_by_range/2` | both | +| `beacon_blocks_by_root/2` | both | +| `data_column_sidecars_by_root/1` | both | +| `data_column_sidecars_by_range/1` | both | + +The two data column sidecar protocols are registered because this node +custodies the columns its node id selects and can answer for them out of +`Table::DataColumns` (see [data_storage.md](./data_storage.md)); see +[Data column sidecars](#data-column-sidecars) for what each serves and asks +for. The blob sidecar protocols stay absent: nothing here custodies a whole +blob, only the erasure-coded columns fulu derives it into, and a registered +protocol with no implementation behind it is an untested encoder peers can +reach. + +Only version 2 of the two block protocols is registered. Version 1 is deprecated +by the spec, which lets a client answer it with an empty list, and its chunks +carry no ``, so serving it would mean a second response encoder +for a shape no mainnet peer needs. + +The `Status` this node sends is derived from its anchored store, so a peer +reading it has a reason to ask for the blocks the two block protocols serve. +There is one window where every checkpoint is still zero, before fork choice has +inserted the block `KEY_HEAD` names; lighthouse's relevance check exempts a zero +`finalized_root`, so that reads as "peer is syncing" rather than as a +conflicting chain. Either way it is answered in the version the stream asked +for: a v1 body on a v2 stream is eight bytes short and the codec refuses to +write it, which drops the connection of every peer that opens the handshake on +`status/2`. + +### The two block protocols + +Both request bodies have a shape that cannot be guessed from the response they +produce: + +| Protocol | Request body | On the wire | +| --- | --- | --- | +| `beacon_blocks_by_range/2` | `(start_slot, count, step)` | 24 bytes, an SSZ **container** | +| `beacon_blocks_by_root/2` | `List[Root, MAX_REQUEST_BLOCKS]` | `32 * n` bytes, an SSZ **field** | + +`step` is deprecated and must be 1, but it is still on the wire: altair says the +v2 request is unchanged from phase0's, and lighthouse pins this protocol's +request length to `min == max == 24 bytes`, so a two-field body is refused before +it is ever decoded. A body naming any other `step` is answered with +`INVALID_REQUEST`, not dropped: the field is carried up to the handler precisely +so the peer learns which rule it broke, where refusing at decode would close the +stream with nothing on it. Phase0 does permit answering a larger step with a +single block, but that leniency is for a transition that finished years ago, and +the spec's requirement on the requester is a MUST. + +The root request is a bare list with no container around it, because the spec +says this body "MUST be encoded as an SSZ-field" where the range body is "an +SSZ-container". The difference shows up on the wire: a container holding one +variable-length field prefixes it with a four-byte offset. + +**Neither of those differences reaches above the codec.** What a peer is asking +for is the same question on either chain, so `req_resp::messages` owns the +shared `BlocksByRangeRequest`, and each chain's encoder converts to and from its +own wire container: lean's has no `step` and fills it with 1 on the way in, +beacon's carries all three fields. The shared struct is deliberately not +SSZ-derived, so it cannot be written to either wire by accident. The root list +needs no conversion at all, since `beacon::primitives::Root` *is* `H256` and +both lists are `SszList`; only lean's container comes off. + +Which chain a request arrived on is not on the message either: a node speaks one +wire for its whole life, so the dispatch reads `Wire::is_beacon` instead. The +same holds for the answer. `ResponsePayload::Blocks` is one variant for all four +block protocols, because `SignedBeaconBlock` already carries a `Lean` variant +and both stores hand blocks back in exactly that type. The narrowing to lean's +own `SignedBlock` happens once, where a fetched block is handed to the chain +actor, and `lean::encoding::write_blocks_response` refuses to put a block of any +other fork on a lean stream. + +Two ceilings apply, and they are not the same number. `MAX_REQUEST_BLOCKS` +(1024) is what an inbound request is judged against, because it is the widest a +peer may ever legitimately have been built to ask for; a `count` above it is +refused as `INVALID_REQUEST`. `MAX_REQUEST_BLOCKS_DENEB` (128) is what an +outbound request is built to and what an answer is truncated to, since the spec +allows "Clients MAY limit the number of blocks in the response" and a peer +asking for more than 128 is running older logic rather than misbehaving. + +A range answer comes off the canonical branch in ascending slot order, with +empty slots skipped: `BlockRoots` holds one root per slot on the branch ending +at the current head, so a sibling block at a slot the head does not descend from +is never read. A root answer follows the order the roots were asked in, and +leaves out any root this node does not hold. + +### Context bytes + +Every successful block chunk carries a four-byte `ForkDigest` between the result +byte and the payload: + +```text +response_chunk ::= | | | +``` + +The digest is the one that block's own epoch computes to, not the one this node +is running on, so a backfill labels each chunk with its own fork. On an error +chunk the field is empty, which is why the reader only reads it after a SUCCESS +byte. Serving history therefore needs `genesis_validators_root` as well as the +current digest, which is why `BeaconWire` and the codec both carry it. + +Going the other way, the fork a chunk decodes under comes from the **slot inside +the payload**, through the same decode path gossip uses, and the context bytes +are then checked against the digest that slot implies. That is a stronger test +than using them as the decoder key: it catches a peer whose +`genesis_validators_root` or fork schedule differs from ours, which is exactly +what the digest exists to say and is not otherwise visible until a signature +fails. A mismatch ends the stream, logged at `warn` with both digests: the one way to +reach it in good faith is a blob schedule of ours that has fallen behind the +network's. + +### Data column sidecars + +Both protocols are registered `Full`, since with the columns on disk this node +can serve every slot it has custodied: + +| Protocol | Served from | +| --- | --- | +| `data_column_sidecars_by_root/1` | a point lookup per identifier: the slot is recovered from `BlockHeaders` off the named root, then each requested column is read straight out of `Table::DataColumns` | +| `data_column_sidecars_by_range/1` | a prefix scan over the slot range, restricted to each slot's canonical root (`BlockRoots`) and filtered to the requested columns | + +Restricting the range answer to the canonical root matters because +`Table::DataColumns` is never pruned and gossip only asks that a sidecar's +block name a known, finalized-descendant parent, not a canonical one: a live +fork can leave both siblings' columns stored at the same slot, and an +unscoped scan would leak the losing side into every future range answer +covering it. + +Both per-item lookups are lenient the way the block protocols are: a column +this node never custodied, or a root it holds no header for, is left out of +the answer rather than turned into an error, since the spec's own words are +"Clients MUST respond with at least one sidecar, if they have it." A +`data_column_sidecars_by_range/1` request starting before `Store::anchor_slot` +gets `RESOURCE_UNAVAILABLE` instead of a merely-empty answer, the same +distinction the block-range handler draws and for the same reason: an empty +window this node's canonical chain skipped is normal, but a window below where +this node's chain begins cannot be served from any point onward. That floor is +the same value `Status` advertises as `earliest_available_slot`, so the refusal +and the advertisement cannot disagree. +`max_request_data_column_sidecars()` (`MAX_REQUEST_BLOCKS_DENEB * +NUMBER_OF_COLUMNS`) bounds what one request may ask for; an answer over that +is truncated, not refused, matching how the block protocols treat a peer +built to an older, wider ceiling. + +*Asking* happens on two paths, bulk and per block. + +The bulk one rides with range sync: every `BeaconBlocksByRange` batch sends a +`data_column_sidecars_by_range/1` for the same slot span, covering this node's +whole custody set. The spec names this protocol for exactly that — +"`DataColumnSidecarsByRange` is primarily used to sync data columns that may +have been missed on gossip and to sync within the +`MIN_EPOCHS_FOR_DATA_COLUMN_SIDECARS_REQUESTS` window" — and it is what keeps a +follower backfilling from a checkpoint anchor from needing the per-block path +at all: the columns are normally already stored by the time their block reaches +the availability gate. Nothing waits on the answer, so a short, empty or +refused one costs nothing and is not retried; the per-block path is the +backstop. + +Since the column request is aimed by custody, a batch reaching fulu is held +back, blocks included, until every custody column has a known custodian among +the connected peers. Lighthouse's range sync holds its batches the same way. +Right after startup, custody is known for almost no peer, since a peer's +custody arrives with its `metadata/3` answer after it connects. A mainnet +follower's first batch after a fresh checkpoint sync went out 5 s after +startup with two peers and at most one known custodian per column, and 121 of +the 122 holds it caused were missing every custody column. The +batch is re-checked after every metadata answer and every `Status` answer, and +it goes regardless once `RANGE_BATCH_CUSTODY_WAIT` has passed, because nothing +here searches for a custodian of a specific column; past that deadline the +uncovered columns go to a couple of peers whose custody is unknown, as before. + +The per-block one starts at `hold_block_for_columns`, called when a +fulu block carrying commitments arrives short of the columns this node +custodies for it. Import holds the block — it stays out of fork choice, but +its header, body and proof are already written, the same way a block missing +its parent is held. What happens next depends on the block's age. A block +still at or ahead of the current slot asks for nothing yet: its columns are +published alongside it, so the rest are normally already in flight on gossip, +and a peer asked at that moment usually does not have them either; on mainnet +followers at the tip, gossip completed a held block within 0.3 s at p99. A +block already older than the current slot has no such gossip left to race, +since whatever delivered it did so long before this node held it, so it sends +`data_column_sidecars_by_root/1` immediately instead: a range-synced catch-up +hits this on every held block, and without it such a follower imported +roughly one block per slot, waiting out the redrive below for a request +gossip was never going to answer. Either way, `redrive_held_blocks` runs on +every slot tick: for each block still held it sends the same request for +exactly the columns still missing, with +`MAX_FETCH_RETRIES` attempts and backoff doubling from `INITIAL_BACKOFF_MS`, a +peer that has already failed this lookup excluded until the whole pool is +exhausted. A lookup that runs out of peers or +retries stops asking until the next tick asks again; see +`lean_data_column_fetch_failures_total` in [metrics.md](./metrics.md). The +block is released the moment its last missing column arrives, and dropped +along with any pending descendants once finality passes its slot, whichever +comes first — nothing else times out a hold, so a peer that claims commitments +and never answers pins the block only until finality clears it, not +indefinitely. + +Both asking paths aim per column rather than per peer. A peer's custody set is +a public function of its node id and its advertised custody group count, so +this node computes it and sends each column to someone who actually holds it — +the spec's own observation that "due to the deterministic custody functions, a +node knows exactly what a peer should be able to respond to". The count comes +from `metadata/3`, requested once per connection right behind `Status`, and is +seeded from the ENR `cgc` for a peer this node dialed; a peer that has supplied +neither is not assumed to custody anything, and the by-root path falls back to +asking one at random for whatever no known custodian covers. At mainnet's +`CUSTODY_REQUIREMENT` a peer holds 8 of 128 columns, so this is the difference +between a request that can be answered and one that usually cannot. + +What this node deliberately does not do with its own slice of the matrix: it +does not run `compute_matrix` or `recover_matrix` to reconstruct the rest of a +block's data from it, since reconstruction needs half of +`NUMBER_OF_CUSTODY_GROUPS` and this node never holds more than its own +sampling size; and, over req/resp, it does not cross-seed a verified column to +a peer that never asked for it: every sidecar this node sends over +`data_column_sidecars_by_{root,range}` leaves in direct answer to that peer's +own request. Gossip is the one path where the same column *is* an +unsolicited push by design: an Accept verdict (see [Gossip](#gossip) above) +both hands the sidecar to the chain actor and re-propagates it to every mesh +peer, whether or not any of them asked for it. + +### What is not wired yet + +Serving both protocols is live, off the checkpoint-anchored store, and so is +asking on both. A request +that reaches a node whose data directory is *not* a beacon one is refused with +`RESOURCE_UNAVAILABLE`, the spec's own code for a peer "unable to reply to block +requests", where `INVALID_REQUEST` would blame the asker for a request that was +fine. That guard is for a lean directory, not for the ordinary case. + +*Asking* is now driven from two places. A peer's `Status` starts a range +session, which sends through `request_beacon_blocks_by_range`; and +`Handler` reaches `request_beacon_block_by_root` through +`fetch_block_from_peer`, which picks its protocol from the wire so a beacon +node cannot put a lean-framed `BlocksByRoot` on its beacon streams. + +Both paths now import. A fetched block reaches the chain actor as +`BlockSource::Sync`, keeping every per-block range and root check on the way in, +and the by-root path is what resolves a gossiped block's missing parent. + +Range sync is paced by `P2PServer::beacon_fetched_through`, the highest slot +handed to the actor, not by the store's head. Delivery is a message and import +is work, so the store trails a delivered batch by the whole actor mailbox. +Driven off the head, every resync tick re-requested the part still draining, +which on the live follower meant 11,213 blocks off the wire to import 100, each +duplicate paying a `hash_tree_root` before the store could reject it. Paced off +the watermark, the ratio was 1.7:1. + +`Status` is store-derived: head from `Store::beacon_head`, the finalized +checkpoint from `Store::beacon_finalized_checkpoint`, and +`earliest_available_slot` from the anchor's own slot rather than +`finalized_epoch * SLOTS_PER_EPOCH`, since this node keeps only the unfinalized +window above its anchor and the epoch's first slot is not necessarily one it +holds. Naming that slot tells a peer not to ask for anything older; naming zero +would claim genesis is in reach. There is one window where `beacon_head` answers +`None`, between `init_beacon` seeding `KEY_HEAD` and fork choice inserting the +block that root names, and every field but the fork digest falls back to zero +there. That stays honest: lighthouse's relevance check exempts a zero +`finalized_root` from its finalized-root comparison, reading it as "this peer is +syncing" rather than as a conflicting chain. + +## The ENR + +| Entry | Value | +| --- | --- | +| `eth2` | the computed `ENRForkID` | +| `attnets` | 64 bits, with this node's `SUBNETS_PER_NODE` backbone subnets set | +| `cgc` | `CUSTODY_REQUIREMENT` | +| `quic` | `--gossipsub-port` | +| `tcp` | `--gossipsub-port`, the same number: TCP and UDP are separate namespaces | +| `udp` | `--discovery.port` | + +Two of these advertise less, or more, than they look like: + +- `attnets` names exactly the subnets this node subscribed to, which is the + only honest value: claiming one it does not serve earns peer-score penalties + for silence there, and claiming none while serving two loses the peers + looking for precisely that. It used to be all-unset, which was honest while + this node held no subscription at all. +- `cgc` advertises `CUSTODY_REQUIREMENT`, the floor below which peers may + reject a record outright, not `sampling_size(CUSTODY_REQUIREMENT)`, the + larger number of columns this node actually custodies, stores and serves + (see [Data column sidecars](#data-column-sidecars)). That undersells rather + than oversells: a request for more than `cgc`'s worth of columns still + succeeds, since this node holds every column its own node id assigned it at + the sampling size, not merely `cgc`'s worth. + +The `tcp` entry is what makes us discoverable in return: lighthouse's discovery +predicate requires `enr.tcp4().is_some() || enr.tcp6().is_some()` and applies it +as a query filter, so a `quic`-only record is invisible to it. + +## Metrics + +| Metric | Meaning | +| --- | --- | +| `lean_beacon_gossip_messages_total{topic,result}` | Gossip received, by topic and by `decoded` / `decode_failed` / `decompress_failed` | +| `lean_beacon_gossip_validation_total{kind,outcome,reason}` | Gossip verdicts, now including `beacon_aggregate_and_proof` and `beacon_attestation`; see [metrics.md](./metrics.md#beacon-gossip-validation) | +| `lean_beacon_gossip_verdict_expired_total{kind}` | Verdicts that came too late to propagate anything; should stay at zero | +| `lean_beacon_status_digest_mismatch_total` | Handshakes seen from another fork digest | +| `lean_beacon_fork_digest{digest}` | The digest computed at startup, as a label | +| `lean_beacon_aggregate_decode_seconds` | Time spent decoding one aggregate off the wire | +| `lean_beacon_aggregate_mailbox_wait_seconds` | How long an aggregate sat in the chain actor's mailbox | +| `lean_beacon_aggregate_processing_seconds` | Time the chain actor spent applying one already-verified aggregate | +| `lean_beacon_aggregate_end_to_end_seconds` | Wire to fork choice, for aggregates applied on arrival | +| `lean_beacon_aggregate_total{outcome}` | Aggregates by `applied`, `invalid`, `known_subset` or `queue_full` | +| `lean_beacon_aggregates_deferred` | Aggregates held until their own slot has passed | + +The four aggregate histograms no longer cover what they used to: gossip +validation (committees, all three signatures, the seen caches) runs in p2p now +and is folded into `lean_beacon_gossip_validation_seconds{kind="beacon_aggregate_and_proof"}` +instead. `decode` is p2p's own decode step; the other three, all in +`ethlambda-blockchain`, now measure only `apply_verified_aggregate` and the +actor's applied-bits gate, which is why `processing` reads far lower than it +used to. `mailbox_wait` is still the one that cannot be derived any other way, +and it is still the failure mode this path introduces: roughly a thousand +aggregates a slot (verified ones only, now) queueing behind block imports arrive too +late to move the head while every other timing still looks healthy. + +The `lean_` prefix is the repo-wide convention and applies here too. Data +column sidecar metrics (`lean_data_columns_stored_total`, +`lean_data_columns_rejected_total`, `lean_data_column_kzg_verify_seconds`, +`lean_data_column_fetch_failures_total`, `lean_blocks_held_for_columns`) and +the disk-growth gauge for +`Table::DataColumns` (`lean_table_bytes{table="data_columns"}`) are documented +in full in [metrics.md](./metrics.md) rather than repeated here. + +`ethlambda beacon` serves these on `--metrics-port`, alongside `/health` and +the `/debug/pprof` heap-profiling routes, through the same `start_rpc_server` +entry point `ethlambda node` uses: one HTTP call site in `run_node` serves both +chains. It also binds `--api-port` and mounts the `/lean/v0` API, off the empty +in-memory `Store` and the default `AggregatorController`, `SyncStatusController` +and `EventBus` this startup path hands it. Those routes answer for a chain that +is not running; treat `/metrics` as the only meaningful HTTP surface of a +`beacon` run until the follower gets an API of its own. + +## Checking it against the live network + +No unit test can assert "peers with mainnet", so this is the procedure. + +```bash +openssl rand -hex 32 > /tmp/beacon-node-key +RUST_LOG=info,ethlambda_p2p=debug \ +cargo run --profile release-fast -p ethlambda --bin ethlambda -- beacon \ + --node-key /tmp/beacon-node-key \ + --gossipsub-port 9001 +``` + +Within 5 seconds: + +``` +Derived the mainnet wire parameters genesis_time=1606824023 genesis_validators_root=0x4b363db9… epoch=… fork=fulu fork_digest=8c9f62fe +No fork or blob-schedule boundary is scheduled +Custodying data columns columns=[…] +Backboning attestation subnets subnets_per_node=2 subnets=[…] +Advertising cgc=4 while subscribing to no sync committee subnet, and publishing nothing +Beacon P2P node started socket=0.0.0.0:9001 fork_digest=8c9f62fe topics=17 columns=8 attestation_subnets=[…] +HTTP server listening addr=127.0.0.1:5054 +Starting discv5 discovery discovery_addr=0.0.0.0:9002 seeds=17 total_bootnodes=17 +Local ENR enr=enr:-… +``` + +The `Advertising cgc=…` line names what is still true: this node subscribes to +no sync-committee subnet and publishes nothing of its own. Storing and serving +the columns it custodies (see [Data column +sidecars](#data-column-sidecars)) is no longer part of that gap, and neither is +the attestation subnet backbone. + +`seeds=17` proves the built-in list parsed; a lower number means a bootnode +ENR was skipped with a warning. `topics=17` proves the subscription set: the 7 +global topics, the 8 column subnets this run's (randomly generated) node id +selected, and the 2 attestation subnets the same id selected; `columns=8` and +`attestation_subnets=[…]` confirm the two families directly. + +Within 30 seconds: + +``` +External IP detected via PONG voting, updating local ENR old_ip=0.0.0.0 new_ip=… +Beacon block decoded slot=… proposer=… fork=fulu block_root=… bytes=… +Beacon aggregate attestation decoded slot=… aggregator=… attesters=… target_epoch=… target_root=… bytes=… +``` + +A measured run on 2026-08-31, from a laptop behind NAT with no port forwarding, +reached its first decoded block 16 seconds after start and its first aggregate +at 24 seconds, then took a block every slot. `bytes` on mainnet is typically +100 KB to 310 KB for a block and about 500 for an aggregate, and `attesters` +sits in the low hundreds. Peer lifecycle lines (`Peer connected`, `Beacon +handshake complete`) are at `trace`: at mainnet peer counts they are what +drowned the log, so raise the level to see them. + +What each failure looks like: + +| Symptom | Cause | +| --- | --- | +| `Peer said goodbye reason=129` right after a connection | nothing local: 129 is lighthouse's `TooManyPeers` | +| `fork_digest` is not what a live crawl reports | the blob-schedule branch, or the schedule itself | +| connections but no `Beacon handshake complete` | the `Status` encoding, or answering the wrong version | +| `Handshake answered from another fork digest` | the digest | +| `Beacon gossip decode failed` for `beacon_block` | the fork selection or the slot offset | +| the same for `beacon_aggregate_and_proof` alone | the electra boundary in `decode_gossip`; the block path is fine | +| connected peers above zero, no gossip at all | the topic hash: almost always the digest's hex formatting or `compute_message_id` | +| every candidate rejected as `missing or undecodable eth2 entry` | see below | + +### Both transports, not just QUIC + +ethlambda used to dial QUIC only, against the `quic` port a peer's ENR +advertises. Most mainnet beacon nodes are reachable over TCP and advertise a +`quic` entry that does not answer, either because the node is behind a NAT that +forwards only TCP or because QUIC is disabled behind an advertised port. The +result was a long run of `Handshake with the remote timed out` against otherwise +valid, correctly admitted peers. + +The node now listens on and dials both transports, and admission accepts a peer +advertising either, so a dead `quic` entry falls back to TCP within the same +dial rather than ending it. The TCP transport offers mplex alongside yamux +because mainnet peers answer `na` to a yamux-only proposal; see the `muxers` +module in `crates/net/p2p/src/lib.rs` for that measurement. + +A node behind NAT with no inbound UDP can only ever be the dialer, and peers +fill up: expect to wait for a peer that stays, and expect a first connection to +be refused with a `Goodbye`. + +A run where discv5 finds contacts but every one is rejected for a missing `eth2` +entry means the records reaching the dial loop are not the ones the peers +published. discv5 hands back a full `NodeRecord` per NODES response, so an +`eth2` entry that was there on the wire and absent here is a record-plumbing +problem in the discovery layer rather than anything in `admit`, which is +covered by unit tests against records carrying every entry. Bootnode records are +a poor control: mainnet's advertise the phase0 digest and are *supposed* to be +rejected, just as `fork digest mismatch` rather than as `missing`. diff --git a/docs/benchmarking.md b/docs/benchmarking.md index cfc2dece8..a01f2f1a4 100644 --- a/docs/benchmarking.md +++ b/docs/benchmarking.md @@ -1,16 +1,23 @@ -# Benchmarking block building +# Benchmarking -`ethlambda benchmark` measures block building the way the node performs it when -it proposes, against a reproducible synthetic workload, with no devnet running. +`ethlambda benchmark` measures two things the node does in production, each +against a reproducible offline workload, with no devnet running: -Block building is otherwise only observable through the Prometheus histograms a -live node exports. Those are noisy, depend on whatever the network happened to -be doing, and cannot be diffed against a baseline — which makes them a poor -instrument for tracking performance. The benchmark -trades network realism for repeatability: the same parameters produce the same -blocks every run, so two reports differ only where the code differs. +- **`synthetic`**: block building, the way the node performs it when it + proposes, on a synthetic in-memory chain built for the run. +- **`import`**: block import, replaying a corpus of real blocks through the + node's own import path. See [Import workload](#import-workload) below. -## Running it +Both are otherwise only observable through the Prometheus histograms a live +node exports. Those are noisy, depend on whatever the network happened to be +doing, and cannot be diffed against a baseline, which makes them a poor +instrument for tracking performance. Each benchmark trades some realism for +repeatability: the same parameters (or the same corpus) produce the same +result every run, so two reports differ only where the code differs. + +## Synthetic workload (block building) + +### Running it ```bash make bench # defaults, mock crypto @@ -50,7 +57,7 @@ second, which is why CI can afford to run one on every pull request. Logs go to stderr and the report to stdout, so `--format json` pipes straight into `jq`. -## What it measures +### What it measures Each iteration enters `produce_block_with_signatures` and then `seal_block` — the same functions `BlockChainServer::propose_block` calls — and the harness @@ -91,7 +98,7 @@ part. Nothing is added to the hot path for the benchmark's benefit. The harness asserts each phase was observed exactly once per build and fails the run otherwise, because a mis-attributed report is worse than no report. -## Reading a report +### Reading a report ``` Block-building benchmark — synthetic workload (real crypto) @@ -130,7 +137,7 @@ sequence unchanged, it changed only speed and not which attestations were selected. If the roots move, the change altered block contents and the timing comparison means something different than intended. -## Comparing two runs +### Comparing two runs Same seed and same parameters produce identical root sequences, so a baseline and a candidate can be diffed directly. The header line exists to tell you when @@ -145,18 +152,186 @@ they *cannot* be compared: Two reports that disagree on any of those are not measuring the same thing. -## Limitations +### Limitations -- **Synthetic workloads only.** Replaying a real datadir is not implemented, so - results reflect a synthetic chain rather than a deep production state. +- **Synthetic chain, not a production state.** This workload builds on a + genesis constructed for the run, not a deep chain. The [import + workload](#import-workload) below covers real production states instead, by + replaying real blocks rather than building synthetic ones. - **Short-lived keys.** Real-mode XMSS keys are generated for exactly the slots the run signs, so key generation is cheap but the OTS window advancement a long-lived validator key performs every 65,536 slots is never exercised. - **Mock mode skips the seal.** Without keys there is nothing to sign, so the three seal phases only appear in real runs. -## In CI +### In CI The Test job runs a short mock benchmark and asserts the JSON report's shape (`schema_version`, one sample per iteration). It costs seconds, and it means a change to the report contract cannot land unnoticed. + +## Import workload + +`ethlambda benchmark import` measures block import: the state transition, +attestation processing, persistence and fork-choice work a node does for every +block it receives, whether proposed locally or gossiped in. It replays a +corpus of real blocks through the node's own import path, offline. + +### Why a corpus + +Measuring import on a live mainnet follower is possible, but the numbers it +produces are hard to trust: + +- Each comparison leg needs a restart, so a run costs about ten minutes before + a single block is measured. +- The denominator moves between legs. How much of that ten minutes was spent + actually importing, versus holding a block for data availability or waiting + on a column fetch, differs run to run with whatever the network happened to + be doing. +- A live follower's process is not only importing. It is simultaneously + serving gossip, req/resp, discovery and column custody, all competing for + the same CPU the import path is trying to use. + +The work being measured, one block's state transition and its consequences, is +deterministic. Capturing a range of real blocks once and replaying it offline +removes all three problems: no restart between legs, no held-block noise in +the denominator, and nothing else running in the process. + +### The two phases + +**`fetch`** pulls a slot range from a running beacon node's standard Beacon +API into a corpus directory: a manifest, the anchor state and block the range +builds on, and one SSZ file per non-empty slot from the anchor to the end of +the range. The anchor sits on the first slot of an epoch (see +[Requirements and limits](#requirements-and-limits)), so the blocks between it +and `--from` are fetched too, and recorded as warm-up blocks the replay imports +without sampling. It prints a progress line to stderr every hundred slots. + +A 404 from the Beacon API means an empty slot, a slot past the source's head, or +one before its history, and `fetch` cannot tell those apart from the status +alone. So it refuses a `--to` past the source's head, and checks that every +block names the previous one as its parent: a block missing from the middle of +the range, or a reorg between two requests, stops the fetch instead of turning +into a replay that fails many minutes later. A fetch that fails removes the +corpus directory if it was the one that created it. + +```bash +ethlambda benchmark import fetch \ + --url http://127.0.0.1:5052 \ + --from 9123456 --to 9133456 \ + --corpus ./corpus/9123456-9133456 \ + --network mainnet +``` + +**`replay`** drives that corpus's blocks, in manifest order, through +`BlockChainServer::import_block` on a freshly bootstrapped RocksDB store, and +reports per-block, per-phase timings for the blocks in the range. Each block +prints a progress line to stderr as it imports, since a mainnet block takes +seconds and a long corpus would otherwise run silent for hours. + +```bash +ethlambda benchmark import replay \ + --corpus ./corpus/9123456-9133456 \ + --data-dir ./replay-data \ + --network mainnet \ + --format json --output report.json +``` + +### What is measured + +The measured span is one `BlockChainServer::import_block` call per block, the +same `on_block` entry a live node's own cascade uses. That covers the state +transition, block-borne attestation processing, state persistence to RocksDB, +and the beacon head recomputation: `on_block` recomputes the head itself after +every import rather than waiting for a tick to do it, so that cost is inside +the span too. + +Per-block, per-phase numbers come from the `lean_block_import_phase_seconds` +histogram, read before and after each import the same way the synthetic +workload reads its own histogram. A replayed block reports under +`source="replay"`, a label no node ever writes, so it reaches the histogram +without passing for a gossip or sync arrival. The phases are the +`BLOCK_IMPORT_PHASES` labels: `decode`, `queue`, `defer`, `admit`, `guards`, +`preamble`, `parent_wait`, `cascade_wait`, `da_check`, `columns_wait`, `engine`, +`verify_struct`, `verify_crypto`, `stf`, `db_write`, `fc_head`, `block_atts`; +plus the per-arrival sections that are not spans around the others, `prune`, +`get_head` and `fcu`, since each `import_block` call is one arrival. `get_head` +is where the head recomputation after every import is charged. + +### What is excluded, and why + +Replay runs nothing that a corpus already answers for: + +- **No execution client**, so `engine` reports a near-zero section with nothing + to call and `fcu` never runs. On a live follower paired with one, `engine` was + 31ms p50, about 0.5% of an import; a replay's numbers are complete without it. +- **An empty custody set**, so `columns_wait` never runs and no block is ever + held waiting on data availability. A corpus supplies every block directly; + there are no columns to wait for. +- **No gossip, req/resp or discovery.** `replay` drives `import_block` + directly on the caller's task: no mailbox, no tick loop, no p2p, so none of + that traffic competes with import for CPU. + +A phase that did not run on a given block is **absent from that block's +report, not zero**. That is what keeps a phase that genuinely took no time +distinguishable from one that never ran at all. + +### Requirements and limits + +- **The anchor sits on the first slot of an epoch.** Fork choice makes the + anchor its finalized checkpoint at the anchor state's own epoch, and every + import walks back to that epoch's first slot to find the checkpoint's block. + From an anchor past that slot the walk steps below the anchor, onto a block + the store never held, and the first import fails with + `spec assertion failed: root in store.blocks`. So `fetch` anchors on the + block at the first slot of the epoch holding `--from - 1`, stepping back an + epoch at a time while that slot is empty, and fetches that block's own + post-state. `replay` refuses a corpus whose anchor is anywhere else, which + only a corpus fetched before this rule can have. +- **The source node must still hold a state at the anchor slot.** Most beacon + nodes serve only recent states, so an old range fails at `fetch` with a 404 + naming the state endpoint it tried. +- **The range is deliberately not capped.** `fetch` streams: the anchor state + is the only state it ever holds, decoded once to read the genesis + fingerprint, written to disk and dropped before the block loop starts, and + each block is written as its response completes rather than accumulated. A + long range costs disk and time, not memory. +- **A replay's RAM floor is the store's own state cache.** `replay` shares the + same `STATE_CACHE_CAPACITY`-bounded LRU every node runs with. With the + registry and balances in persistent trees, cached states share every + unchanged subtree, so the cache costs far less than its capacity times one + state: a 128-block mainnet replay at 2.4M validators measured 2.9 GiB of RSS + once every block was in, against 11.6 GiB when each state held flat copies. + That ceiling belongs to the store, not to this harness. +- **`--network` selects the decoder, not just a genesis check.** + `decode_block` resolves each block's fork from its own slot through that + network's fork schedule, so replaying against the wrong network can decode a + block at the wrong fork. The manifest records the genesis validators root + the corpus was fetched against, and `replay` refuses a mismatch against + `--network` before decoding a single block. + +### How to A/B two revisions + +Fetch one corpus, then replay it twice, once per revision, against separate +`--data-dir`s: + +```bash +ethlambda benchmark import replay --corpus ./corpus/9123456-9133456 \ + --data-dir ./replay-baseline --format json --output baseline.json + +ethlambda benchmark import replay --corpus ./corpus/9123456-9133456 \ + --data-dir ./replay-candidate --format json --output candidate.json +``` + +The corpus is the fixed input both legs share, so `baseline.json` and +`candidate.json` differ only where the code differs. `--format json --output +` is what makes that diffable; the human table on stdout is for reading +a single run, not for diffing two. + +### Determinism + +Two replays of one corpus, on the same revision, import identical block roots +in the same order. Each sample's `block_root` is a per-block checksum for +exactly that: if a change alters the sequence of roots a corpus produces, it +changed what got imported, not just how fast, and a timing comparison against +that run means something different than intended. diff --git a/docs/checkpoint_sync.md b/docs/checkpoint_sync.md index c6e3a5106..ff92020b1 100644 --- a/docs/checkpoint_sync.md +++ b/docs/checkpoint_sync.md @@ -4,8 +4,12 @@ Checkpoint sync allows a new consensus node to skip replaying the entire chain from genesis. Instead, it downloads a recent finalized state from a running peer and starts from there. This mitigates long-range attacks by starting from a recent trusted checkpoint. +Both `ethlambda node` (the lean consensus chain) and `ethlambda beacon` (an Ethereum Beacon Chain gossip follower, mainnet by default, or another network via `--network`) use it. The two paths share the retry loop and the URL fan-out, and now share the same fetch order, but talk to different endpoints and verify a different container shape; where they differ, this document says so. + ## Usage +### `ethlambda node` + Checkpoint sync still requires the network config files (genesis, validators, bootnodes, etc.). The genesis config is needed to verify the downloaded state: checkpoint sync only replaces the starting state, not node configuration. Pass the `--checkpoint-sync-url` flag when starting ethlambda: @@ -26,77 +30,102 @@ Where `` is the address of a checkpoint source (see [Checkpoint Sources](#c State already on disk takes precedence over both checkpoint sync and genesis: if the data directory holds a previous run's chain state for this network, the node resumes from it. `--checkpoint-sync-url` is the fallback for when there is nothing resumable on disk, or when what is there has fallen too far behind (see [Restarts and Existing State](#restarts-and-existing-state)). With no resumable state and no URL, the node initializes from genesis. +### `ethlambda beacon` + +`beacon` takes no genesis config, validator registry, or bootnode file of its own: it takes `--network` instead, naming a built-in network or a directory of published network files (see [`cli.md`](cli.md)). `genesis_time`, `genesis_validators_root`, and the bootnode list all come from whichever network `--network` resolves to; for the built-in networks (`mainnet`, the default, plus `sepolia` and `hoodi`), they are built into the binary (see `CLAUDE.md`'s "Built-in networks"). + +```bash +ethlambda beacon --network --checkpoint-sync-url +``` + +The anchor precedence is, in order: a resumable data directory, then `--checkpoint-sync-url`, then, for a loaded network only, that directory's own `genesis.ssz`, then abort. The URL is therefore **required** on a fresh data directory only for a built-in network, unlike on `node`: this follower imports nothing past its anchor, so anchoring a built-in network at genesis would leave it parked at slot 0 while claiming to follow a chain that has been live for years. A loaded network's own genesis state is a legitimate anchor instead, since a freshly started devnet has no checkpoint provider at slot 0 and this is the only way to join one. With neither a resumable DB, a URL, nor (for a loaded network) a genesis fallback, startup aborts with `CheckpointSyncError::BeaconGenesisSync`. A data directory already anchored from a previous run resumes exactly as `node`'s does, without a URL, subject to the same resume window (see [Restarts and Existing State](#restarts-and-existing-state)). + ## Checkpoint Sources -### Direct peer +### Direct peer (lean) Any running node that serves the finalized state as SSZ can be used as a checkpoint source, not just ethlambda. For ethlambda nodes, the endpoint is `/lean/v0/states/finalized`. This is the simplest option, with no additional infrastructure needed. The trade-off is that you trust a single peer to provide a correct finalized state. -### Leanpoint +### Leanpoint (lean) [Leanpoint](https://github.com/blockblaz/leanpoint) is a dedicated checkpoint sync provider. It polls multiple nodes and only serves state when 50%+ agree on finality, adding a layer of consensus validation. This is the recommended option for production deployments since it reduces trust in any single peer. +### Any standard Beacon API provider (beacon) + +`ethlambda beacon` speaks the standard Beacon API against `--checkpoint-sync-url`, so any conforming server works as a source: a public checkpoint-sync provider, an infrastructure endpoint, or another beacon client's own API port. Nothing ethlambda-specific is required of the peer; it only has to answer the two endpoints in [How It Works](#how-it-works) below the way the spec describes them. + ## How It Works -1. **Fetch and verify**: The node sends an HTTP GET to the provided URL requesting the SSZ-encoded finalized state. Once downloaded, the state is decoded and verified against the local genesis config (see [Verification Checks](#verification-checks) below). +1. **Fetch, sequentially**: the node downloads the finalized state first, decodes and verifies it, then downloads the anchor block. State before block, on both chains, because the block is addressed by a slot that only the state carries; the two fetches used to run concurrently on lean (`tokio::try_join!`), but the beacon path cannot do that (it cannot address its block request until it has read the anchor block's slot off the state), so both now run sequentially. This is lighthouse's order. + + | | `node` (lean) | `beacon` | + | --- | --- | --- | + | Finalized state | `GET /lean/v0/states/finalized` | `GET /eth/v2/debug/beacon/states/finalized` | + | Anchor block | `GET /lean/v0/blocks/finalized` | `GET /eth/v2/beacon/blocks/{slot}`, where `slot` is read off `state.latest_block_header.slot` | + + Both endpoints on both chains mean "whatever is finalized right now", so the peer can advance finalization between the state and block requests; a mismatched pair is retried rather than treated as a hard failure (see [Anchor Pairing](#anchor-pairing) below). - Timeouts: + Timeouts, shared by both chains: - **Connect**: 15 seconds (fail fast if peer is unreachable) - **Read**: 15 seconds of inactivity that resets on each successful read, so large states can download as long as data keeps flowing -2. **Initialize**: The node stores the block header and the full state from the checkpoint. No block body is stored since it isn't available from the checkpoint. The node does not need the anchor block body to participate from this point forward. +2. **Fork resolution (beacon only)**: SSZ carries no type tag, so decoding the downloaded state first requires knowing which fork's container shape to decode it as. `BeaconState::slot_from_ssz` reads the slot straight off its fixed byte offset, without decoding the rest of the container; the configured fork schedule then names the fork at that slot. The `Eth-Consensus-Version` response header is not consulted at all. This matches lighthouse's checkpoint-sync client, and removes any dependence on the peer setting that header correctly: a self-describing slot cannot lie about its own fork the way a header can. The anchor block is decoded the same way, reusing the gossip path's own slot-peeking decoder. + +3. **Initialize**: the node stores the anchor block's header, its body (present unless the fetched block's own body happens to be empty, same as any other block), and the full state from the checkpoint. On `node`, persisting the block itself also means it can be served over `BlocksByRoot`; without that, peers requesting the anchor by root would get a synthetic block whose hash differs from `latest_finalized.root` and would score-penalize this node. `beacon`'s req/resp protocol has no `BlocksByRoot` handler yet, so that benefit doesn't apply there today; the block is stored anyway, since the pairing check above needs it. ### Failure and success If any step fails (network error, decoding error, verification failure), the node logs the error and exits. There is no automatic retry; restart the node to try again. The database is not modified until verification succeeds, so a failed checkpoint sync leaves the data directory clean. -After successful initialization, the node starts normally: it connects to the P2P network and begins participating from the checkpoint slot. +After successful initialization, the node starts normally: `node` connects to the P2P network and begins participating from the checkpoint slot; `beacon` joins the resolved network's gossip and logs what it decodes, without advancing its state past the anchor (see `docs/cli.md`, "What `ethlambda beacon` does today"). ## Restarts and Existing State -A node restarted against a populated data directory resumes from disk rather than re-initializing, so no flag is needed to preserve the chain across a redeploy. The decision is made before any download: +A node restarted against a populated data directory resumes from disk rather than re-initializing, so no flag is needed to preserve the chain across a redeploy. The decision is made before any download, and is the same shape on both chains except for one row: -| State in data directory | `--checkpoint-sync-url` | Result | -| ------------------------- | ------------------------- | -------- | -| None | omitted | Initialize from genesis | -| None | set | Checkpoint sync | -| Present, head within the resume window | either | Resume from disk (no download) | -| Present, head beyond the resume window | set | Checkpoint sync | -| Present, head beyond the resume window | omitted | Resume from disk anyway, with a warning | -| **From another network** | either | **Startup aborts** (see [Foreign State](#foreign-state)) | +| State in data directory | `--checkpoint-sync-url` | `node` | `beacon` | +| --- | --- | --- | --- | +| None | omitted | Initialize from genesis | Built-in network: abort, `CheckpointSyncError::BeaconGenesisSync` (no genesis-sync path). Loaded network: initialize from the directory's own `genesis.ssz` | +| None | set | Checkpoint sync | Checkpoint sync | +| Present, head within the resume window | either | Resume from disk (no download) | Resume from disk (no download) | +| Present, head beyond the resume window | set | Checkpoint sync | Checkpoint sync | +| Present, head beyond the resume window | omitted | Resume from disk anyway, with a warning | Resume from disk anyway, with a warning | +| **Wrong network, or the other chain** | either | **Startup aborts** (see [Foreign State](#foreign-state)) | **Startup aborts** (see [Foreign State](#foreign-state)) | -The resume window is `MAX_RESUMABLE_DB_STATE_AGE` (450 slots, ~30 minutes at 4-second slots) measured as `current_slot - head_slot`. Staleness is measured against the head, not the finalized checkpoint, so a node whose head is current still resumes during a finality stall. +The resume window is `MAX_RESUMABLE_DB_STATE_AGE` (450 slots) measured as `current_slot - head_slot`. Staleness is measured against the head, not the finalized checkpoint, so a node whose head is current still resumes during a finality stall. The same 450-slot window means a different wall-clock budget on each chain: ~30 minutes at lean's four-second slots, ~90 minutes at beacon's twelve-second ones. -Beyond that window the node prefers a checkpoint when one is offered, since catching up over P2P costs more than downloading a recent state. With no URL configured there is no anchor to switch to, so the node simply runs against the data directory it was given: that is the setup that was asked for. The warning is there because range sync may not be able to close a gap this large. Peers prune block signatures past `SIGNATURE_PRUNING_RANGE` (21600 slots, ~1 day), so beyond that horizon they cannot serve the history the node is missing and it needs a checkpoint URL to catch up at all. The warning logs the gap so this is visible in the boot log. +Beyond that window, `node` prefers a checkpoint when one is offered, since catching up over P2P costs more than downloading a recent state. With no URL configured there is no anchor to switch to, so the node simply runs against the data directory it was given: that is the setup that was asked for. The warning is there because range sync may not be able to close a gap this large. Peers prune block signatures past `SIGNATURE_PRUNING_RANGE` (21600 slots, ~1 day), so beyond that horizon they cannot serve the history the node is missing and it needs a checkpoint URL to catch up at all. The warning logs the gap so this is visible in the boot log. `beacon` follows the same preference, though it imports nothing past its anchor today, so there is no backfill cost yet to weigh against a fresh download. -When a checkpoint URL *is* set and every URL fails, the node exits rather than falling back to the stale state on disk. This is intentional: configuring the flag asks for a specific anchor, so an unreachable source is a misconfiguration worth surfacing at boot instead of quietly starting a node that is hours behind. Omitting the flag is how you ask for "resume whatever is on disk"; that path never exits. +When a checkpoint URL *is* set and every URL fails, the node exits rather than falling back to the stale state on disk. This is intentional, on either chain: configuring the flag asks for a specific anchor, so an unreachable source is a misconfiguration worth surfacing at boot instead of quietly starting a node that is hours behind. Omitting the flag is how you ask for "resume whatever is on disk"; that path never exits. -To deliberately discard existing state and start over from genesis or from a checkpoint, remove the data directory first. Checkpoint sync itself writes its anchor state on top without clearing existing data. +To deliberately discard existing state and start over, remove the data directory first. `node` then starts over from genesis or from a checkpoint, whichever `--checkpoint-sync-url` says; `beacon` does the same, except that a built-in-network run still needs the URL, since the built-in networks alone have no genesis-sync path. Checkpoint sync itself writes its anchor state on top without clearing existing data. ### Foreign State -Persisted state is accepted only after it is verified against the local genesis config: same `GENESIS_TIME`, same `MILLISECONDS_PER_SLOT`, and the same validator registry (count, sequential indices, and both pubkeys per validator). The validator set is fixed at genesis, so any state of this chain must carry exactly that registry. These are the same identity checks checkpoint sync applies to a downloaded state, sharing one implementation. +Persisted state is accepted only after its genesis identity is verified against the network the node is configured for: the pair `(genesis_time, genesis_validators_root)`. Lean has no `genesis_validators_root` field of its own, but its validator registry is fixed at genesis and never mutates afterward, so the root of the registry a lean state carries today is the root it had at genesis; that is the value the `Lean` variant's `genesis_validators_root()` accessor answers with. Both fields are compared by one function, `verify_state_genesis`, shared by the resume path on either chain and by both checkpoint-sync paths. The slot duration is compared separately, against the persisted `config` row rather than the state: a lean network sets it in its config file and no state carries it, so a data directory built at another cadence is caught there. + +`Store::from_db_state` no longer runs this check itself: it loads whatever chain a data directory holds and hands back a `Store`, without judging whether that is the chain or the network the operator configured. The caller (`fetch_initial_state` for `node`, `fetch_initial_beacon_state` for `beacon`) checks `Store::chain()` against the sub-command it is running under, then runs `verify_state_genesis` against the loaded finalized state. Either check failing aborts startup: a chain mismatch as `CheckpointSyncError::WrongChain`, a genesis mismatch as `CheckpointSyncError::Genesis` (wrapping `GenesisMismatch::GenesisTime` or `GenesisMismatch::GenesisValidatorsRoot`). Both used to be raised from inside `from_db_state` itself, as `ethlambda_storage::Error::GenesisMismatch`/`::WrongChain`; those two variants have been removed from the storage crate now that the check runs one layer up, in the caller that actually knows which network and which chain it wants. -If the data directory belongs to a different network, startup **aborts** with `persisted state does not match the configured genesis: …`. It is not treated as an empty directory, because initializing a new anchor on top would leave the foreign chain's rows in place, and the slot-indexed reads behind `BlocksByRange` would then serve those blocks to peers. Point `--data-dir` at the right directory, or remove it. +Either failure is not treated as an empty directory, because initializing a new anchor on top would leave the foreign chain's rows in place, and the slot-indexed reads behind `BlocksByRange` would then serve those rows to peers. Point `--data-dir` at the right directory, or remove it. -Note that a genesis time comparison alone would not catch a network that was regenerated with the same `GENESIS_TIME` but a different validator set, which is why the whole registry is compared. +Note that a genesis-time-only comparison still would not catch a network that was regenerated with the same `genesis_time` but a different validator set, which is why the registry root is compared too; that reasoning is unchanged; only its mechanism is, since a list's root already commits to the count, each validator's index, and both pubkeys, where this used to be four separate comparisons. ## Verification Checks -All checks are performed before a downloaded checkpoint state is accepted. The genesis-identity subset (marked below) is shared with the resume-from-disk path: +All checks are performed before a downloaded checkpoint anchor is accepted. The genesis-identity checks (marked *(shared)*) are the ones the resume-from-disk path also runs, on either chain, through the same `verify_state_genesis` function described in [Foreign State](#foreign-state) above. + +### Lean | Check | What it catches | -| ------- | ----------------- | +| --- | --- | | Slot > 0 | Checkpoint state cannot be genesis (slot 0) | | Validators non-empty | State must contain validators | | Genesis time matches *(shared)* | Wrong network or misconfigured peer | -| Validator count matches *(shared)* | Validator set size differs from genesis config | -| Sequential validator indices *(shared)* | Indices must be 0, 1, 2, ... in order | -| Validator pubkeys match *(shared)* | Validator identity differs from genesis config | +| Genesis validators root matches *(shared)* | Wrong validator set: the root commits to the count, each validator's index, and both pubkeys, so this one comparison subsumes what used to be four | | Finalized slot <= state slot | Finalized checkpoint cannot be in the future | | Justified slot >= finalized slot | Justified must be at or after finalized | | Same-slot checkpoints have matching roots | If justified and finalized are at the same slot, they must agree on the root | @@ -104,7 +133,35 @@ All checks are performed before a downloaded checkpoint state is accepted. The g | Block header root matches finalized | If header is at finalized slot, its root must match the finalized root | | Block header root matches justified | If header is at justified slot, its root must match the justified root | -HTTP errors and SSZ decoding failures are caught before verification runs. +### Beacon + +| Check | What it catches | +| --- | --- | +| Slot > 0 | Checkpoint state cannot be genesis (slot 0) | +| Validators non-empty | State must contain validators | +| Genesis time matches *(shared)* | Wrong network or misconfigured peer | +| Genesis validators root matches *(shared)* | Wrong validator set | +| Finalized epoch <= current epoch | Finalized checkpoint cannot be in the future | +| Justified epoch >= finalized epoch | Justified must be at or after finalized | +| Block header slot <= state slot | Block header cannot be ahead of the state | +| Block's fork matches state's fork | The fetched block must decode as the same fork the state resolved to | +| Anchor pairing | See [Anchor Pairing](#anchor-pairing) below | + +Beacon has no same-slot-checkpoints-matching-roots check: nothing here computes two separate checkpoint roots to compare, since a trusted checkpoint-synced anchor is finalized by fiat, and the beacon spec's own construction applies that single checkpoint to all four of the store's justified/finalized slots at once. + +HTTP errors and SSZ decoding failures are caught before verification runs, on both chains. + +## Anchor Pairing + +### Beacon: paired on the header root, not the state root + +The consensus specification's `get_forkchoice_store` asserts `anchor_block.state_root == hash_tree_root(anchor_state)`: the anchor state is meant to be the anchor block's own post-state. A checkpoint-synced anchor is not, in general: the Beacon API's `states/finalized` endpoint resolves to the state at `finalized_checkpoint.epoch.start_slot()`, and when that boundary slot was empty (no block was proposed there), the state has advanced past its own `latest_block_header` by one or more empty slots, so its own root no longer matches what the header committed to. This is routine on mainnet, not a transient race: an empty slot at an epoch boundary is a normal outcome, not a fault to work around. + +Both `get_forkchoice_store` (`ethlambda_state_transition::beacon::fork_choice`, the specification's own construction rules) and the checkpoint-sync path's own pairing check instead verify that the state's `latest_block_header` hashes to the anchor block's own root, substituting the state's own root for the header's `state_root` field when that field still holds its zero placeholder. (That field is left zero only for the duration of the header's own slot; `process_slot` fills it in with the real value the moment the slot moves past it, so once the slot has advanced the header already carries the real value and is trusted as-is.) The header root pins the pair just as precisely as the specification's check does, since it names exactly one block, and unlike the state's own root it is unaffected by the state having advanced past that block. + +This is a **documented deviation from the consensus specification**, not an oversight: lighthouse's checkpoint-sync client (`beacon_node/beacon_chain/src/builder.rs`, `weak_subjectivity_state`) makes the same one, for the same reason. + +Lean's own anchor pairing (`anchor_pair_is_consistent`) needs no such deviation: lean's finalized-state endpoint always names the state at the same slot as the anchor block itself, so there is no epoch-boundary snapping and no empty-slot gap between them to account for. ## Security Considerations @@ -121,9 +178,8 @@ What you **are** trusting: What verification **does** protect against: -- Wrong network (genesis time mismatch) -- Wrong validator set (pubkey or count mismatch) -- Structurally invalid states (impossible slot orderings, inconsistent checkpoints) +- Wrong network (genesis time or genesis validators root mismatch) +- Structurally invalid states (impossible slot or epoch orderings, inconsistent checkpoints, a block that does not pair with its state) - Corrupted data (SSZ decode failures) What verification **does not** protect against: diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 000000000..4b665e167 --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,404 @@ +# Command line + +`ethlambda` follows one of two chains, selected by a subcommand: + +| Invocation | Chain | +|---|---| +| `ethlambda node ` | The lean consensus protocol this repository implements | +| `ethlambda beacon ` | The Ethereum Beacon Chain, as a gossip follower | +| `ethlambda ` | `node`: the subcommand is injected | + +Two subcommands follow no chain. `ethlambda benchmark` is the offline +benchmarking harness, covering block building (`benchmark synthetic`, run +through `make bench`) and block import (`benchmark import fetch` / +`benchmark import replay`). See [`benchmarking.md`](./benchmarking.md) for +what each workload measures; `benchmark import`'s flags are in +[their own section](#benchmark-import-flags) below. `ethlambda validator` is +the validator client: a separate process that holds keys and performs duties +against a beacon node over the standard REST API, documented in +[its own section](#validator-flags) below. + +## `node` is the default + +clap has no native default subcommand, so the binary rewrites its own argv +before parsing. If the first argument is not `node`, `beacon`, `benchmark`, +`validator`, `help`, `-h`, `--help`, `-V`, or `--version`, then `node` is +inserted ahead of it. The function is `inject_default_subcommand` in +`bin/ethlambda/src/command.rs`. Every subcommand has to be listed there: a +missing one would have `node` inserted ahead of it and become unreachable. + +``` +ethlambda --genesis c.yaml ... -> ethlambda node --genesis c.yaml ... +ethlambda node --genesis c.yaml ... -> unchanged +ethlambda beacon --gossipsub-port 9001 ... -> unchanged +ethlambda benchmark synthetic ... -> unchanged +ethlambda --help | --version | -h | -V -> unchanged, no injection +``` + +This is what keeps every existing caller working with no edit: the Docker +`ENTRYPOINT`, `lean-quickstart`'s `client-cmds/ethlambda-cmd.sh`, the Hive lean +client shim, `preview-config.nix`, and the `docker run` blocks in the +devnet-runner skill all pass bare flags. + +`ethlambda` with no arguments at all is left alone, so it prints the subcommand +listing rather than a missing-flag error for `node`. + +## Common flags + +Taken by both subcommands, with the same meaning and the same defaults, from +one `CommonOptions` struct flattened into each. + +| Flag | Default | Meaning | +|---|---|---| +| `--data-dir` | `./data` | RocksDB directory. Both sub-commands open one here now: `node`'s live chain state, and `beacon`'s checkpoint-synced (or resumed) anchor | +| `--gossipsub-port` | `9001` | Port for libp2p gossip: UDP for QUIC and TCP for the noise+yamux fallback, same number on both, since they are separate namespaces. Binding TCP puts it in the HTTP servers' namespace, so it must differ from `--api-port` and `--metrics-port` as well as from `--discovery.port` | +| `--http-address` | `127.0.0.1` | Bind address for both HTTP servers | +| `--api-port` | `5052` | API server port. `beacon` binds it too, off its own anchored store rather than an empty one; the lean-shaped `/lean/v0` routes still don't answer for it (see [`beacon` flags](#beacon-flags) below) | +| `--metrics-port` | `5054` | Metrics and debug server port. Equal to `--api-port` merges the routers onto one listener | +| `--node-key` | generates an ephemeral key | Hex file holding the secp256k1 key that is this node's libp2p and discv5 identity. When omitted, a fresh key is generated in memory each start (logged as a warning), so the PeerId and ENR differ on every restart | +| `--bootnodes` | see below | Bootnode ENR list: one `enr:...` per line, as a YAML block sequence, a YAML flow sequence, or a plain list (`#` comments and leading `- ` are both tolerated) | +| `--checkpoint-sync-url` | see below | API base URLs, tried in order until one answers. Supplies `node`'s starting state on the lean API, and `beacon`'s anchor on a standard Beacon API; required on a fresh `beacon` data directory only for a built-in network, since a loaded network anchors at its own `genesis.ssz` instead. See [`checkpoint_sync.md`](checkpoint_sync.md) | +| `--discovery.port` | `9000` | discv5 UDP port; must differ from `--gossipsub-port`. See [Peer discovery](./discovery.md) | +| `--discovery.advertise-ip` | bind address | IP published in the ENR | +| `--discovery.target-peers` | `200` | Connected-peer count above which discovery stops dialing | + +Three of those have no clap default, because the two chains answer them +differently. A single `default_value` can only say one thing, so the flag +carries none and each chain resolves an absent value itself: + +| Flag | Absent on `node` | Absent on `beacon` | +|---|---|---| +| `--node-key` | ephemeral in-memory key, warned about | same | +| `--bootnodes` | no bootnodes: peers only via discv5 (with `--discovery.enable`) or by being dialed, warned about | falls back to the resolved network's own bootnode list: a built-in network's embedded list, or a loaded directory's `bootstrap_nodes.yaml`/`.txt` | +| `--checkpoint-sync-url` | start from a resumable DB, else from genesis | start from a resumable DB, else anchor at the resolved network's own genesis (loaded network only), else abort | + +Whether discv5 runs is not a common flag. `beacon` always runs it, since +published mainnet bootnode ENRs carry no `quic` entry and so are not statically +dialable; `node` runs it only with `--discovery.enable` (see below). Where it +runs, it binds `DEFAULT_DISCOVERY_PORT` (9000) unless `--discovery.port` says +otherwise. The two defaults are one apart because both sockets are UDP; pointing +either flag at the other's port is rejected at startup, before the node touches +its data directory. Without discovery neither that check nor the ban on +`--gossipsub-port 0` applies, since nothing binds the discovery port and no ENR +is published. + +## `node` flags + +On top of the common flags above. + +| Flag | Default | Meaning | +|---|---|---| +| `--genesis` | required | Chain genesis config, e.g. `config.yaml` | +| `--validators` | required | Validator registry, e.g. `annotated_validators.yaml` | +| `--validator-config` | required | `validator-config.yaml`, the node-name registry | +| `--hash-sig-keys-dir` | required | Directory of per-validator XMSS keys | +| `--node-id` | required | The key in `annotated_validators.yaml` naming this node, e.g. `ethlambda_0` | +| `--is-aggregator` | `false` | Seed the runtime aggregator flag | +| `--discovery.enable` | `false` | Run discv5 peer discovery; off, the node peers from `--bootnodes` alone. See [Peer discovery](./discovery.md) | +| `--aggregate-subnet-ids` | this node's subnets | Subnets to aggregate on; requires `--is-aggregator` | +| `--attestation-committee-count` | from `validator-config.yaml`, else `1` | Committees per slot | +| `--enable-proposer-aggregation` | `false` | Merge same-data proofs when building a block | +| `--max-attestations-per-block` | `3` | Proposer-side self-limit | +| `--disable-duty-sync-gate` | `false` | Track sync state without suppressing duties | + +A `shadow-integration` build adds the `--shadow-xmss-*` flags; they are absent +from a normal build. + +## `beacon` flags + +| Flag | Default | Meaning | +| --- | --- | --- | +| `--custody-group-count` | `CUSTODY_REQUIREMENT` (4) | How many custody groups this node custodies, advertised as the ENR's `cgc` | +| `--execution-endpoint` | none | Base URL of the execution client's Engine API endpoint, e.g. `http://127.0.0.1:8551`. Must be given together with `--execution-jwt-secret` | +| `--execution-jwt-secret` | none | File holding the 32-byte hex JWT secret shared with the execution client | +| `--safe-slots-to-import-optimistically` | the specification's own value | How far behind the wall clock a block must be before it may be imported optimistically on age alone | + +Accepted range is `CUSTODY_REQUIREMENT` to `NUMBER_OF_CUSTODY_GROUPS` (4 to +128), enforced at parse time rather than clamped: serving a different set than +the operator asked for is the failure hardest to notice. + +**It is not the number of columns custodied.** That is `sampling_size`, the +larger of this and `SAMPLES_PER_SLOT`, so the default still custodies 8 columns +and the two only converge once the flag is raised past 8. A node at 128 is a +supernode, custodying every column; raising the value costs storage and +bandwidth in proportion and makes this node useful to more peers, which on a +network where a `cgc=4` peer holds 4 of 128 columns is what decides whether a +lookup finds a custodian at all. + +Changing it changes which columns this node custodies, since the custody set is +a function of the node id *and* the count. Sidecars already on disk belong to +the old set: nothing is corrupted, but the node advertises a set it has not +finished filling until it backfills the difference. + +With neither execution flag, the follower contacts no execution client and +imports blocks without validating their payloads, which is what it did before +those flags existed. Supplying one without the other is refused at startup: an +Engine API endpoint always requires authentication. See +[the execution layer pairing](./beacon_engine.md) for what each verdict does and +for the limitations that go with the retry ladder. + +`beacon` takes one more flag of its own, and two common flags also mean +something specific here. + +| Flag | Default | Meaning | +|---|---|---| +| `--network` | `mainnet` | A built-in network name (`mainnet`, `sepolia` or `hoodi`), or a path to a directory of published network files. A value containing a slash is always a path, so `mainnet` is the built-in and `./mainnet` is a directory. The directory must hold `config.yaml` and `genesis.ssz`, and may hold `bootstrap_nodes.yaml` or `bootstrap_nodes.txt` | + +`genesis_validators_root` and `genesis_time`, which the fork digest that keys +every gossip topic, the ENR `eth2` entry and discv5 admission is computed from, +come from the resolved network: + +| `--network` | genesis values | config | bootnodes | +|---|---|---|---| +| `mainnet` (default), `sepolia`, `hoodi` | two constants in `network::built_in`; no genesis state is carried (a built-in network never anchors at genesis, and the states run from 5 MB to 150 MB) | `bin/ethlambda/assets//config.yaml`, `eth-clients/`'s file byte for byte | `bin/ethlambda/assets//bootstrap_nodes.yaml`, from the same repo | +| a directory | read off the directory's own `genesis.ssz` | the directory's `config.yaml` | the directory's `bootstrap_nodes.yaml`/`.txt` | + +The genesis constants are checked twice at runtime: checkpoint sync verifies the +downloaded anchor state against both, and a resumed data directory is checked +the same way, so a wrong constant fails at startup rather than following the +wrong chain. + +`beacon` therefore takes no genesis config, validator registry, or bootnode file +of its own the way `node` does: those are read off the resolved network instead +of being separate operator input. A `config.yaml` (a directory's, or a built-in +network's embedded one) is read permissively: absent keys fall back to mainnet's values, +numbers are accepted quoted or bare, and unrecognised keys (on a current +config, the gloas and heze schedule this build cannot process) are dropped +with one warning line naming each. Its `PRESET_BASE` is checked against the +compiled preset; a mismatch is a hard startup error naming the cargo feature +that would fix it. The keys this build runs on compile-time constants for (the +custody and subnet counts, the `MAX_REQUEST_*` limits, `MAX_PAYLOAD_SIZE`, the +snappy message domains and `MAXIMUM_GOSSIP_CLOCK_DISPARITY`) must equal those +constants, or startup fails naming each key that differs: the node cannot +follow a network that sets them otherwise, and `/eth/v1/config/spec` would +report values it does not use. For a directory, both checks run before its +`genesis.ssz` is decoded, so a directory built for the other preset fails with +the preset error rather than an SSZ one. + +Resuming a data directory under a `config.yaml` whose `CONFIG_NAME` differs +from the stored one only logs a warning; the stored name is kept. Any changed +chain value (a fork epoch or version, the slot time, `PRESET_BASE`) is still a +hard error. + +`--checkpoint-sync-url` now supplies the beacon anchor: a finalized +`BeaconState` and its anchor block, fetched from a standard Beacon API server +and verified against the genesis identity above plus the anchor's own internal +consistency (see [`checkpoint_sync.md`](checkpoint_sync.md)). The full anchor +precedence is: a resumable data directory, then `--checkpoint-sync-url`, then, +for a loaded network only, that directory's own `genesis.ssz`, then abort. The +URL is therefore **required** on a fresh data directory only for a built-in +network: this follower imports nothing past its anchor, so anchoring a built-in +network at genesis would leave it parked at slot 0 while claiming to follow a +chain that has been live for years. A loaded network's own genesis +state is a legitimate anchor instead, since a freshly started devnet has no +checkpoint provider at slot 0 and this is the only way to join one. A +directory already anchored from a previous run resumes without the flag +either way, the same way `node`'s does. + +`--api-port` is bound here too, off `beacon`'s own anchored store now rather +than an empty one: one HTTP call site (`start_rpc_server`) serves both chains. +The lean-shaped `/lean/v0/...` routes read metadata keys and state variants a +beacon directory never carries, though, so calling one of them, e.g. `GET +/lean/v0/states/finalized`, panics that request rather than answering for a +chain that isn't running. This is deliberate for now: giving the beacon +follower its own HTTP surface is a change of its own. Treat `/metrics` on +`--metrics-port` as the only meaningful HTTP surface of a `beacon` run today. + +## What `ethlambda beacon` does today + +It follows the resolved network's gossip and nothing above it. It now anchors a real, +RocksDB-backed store at a checkpoint-synced (or resumed) finalized state, but +does nothing more with it: no state transition, no fork choice, and no block +import past that anchor. The node joins the network, decodes what arrives, and +logs it. See [`beacon_wire.md`](./beacon_wire.md) for what goes on the wire and +how to check a run against the live network. + +### One entry point, two chains + +Both sub-commands run through `run_node` in `bin/ethlambda/src/main.rs`. Each +parses into a single `cli::Options`, whose `network` field is `Network::Lean` or +`Network::Mainnet` and carries that chain's own flags. `run_node` does what is +not chain-specific, branches once, and shares the shutdown: + +| step | lean | mainnet | +|---|---|---| +| validate the ports (discv5 rules only where it runs) | shared | shared | +| register metrics, print the banner, log the version | shared | shared | +| raise `RLIMIT_NOFILE` | shared | shared | +| `HIVE_LEAN_TEST_DRIVER` early return | yes | no: those endpoints are lean's | +| resolve `--node-key` | shared | shared | +| resolve `--network` into a `NetworkSource` | n/a: lean has no `--network` | classify the value (built-in name vs. directory); parse the `config.yaml` (embedded, or the directory's) and check `PRESET_BASE` against the compiled preset and the constant-backed keys against their constants; for a directory, then decode `genesis.ssz` | +| read `--bootnodes` (falls back to the chain's default list; see the table above) | shared | shared | +| build the aggregator, sync-status and event handles | shared | shared | +| open `--data-dir`'s RocksDB backend | shared | shared | +| **the one `match`**: produce a `ChainSetup` | genesis config, validator keys, checkpoint sync or resume onto the shared backend, subnets | `beacon::wire_params` (genesis metadata, epoch, fork digest), then checkpoint sync or resume onto the same backend | +| build the swarm, spawn P2P, start discv5 | shared; discv5 only with `--discovery.enable` | shared | +| start the HTTP server | shared | shared | +| spawn the chain actor and wire it to P2P | yes | no: it imports nothing | +| ctrl-c, stop and join the actors | shared | shared | + +`ChainSetup` is what the `match` produces: the wire configuration, the ENR +entries that describe it, the store the req/resp handlers answer from, the +node-name roster, and an `Option` holding the validator keys and +`BlockChainConfig`. The operator-supplied half of the discv5 configuration (node +key, ports, bootnodes, peer target) is the same on either chain, so it is filled +in once below the match. That `Option` being `None` is what ends the mainnet path: +`run_node` returns straight into the shared shutdown after starting the wire. + +`beacon::wire_params` reads `genesis_time` and `genesis_validators_root` off +the resolved network (a built-in network's two constants, or a loaded +network's own `genesis.ssz`, decoded at whatever fork its own schedule names +for epoch 0), derives the wall-clock epoch +and fork digest from them, and logs the next boundary that would move the +digest. It builds nothing: one `build_swarm` serves both chains, dispatching on +the `WireConfig` variant for the topics, the req/resp protocol set, the +gossipsub `seen_ttl`, the identify version and the connection limits. + +The chain actor is the one thing mainnet has none of, so `RunningNode` +carries it as an `Option` and the shared shutdown skips it. + +Not implemented here: block import and fork choice past the anchor. A decoded +block is logged and dropped. + +## `validator` flags + +`ethlambda validator` is not a node. It binds no libp2p port, opens no +database, runs no discovery and follows no chain: it holds validator keys and +performs duties against a beacon node's standard REST API. It works against any +conformant beacon node, this repository's `beacon` subcommand or another +implementation's, so none of the [common flags](#common-flags) apply to it. + +> **It keeps no slashing-protection record.** Read +> [Spec Deviations](./spec_deviations.md#the-validator-client-keeps-no-slashing-protection-record) +> before running it with keys that hold real stake. In short: do not run these +> keys in any other client while this one runs, and treat a restart with the +> same care as a manual key move. The client repeats this warning at startup. + +| Flag | Default | Meaning | +|---|---|---| +| `--beacon-nodes` | required | Base URLs of the beacon nodes to use, comma-separated or repeated. Tried in list order; the first that answers serves the request, so the order is a preference, not load balancing | +| `--validators-dir` | required | Directory holding the EIP-2335 keystores and `validator_definitions.yml` | +| `--secrets-dir` | required | Directory holding one password file per keystore, named after the validator's public key | +| `--http-address` | `127.0.0.1` | Bind address for the metrics and keymanager servers | +| `--metrics-port` | `5064` | Prometheus metrics port | +| `--keymanager-port` | `5062` | Keymanager API port. Only bound with `--enable-keymanager` | +| `--suggested-fee-recipient` | none | Execution address to receive block rewards, `0x`-prefixed. Optional, and startup warns when it is absent: without it the beacon node picks an address, and it will not be yours | +| `--graffiti` | empty | Text for the graffiti field of proposed blocks. At most 32 bytes as UTF-8, right-padded with zeros. Refused rather than truncated if longer | +| `--enable-keymanager` | off | Serve the keymanager API. Off by default because it mutates key material | + +### What it does today + +Attestations, block proposals and attestation aggregation. + +Each epoch it resolves its validators' indices, fetches their attester duties +for this epoch and the next, fetches this epoch's proposer duties, subscribes +to the committee subnets the attester duties need, and registers its fee +recipient with the beacon node. The subscription also tells the node which +committees this client will aggregate for, which it has to know in advance so +it can collect the votes. + +Each slot it wakes at the boundary. If one of its validators proposes that +slot, it signs the RANDAO reveal, asks the beacon node for a block, checks that +the block is for the slot and proposer it asked about, signs it and publishes +it. One third into the slot it fetches the attestation data, signs for every +validator due that slot, and submits the batch. Two thirds in, for any duty it +was selected to aggregate, it asks the node for the aggregate covering that +committee's votes on the data it just signed, wraps it with the selection proof +and publishes it. + +Aggregation is not a choice. A validator signs the slot under a dedicated +domain, and whether the hash of that signature divides evenly by a modulus from +the committee's size decides it. Signatures are deterministic, so a validator +gets one answer per slot, cannot search for a better one, and cannot decline: +the beacon node checks the same thing when the aggregate arrives. + +Proposals are bounded by that one-third mark rather than by the end of the +slot. The specification defines no block-production deadline; the nearest thing +it defines is the point attesters stop waiting for a block, which is the same +instant. A proposal still running then has already lost most of the block's +value, and every second past it comes out of this client's own attestations, so +it is abandoned and the slot's attesters still vote. + +Electra is the earliest fork it will propose under. A deneb block body has one +field fewer than an electra one, so it would need a container pair of its own, +and it would buy nothing: the attestations this client submits are electra's +`SingleAttestation`, which has no earlier form, so a pre-electra chain is one it +cannot serve whatever it does with blocks. A block from any earlier fork is +refused by name rather than failing to decode. + +Blocks are fetched and published as SSZ rather than JSON. Signing a block means +computing its `hash_tree_root`, which only the typed container can give; a +hand-written JSON mapping of an execution payload would be a large surface on +which a single wrong field silently produces a signature over the wrong block. + +Not implemented: the builder flow and blinded blocks (the client asks for an +unblinded block and refuses a blinded one), sync-committee duties, voluntary +exits, doppelganger protection and remote signing. + +### Duty offsets come from the network + +The slot length and the two duty offsets are read from the beacon node's +`/eth/v1/config/spec`, not divided out of a compiled-in constant. The +specification states them as `SLOT_DURATION_MS` plus basis points of it, +`ATTESTATION_DUE_BPS` and `AGGREGATE_DUE_BPS`, which on mainnet's 12-second slot +work out at 3999 ms and 8000 ms. + +`SECONDS_PER_SLOT` no longer exists in the specification and is accepted only as +a fallback, since deployed nodes still send it. A node sending both is required +to agree with itself. + +### Proposer duties are less durable than attester ones + +Worth knowing if a proposal is ever missed after a reorg. An attester schedule +depends on the block two epochs back and survives anything shallower; a +proposer schedule depends on the block one epoch back, so a reorg that leaves +attester duties untouched can still move a proposer between slots. This client +fetches proposer duties once per epoch, at the boundary, so a reorg inside the +epoch is not picked up until the next one. + +### The keymanager API + +Off unless `--enable-keymanager` is passed. It serves `GET`, `POST` and +`DELETE /eth/v1/keystores`, authenticated with a bearer token read from +`api-token.txt` in the validators directory and generated there on first start +if absent. The token file is written `0600` and a file too short to be a real +token is refused rather than accepted. + +The specification requires TLS. This binds to `--http-address`, loopback by +default; exposing it further means putting a TLS terminator in front. + +### Metrics + +Served on `--metrics-port`, prefixed `ethlambda_validator_` rather than the +`lean_` used elsewhere in this repository, since this process follows the +beacon chain. Every series is registered at startup so it reads zero rather +than being absent before its first event, which is what lets an alert fire on +"attestations stopped". See [Metrics](./metrics.md#validator-client). + +## `benchmark import` flags + +Neither sub-command follows a chain; see [`benchmarking.md`](./benchmarking.md#import-workload) +for what the workload measures and why it exists. + +### `benchmark import fetch` + +| Flag | Default | Meaning | +| --- | --- | --- | +| `--url` | required | Base URL of the source beacon node, e.g. `http://127.0.0.1:5052` | +| `--from` | required | First slot of the range, and its first sample, so it must hold a block. The anchor is the block at the first slot of the epoch before it; the blocks in between are fetched too, and replayed as unsampled warm-up | +| `--to` | required | Last slot of the range, inclusive. May not pass the source's head. Otherwise not capped: `fetch` streams, so a long range costs disk and time but not memory | +| `--corpus` | required | Corpus directory to create | +| `--force` | `false` | Replace an existing corpus in that directory | +| `--network` | `mainnet` | Which network the source follows | + +### `benchmark import replay` + +| Flag | Default | Meaning | +| --- | --- | --- | +| `--corpus` | required | Corpus directory written by `fetch` | +| `--network` | `mainnet` | Which network the corpus belongs to. Checked against the manifest's recorded genesis validators root before anything is built | +| `--data-dir` | required | Where to build the replay's RocksDB store. A real backend rather than the in-memory one, since state persistence is part of what is measured | +| `--force` | `false` | Replace an existing store in that directory | +| `--safe-slots-to-import-optimistically` | the specification's own value | How far behind the wall clock a block must be before it may be imported optimistically on age alone. Exposed for parity with `beacon`'s own flag of the same name; a corpus replay never sees the merge transition block it gates | +| `--format` | `human` | `human` or `json` | +| `--output` | none | Also write the JSON report to this file | diff --git a/docs/data_storage.md b/docs/data_storage.md index 506fdb5c5..cc238c836 100644 --- a/docs/data_storage.md +++ b/docs/data_storage.md @@ -2,7 +2,7 @@ This doc explains how ethlambda saves data. Especially, the split between the fork choice `Store` and the `StorageBackend` trait, -what each of the eight tables holds, and which data is in-memory only. +what each of the ten tables holds, and which data is in-memory only. ## Overview @@ -56,8 +56,11 @@ Everything persisted is SSZ-encoded bytes. The `StorageBackend` trait knows nothing about consensus types. It moves raw bytes in and out of named tables: -- `begin_read()` returns a `StorageReadView` with `get(table, key)` and - `prefix_iterator(table, prefix)`. +- `begin_read()` returns a `StorageReadView`. Its one required lookup is + `read(table, key, read_fn)`, which lends the value to `read_fn` without + copying it; `get` (an owned copy) and `contains` are built on it, and + `StorageReadViewExt::read_with` decodes straight from the borrowed bytes. + It also has `prefix_iterator(table, prefix)`. - `begin_write()` returns a `StorageWriteBatch` with `put_batch`, `delete_batch`, and `commit()`. A batch stages puts and deletes across **multiple tables** and applies them **atomically** on commit. @@ -66,7 +69,7 @@ The two implementations live in `crates/storage/src/backend/`: | Backend | Details | | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `RocksDBBackend` | One column family per table. Writes go through a native `WriteBatch` with `sync=false` (no fsync per commit). | +| `RocksDBBackend` | One column family per table. Writes go through a native `WriteBatch` with `sync=false` (no fsync per commit). `States` and `StateDiffs` keep values of 4 KiB and up in LZ4-compressed blob files, so compaction never rewrites a snapshot and a lookup miss never reads one. | | `InMemoryBackend` | A `HashMap` per table behind an `RwLock`. Its `prefix_iterator` sorts keys lexicographically to match RocksDB's iteration order, because pruning relies on slot-ordered early-stop scans (see [Key encoding](#key-encoding)). | ### Store: all the semantics @@ -103,33 +106,36 @@ is built from it, and clones are handed to the BlockChain and P2P actors. │ │ StateDiffs │ │ attestations) │ │ │ │ Metadata │ │ gossip_signatures │ │ │ │ LiveChain │ │ (raw XMSS sigs │ │ - │ └─────────────────────┘ │ awaiting │ │ - │ │ aggregation) │ │ - │ Survives restarts. │ state_cache (LRU) │ │ + │ │ DataColumns │ │ awaiting │ │ + │ │ PendingDataColumns │ │ aggregation) │ │ + │ └─────────────────────┘ │ state_cache (LRU) │ │ │ └──────────────────────┘ │ - │ │ - │ Lost on restart. │ + │ Survives restarts, except │ + │ PendingDataColumns, which Lost on restart. │ + │ is cleared at startup. │ └───────────────────────────────────────────────────────────────┘ ``` ## The Tables -The eight variants of the `Table` enum (`crates/storage/src/api/tables.rs`): - -| Table | Key | Value | Pruned? | -| ----------------- | ----------- | ----------------------------------------- | -------------------------------- | -| `BlockHeaders` | root | `BlockHeader` | never | -| `BlockBodies` | root | `BlockBody` | never | -| `BlockProof` | slot ‖ root | aggregate proof (`MultiMessageAggregate`) | yes: finalized older than ~1 day | -| `BlockRoots` | slot | block root (`H256`) | never | -| `States` | root | full `State` snapshot | never | -| `StateDiffs` | root | `StateDiff` | never | -| `Metadata` | string | SSZ scalars | never | -| `LiveChain` | slot ‖ root | `parent_root` | yes: below finalized | +The ten variants of the `Table` enum (`crates/storage/src/api/tables.rs`): + +| Table | Key | Value | Pruned? | +| ------------------ | --------------------------- | ----------------------------------------- | --------------------------------- | +| `BlockHeaders` | root | `BlockHeader`, or a whole beacon block | never | +| `BlockBodies` | root | `BlockBody` (lean only) | never | +| `BlockProof` | slot ‖ root | aggregate proof (`MultiMessageAggregate`) | yes: finalized older than ~1 day | +| `BlockRoots` | slot | block root (`H256`) | never | +| `States` | root | full `State` snapshot | never | +| `StateDiffs` | root | `StateDiff` | never | +| `Metadata` | string | SSZ scalars | never | +| `LiveChain` | slot ‖ root | `parent_root` | yes: below finalized | +| `DataColumns` | slot ‖ root ‖ column_index | `DataColumnSidecar` (SSZ-encoded) | no (see [DataColumns](#datacolumns) below) | +| `PendingDataColumns` | slot ‖ root ‖ column_index | `DataColumnSidecar` (SSZ-encoded), unverified | yes: on replay, below finalized, and wholly at startup | ### Key encoding -Three key layouts are used: +Four key layouts are used: - **Root-keyed** tables use the 32-byte SSZ encoding of the block root (`root.to_ssz()`). @@ -138,6 +144,13 @@ Three key layouts are used: 32-byte root. Big-endian means lexicographic key order equals numeric slot order, so pruning can iterate from the start of the table and stop at the first key past its cutoff instead of scanning everything. +- **Slot-prefixed, then column** (`DataColumns`) extends the same + `slot ‖ root` prefix with an 8-byte big-endian `column_index`, via + `data_column_key`. A block's own sidecars therefore share one lexicographic + run under their `slot ‖ root`, so a prefix scan over just that pair (what + `data_column_indices_for` and the by-range handler both do; see + [DataColumns](#datacolumns)) recovers every column of one block without + touching any other block's. - **Slot-only** (`BlockRoots`) uses `encode_block_root_key`: just the 8-byte big-endian slot, since the value already holds the root. This table is never pruned, so the ordering buys nothing here; it is kept only for @@ -150,14 +163,33 @@ block, and never pruned: headers are the permanent record of the chain. Headers are also read back during state reconstruction (see [State Storage](#state-storage-snapshots--diffs)). +On a **beacon** directory this row holds the whole `SignedBeaconBlock` +instead, prefixed with a one-byte fork selector the way a `States` value is, +since a beacon block's shape varies by fork and SSZ carries no type tag. The +two shapes never coexist in one table: a data directory holds one chain for +its whole life (see `Chain`). + +`Store::block_entry` is the chain-agnostic reader for a stored block's slot +and parent root, and is what the fork-choice tree walk and the `BlockRoots` +index diff share. On the beacon arm it decodes the whole block to reach those +two fields, so a caller walking a chain of them should build +`Store::block_index` once instead, which reads the same links out of +`LiveChain`. + ### BlockBodies -`root → BlockBody`. Written for every block **except** those with an empty -body: if `header.body_root == EMPTY_BODY_ROOT` (the hash tree root of -`BlockBody::default()`), nothing is stored and reads synthesize +`root → BlockBody`, **lean only**. Written for every block **except** those +with an empty body: if `header.body_root == EMPTY_BODY_ROOT` (the hash tree +root of `BlockBody::default()`), nothing is stored and reads synthesize `BlockBody::default()`. This covers the genesis block and checkpoint sync anchors, whose bodies are either empty or unavailable. Never pruned. +The split exists so a header-only query need not pay for the body, and so an +empty body can be left out entirely. A beacon block has neither property, and +nothing reads a beacon header without its block, so the beacon arm writes no +row here at all: a second row would only add a write and a way for the two to +disagree. + ### BlockProof `slot ‖ root → MultiMessageAggregate`. This table stores the block's **merged @@ -210,31 +242,144 @@ diff contains and how states are rebuilt. ### Metadata -String keys mapping to SSZ-encoded scalars — the `Store`'s own persistent -fields: - -| Key | Type | Meaning | -| ------------------ | ----------------- | ------------------------------------------------------ | -| `time` | `u64` | Intervals elapsed since genesis (the store clock) | -| `config` | `ChainConfig` | Genesis time and slot duration | -| `head` | `H256` | Current fork choice head | -| `safe_target` | `H256` | Current safe target (see [lmd_ghost.md](lmd_ghost.md)) | -| `latest_justified` | `Checkpoint` | Latest justified checkpoint | -| `latest_finalized` | `Checkpoint` | Latest finalized checkpoint | - -`config` is the odd one out: `init_store` writes it once at bootstrap and -nothing ever rewrites it afterward (it has a getter, `Store::config`, but no -setter). Because it never changes, the `Store` keeps a copy in memory and -reads of it never reach the backend. It is also part of the DB's fingerprint: -`from_db_state` refuses to resume a data directory belonging to another -network (see [Startup and Restore](#startup-and-restore)). Every other -`Metadata` key is mutated in place as the chain progresses. +String keys mapping to (mostly) SSZ-encoded scalars — the `Store`'s own +persistent fields. Four are written on every directory, whichever chain it +holds; the rest are chain-specific, since lean and a beacon directory keep +different checkpoints. + +| Key | Type | Chain | Meaning | +| ------------------------------ | ------------ | ------ | ------------------------------------------------------------- | +| `db_version` | `u64` | both | On-disk format version this directory was written at | +| `chain` | raw byte | both | Which consensus protocol this directory holds (`Chain`) | +| `preset` | raw byte | both | Which SSZ preset the writing build used (`Preset`) | +| `time` | `u64` | both | The store clock, as a **UNIX timestamp in milliseconds** | +| `config` | `Config` | both | The node's runtime configuration | +| `head` | `H256` | both | Current fork choice head | +| `anchor_slot` | `u64` | both | The slot this directory's chain begins at | +| `safe_target` | `H256` | lean | Current safe target (see [lmd_ghost.md](lmd_ghost.md)) | +| `latest_justified` | `Checkpoint` | both | Latest justified checkpoint | +| `latest_finalized` | `Checkpoint` | both | Latest finalized checkpoint | +| `beacon_unrealized_justified` | `Checkpoint` | beacon | Unrealized justified checkpoint (beacon `Checkpoint`) | +| `beacon_unrealized_finalized` | `Checkpoint` | beacon | Unrealized finalized checkpoint (beacon `Checkpoint`) | + +Rows marked for one chain are never written on the other, so reaching one +through the wrong chain's accessor panics naming the key rather than reading a +zero. That is deliberate: a data directory holds one chain for its whole life. + +#### One clock, three readings + +`time` is the only clock either chain keeps, and it is a UNIX millisecond. +Milliseconds because the row has to be fine enough for the finest grid either +chain schedules on, which is lean's interval: with `INTERVALS_PER_SLOT` +intervals to a slot, most interval boundaries fall strictly between two whole +seconds, and a second-resolution row could not name them. + +Everything coarser is derived from it, exactly and in one direction: + +| Reading | Method | Used by | +| --- | --- | --- | +| Milliseconds past genesis | `Store::ms_since_genesis` | placing a moment within the current slot, against the beacon basis-point deadlines | +| Intervals past genesis | `Store::intervals_since_genesis` | lean's tick pipeline, its future-slot guards, its fork-choice fixtures and the Hive driver | +| Slot | `Store::current_slot` | both chains, including the beacon `get_slots_since_genesis` | + +Nothing stores a derived reading, so none of them can fall out of step with +the row or with each other. The beacon specification denominates its own +`Store.time` in seconds, so `on_tick` and `on_tick_per_slot` convert on the +way in and the fixture harness divides on the way out; that conversion lives +at those edges rather than in a second row. + +Lean's `on_tick` writes the row only at interval boundaries, since the +boundary is what it has actually processed: it walks forward one interval at a +time running that interval's duty, and the leftover milliseconds up to the +tick's own timestamp carry no duty that has run. The one time it moves the +clock backwards is the deliberate rewind that replays a slot after a gap. + +#### Format and shape tags + +`db_version`, `chain` and `preset` are what `from_db_state` checks before it +decodes anything else, and the two tags are raw bytes rather than SSZ for that +reason: they have to be readable by a build that would decode the rest of the +directory into the wrong shape. `db_version` catches a layout change this build +knows about; `chain` catches lean rows being opened as beacon or the reverse; +`preset` catches a `preset-minimal` build opening a mainnet directory, which +the other two cannot, since both presets write the same layout at the same +version while bounding every SSZ container differently. None of the three has a +migration path. + +`config` and `chain` are the odd ones out among the mutable rows: written once +at bootstrap (`init_store` for lean, `init_beacon` for beacon) and never +rewritten afterward (`config` has a getter, `Store::config`, and `chain` has +`Store::chain`, but neither has a setter). Because they never change, the +`Store` keeps a copy of each in memory, and reads of them never reach the +backend. Every other `Metadata` key is mutated in place as the chain +progresses. + +`config` used to double as the DB's fingerprint on its own, with +`from_db_state` refusing to resume a data directory belonging to another +network. `from_db_state` no longer judges the network or the chain (see +[Startup and Restore](#startup-and-restore)): it reads `config` and `chain` +back and hands both to the caller, which compares `config`'s `genesis_time` +and slot duration (and `genesis_validators_root`, computed off the anchored +state itself rather than stored in `Metadata`) against the network it was +configured for, and `chain` against the sub-command it is running. A beacon +resume goes further, comparing every chain value in `config` (the fork +schedule, the slot time, `PRESET_BASE`, ...) against the `config.yaml` it was +started with, in `first_config_difference`. `CONFIG_NAME` is the exception: a +label with no consensus effect, so a changed one is only warned about, and the +stored name stays, since `config` is never rewritten. + +A beacon directory **shares** `head`, `latest_justified` and `latest_finalized` +rather than keeping parallel ones. `init_beacon` seeds all three from one +trusted anchor, exactly as `init_store` seeds them on a lean directory, so +`update_checkpoints` is a single writer for both chains instead of growing an +absent-key branch per chain. The specification gives a trusted anchor's +finality the same starting value as its justification, rather than one actually +reached through the FFG rules, which is why both start equal. + +The two chains denominate a checkpoint differently, and the stored form is +lean's: a beacon epoch is written as its own start slot. That is what makes the +conversion exact in both directions, through +`Store::beacon_checkpoint_as_stored` on the way in and +`Store::beacon_finalized_checkpoint` on the way out, and it is what lets the +shared finalization-advance comparison read a beacon checkpoint with no second +rule for it. + +Note what is *not* in the table. There is no stored beacon head row: +`beacon_head()` derives its pair from `head` plus the head block's own entry, +because a second row denominated in `slot ‖ root` would be a value that could +drift from the first. + +`init_beacon` writes the two beacon-only keys plus `db_version`, `chain`, +`preset`, `config`, `time`, `head`, `anchor_slot`, `latest_justified` and +`latest_finalized` in one atomic batch. + +`anchor_slot` is the odd one among these: every other row is either a format +tag or something a later writer moves, while this one is written once at +bootstrap and never again, like `config` and `chain`. It has to be persisted +rather than derived because it is the store's only record of where its chain +starts. `latest_finalized` is seeded to the anchor as well, but climbs away +from it at the first finalization, so after that nothing else on disk can +answer the question. Both bootstrap paths take it from the anchor block's own +slot — `anchor_state.latest_block_header.slot` on lean, `anchor_state.slot()` +on beacon — rather than from the anchor checkpoint, whose beacon form is +epoch-denominated and would name the epoch's start slot instead. `from_db_state` +reads it back into a `Store` field, so `Store::anchor_slot()` costs no backend +round trip; the `Status` message's `earliest_available_slot` and the +`data_column_sidecars_by_range/1` floor are both that one value, which is what +keeps a refusal from contradicting an advertisement. A directory's finalized state root, whichever chain it +holds, is read through `Store::finalized_state_root`. That needs no +chain-specific branch: both chains keep their finalized checkpoint in +`latest_finalized`, and the epoch-to-slot conversion above touches only the +slot, never the root. It treats a zero root as `Error::UnanchoredDirectory` +rather than a valid answer, since both `init_store` and `init_beacon` always +anchor at a real block root, so a zero root means the directory was built some +other way or its metadata was corrupted at rest. Note that this is *not* the SSZ `StateConfig` carried inside `State`. That one is merkleized into the state root, so its layout is fixed by the spec and holds only -`genesis_time`; `ChainConfig` adds the slot duration, which the node needs to -schedule duties but which never enters a state root. A blob written before the slot -duration existed still decodes, filling in the 4-second default that chain ran on. +`genesis_time`; the runtime `Config` adds the slot duration and the beacon fork +schedule, which the node needs to schedule duties but which never enter a state +root. ### LiveChain @@ -251,6 +396,98 @@ while the block waits for its parent. When the block is later processed, `insert_signed_block` overwrites the same keys (idempotent) and adds the `LiveChain` entry. +### DataColumns + +`slot ‖ root ‖ column_index → DataColumnSidecar` (SSZ-encoded). Beacon only: +this is where a fulu data-availability-sampling follower keeps the column +sidecars its own node id assigned it custody of, so it can verify a block's +availability and answer `data_column_sidecars_by_{root,range}/1` for peers; +see [beacon_wire.md](./beacon_wire.md#data-column-sidecars) for the wire side +and how a node's custody set is selected. + +Keyed slot-first, ahead of the root, for the same reason `BlockProof` is: it +gives a by-range answer a prefix scan per slot instead of a full-table scan, +and gives a future pruner a scan that stops early once it passes its cutoff. +Every writer and reader already has the slot in hand — an arriving sidecar +carries `signed_block_header.message.slot`, and the availability check and the +held-block release path both start from the block itself — so the slot prefix +costs nothing to supply. + +Sidecars are written on arrival, once the p2p layer has verified one (gossip +validation for a sidecar gossip accepted, the chain checks in +`beacon::gossip::column::chain_checks` for any other), rather than at block +import. That ordering is what lets the availability +gate read a block's columns before the block itself is allowed to import (a +column has to exist first for the gate to find it), and what lets a restart +keep every sidecar this node already paid a KZG batch to verify rather than +re-fetching and re-verifying them. One consequence: a sidecar is stored for +any block whose header, parent and proposer check out, whether or not that +block ever actually imports — an equivocation or an orphaned fork's sidecars +land here too, since gossip only requires a known, unfinalized parent, not a +canonical one. `data_column_sidecars_in_range` (the by-range handler's source) +compensates by restricting each slot's answer to that slot's canonical root +via `BlockRoots`, so a losing sibling's columns are stored but never served. + +`DataColumns` is the one table in the enum with **no pruning rule at all**, +not even the finalized-window pruning `LiveChain` and `BlockProof` get: every +sidecar this node has ever custodied is kept forever, by design, until a +pruner is written (`MIN_EPOCHS_FOR_DATA_COLUMN_SIDECARS_REQUESTS` is the +epoch depth spec gives a future one permission to prune below). That makes it +the one table whose size a long-running node must actually watch rather than +assume bounded: at the blob cap this is roughly 360 KB per slot across the +columns this node custodies, nearer 1 GB per day at current mainnet blob +counts. `lean_table_bytes{table="data_columns"}` (see [metrics.md](./metrics.md)) +is that growth made visible; watch it, and size the disk against however long +the node runs unattended before a pruner exists. + +### PendingDataColumns + +Same key and same encoding as `DataColumns`, holding sidecars that have **not +been verified yet**. A sidecar lands here when its block's parent has no +post-state for the chain checks to check the proposer against: the parent may +still be in flight, or it may be a block the availability gate is itself +holding. The specification's gossip rule for that case is `[IGNORE]` with an +explicit licence to come back to it, so the sidecar is parked rather than +dropped, and sent back through the chain checks when the parent gains a +post-state. + +Two tables rather than one, and that is the whole point of this one. A parked +sidecar has passed only the cheap structural checks — not its inclusion proof, +not its KZG batch, not its proposer signature, which are held back so a replay +pays for them once rather than once per attempt. `data_column_indices_for` +reads `DataColumns` and nothing else, and that read is what the data +availability gate believes; an unverified row there would let a peer satisfy +the gate with a column nothing ever judged. A row moves from here to +`DataColumns` only by passing every check when it is sent back. + +The chain actor keeps one key per parked row in memory +(`sidecars_awaiting_parent`, keyed by the parent root it waits on) and the +bytes here, because a sidecar carries a cell per blob and the queue's size is +chosen by whichever peer is gossiping. + +Nothing caps that queue. The finality sweep that evicts held blocks also +deletes the rows of parked sidecars at or below the finalized slot, and that is +the only thing reclaiming them, so it bounds how *long* a row lives but not how +fast rows arrive. The chain checks do not require a sidecar's +`parent_root` to name a block this node knows. Every parked sidecar, gossiped +or fetched, has had its header's signature checked against the head state +before it gets this far, but only when a head state is already cached *and* +the header's `proposer_index` names a validator in it: with no cached head +state, or a proposer index that names none (`u64::MAX`, say), the checks skip +that step and a made-up header still reaches here. A peer exploiting either +gap can still park rows as fast as it can invent a slot, proposer and index. +Watch +`lean_sidecars_awaiting_parent`: it is the only signal that this is +happening, and unlike `DataColumns` these rows were written on a peer's +say-so. + +That in-memory map is also the *only* index into this table, and it does not +survive a restart, so `start_actor` clears the table outright before building +an empty one. Nothing is lost: a parked sidecar had passed no check worth +preserving, and the block it belongs to asks for its columns again. Without +that, every crash would leave every row it had parked unreadable, kept until +the directory was deleted. + ## State Storage: Snapshots + Diffs Storing a full `State` per block would be wasteful: most fields never change @@ -334,14 +571,17 @@ sequence of independent write batches: │ justified a higher slot) (+ triggers pruning) │ ├─ 2. insert_signed_block() ┐ BlockHeaders[root] - │ │ BlockBodies[root] (if non-empty) + │ │ BlockBodies[root] (lean, if non-empty) │ ├─one batch─ BlockProof[slot‖root] │ │ LiveChain[slot‖root] │ ┘ │ - ├─ 3. insert_state() ┐ StateDiffs[root] - │ ├─one batch─ States[root] (anchors only) - │ ┘ (+ LRU cache insert) + ├─ 3. insert_state() cache + PendingStates insert (synchronous), + │ (hands the state to the then a hand-off to the background writer + │ storage crate's own writer thread, which commits + │ thread; see below) StateDiffs[root] + │ States[root] (anchors only) + │ on its own schedule, not this one │ └─ 4. update_head() Metadata: head (re-runs fork choice) (+ justified/finalized if advanced, @@ -350,16 +590,31 @@ sequence of independent write batches: ``` Each numbered step is atomic on its own, but the import as a whole is **not** -one transaction. The commit order keeps the on-disk store consistent after any -prefix of these steps: the justified checkpoint written in step 1 always names -an already-persisted **ancestor** of the imported block (the state transition -only counts attestations whose roots match the state's own -`historical_block_hashes`, and every ancestor was fully persisted when it was -imported), and the head only advances in step 4, after the block and state are -durable. A crash mid-import can therefore lose the tail of the import — e.g. a -persisted block and state the head does not point to yet — but never leave -metadata referencing missing data. Re-importing the block is idempotent (a -duplicate is skipped via `has_state`). +one transaction, and step 3 is no longer one write at all: `insert_state` +returns once the state is cached and buffered (see `crates/storage/src/state_writer.rs`), +not once the writer thread has actually committed it. That thread drains a +FIFO one state behind the importer at most (`STATE_WRITE_QUEUE_CAPACITY = 2`, +plus the one write it can be holding), so steps 1, 2 and 4 can all reach disk +before step 3's own commit does. An unclean shutdown inside that window can +therefore leave `head` (from step 4), or `latest_justified` (from step 1, for +an even earlier block whose own step 3 might itself still be mid-flight), or +the finalized checkpoint `update_head` derives from the head state, naming a +root this directory has no state for yet — the exact thing "never leave +metadata referencing missing data" used to rule out, before the state write +moved off the importer's thread. + +`Store::repair_head`, called by a resuming caller (see +[Startup and Restore](#startup-and-restore) below), is what restores that +property for the head: it walks back to the newest ancestor with a persisted +state and rewinds there, dropping the `LiveChain` rows of whatever it hops +over so fork choice does not walk straight back to the stateless tip. +Justified and finalized are never rewound the same way — they are consensus +statements, not a pointer a repair may quietly move to an earlier one — so +`Store::verify_anchor_states` checks them instead, and a directory that fails +it is treated as needing a fresh anchor. Re-importing a block whose `LiveChain` +row was dropped this way is idempotent the same way any duplicate is: the skip +check is keyed on `has_state`, not on whether the block is already on record, +so a stateless block is reprocessed rather than skipped. ## Pruning @@ -388,22 +643,31 @@ processed): not needed for fork choice, reorg safety, or re-aggregation once outside the window. -**Never pruned:** `BlockHeaders`, `BlockBodies`, `BlockRoots`, `States`, -`StateDiffs`, and `Metadata`. Headers, bodies, the canonical slot index, and -the snapshot+diff chain are the full historical record; only the proof blobs -and the (non-finalized) fork choice index are disposable. +**Never pruned, by design:** `BlockHeaders`, `BlockBodies`, `BlockRoots`, +`States`, `StateDiffs`, and `Metadata`. Headers, bodies, the canonical slot +index, and the snapshot+diff chain are the full historical record; only the +proof blobs and the (non-finalized) fork choice index are disposable. + +**Not pruned yet, unlike the six above:** `DataColumns`. It is not part of the +historical-record set above; it simply has no pruning rule written for it, +which is a gap rather than a decision — see [DataColumns](#datacolumns) for +what bounds it in the meantime (`lean_table_bytes{table="data_columns"}`) and +where a future pruner is meant to land +(`MIN_EPOCHS_FOR_DATA_COLUMN_SIDECARS_REQUESTS`). ## In-Memory Only (Lost on Restart) -Four `Store` fields never touch the backend. All are bounded buffers shared -across `Store` clones: +Five `Store` fields never touch the backend. All are bounded buffers (or, for +`beacon`, bounded in practice by the validator set and the unfinalized window) +shared across `Store` clones: | Buffer | Capacity | Contents | | ------------------- | --------------- | ---------------------------------------------------------------------------------------- | | `new_payloads` | 64 messages | Pending aggregated attestation proofs, not yet active for fork choice | | `known_payloads` | 512 messages | Fork-choice-active aggregated proofs | | `gossip_signatures` | 2048 signatures | Raw per-validator XMSS signatures awaiting aggregation (each ~3 KB, so ~6 MB worst case) | -| `state_cache` | 32 states | LRU memoization of reconstructed/imported states | +| `state_cache` | 32 states | LRU memoization of block *and* checkpoint post-states (either chain), each held behind an `Arc` so a hit is not a copy; one bound covers both kinds, keyed apart by a small enum, and a miss is just a reconstruction rather than an error | +| `beacon` | unbounded | Beacon fork-choice scratch: proposer boost root, block timeliness, equivocating validator indices, latest messages, PoW blocks, and unrealized justifications. None of it is persisted: proposer boost resets every slot, timeliness is read only by the same-slot reorg helpers, equivocators come back from replaying attester slashings on sync, latest messages from one epoch of attestations, PoW blocks stand in for an execution-client call a restarted node would simply make again, and unrealized justifications are refilled as a node re-imports the unfinalized window from its anchor | The payload buffers evict FIFO when full, and redundant proofs (whose participants are a subset of an existing proof for the same attestation data) @@ -417,38 +681,74 @@ pools. After a restart these buffers start empty: pending attestations and un-aggregated gossip signatures are lost and must be re-collected from the -network. Everything persisted in the eight tables survives. +network. Everything persisted in the ten tables survives, except `PendingDataColumns`, which is cleared outright: its only index is in memory. ## Startup and Restore -A `Store` is created through one of three constructors in +A `Store` is created through one of four constructors in `crates/storage/src/store.rs`: -| Constructor | When | What it does | -| ---------------------- | -------------------------------------- | -------------------------------------------------------------------------------------------------- | -| `from_anchor_state` | Genesis boot | Initializes from the genesis state (no anchor block body) | -| `get_forkchoice_store` | [Checkpoint sync](checkpoint_sync.md) | Initializes from a downloaded finalized state + anchor block, after validating they are consistent | -| `from_db_state` | Resume from an existing data directory | Re-opens the persisted store as-is | +| Constructor | When | What it does | +| ---------------------- | ---------------------------------------- | -------------------------------------------------------------------------------------------------- | +| `from_anchor_state` | Lean genesis boot | Initializes from the genesis state (no anchor block body) | +| `get_forkchoice_store` | Lean [checkpoint sync](checkpoint_sync.md) | Initializes from a downloaded finalized state + anchor block, after validating they are consistent | +| `init_beacon` | Beacon [checkpoint sync](checkpoint_sync.md) | Writes a beacon directory's `Metadata` in one atomic batch, all seeded to one trusted anchor. The anchor block and state themselves are written separately, by `ethlambda_state_transition::beacon::fork_choice::get_forkchoice_store` (the specification's own construction rules, which this crate cannot depend on) | +| `from_db_state` | Resume from an existing data directory | Loads whichever chain the directory holds, without judging whether it is the right one | The first two funnel into `init_store`, which writes the anchor in **one -atomic batch**: all six `Metadata` keys (time = 0, config, head = safe_target -= anchor root, justified = finalized = anchor checkpoint), the anchor header, -its `BlockRoots` entry, the body if non-empty, a full snapshot into `States` -(the base of every future diff chain), and the anchor's `LiveChain` entry. - -`from_db_state` is the restore path: it reads `config` and `latest_finalized` -from `Metadata`, returning `None` for an empty DB. A populated DB from another -network is fatal instead: the persisted `config`'s genesis time and slot -duration, plus the finalized state's genesis time and validator registry, are -compared against the genesis config, and a mismatch fails with -`Error::GenesisMismatch` rather than being treated as empty, since writing a -fresh anchor would leave the foreign chain's rows in place to be served to -peers. The slot duration has to be checked against the persisted `config` -because it is absent from the state by design. At startup the node prefers -this path but only -accepts the on-disk store if its head is at most `MAX_RESUMABLE_DB_STATE_AGE -= 450` slots (~30 minutes) behind the current slot; a staler DB falls through -to checkpoint sync, which writes a fresh anchor on top of the existing data. +atomic batch**: the `Metadata` keys a lean directory needs (the format version +and the chain and preset tags, time, config, head = safe_target = anchor root, +justified = finalized = anchor checkpoint), the anchor header, its +`BlockRoots` entry, the body if non-empty, a full snapshot into `States` (the +base of every future diff chain), and the anchor's `LiveChain` entry. `time` +starts at genesis rather than at zero because it is an absolute timestamp, so +genesis is the value that means "the clock has not moved yet"; every derived +reading is zero there. + +`init_beacon`'s own atomic batch is the beacon-directory keys listed under +[Metadata](#metadata) above; the anchor's `States`/`BlockHeaders`/`BlockBodies` +entries are written by its caller instead, through the same `insert_state` and +`insert_signed_block` an ordinary block import uses. + +`from_db_state` is the restore path: it reads `db_version`, `preset`, `chain`, +`config` and `anchor_slot` from `Metadata`, returning `None` for an empty DB. A format +mismatch is still fatal, failing with `Error::DbVersionMismatch` or +`Error::PresetMismatch` rather than reading on, but `from_db_state` no longer +judges the network or the chain, and it does not mutate anything either: it +hands back whichever chain the directory holds, without comparing either +against anything or repairing anything. Both are now the caller's job, in a +fixed order: `fetch_initial_state` (lean) and `fetch_initial_beacon_state` +(beacon) each check `Store::chain()` against the sub-command they are running +under, then call `Store::verify_anchor_states` — which reads back both the +justified and finalized checkpoints (see [Metadata](#metadata) above) and +confirms each has a persisted state, returning the finalized state — before +running `verify_state_genesis` against it. Only once both of those pass does +either caller call `Store::repair_head`, described under +[Write Paths](#write-paths-what-a-block-import-persists) above: it is a +mutation, so it waits until the store it would mutate has been judged fit to +resume at all, and it leans on `verify_anchor_states` having already confirmed +finalized has a state, which is what lets it treat reaching finalized's slot +during its own walk as a plain stopping point rather than something it has to +guard against. + +A chain mismatch aborts with `CheckpointSyncError::WrongChain`; a genesis +mismatch aborts with `CheckpointSyncError::Genesis`, wrapping +`GenesisMismatch::GenesisTime` or `::GenesisValidatorsRoot`; a missing anchor +state (`storage::Error::AnchorStateLost`) is treated exactly like a stale DB, +described next. All three used to be raised (the first two) or were not yet +possible to raise (the third) from inside `from_db_state` itself; none of that +judgment lives in the storage crate anymore, now that the checks that need it +moved one layer up, to the caller that actually knows which network and which +chain it wants. None of these failures are treated as an empty directory: +writing a fresh anchor on top would leave the foreign (or merely incomplete) +chain's rows in place, to be served to peers. + +At startup, each chain prefers this path but only accepts the on-disk store if +its head is at most `MAX_RESUMABLE_DB_STATE_AGE = 450` slots behind the +current slot: ~30 minutes at lean's four-second slots, ~90 minutes at beacon's +twelve-second ones. A staler DB, or one whose anchor checkpoint state is +missing, falls through to checkpoint sync, which writes a fresh anchor on top +of the existing data. ## Key Files diff --git a/docs/discovery.md b/docs/discovery.md index 915ed477d..f8285524f 100644 --- a/docs/discovery.md +++ b/docs/discovery.md @@ -6,28 +6,37 @@ instead of relying only on the static bootnode list. The implementation reuses [ethrex](https://github.com/lambdaclass/ethrex)'s discovery stack, with discv4 disabled. -Discovery is **off by default**. Nothing else on the lean network speaks discv5 -today: not leanSpec, not ream's lean network, not zeam. Enabling it currently -only finds other ethlambda nodes. +Discovery is **always on for `beacon`** and **opt-in for `node`**. `beacon` +never had a choice: published mainnet bootnode ENRs carry no `quic` entry, so +none of them is statically dialable and a crawl is the only way to reach a peer. +On lean it stays behind `--discovery.enable` (off by default), because nothing +else on that network speaks discv5 yet, so a crawl there finds only other +ethlambda nodes. Without the flag a lean node binds no discovery socket and +peers from its `--bootnodes` list alone, which is also what lets several devnet +nodes share one host without a `--discovery.port` each. -## Enabling it - -```bash -ethlambda --discovery.enable -``` +## Configuring it | Flag | Default | Meaning | | --- | --- | --- | -| `--discovery.enable` | `false` | Run the discv5 server and the dial loop | -| `--discovery.port` | `9000` | UDP port for the discv5 socket | +| `--discovery.enable` | `false` | `node` only: run discv5. `beacon` has no such flag and always runs it | +| `--discovery.port` | `9000` (`DEFAULT_DISCOVERY_PORT`) | UDP port for the discv5 socket | | `--discovery.advertise-ip` | bind address (`0.0.0.0`) | IP address to advertise in the ENR | -| `--discovery.target-peers` | `200` | Connected-peer count above which dialing stops | +| `--discovery.target-peers` | `200` | Peers this node holds: the dial loop's cutoff, and on `beacon` the connection limits too | + +All but `--discovery.enable` are common flags with the same default on `node` +and `beacon`; on a `node` without `--discovery.enable` they are accepted and +ignored. See [the CLI reference](./cli.md). `--discovery.port` and `--gossipsub-port` (default `9001`, libp2p QUIC) are both UDP and so cannot share a port. `--gossipsub-port` also binds a libp2p TCP listener on the same number, which collides with neither: TCP and UDP are -separate namespaces. The defaults are one apart, so `--discovery.enable` -works on its own; overriding either onto the other is rejected at startup. +separate namespaces. The defaults are one apart, so a default invocation works; +pointing either flag at the other's port is rejected at startup, before the node +touches its data directory. Co-located nodes on one host that run discovery need +an explicit `--discovery.port` each, the same way they already need an explicit +`--gossipsub-port`. Neither check applies to a `node` without +`--discovery.enable`, since it binds no discovery socket and publishes no ENR. The discv5 socket always binds the wildcard `0.0.0.0`, since that is where we listen, not where peers should dial us. Without `--discovery.advertise-ip` the @@ -52,6 +61,7 @@ The layout follows the discovery domain of the beacon-chain | `secp256k1` | compressed public key from `--node-key` | | `eth2` | SSZ `ENRForkID`, 16 bytes | | `attnets` | subscribed attestation subnet bitfield | +| `cgc` | `--custody-group-count`, on `beacon` only; omitted on lean, which has no data-availability domain | `tcp` and `quic` share the same port number: TCP and UDP are separate namespaces, so `build_swarm` binds both without a collision. Advertising both @@ -82,36 +92,78 @@ A discovered peer is admitted only if: - that entry's `fork_digest` equals ours, **and** - it advertises a `quic` port, a `tcp` port, or both. +The digest compared against is the caller's, not a constant: lean passes its +hardcoded dummy and `beacon` passes the digest it derived at startup, so one +admission policy serves both chains. + A differing `next_fork_version` or `next_fork_epoch` is *not* grounds for rejection: the spec permits connecting to a peer that is incompatible with an upcoming fork but compatible now. +A peer advertising both transports is dialed with a QUIC-first address list, so +libp2p races them within one attempt and a `quic` entry that does not answer +falls back to TCP rather than ending the dial. Most mainnet beacon nodes are in +exactly that state, which is why a QUIC-only dialer peered so poorly with them. + These checks are handed to ethrex's peer table as a `PeerFilter`, so each record is judged the moment it arrives and a peer that fails is not offered for dialing. No rejection is final: the peer table runs the filter again as soon as the peer -publishes a higher-`seq` ENR, so a node that adds a `quic` entry, or gains an +publishes a higher-`seq` ENR, so a node that adds a transport entry, or gains an address through discv5's IP voting, is reconsidered without a restart. A peer's dial list carries every address it advertises, `quic` and `tcp` both, -in one dial attempt. libp2p races them: it starts up to `dial_concurrency_factor` -handshakes at once and keeps whichever completes first, dropping the other. So a -peer whose `quic` port does not answer still connects over `tcp` with no separate -retry and no connect timeout waited out first. - -The list order is not a preference, and nothing should be read into it: the -default concurrency factor exceeds the two addresses a lean peer can offer, so -both are always attempted. The cost of that is the thing to know, since it is -paid on every dial rather than only on a failure: two sockets and two handshakes -per peer, on both ends, until one wins. +in one dial attempt, `quic` first. libp2p walks that list `dial_concurrency_factor` +addresses at a time, and ethlambda pins the factor to one, so the `tcp` address is +reached only after the QUIC attempt ahead of it has failed. A peer whose `quic` +port does not answer still connects over `tcp` with no separate retry, but it +waits out `libp2p_quic`'s handshake timeout first. + +The list order is therefore a preference and should be read as one. It was a race +until the mainnet follower was profiled: both handshakes started at once, TCP won +most of them, and every TCP win is a connection carrying mplex, whose waker +bookkeeping cost more CPU than the entire beacon state transition. QUIC multiplexes +natively and reaches none of that code, so the cheaper transport is now asked for +first instead of merely offered. A lean peer advertises `quic` alone and is +unaffected either way. Admitted peers are ranked by how many attestation subnets they advertise that no currently connected peer covers, so discovery preferentially fills gaps in subnet coverage. A peer advertising no `attnets` is ranked last but never dropped. Dialing stops once `--discovery.target-peers` peers are connected, and resumes -if that count drops. That is all the flag does: it is the dial loop's cutoff, and -nothing in ethrex's peer table or discv5's own pacing enforces it (see -[below](#discv5-lookups-run-at-the-startup-rate)). +if that count drops. Nothing in ethrex's peer table or discv5's own pacing +enforces it (see [below](#discv5-lookups-run-at-the-startup-rate)). + +On `beacon` the flag is more than the dial loop's cutoff: the swarm's connection +limits are derived from it too, so what this node refuses and what it goes +looking for are two readings of one number. + +| Derived from the target | At the default 200 | +| --- | --- | +| Ceiling on established connections | 200 | +| Of those, the most inbound demand may hold (70%) | 140 | +| The rest, reserved for peers this node dials | 60 | + +The reservation is what the dial loop chases as its second shortfall, alongside +the plain "are we at target" one, so inbound saturation cannot suppress dialing: +outbound peers are the only ones this node chooses, and so the only lever it has +on which custody columns its peer set covers. Deriving both from one number is +what keeps the loop from chasing slots the swarm would refuse, or stopping while +slots it reserved sit empty. `--discovery.target-peers 0` therefore holds no +peers at all, dialing none and admitting none. + +Lean is unaffected: it runs unlimited connections, so it has nothing to reserve +against and paces on the total peer count alone. + +The loop opens one dial per tick and varies the tick instead of the batch, on +ethrex's own easeInOutCubic curve, so this node's dialing, its discv5 lookups and +ethrex's RLPx dialing all ramp the same way. A node with no peers dials at +`MAX_DIAL_RATE_PER_SECOND`; the gap eases out to ethrex's `LOOKUP_INTERVAL_MS` +as the table fills, and dialing ends outright at the target. A tick that opens no +dial waits that same ceiling regardless of the curve, which is what keeps a +network with fewer peers to offer than `--discovery.target-peers` — every lean +devnet, against a default target of 200 — from holding the loop at its floor for +the life of the process, re-drawing candidates it is already connected to. ## Bootnodes @@ -128,11 +180,11 @@ A bootnode is dropped only when it has none of the three: neither transport to dial nor a `udp` port to seed discv5 from. Any other combination is kept, including one with only `quic`, only `tcp`, only `udp`, or any pair. The ENRs `lean-quickstart` generates today carry `ip`/`quic`/`secp256k1` and no `udp`, -so they stay reachable but contribute nothing to discovery. A beacon-chain -bootnode is close to the mirror image, `udp` and `tcp` but no `quic`, and the -`tcp` entry is what now makes it statically dialable rather than a discv5 seed -only. A record missing an `ip` or a `secp256k1` key is dropped regardless of -its transports. +so they stay reachable but contribute nothing to discovery. The built-in +mainnet bootnode list is close to the mirror image, `udp` and no `quic`, and a +`tcp` entry is what makes such a record statically dialable rather than a +discv5 seed only: four of the seventeen carry one. A record missing an `ip` or +a `secp256k1` key is dropped regardless of its transports. The ENR a node logs at startup is only useful to a peer if that node was started with a real `--discovery.advertise-ip`. @@ -184,12 +236,17 @@ startup, which is what several beacon clients do. ### One lean devnet is not separated from another The spec's `fork_digest` is derived from genesis, so it separates one chain from -another. ethlambda's is the hardcoded cross-client dummy `0x12345678`, and lean -defines no fork schedule, so every `ENRForkID` field is a constant. The `eth2` -check therefore separates lean from non-lean but **not one lean devnet from -another**: two devnets running this code will peer with each other. Closing that -gap requires lean adopting a genesis-derived fork digest, which is a -cross-client change to gossip topic names. +another. Lean's is the hardcoded cross-client dummy `0x12345678`, and lean +defines no fork schedule, so every `ENRForkID` field it publishes is a constant. +The `eth2` check therefore separates lean from non-lean but **not one lean +devnet from another**: two devnets running this code will peer with each other. +Closing that gap requires lean adopting a genesis-derived fork digest, which is +a cross-client change to gossip topic names. + +This is a lean limitation only. `ethlambda beacon` derives a real digest from +mainnet's fork schedule and `genesis_validators_root`, so its `eth2` check +separates mainnet from every other beacon network; see +[`beacon_wire.md`](./beacon_wire.md). ### discv5 lookups run at the startup rate diff --git a/docs/metrics.md b/docs/metrics.md index fd3a73a0d..bf215a3c6 100644 --- a/docs/metrics.md +++ b/docs/metrics.md @@ -133,8 +133,9 @@ The metrics below are not part of the [leanMetrics specification](https://github ### Peer Discovery -Only emitted when discv5 discovery is enabled (`--discovery.enable`); see -[Peer discovery](./discovery.md). Counts dials discovery initiated, as opposed to +See [Peer discovery](./discovery.md), which is always on for `beacon` and +opt-in (`--discovery.enable`) for `node`. Counts dials +discovery initiated, as opposed to the static bootnode dials every node makes. Connection outcomes are not repeated here: a discovery dial that succeeds or fails shows up in `lean_peer_connection_events_total` like any other. @@ -161,6 +162,140 @@ which nothing ethlambda binds produces. |------|------|-------|-------------------------|--------| | `lean_peer_connections_by_transport_total` | Counter | Established peer connections by the transport that carried them | On connection established | direction=inbound,outbound
transport=quic,tcp,unknown | +### Why Peers Leave + +`lean_peer_disconnection_events_total` above is leanMetrics-specified down to +its `reason` values, and those four cannot carry this: measured on the mainnet +follower, 92% of outbound closes land in `error`, which says only that libp2p +handed back a cause. These two split that bucket without widening the specified +metric, the same way the transport counter above sits beside the specified +connect counter rather than inside it. + +`lean_peer_goodbye_total` is the only one of the three that is not an +inference. A `goodbye` is the peer stating why it is dropping us, and the two +readings that matter are indistinguishable from the socket alone: +`too_many_peers` means the peer had no room, while `bad_score`, `banned` and +`banned_ip` mean it decided against *this node*, and those call for opposite +responses. It has no `direction` label because `goodbye/1` is registered +inbound-only; ethlambda never sends one. The reason is a `u64` off the wire, so +the labels are a fixed set with `other` as the residue: a remote must not be +able to choose this node's metric cardinality. + +`lean_peer_disconnect_cause_total` covers the closes that carry no `goodbye`, +read off the `ConnectionError` variant and the error types inside it. It is +charged on the same event as the specified counter, once per peer fully +disconnecting rather than once per connection, so the two total to the same +number and can be read against each other directly. `clean_close` is libp2p +reporting no error at all, which for a beacon peer is the ordinary shape of a +deliberate disconnect and should be read beside `lean_peer_goodbye_total`. + +An I/O close almost always arrives as `ErrorKind::Other` with the muxer's own +error inside, so the label comes from that inner error. The `quic_*` values +are QUIC's close reasons: `quic_application_close` is the peer's application +closing (libp2p's normal close after a `goodbye`, and go-libp2p's connection +gater), `quic_transport_close` a transport-level close. TCP closes resolve to +the socket error beneath the muxer where there is one (`unexpected_eof`, +`connection_reset`), otherwise `yamux_closed` for a clean yamux shutdown. +`io_other` is the residue and should stay near empty; the `Peer connection +closed` line at `DEBUG` prints the full cause for whatever lands there. + +| Name | Type | Usage | Sample collection event | Labels | +|------|------|-------|-------------------------|--------| +| `lean_peer_goodbye_total` | Counter | Goodbye messages received, by the reason code the peer sent | On receiving a `goodbye/1` | reason=unknown,client_shutdown,irrelevant_network,fault,unable_to_verify_network,too_many_peers,bad_score,banned,banned_ip,other | +| `lean_peer_disconnect_cause_total` | Counter | Closed peer connections by the cause libp2p reported for the close | On a peer's last connection closing | direction=inbound,outbound
cause=clean_close,keep_alive_timeout,connection_reset,connection_aborted,broken_pipe,not_connected,timed_out,unexpected_eof,quic_application_close,quic_transport_close,quic_reset,quic_timed_out,quic_local_close,quic_other,yamux_closed,yamux_other,mplex_other,io_other | + +Reason codes 1, 2 and 3 are the only ones +[the spec](https://github.com/ethereum/consensus-specs/blob/master/specs/phase0/p2p-interface.md) +names; it reserves `[4, 127]` and leaves 128 and up to each client. The four +above 127 follow lighthouse's `GoodbyeReason`, which is what mainnet peers +actually send. + +### Peer Supply + +Who this node is connected to, and whether those peers can serve what it needs. +All three are set from a full re-count of the state that decides them rather +than incremented and decremented per event: a gauge meant to reveal a leak must +not be able to leak itself. + +`lean_peers_by_direction` read against `lean_swarm_established_connections` is +that leak check. The first counts peers this node believes it holds; the second +libp2p's own counters, which the connection limits in +[discovery.md](./discovery.md) are enforced against. A persistent gap is a +connection charged to the cap that no live peer is using. + +`lean_custody_column_peers` is the supply side of the data-availability gate: a +fulu block is held until every column this node custodies arrives, so a column +sitting at zero connected custodians is a stall waiting to happen, and it is +invisible in a total peer count. Only the columns this node samples get a +series, since publishing all 128 would bury the ones that can actually block an +import. Read it beside `lean_blocks_held_for_columns`. Beacon-only; a lean node +samples nothing and publishes no series here. + +| Name | Type | Usage | Sample collection event | Labels | +|------|------|-------|-------------------------|--------| +| `lean_peers_by_direction` | Gauge | Connected peers by the direction the connection was opened in | On every connection established and closed | direction=inbound,outbound | +| `lean_swarm_established_connections` | Gauge | Established connections as libp2p itself counts them | On the swarm's own metric tick | direction=inbound,outbound | +| `lean_custody_column_peers` | Gauge | Connected peers known to custody each data column this node samples | On every connection established and closed, and whenever a peer's custody is recorded from its `metadata/3` answer or its ENR `cgc` | column=`` | + +### Block Import Timing + +These record where a block's time goes between coming off the wire and having a post-state, which is the question `lean_fork_choice_block_processing_time_seconds` cannot answer: it starts inside `store::on_block`, so the mailbox hop, the holds, the head update and the per-table size estimates every import pays for all fall outside it. The sections here are the same ones the `Block import timing` log prints as a tree, and a test asserts the two lists stay identical. + +One histogram carries every section, including `total` for a whole import and the sections an arrival is charged once for. They share a unit and a bucket set, and the query that matters is `rate(..._sum[5m])`, seconds spent per second, which does not divide by an event count and so does not care that some sections are counted per block and others per arrival. `lean_block_import_cascade_blocks` is what relates the two counts when you do need them. + +`total` is written only when the block actually imported. A held block publishes every section it crossed and no total, because its import has not finished: that is what keeps a block that waited two slots for its parent out of the import-cost percentiles, and why there is no `outcome` label to filter on. + +`parent_wait` and `columns_wait` are separate sections rather than one "pending", so a block held for its parent and a block held for its custody columns never collapse into the same number. An absent `columns_wait` still has two readings, and only the log separates them: its `da_complete_on_arrival` field says whether the columns were never missing, or landed while the block was held for its parent. + +The bookkeeping an import triggers (chain-event emission, the finality eviction sweep, the gauge refresh including a RocksDB size estimate per table) has no section of its own. It is charged to the section it follows, which is `fc_head` on lean and `block_atts` on beacon. Those three were sections once: across 5248 imports on a mainnet follower none of them reached a millisecond, so they were three rows that never moved in every tree and three label values that never said anything. The work is still counted, just where it happens. + +`decode` is a gossip-only section, so `queue` is the only one a fetched block crosses before the chain actor. The req/resp codec has already turned the bytes into a block before any handler sees one, leaving no decode boundary to take; the path reports nothing rather than a zero, since a zero reads as free work rather than as unmeasured work and would drag the decode histogram down with samples that measured nothing. Two consequences: `decode` is a gossip population even though the `source` label allows `sync`, and a fetched block's `total` starts later in its life than a gossiped block's, having never counted the request round trip at all. + +On the beacon wire, `decode` for `source="gossip"` spans more than its name says. `BlockArrival::decode_start` is still the wire arrival, but `handed_off` is stamped only once the block has a gossip verdict, so the section also covers the cheap, stateless checks, the stateful check's own `spawn_blocking` task, and the verdict's trip back through the p2p actor's mailbox. There is no wait for a free validation slot to attribute here either: `try_acquire_owned` never blocks, and a message arriving with none free is reported `Ignore(Overloaded)` (and still forwarded to the chain actor) rather than queued. A rising `decode` on the beacon wire alone therefore does not mean decoding got slower; check `lean_beacon_gossip_validation_seconds` before assuming so. + +`engine` and `fcu` are execution-client round trips. They are I/O waits rather than work, so a node whose import time is dominated by them is waiting on its execution client, not spending CPU. + +The `source` label has two values, `gossip` and `sync` (req/resp backfill). Two populations are deliberately absent. A block re-delivered to itself because its slot had not started reports under the source it first arrived on, since the hold is already visible as its `defer` section and a third label value would take the block out of the population it belongs to for every section it has left. A block this node built itself is not measured at all: it crossed no wire, so it has no `decode` and an empty `queue` taken when the import began. Both still appear in the log, which names them `deferred` and `local`. + +Series appear as blocks arrive rather than being seeded, since a third of the phases are beacon-only and a lean node can never write to them. + +| Name | Type | Usage | Sample collection event | Labels | Buckets | +|------|------|-------|-------------------------|--------|---------| +| `lean_block_import_phase_seconds` | Histogram | Time one section of a block's import, or of the arrival carrying it, took | Per section that ran, on each import, hold or failure | phase (see below); source=gossip,sync | 0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 0.75, 1, 1.5, 2, 3, 4, 6, 8, 12, 16, 32 | +| `lean_block_import_cascade_blocks` | Histogram | Blocks one arrival put through the import path (attempts, so a block that ends held or pended counts) | Once per arriving block message | | 1, 2, 3, 5, 8, 16, 32, 64, 128 | + +Per-block phases, in the order a block crosses them, plus `total` for a completed import: + +| Phase | What it covers | Chain | +|-------|----------------|-------| +| `decode` | Snappy decompression, SSZ decode and the root the p2p handler computes; on the beacon wire this also spans gossip validation. Gossip only; see above | both | +| `queue` | The wait in the chain actor's mailbox | both | +| `defer` | Held because the block's own slot had not started yet | beacon | +| `guards` | Finality and future-slot checks, the already-imported check, the parent-state lookup | both | +| `preamble` | The checks `store::on_block` makes before verifying: parent state load, duplicate attestation data scan | lean | +| `parent_wait` | Held because the parent had no post-state | both | +| `cascade_wait` | Between the parent's import finishing and this block being popped off the cascade queue | both | +| `da_check` | The custody-column availability check itself, not the wait | beacon | +| `columns_wait` | Held because custody columns had not all arrived | beacon | +| `engine` | The `engine_newPayload` round trip, including its retry ladder | beacon | +| `verify_struct` | Participant bounds checks and pubkey resolution | lean | +| `verify_crypto` | The leanVM multi-message aggregate verification | lean | +| `stf` | The state transition. On beacon this bundles the transition, the state root and the state write | both | +| `db_write` | The block write and handing the post-state off to the storage crate's background writer; the state's own encode/diff/commit cost is `lean_state_write_seconds` instead, not this row | lean | +| `fc_head` | `update_head` | lean | +| `block_atts` | Replaying the block's own attestations and slashings into fork choice | beacon | +| `total` | The whole import, wire to post-state, spanning any holds. Only on a completed import | both | + +Per-arrival phases, charged once per arriving message however many blocks its cascade imported: + +| Phase | What it covers | +|-------|----------------| +| `arrival` | The whole handler call: the cascade plus everything after it | +| `cascade` | The block drain alone | +| `prune` | `prune_old_data` after the cascade (lean) | +| `get_head` | `fork_choice::get_head` (beacon) | +| `fcu` | The `engine_forkchoiceUpdated` round trip (beacon) | + ### Gossip Arrival Timing These histograms record the absolute distance between a gossip message's arrival and the start of the interval it was due in, so an arrival that is early by some amount and one that is late by the same amount land in the same bucket; the counters' `position` label is what tells them apart. `inside` means the message arrived within the interval it was due in, not merely somewhere in the right slot: an attestation for slot 10 that lands during slot 10's interval 2 is `after`, not `inside`, since it missed the AttestationProduction interval it was actually due in. @@ -189,6 +324,228 @@ In practice the distribution is bimodal and dominated by production rather than | Name | Type | Usage | Sample collection event | Labels | |------|------|-------|-------------------------|--------| | `lean_table_bytes` | Gauge | Estimated byte size of a storage table (key + value bytes) | After each processed block (one update per table); retains its previous value on empty slots | table=`` | +| `lean_state_write_queue_depth` | Gauge | States handed to the background writer but not yet committed | On every hand-off and on every commit | | +| `lean_state_write_seconds` | Histogram | Time the background writer spends encoding, diffing and committing one state | Per state written | | + +**On a beacon follower, watch `lean_table_bytes{table="data_columns"}`.** Every +other table's series either stays flat or is bounded by pruning; `data_columns` +backs `Table::DataColumns`, the one table with no pruning rule (see +[data_storage.md](./data_storage.md#datacolumns)), so this is the disk-growth +trajectory for the whole node. Budget roughly 360 KB per slot across the +columns this node custodies at the blob cap, nearer 1 GB per day at current +mainnet blob counts, and size the disk against however long the node is meant +to run before a pruner exists. + +**`lean_state_write_queue_depth` is the early warning for a slow disk.** Up to +three states can be in flight without the importer ever blocking — two queued +plus the one the writer thread is currently encoding, diffing and committing — +so a depth resting at or below three is healthy overlap. A depth climbing past +three means `insert_state` blocked on the full channel waiting for the writer, +and `lean_state_write_seconds` says whether that time went into the encode or +the commit. A depth that stays at zero means the writer has nothing +outstanding. + +### Beacon Gossip Validation + +Every beacon gossip message gets a verdict before gossipsub forwards it +(`validate_messages()` is on for the beacon wire only), `beacon_aggregate_and_proof` +and `beacon_attestation_{subnet_id}` included: both are validated the same way +blocks and columns are, in `ethlambda_state_transition::beacon::gossip::{aggregate,attestation}`. +See [beacon_wire.md](./beacon_wire.md#gossip) for the flow and +[beacon_wire.md](./beacon_wire.md#aggregate-attestations) for the two topics' +own section. These are ethlambda-specific, not part of the leanMetrics spec. + +| Name | Type | Usage | Sample collection event | Labels | Buckets | +|------|------|-------|-------------------------|--------|---------| +| `lean_beacon_gossip_validation_total` | Counter | Verdicts, by topic kind, outcome and reason | On every verdict reported to gossipsub | kind, outcome=accept,queue,ignore,reject, reason | | +| `lean_beacon_gossip_validation_seconds` | Histogram | Time from a message's arrival to its verdict | On every verdict reported to gossipsub | kind | 0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2, 4, 8 | +| `lean_beacon_gossip_verdict_expired_total` | Counter | Verdicts that arrived after gossipsub evicted the message, so an Accept propagated nothing | When `report_message_validation_result` returns `false` | kind | | + +`kind` is the topic kind, with every `data_column_sidecar_{subnet}` sharing the +label `data_column_sidecar` and every `beacon_attestation_{subnet_id}` sharing +`beacon_attestation`. `queue` means IGNORE to gossipsub while the chain actor +still receives the object and parks it; neither the aggregate nor the +attestation topic ever answers `queue`, since the vote block's post-state is +either cached or it is not (`IgnoreReason::UnknownBlock`/`StateUnavailable`), +with nothing to hold the message for. **`verdict_expired_total` should stay at +zero**: a rising count means validation is too slow for gossipsub's message +cache. + +The two topics add reasons the others do not, all `reason` label values on +`lean_beacon_gossip_validation_total`: `outside_epoch_window`, `covered_bits` +(aggregate only), `unknown_block`, `state_unavailable`, +`finalized_not_ancestor`, `ancestry_unknown` on the ignore side; +`epoch_mismatch`, `no_participants` (aggregate only), `non_zero_data_index`, +`committee_bits` (aggregate only), `committee_index`, `bits_length` (aggregate +only), `not_aggregator` (aggregate only), `not_in_committee`, +`unknown_validator`, `selection_proof` (aggregate only), +`aggregator_signature` (aggregate only), `aggregate_signature` (aggregate +only), `target_not_ancestor`, `wrong_subnet` (attestation only) on the reject +side. `already_seen` and `overloaded` are shared with every other topic. + +Two permit pools bound the blocking-thread half of validation: +`gossip_validation_permits` for blocks and columns, +`attestation_validation_permits` for aggregates and subnet attestations. They +are deliberately separate: a mainnet slot's worth of aggregates and backbone +attestations arrives every slot, not only during a range sync, and sharing one +pool would let that burst answer `Ignore(Overloaded)` for a block or a column +instead. Neither pool has a metric of its own yet; a permit exhausted on +either shows up as `outcome="ignore",reason="overloaded"` on +`lean_beacon_gossip_validation_total`, for the topics that draw from it. + +### Beacon Committee Cache + +`ethlambda beacon` derives an epoch's attester committees with one whole-epoch +shuffle and keeps the result in `CommitteeCache`, keyed by epoch and the block +that decided the shuffling. The cache itself lives in the `Store` +(`crates/storage/src/committee_cache.rs`), shared by both actors: the chain +actor's own attestation processing and p2p's gossip validation for +`beacon_aggregate_and_proof`/`beacon_attestation_{subnet_id}` read the same +entries rather than each keeping a copy, which is what lets a validation task +and the actor race a shuffle at an epoch boundary and have only the first one +pay for it. `CommitteeCacheExt` in +`crates/blockchain/state_transition/src/beacon/helpers/accessors.rs` is the +consensus-logic wrapper (`ShufflingKey` derivation, the shuffle itself) around +the storage-side container. This is ethlambda-specific, not part of the +leanMetrics spec. + +| Name | Type | Usage | Sample collection event | Labels | +|------|------|-------|-------------------------|--------| +| `lean_beacon_committee_cache_lookups_total` | Counter | Committee lookups, by whether the cache served them | On every `CommitteeCacheExt::committees` call: the state transition's, fork choice's and p2p's gossip validation's attestation processing | result=hit,miss,unkeyable | + +**Read the miss rate against the epoch rate, not the hit rate.** A follower on +one chain misses about once per epoch, when the first block of a new epoch asks +for that epoch's shuffling, and hits on everything else. Misses that track +imports instead mean entries are being evicted and rebuilt, which happens when +more branches are being imported at once than the cache has room for; each miss +is a registry scan plus a whole-epoch shuffle on the import thread. `unkeyable` +is a lookup the cache could not key at all, mostly the genesis state asking +about its own first epochs, and should be zero on a checkpoint-synced follower. + +### Beacon Pubkey Cache + +Every BLS signature check `ethlambda beacon` runs (block import, fork choice, +gossip validation) resolves its signers' compressed public keys to validated +curve points through one process-wide cache, keyed by the compressed bytes. See +`PubkeyCache` in `crates/blockchain/state_transition/src/beacon/bls.rs`. This is +ethlambda-specific, not part of the leanMetrics spec. + +| Name | Type | Usage | Sample collection event | Labels | +|------|------|-------|-------------------------|--------| +| `lean_beacon_pubkey_cache_lookups_total` | Counter | Public keys resolved for a signature check, by whether the cache already held them | Once per signature check, counting every signer | result=hit,miss | +| `lean_beacon_pubkey_cache_entries` | Gauge | Validated public keys the cache holds | On each key added; the cache never removes one | | + +**Misses should fall to near zero within an epoch of a start**, once every +active validator has signed something the node checked. A steady miss rate +after that means keys the cache is refusing to hold: either the entry bound is +reached (the gauge stops rising), or the keys are invalid, which are never +cached and pay the full check every time. `entries` is also the cache's memory +footprint, roughly a decompressed point plus its compressed key per entry. + +### Data Column Sidecars (Fulu DAS) + +`ethlambda beacon` is a fulu data-availability-sampling custodian: it derives +a slice of the column matrix from its own discv5 node id (see +[beacon_wire.md](./beacon_wire.md#data-column-sidecars)), verifies what gossip +and req/resp deliver for that slice, stores what verifies, serves it back to +peers, and refuses to import a fulu block until every column it owes for that +block is on hand. These are ethlambda-specific, not part of the leanMetrics +spec. + +| Name | Type | Usage | Sample collection event | Labels | Buckets | +|------|------|-------|-------------------------|--------|---------| +| `lean_data_columns_stored_total` | Counter | Data column sidecars verified and written to `Table::DataColumns` | On the chain actor storing a sidecar the p2p layer verified | | | +| `lean_data_columns_rejected_total` | Counter | Sidecars the p2p layer's chain checks dropped, by reason | On each drop in `beacon::column_checks`, except an already stored sidecar | reason=malformed,future_slot,finalized,not_after_parent,unknown_proposer,bad_signature,wrong_proposer,finalized_not_ancestor,parent_not_ready,inclusion_proof,kzg,internal | | +| `lean_data_column_kzg_verify_seconds` | Histogram | Time spent batch-verifying one sidecar's cells against its own commitments | On each KZG batch verification, in gossip validation or the chain checks | | 0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0 | +| `lean_data_column_fetch_failures_total` | Counter | `DataColumnsByRoot` lookups this node gave up on, by reason | On lookup abandonment | reason=no_peers,max_retries | | +| `lean_blocks_held_for_columns` | Gauge | Blocks held out of fork choice pending their custody columns | On every hold, release, and finality eviction of the held-block set | | | +| `lean_sidecars_awaiting_parent` | Gauge | Sidecars parked until their block's parent has a post-state | On every park, replay, and finality eviction of the parked set | | | + +`lean_data_columns_rejected_total` counts the chain checks, which run in the +p2p layer on every sidecar gossip did not accept: every fetched sidecar, a +gossiped one reported `Queue` or `Ignore(Overloaded)`, and a parked one sent +back once its parent imported. A gossiped sidecar is judged first by the +gossip rules and counted in +`lean_beacon_gossip_validation_total{kind="data_column_sidecar"}`; the reasons +here are that counter's reason labels. The chain actor stores what reaches it +without checking it again. + +**Watch `lean_blocks_held_for_columns`.** It is the first symptom of a stalled +availability gate, and a healthy node returns it to zero within a slot or two +of a hold: fetch or gossip should complete well inside the retry ladder +`lean_data_column_fetch_failures_total` counts down. A gauge that sits above +zero for several slots means either the fetch is failing — check whether +`lean_data_column_fetch_failures_total`'s `no_peers` or `max_retries` reason +is climbing — or no reachable peer actually serves the missing columns; short +of that, only finality passing the held block's slot clears it, which can be +minutes away. + +**Read `lean_sidecars_awaiting_parent` beside it.** A sidecar whose block's +parent has no post-state yet is parked rather than dropped, and a held block is +precisely a block with no post-state, so the two gauges rise together while the +gate waits: held blocks on one, their children's columns on the other. Both +returning to zero is a recovered hold. `lean_sidecars_awaiting_parent` climbing +without coming back down while `lean_blocks_held_for_columns` stays above zero +is the signature of a gate that is not recovering. The parked queue is +uncapped, so that gauge is also the only warning that a peer is parking +sidecars under parents it never means to supply: each one holds a +`Table::PendingDataColumns` row until finality passes its slot. + +### Beacon Aggregate Attestations + +`ethlambda beacon` applies `beacon_aggregate_and_proof` to fork choice, which is +how it sees votes for the current head rather than only the votes a block body +carries (see +[beacon_wire.md](./beacon_wire.md#aggregate-attestations)). These are +ethlambda-specific, not part of the leanMetrics spec, and lean nodes never emit +them. + +| Name | Type | Usage | Sample collection event | Labels | Buckets | +|------|------|-------|-------------------------|--------|---------| +| `lean_beacon_aggregate_decode_seconds` | Histogram | Time the p2p actor spent decoding one aggregate off the wire | On each successful decode in the gossip handler | | 0.0001, 0.00025, 0.0005, 0.001, 0.0025, 0.005, 0.01, 0.025, 0.05 | +| `lean_beacon_aggregate_mailbox_wait_seconds` | Histogram | Time an already-verified aggregate spent in the chain actor's mailbox | On the chain actor taking one off the mailbox | | 0.0005 … 2.5 | +| `lean_beacon_aggregate_processing_seconds` | Histogram | Time the chain actor spent applying one already-verified aggregate (`apply_verified_aggregate` plus the applied-bits gate) | On each aggregate the actor processed, applied or not | | 0.0005 … 2.5 | +| `lean_beacon_aggregate_end_to_end_seconds` | Histogram | Wire to fork choice, for aggregates applied on arrival | On applying an aggregate that was not deferred | | 0.0005 … 2.5 | +| `lean_beacon_aggregate_total` | Counter | Aggregates by outcome | On each aggregate reaching a verdict | outcome=applied,invalid,known_subset,queue_full | | +| `lean_beacon_aggregates_deferred` | Gauge | Aggregates held until their own slot has passed | On every defer and every per-slot drain | | | + +`decode` is the only one of the four histograms still timing what it always +did. The other three no longer include gossip validation: committees, all +three signature checks and the specification's own seen sets moved to p2p +(`lean_beacon_gossip_validation_seconds{kind="beacon_aggregate_and_proof"}` +covers that half now), so `processing` and `end_to_end` read a great deal +lower than before this change, and a comparison against an older deployment's +values is comparing two different things. What is left for these three to +measure is real, though: `validate_on_attestation_indexed` and the applied-bits +gate still cost a `Table::LiveChain` scan and a hash-map lookup per aggregate, +so a slow chain-actor number still points at the store rather than at +cryptography. + +**Watch `lean_beacon_aggregate_mailbox_wait_seconds`.** It is the failure mode +this path introduces and the one no other timing can show: a mainnet slot +carries up to `MAX_COMMITTEES_PER_SLOT * TARGET_AGGREGATORS_PER_COMMITTEE` +aggregates, and if they queue behind block imports their votes arrive too late +to move the head while every per-aggregate timing still looks healthy. + +**Read `lean_beacon_aggregate_total{outcome}` as a ratio, not a rate.** +`known_subset` is the actor's own applied-bits gate now, not the specification's +seen set (that one is p2p's, and a duplicate or already-covered aggregate is +refused there, under `lean_beacon_gossip_validation_total`, before it ever +reaches this counter). `known_subset` dominating is still the design working: +a committee's aggregators mostly converge on the same votes, and each one the +running union already covers is dropped before another +`apply_verified_aggregate` call. `applied` falling toward zero while +`known_subset` stays high means the node is seeing only aggregates it has +already covered, which is normal; `invalid` climbing means aggregates gossip +already accepted are failing `apply_verified_aggregate` regardless, most often +because this node has not imported the block being voted for, or has since +finalized past its target. + +**`queue_full` should be zero.** The deferral queue holds two slots' worth, and +reaching its cap means aggregates are arriving faster than the once-per-slot +drain clears them. Read it beside `lean_beacon_aggregates_deferred`, which sits +near one slot's worth in the steady state and near the cap when the drain is +falling behind. ### Attestation Aggregate Coverage @@ -208,6 +565,63 @@ Observability into how many validators/subnets are covered by the attestations t ✅(*) **Partial support**: These metrics are implemented but not collected "on scrape" as the spec requires. They are updated on specific events (e.g., on tick, on block processing) rather than being computed fresh on each Prometheus scrape. +## Validator client + +Served by `ethlambda validator` on its own `--metrics-port`, separate from the +node's. Prefixed `ethlambda_validator_` rather than `lean_`, because this +process follows the beacon chain and a `lean_` series here would be misleading +on a shared dashboard. + +Every series is registered at startup rather than on first use, so each reads +zero from the moment the process is up. That matters for alerting: a rule on +"attestations stopped" cannot fire against a series that does not exist yet, +and absent is not the same as zero. + +| Metric | Type | Description | +|---|---|---| +| `ethlambda_validator_validators_loaded` | Gauge | Validator keys loaded from the keystores | +| `ethlambda_validator_validators_resolved` | Gauge | Loaded keys that have an index on chain, out of `validators_loaded`. A persistent gap means validators are deposited but not yet activated | +| `ethlambda_validator_duties_held` | Gauge | Attester duties currently scheduled | +| `ethlambda_validator_attestations_published_total` | Counter | Attestations a beacon node accepted | +| `ethlambda_validator_attestation_failures_total` | Counter | Slots whose attestation duty failed and returned | +| `ethlambda_validator_attestation_deadline_missed_total` | Counter | Slots whose duty never returned in time and was abandoned. Points at a slow or hung beacon node rather than a rejected attestation | +| `ethlambda_validator_attestations_refused_total` | Counter | Signatures deliberately not attempted, because this process had already signed a conflicting attestation for that validator. Should normally read zero | +| `ethlambda_validator_signing_failures_total` | Counter | Signatures attempted and failed | +| `ethlambda_validator_blocks_proposed_total` | Counter | Blocks signed and accepted by a beacon node | +| `ethlambda_validator_blocks_broadcast_not_imported_total` | Counter | Blocks a node broadcast but could not import into its own database, which is what a 202 means. A subset of `blocks_proposed_total`, not a failure: the block reached the network. Points at that node's execution layer | +| `ethlambda_validator_blocks_refused_total` | Counter | Blocks deliberately not signed, because this process had already proposed that slot for that validator. Should normally read zero | +| `ethlambda_validator_block_proposal_failures_total` | Counter | Proposal duties that did not end in a published block, including ones abandoned for overrunning | +| `ethlambda_validator_aggregates_published_total` | Counter | Aggregates accepted by a beacon node. Bursty rather than steady: a validator is selected a few times a day, so hours at zero are normal for a small deployment | +| `ethlambda_validator_aggregation_failures_total` | Counter | Aggregation duties that ended in no published aggregate, including ones abandoned for overrunning the slot | +| `ethlambda_validator_fee_recipient_mismatches_total` | Counter | Blocks paying execution rewards to an address this client did not request. Should read zero forever; a non-zero value means every proposal is paying somewhere else | +| `ethlambda_validator_block_publication_delay_seconds` | Histogram | Slot start to block accepted. Its buckets are tighter than the attestation histogram's, because a block is due at the slot boundary rather than a third of the way in | +| `ethlambda_validator_beacon_node_available` | Gauge | 1 when a beacon node answered the last duty refresh or attestation | +| `ethlambda_validator_signing_duration_seconds` | Histogram | Time to sign one slot's batch, store read lock included | +| `ethlambda_validator_publication_delay_seconds` | Histogram | Slot start to attestations accepted. The headline health number: it should sit near the one-third-slot duty offset | + +Three of these distinguish failures that look alike on a dashboard but have +different causes, and the distinction is the reason they are separate series: +`attestation_failures_total` is a duty that ran and failed, +`attestation_deadline_missed_total` is one that never finished, and +`attestations_refused_total` is one deliberately not attempted. A rise in the +second points at the beacon nodes; a rise in the third points at the clock or +the duty schedule and is worth investigating even though the attestation was +correctly suppressed. + +The proposal series split the same way, plus one that is neither a success nor +a failure. `blocks_proposed_total` counts blocks a node accepted; +`block_proposal_failures_total` counts duties that produced none; +`blocks_refused_total` counts blocks deliberately not signed. Between them, +`blocks_broadcast_not_imported_total` counts blocks that did reach the network +but that the node answering could not import, which is a beacon-node fault +rather than a validator one and is why it is not folded into either. + +Two series should read zero for the life of a healthy deployment, and are the +ones worth alerting on at any non-zero value rather than on a rate: +`attestations_refused_total` and `blocks_refused_total` mean this client's own +guards caught a duty they judged unsafe, and `fee_recipient_mismatches_total` +means blocks are being proposed that pay someone else. + ## Troubleshooting ### Docker Desktop on MacOS diff --git a/docs/rpc.md b/docs/rpc.md index 2d733f770..cd0f22595 100644 --- a/docs/rpc.md +++ b/docs/rpc.md @@ -6,7 +6,12 @@ ethlambda exposes HTTP over **two independent [Axum](https://github.com/tokio-rs - **Metrics & debug server** — Prometheus metrics and heap-profiling endpoints. No store access. -All consensus API paths are versioned under the `/lean/v0` prefix. Roots are serialized as `0x`-prefixed hex strings. +Which API server a node serves follows from its store's chain tag, not from the +sub-command: `ethlambda node` serves the lean surface under `/lean/v0`, +`ethlambda beacon` serves the [Beacon API](#beacon-api-server-5052-on-ethlambda-beacon) +under `/eth/v1` and `/eth/v2`. The two are alternatives, never merged, because the +lean handlers read state variants and metadata keys a beacon directory does not +carry. Roots are serialized as `0x`-prefixed hex strings on both. ## Servers & Ports @@ -26,7 +31,7 @@ If `--api-port` and `--metrics-port` are equal, all routers are merged onto a si | `GET` | `/lean/v0/config/spec` | JSON | Protocol constants the node runs with | | `GET` | `/lean/v0/genesis` | JSON | Genesis time and validator count | | `GET` | `/lean/v0/states/finalized` | SSZ | Latest finalized `State` | -| `GET` | `/lean/v0/blocks/finalized` | SSZ | Latest finalized `SignedBlock` | +| `GET` | `/lean/v0/blocks/finalized` | SSZ, or JSON on request | Latest finalized `SignedBlock` | | `GET` | `/lean/v0/checkpoints/justified` | JSON | Latest justified `Checkpoint` | | `GET` | `/lean/v0/events` | SSE | Live stream of chain events | | `GET` | `/lean/v0/blocks/{block_id}` | JSON | Block by root or slot | @@ -211,6 +216,178 @@ curl -X POST http://127.0.0.1:5052/lean/v0/admin/aggregator \ > **Note:** Runtime toggles do **not** resubscribe gossip subnets, which are frozen at startup. A standby aggregator should boot with `--is-aggregator=true` (so subscriptions are in place), then use this endpoint to rotate duties. See the CLAUDE.md "Runtime Aggregator Toggle" notes for the operational model. +## Beacon API Server (`:5052`, on `ethlambda beacon`) + +The subset of the [Ethereum Beacon API](https://ethereum.github.io/beacon-APIs/) +that this follower can answer from its own store. It **replaces** the `/lean/v0` +surface rather than sitting beside it; a `/lean/v0` path on a beacon node is a +`404`. + +| Method | Path | Response | Description | +|--------|------|----------|-------------| +| `GET` | `/eth/v2/beacon/blocks/{block_id}` | JSON or SSZ | `SignedBeaconBlock` at `block_id` | +| `GET` | `/eth/v1/beacon/blocks/{block_id}/root` | JSON | That block's root | +| `GET` | `/eth/v1/beacon/headers/{block_id}` | JSON | `SignedBeaconBlockHeader`, plus `canonical` | +| `GET` | `/eth/v2/debug/beacon/states/{state_id}` | JSON or SSZ | `BeaconState` at `state_id` | +| `GET` | `/eth/v1/beacon/states/{state_id}/finality_checkpoints` | JSON | That state's three checkpoints | +| `GET` | `/eth/v1/beacon/genesis` | JSON | Genesis time, validators root, fork version | +| `GET` | `/eth/v1/config/spec` | JSON | The store's `Config`, plus `PRESET_BASE`, `CONFIG_NAME`, the preset and the constants (see below) | +| `GET` | `/eth/v1/node/syncing` | JSON | Head slot, sync distance, optimistic flag | +| `GET` | `/eth/v1/node/health` | *(status only)* | `200` caught up, `206` syncing | +| `GET` | `/eth/v1/node/version` | JSON | Client version string | +| `GET` | `/eth/v1/node/identity` | JSON | Peer ID and metadata only (see below) | +| `GET`, `POST` | `/eth/v1/beacon/states/{state_id}/validators` | JSON | Registry entries by index or pubkey, with status | +| `GET` | `/eth/v1/validator/duties/proposer/{epoch}` | JSON | Proposers for the head's epoch or the next | +| `POST` | `/eth/v1/validator/duties/attester/{epoch}` | JSON | Committee assignments for the given indices | +| `GET` | `/eth/v1/validator/attestation_data` | JSON | What to attest to at `slot` | +| `POST` | `/eth/v2/beacon/pool/attestations` | *(status only)* | Validate and gossip `SingleAttestation`s | +| `POST` | `/eth/v1/validator/beacon_committee_subscriptions` | *(status only)* | Aggregators' entries join their committee's subnet | +| `GET` | `/eth/v2/validator/aggregate_attestation` | JSON | The pooled votes for a data root and committee, aggregated | +| `POST` | `/eth/v2/validator/aggregate_and_proofs` | *(status only)* | Validate and gossip `SignedAggregateAndProof`s | +| `GET` | `/eth/v3/validator/blocks/{slot}` | SSZ or JSON | An unsigned block built on the head (`produceBlockV3`) | +| `POST` | `/eth/v2/beacon/blocks` | *(status only)* | Gossip and import a signed block (`publishBlockV2`, SSZ) | +| `POST` | `/eth/v1/validator/prepare_beacon_proposer` | *(status only)* | Acknowledged, not acted on (see below) | + +### Validator endpoints + +These are what `ethlambda validator` needs to attest through this node. Every +answer is computed from the fork-choice head's post-state, read off the store +the chain actor writes, so no request waits on the actor. + +- **Duties** answer for a window around the head, not any epoch. Proposer + duties read fulu's `proposer_lookahead`, which covers the head's epoch and the + next; attester duties cover the head's previous, current and next epoch, + which is as far as its shuffling is already fixed. Anything else is a `400`. + `dependent_root` follows each endpoint's v1 definition. Attester duties walk + every committee of the epoch, a full shuffle per request on mainnet. +- **`attestation_data`** follows phase0's `validator.md`: the head block, the + epoch's boundary block as target, and as source the current justified + checkpoint of the head state advanced to the slot's epoch (through fork + choice's cached `checkpoint_state`, and only when the head is in an earlier + epoch). A slot before the head, or past the wall clock, is a `400`. +- **`pool/attestations`** checks each attestation against the electra + `beacon_attestation_{subnet_id}` gossip conditions it can evaluate (clock + window, `data.index == 0`, target epoch, the voted block known and the target + its checkpoint block, committee membership, BLS signature), then gossips it on + its subnet through fanout, without subscribing. A rejected attestation comes + back in an `IndexedErrorMessage` with its position; the valid ones in the + same batch are still published. There is no seen-attestation cache. Each + accepted attestation also goes into the node's **attestation pool**, since + gossip never delivers a node its own messages. +- **`beacon_committee_subscriptions`**: each aggregator's entry makes the node + join its committee's attestation subnet until the end of that slot, so the + committee's votes from other validators reach the pool too: every + attestation this node relays has passed the `beacon_attestation_{subnet_id}` + checks first, and the ones accepted on a joined subnet are pooled. Joined + subnets are left once their slot has passed, and never appear in `attnets`. +- **`aggregate_attestation`** answers from the pool: every vote held for the + data root and committee, as electra's `Attestation` with the BLS aggregate of + their signatures. `404` when nothing is held. +- **`aggregate_and_proofs`** checks each aggregate with the same + `beacon_aggregate_and_proof` gossip conditions this node applies to its + peers' aggregates (`gossip::aggregate`), signatures included, against a + fresh seen-cache (a node never receives its own messages, so P2P's says + nothing about them). What passes is gossiped on the topic and goes into the + pool. +- **The attestation pool** holds, the best-covered per data root and + committee: votes from `pool/attestations` and the aggregator subnets, + aggregates from `aggregate_and_proofs`, and every electra gossip aggregate + P2P accepts, once all three of its signatures have verified. Gossip + aggregates are pooled on arrival, so a slot's aggregates are there when the + next slot's block is asked for. Entries more than an epoch old are dropped + once a slot. +- **`prepare_beacon_proposer`** records each validator's fee recipient, in + memory (a validator client repeats the call every epoch). +- **`blocks/{slot}`** advances the head state to the slot and asks the node's + own execution client to build on the head (`forkchoiceUpdatedV3` with + payload attributes, then `getPayloadV5`), with the proposer's fee recipient. + The body packs the pool's best aggregates (committees voting alike merged + into one EIP-7549 attestation, up to `MAX_ATTESTATIONS_ELECTRA`). A candidate + is packed only if its target root is the advanced state's own block root for + that epoch and its aggregate signature verifies against that state, so an + aggregate made on another branch cannot fail the whole block. The body also + votes the state's own `eth1_data`, and carries an empty sync aggregate and no + slashings, exits or credential changes. The state root comes from running + the block through `process_block`. The answer is fulu `BlockContents`, with + `Eth-Execution-Payload-Blinded: false`; there is no builder flow. It is a + **`503`** without a configured execution client, or when the payload carries + blobs. +- **`POST beacon/blocks`** takes SSZ `SignedBlockContents`, checks the block + is after the head and its proposer signature, then gossips it on + `beacon_block` and hands it to the chain actor to import. + +**Blobs are not supported yet.** Publishing a blob-carrying block means +computing and gossiping its data column sidecars, which this node does not do, +and peers will not import a block they cannot sample. Such payloads are refused +at production (`503`, which a validator client fails over on) and such blocks +at publication (`400`). + +`tooling/kurtosis-validator/network_params_ethlambda_beacon.yaml` points the +validator client at this node alone, so every block on that devnet is one this +node built. + +### Encoding + +**JSON is the default**; SSZ is served on `Accept: application/octet-stream`, +which is the order lighthouse serves. An `Accept` listing both is ranked by its +`q` weights. Every response carrying a fork-versioned container also sets +`Eth-Consensus-Version` to the lowercase fork name, in both encodings, since SSZ +carries no type tag. + +JSON here follows the Beacon API's own encoding, which is **not** the lean +surface's: every integer is a quoted decimal string, byte strings are +`0x`-prefixed hex, and `Uint256` is quoted decimal rather than hex. Errors are +`{"code": ..., "message": ...}`, where the lean surface uses `{"error": ...}`. + +Serving `/eth/v2/debug/beacon/states/finalized` as SSZ is what makes this client +checkpoint-syncable from itself: it is the exact path +[`checkpoint_sync.rs`](./checkpoint_sync.md) fetches from other clients. + +### `GET /eth/v1/config/spec` + +One flat object holding the network's configuration, the compiled preset, and +the specification's constants, as the Beacon API asks. Validator clients depend +on it: lighthouse's refuses a beacon node whose `PRESET_BASE` does not match +its own, and treats an absent key as a mismatch. + +The key set is lighthouse's, less gloas-only keys (this build cannot process +gloas) and three keys the specification does not define +(`GAS_LIMIT_ADJUSTMENT_FACTOR`, `RESP_TIMEOUT`, `TTFB_TIMEOUT`). Domain types +and withdrawal prefixes are `0x`-prefixed hex; `VERSIONED_HASH_VERSION_KZG` is +a decimal, as lighthouse reports it. `GENESIS_TIME` is absent: +`/eth/v1/beacon/genesis` reports it. + +The configuration keys come from the `Config` the data directory was +initialized with. Some of them (the custody and subnet counts, the +`MAX_REQUEST_*` limits, `MAX_PAYLOAD_SIZE`, the snappy message domains, +`MAXIMUM_GOSSIP_CLOCK_DISPARITY`) the node runs on compile-time constants for, +rather than reading them from `Config`. Startup refuses a network whose +`config.yaml` sets any of them to a different value (see +[`cli.md`](./cli.md)), so what this endpoint reports is what the node uses. +`CONFIG_NAME` is the stored name: a resume under a renamed `config.yaml` warns +and keeps it. + +### Accepted ids, and three that are refused + +`block_id` and `state_id` accept `head`, `finalized`, `justified`, a slot +number, and a `0x`-prefixed 32-byte root. Two cases are deliberate refusals, +both recorded in [Spec Deviations](./spec_deviations.md): + +- **`genesis`** is a `404` on either id. `Table::BlockRoots` indexes the + canonical branch above the store's anchor, and the anchor's own slot is never + written to it; a checkpoint-synced directory has no genesis block either way. +- **A `state_id` given as a `0x…` root** is a `404`: states are keyed by *block* + root here, with no reverse index. The refusal names the ids that do work. + +The **anchor's own slot** does resolve, despite being absent from that index: +the lookup falls back to the roots the store can name and accepts one only when +the block under it really sits at that slot. This matters because it is the +slot a checkpoint-syncing peer asks for right after reading the finalized +state. A slot the store holds nothing at is still a `404`. + +`/eth/v1/node/identity` reports `peer_id` and `metadata`; `enr`, +`p2p_addresses` and `discovery_addresses` are empty. + ## Metrics & Debug Server (`:5054`) | Method | Path | Response | Description | diff --git a/docs/spec_deviations.md b/docs/spec_deviations.md index 8c7c6fa0e..7ff2d24e2 100644 --- a/docs/spec_deviations.md +++ b/docs/spec_deviations.md @@ -1,9 +1,21 @@ # Spec Deviations -ethlambda diverges from the [leanSpec](https://github.com/leanEthereum/leanSpec) -reference in a few places, mainly for performance reasons. This page lists those -deviations; each will be fleshed out with rationale, implementation notes, and -trade-offs over time. +ethlambda diverges from its reference specifications in a few places. This page +lists those deviations; each will be fleshed out with rationale, implementation +notes, and trade-offs over time. + +There are two references, because this repository builds for two chains. The +lean node is measured against [leanSpec](https://github.com/leanEthereum/leanSpec), +and its deviations below are mainly for performance. The beacon node is +measured against [consensus-specs](https://github.com/ethereum/consensus-specs) +and the [Beacon API](https://github.com/ethereum/beacon-APIs), and the validator +client against consensus-specs and the +[keymanager API](https://github.com/ethereum/keymanager-APIs). Their deviations +are scope decisions rather than optimizations. + +> **Read the validator client's deviation before running it with real keys.** +> It is the only entry on this page that can cost you money rather than +> performance. ## Asynchronous signature aggregation with an early start and an early stop @@ -27,3 +39,95 @@ target-slot order as they are scanned. - **leanSpec:** `build_block` scans candidates sorted by `(target.slot, data_root)`, oldest target first, and includes the first ones that pass its filters (greedy, no scoring), re-running the scan as a fixed point when justification/finalization advances. Its proposer budget is `MAX_ATTESTATIONS_DATA` itself. - **Equivalence:** both produce a valid block. ethlambda front-loads the attestations that advance justification and finality, and within those tiers prefers the *newest* target where leanSpec takes the *oldest*; combined with the smaller default budget, an older entry can be outranked by newer ones round after round, so which votes reach peers through blocks differs even though every block stays valid. The smaller budget yields smaller blocks and lower build times. - **Upstream status:** the tiered strategy is proposed upstream as leanSpec [PR #1149](https://github.com/leanEthereum/leanSpec/pull/1149) (open at the time of writing), so this deviation may converge; the recursive-merge collapse follows leanSpec #510. + +## The validator client keeps no slashing-protection record + +`ethlambda validator` signs attestations and blocks without recording what it +has signed. It cannot detect that a previous run, or another client holding the +same keys, already signed. + +- **The specification:** [EIP-3076](https://eips.ethereum.org/EIPS/eip-3076) + defines an interchange format and the conditions a validator client must never + violate. For attestations: signing two *distinct* attestations with the same + target epoch (a double vote), and signing one whose source and target surround + or are surrounded by another's. For blocks: signing two distinct ones for the + same slot. Enforcing any of them requires a durable record, written before the + signature leaves the process. +- **ethlambda:** there is no such record and no store to hold one. This is a + deliberate scope decision for the client's first phase, not an oversight. +- **What partially covers it:** two in-memory guards, one per message kind. + `crate::attestation_guard::AttestationGuard` refuses to sign a second + conflicting attestation for a validator *within one run*, using EIP-3076's + minimal rule (the target must advance, the source must not regress). + `crate::proposal_guard::ProposalGuard` does the same for blocks, with the + minimal rule for those (the slot must strictly advance). Between them they + close the shapes reachable through ordinary operation: a backward wall-clock + step, or a duty schedule replaced mid-epoch. Both are held in memory only, so + a restart empties them and neither knows anything about any other process. +- **Why a proposer slashing is the worse of the two:** a double vote needs a + second validator's attestation to be caught. Two signed block headers for one + slot are the whole of the evidence on their own. +- **What is therefore not covered:** restarting into a state where the chain has + moved, and running these keys in two places at once. Both can produce a + slashable double vote or a double proposal, and nothing in this client will + stop them. +- **Keymanager consequences:** `DELETE /eth/v1/keystores` must return an + EIP-3076 interchange. This client returns a well-formed but empty one whose + `genesis_validators_root` is deliberately all-zero, so a conformant importer + rejects it outright rather than trusting an empty history that was never + recorded. `POST` accepts the `slashing_protection` field and ignores it, with + a server-side warning. A key migrated in or out through this API does not + carry its history. +- **Operationally:** do not run these keys in any other client while this one + runs, and treat a restart as an event that needs the same care a manual key + move would. The client warns about this at startup on every run. + +## `/eth/v1/node/identity` reports no ENR + +The endpoint's `enr`, `p2p_addresses` and `discovery_addresses` are empty +rather than populated, which is not spec-valid. + +- **ethlambda:** `get_identity` (`crates/net/rpc/src/beacon/node.rs`) reports + `peer_id` and a placeholder `metadata` block and nothing else. The ENR is + built for discv5 and owned by the P2P actor; `BuiltSwarm` hands `run_node` + only a `local_peer_id`, so serving the record means widening the + `ethlambda-p2p` surface and threading it through startup. +- **Beacon API:** `enr` is the node's base64 ENR and the two lists are its + libp2p multiaddrs. +- **Consequence:** a consumer reading `enr` to dial this node gets an empty + string rather than a record, so peer discovery through this endpoint does not + work. Everything that reads `peer_id` is unaffected. Out of scope for the + change that added the Beacon API surface; a follow-up exposes the record. + +## `block_id` cannot name `genesis`, and `state_id` cannot be a state root + +Two id forms the Beacon API defines return `404` here. + +- **`genesis`, on either id.** `Table::BlockRoots` is the slot-to-root index + every slot lookup reads, and `Store::update_checkpoints` is its only writer. + That writer computes its delta by walking from the old head to the new one + (`block_root_index_changes`, `crates/storage/src/store.rs`) and returns early + when the two are the same root, which is exactly the situation at bootstrap: + `Store::init_beacon` seeds `KEY_HEAD` with the anchor. So the anchor's own + slot is never written to that index, on either chain, and a + checkpoint-synced directory has no genesis block to serve in any case. + `BlockId::Genesis` refuses outright rather than resolving to the anchor and + calling that genesis. +- **A `state_id` given as a `0x…` root.** That id is a *state* root, and states + are stored keyed by **block** root (`Store::get_state`) with no reverse + index. Refusing is better than answering with a state that is right only when + the two roots happen to coincide. The refusal names the ids that do work. + +**The anchor's own slot is not among these.** It is missing from `BlockRoots` +for the reason above, but it is the slot `checkpoint_sync.rs` asks a peer for +immediately after reading that peer's finalized state, so a `404` there would +make this node unusable as a checkpoint-sync source for any client. +`anchored_root_at_slot` (`crates/net/rpc/src/shared/block_id.rs`) covers it: on +an index miss it tries the roots the store can name (finalized, justified, +head) and accepts one only when `Store::block_entry` confirms that root's block +really sits at the slot asked for. A slot the store holds nothing at still +answers `404`, since no candidate matches. + +This was found by running a mainnet follower and pointing a second one at its +API: before the fallback existed, the second died with `peer served no block at +the anchor slot 15265888`. diff --git a/tooling/kurtosis-validator/kurtosis.yml b/tooling/kurtosis-validator/kurtosis.yml new file mode 100644 index 000000000..ca22df8d2 --- /dev/null +++ b/tooling/kurtosis-validator/kurtosis.yml @@ -0,0 +1,4 @@ +name: github.com/lambdaclass/ethlambda_private/tooling/kurtosis-validator +description: | + A local Ethereum devnet from ethpandaops/ethereum-package, plus the + `ethlambda validator` client signing for validators no other client holds. diff --git a/tooling/kurtosis-validator/main.star b/tooling/kurtosis-validator/main.star new file mode 100644 index 000000000..620ee526a --- /dev/null +++ b/tooling/kurtosis-validator/main.star @@ -0,0 +1,309 @@ +# A devnet from ethpandaops/ethereum-package, plus `ethlambda validator`. +# +# ethereum-package has no ethlambda client type, so this wraps it: run the +# devnet exactly as the package would, then add the ethlambda validator client +# as one more service in the same enclave, pointed at the first participant's +# beacon node over the enclave network. +# +# # Whose keys the ethlambda validator signs with +# +# Keys that are in genesis but that no validator client in the devnet holds. +# `network_params.preregistered_validator_count` is set above the participants' +# total, so the genesis state registers the extra keys while ethereum-package +# generates keystores only for the participants' ranges. This package derives +# the tail range itself, from the same mnemonic, with the same tool. +# +# That exclusivity is not optional. The ethlambda validator client keeps no +# slashing-protection record, so a key held by it and by any other client would +# be signed twice every slot. +# +# # Which beacon node it talks to +# +# By default, the first participant's. With `ethlambda_beacon.enabled`, the +# package also runs `ethlambda beacon`, paired with a geth of its own over the +# Engine API, and hands the validator client both nodes, ethlambda's first: the +# client fails over per call, so everything ethlambda beacon serves goes through +# it. With `ethlambda_beacon.fallback: false` the participant's node is left +# out of the list entirely, and ethlambda beacon serves every duty alone. + +ethereum_package = import_module("github.com/ethpandaops/ethereum-package/main.star") + +# ethereum-package's own default, so the derived keys match its genesis unless +# the args override the mnemonic. +DEFAULT_MNEMONIC = "giant issue aisle success illegal bike spike question tent bar rely arctic volcano long crawl hungry vocal artwork sniff fantasy very lucky have athlete" + +KEYS_ARTIFACT = "ethlambda-validator-keys" +KEYS_MOUNT = "/keys" +METRICS_PORT = 5064 + +# What ethereum-package names the genesis files and the Engine API secret, and +# where its own clients mount them. +GENESIS_ARTIFACT = "el_cl_genesis_data" +GENESIS_MOUNT = "/network-configs" +JWT_ARTIFACT = "jwt_file" +JWT_MOUNT = "/jwt" + +BEACON_API_PORT = 5052 +BEACON_METRICS_PORT = 5054 +BEACON_P2P_PORT = 9001 +BEACON_DISCOVERY_PORT = 9000 +ENGINE_PORT = 8551 + + +def run(plan, args={}): + vc = args.get("ethlambda_validator", {}) + devnet_args = { + k: v for k, v in args.items() if k not in ("ethlambda_validator", "ethlambda_beacon") + } + + network_params = devnet_args.get("network_params", {}) + # One entry per participant ethereum-package will start, in its order: + # `count` expands one config entry into several identical participants. + keys_per_participant = [] + for participant in devnet_args.get("participants", []): + keys = participant.get( + "validator_count", network_params.get("num_validator_keys_per_node", 128) + ) + keys_per_participant += [keys] * participant.get("count", 1) + participants_total = 0 + for keys in keys_per_participant: + participants_total += keys + genesis_total = network_params.get("preregistered_validator_count", 0) + if genesis_total <= participants_total: + fail( + ( + "network_params.preregistered_validator_count ({}) must exceed the " + + "participants' validators ({}), or there are no keys left for the " + + "ethlambda validator that another client does not already hold" + ).format(genesis_total, participants_total) + ) + + first = vc.get("first_index", participants_total) + last = vc.get("last_index", genesis_total) # exclusive + if first < participants_total or last > genesis_total or first >= last: + fail( + "ethlambda_validator keys [{}, {}) must lie within the unassigned range [{}, {})".format( + first, last, participants_total, genesis_total + ) + ) + + output = ethereum_package.run(plan, devnet_args) + beacon_nodes = [output.all_participants[0].cl_context.beacon_http_url] + + beacon = args.get("ethlambda_beacon", {}) + if beacon.get("enabled", False): + ethlambda_url = launch_ethlambda_beacon( + plan, + beacon, + output.all_participants[0].cl_context.enr, + vc.get("image", "ghcr.io/lambdaclass/ethlambda:validator-local"), + ) + # With `fallback: false` the client talks to ethlambda beacon alone, so + # every duty, proposals included, has to go through it. + if beacon.get("fallback", True): + beacon_nodes = [ethlambda_url] + beacon_nodes + else: + beacon_nodes = [ethlambda_url] + name = vc.get("name", "ethlambda-vc") + if "dora" in devnet_args.get("additional_services", []): + label_in_dora(plan, first, last, name) + + mnemonic = network_params.get("preregistered_validator_keys_mnemonic", DEFAULT_MNEMONIC) + derive_keys(plan, mnemonic, first, last) + + cmd = [ + "validator", + "--beacon-nodes", + ",".join(beacon_nodes), + "--validators-dir", + KEYS_MOUNT + "/validators", + "--secrets-dir", + KEYS_MOUNT + "/raw/secrets", + "--http-address", + "0.0.0.0", + "--metrics-port", + str(METRICS_PORT), + "--graffiti", + vc.get("graffiti", name), + ] + fee_recipient = vc.get("suggested_fee_recipient", "") + if fee_recipient: + cmd += ["--suggested-fee-recipient", fee_recipient] + + plan.add_service( + name="vc-ethlambda", + config=ServiceConfig( + image=vc.get("image", "ghcr.io/lambdaclass/ethlambda:validator-local"), + cmd=cmd, + files={KEYS_MOUNT: KEYS_ARTIFACT}, + ports={ + "metrics": PortSpec( + number=METRICS_PORT, transport_protocol="TCP", application_protocol="http" + ), + }, + ), + ) + + plan.print( + "ethlambda validator signs for validators [{}, {}) via {}".format( + first, last, ", ".join(beacon_nodes) + ) + ) + return output + + +def derive_keys(plan, mnemonic, first, last): + """Derive keystores for [first, last) and the definitions file the client reads. + + The same tool and flags ethereum-package uses for its own participants, so + these are the keys its genesis registered at those indices. `--insecure` + picks a cheap KDF, which matters on startup: the real one takes seconds per + key. + """ + # One line, with the commands chained by `&&`. Kurtosis passes the script + # through in a way that breaks a multi-line `for ... do ... done` loop (a + # launch failed with `Syntax error: ";" unexpected`), so nothing here may + # depend on a newline surviving. The printf format is single-quoted so its + # `\n` reaches printf rather than the shell. + definition = ( + "printf -- '- enabled: true\\n voting_public_key: \"%s\"\\n" + + " voting_keystore_path: {m}/raw/keys/%s/voting-keystore.json\\n" + + " voting_keystore_password_path: {m}/raw/secrets/%s\\n'" + + ' "$pubkey" "$pubkey" "$pubkey" >> /out/validators/validator_definitions.yml' + ).format(m=KEYS_MOUNT) + script = " && ".join( + [ + ( + "/app/eth2-val-tools keystores --insecure --prysm-pass unused --out-loc /out/raw" + + ' --source-mnemonic "{}" --source-min {} --source-max {}' + ).format(mnemonic, first, last), + "mkdir -p /out/validators", + 'for dir in /out/raw/keys/*; do pubkey=$(basename "$dir"); ' + definition + "; done", + 'echo "derived $(ls /out/raw/keys | wc -l) keys"', + ] + ) + + plan.run_sh( + name="ethlambda-validator-key-derivation", + description="Deriving the ethlambda validator's keys [{}, {})".format(first, last), + image="protolambda/eth2-val-tools:latest", + run=script, + store=[StoreSpec(src="/out", name=KEYS_ARTIFACT)], + ) + + +def label_in_dora(plan, first, last, name): + """Name the ethlambda validator's range in Dora. + + Dora labels validators from one file ethereum-package writes, listing only + its own participants, so without this our range shows as bare indices and + nothing on screen says ethlambda signed those blocks. Dora reads the file at + startup, so it is appended to and Dora restarted; a container restart keeps + its filesystem, so the edit survives. + """ + plan.exec( + service_name="dora", + description="Naming validators [{}, {}) {} in Dora".format(first, last, name), + recipe=ExecRecipe( + command=[ + "sh", + "-c", + "echo '{}-{}: {}' >> /validator-ranges/validator-ranges.yaml".format( + first, last - 1, name + ), + ] + ), + ) + plan.stop_service(name="dora", description="Restarting Dora to load the new name") + plan.start_service(name="dora", description="Restarting Dora to load the new name") + + +def launch_ethlambda_beacon(plan, beacon, bootnode_enr, default_image): + """Run `ethlambda beacon` with a geth of its own, and return its API URL. + + The geth starts from the same genesis as the devnet's and needs no + execution-layer peers: ethlambda beacon syncs the chain from genesis and + hands geth every payload in order over the Engine API, so each one extends + a parent geth already has. That also makes geth answer VALID rather than + SYNCING, which keeps the node out of optimistic mode, and the validator + client refuses to sign against an optimistic node. + + geth is started first because the Engine API client does not retry a block + once its attempts are spent. + """ + geth = plan.add_service( + name="el-ethlambda", + config=ServiceConfig( + image=beacon.get("geth_image", "ethereum/client-go:latest"), + entrypoint=["sh", "-c"], + cmd=[ + " ".join( + [ + "geth", + "--override.genesis={}/genesis.json".format(GENESIS_MOUNT), + "--datadir=/data/geth", + "--syncmode=full", + "--authrpc.addr=0.0.0.0", + "--authrpc.port={}".format(ENGINE_PORT), + "--authrpc.vhosts=*", + "--authrpc.jwtsecret={}/jwtsecret".format(JWT_MOUNT), + "--nodiscover", + "--maxpeers=0", + ] + ) + ], + files={GENESIS_MOUNT: GENESIS_ARTIFACT, JWT_MOUNT: JWT_ARTIFACT}, + ports={ + "engine": PortSpec(number=ENGINE_PORT, transport_protocol="TCP"), + }, + ), + ) + + bootnodes = plan.render_templates( + name="ethlambda-beacon-bootnodes", + config={"bootnodes.txt": struct(template="{{.enr}}\n", data={"enr": bootnode_enr})}, + ) + + service = plan.add_service( + name="cl-ethlambda", + config=ServiceConfig( + image=beacon.get("image", default_image), + cmd=[ + "beacon", + "--network", + GENESIS_MOUNT, + "--execution-endpoint", + "http://{}:{}".format(geth.ip_address, ENGINE_PORT), + "--execution-jwt-secret", + "{}/jwtsecret".format(JWT_MOUNT), + "--bootnodes", + "/bootnodes/bootnodes.txt", + "--data-dir", + "/data", + "--http-address", + "0.0.0.0", + "--api-port", + str(BEACON_API_PORT), + "--metrics-port", + str(BEACON_METRICS_PORT), + "--gossipsub-port", + str(BEACON_P2P_PORT), + "--discovery.port", + str(BEACON_DISCOVERY_PORT), + ], + files={ + GENESIS_MOUNT: GENESIS_ARTIFACT, + JWT_MOUNT: JWT_ARTIFACT, + "/bootnodes": bootnodes, + }, + ports={ + "http": PortSpec( + number=BEACON_API_PORT, transport_protocol="TCP", application_protocol="http" + ), + "metrics": PortSpec( + number=BEACON_METRICS_PORT, transport_protocol="TCP", application_protocol="http" + ), + }, + ), + ) + return "http://{}:{}".format(service.ip_address, BEACON_API_PORT) diff --git a/tooling/kurtosis-validator/network_params.yaml b/tooling/kurtosis-validator/network_params.yaml new file mode 100644 index 000000000..fcd2abdff --- /dev/null +++ b/tooling/kurtosis-validator/network_params.yaml @@ -0,0 +1,36 @@ +# geth + lighthouse, with ethlambda as the only validator client. +# +# The lighthouse participant gets no keys, so ethereum-package registers all 128 +# genesis validators (`preregistered_validator_count`) without handing any of +# them to a client. The ethlambda validator derives and signs for all of them, +# and the keyless validator client ethereum-package still starts for lighthouse +# is removed. The chain therefore finalizes only if ethlambda works. +# +# Run from the repository root, after `make docker-build DOCKER_TAG=validator-local`: +# +# kurtosis run --enclave ethlambda-devnet tooling/kurtosis-validator \ +# --args-file tooling/kurtosis-validator/network_params.yaml +# +# Mainnet preset is mandatory, not a preference: the ethlambda binary is built +# with mainnet SSZ bounds and 32-slot epochs. +participants: + - el_type: geth + cl_type: lighthouse + validator_count: 0 + # Fulu needs a node custodying every data column. A node normally earns that + # from the validators attached to it, and this one has none. + supernode: true +network_params: + preset: mainnet + electra_fork_epoch: 0 + fulu_fork_epoch: 0 + preregistered_validator_count: 128 +additional_services: + - dora + +# Read by this package, not by ethereum-package. The key range is derived: every +# genesis validator no participant holds, which here is all of them. +ethlambda_validator: + image: ghcr.io/lambdaclass/ethlambda:validator-local + name: ethlambda-vc # shown in Dora, and used as the block graffiti + suggested_fee_recipient: "0x000000000000000000000000000000000000dEaD" diff --git a/tooling/kurtosis-validator/network_params_ethlambda_beacon.yaml b/tooling/kurtosis-validator/network_params_ethlambda_beacon.yaml new file mode 100644 index 000000000..de0c104d4 --- /dev/null +++ b/tooling/kurtosis-validator/network_params_ethlambda_beacon.yaml @@ -0,0 +1,46 @@ +# The devnet in network_params.yaml, with the ethlambda validator talking to +# `ethlambda beacon` first and the lighthouse participant second. +# +# ethlambda beacon runs with a geth of its own and serves the duties, +# attestation data, attestation pool, aggregation and block production, and the +# validator client talks to it alone (`fallback: false`), so every block on the +# chain is one ethlambda beacon built. Lighthouse stays in the network as a +# peer. Run from the repository root, after +# `make docker-build DOCKER_TAG=beacon-block-production`: +# +# kurtosis run --enclave ethlambda-beacon-devnet tooling/kurtosis-validator \ +# --args-file tooling/kurtosis-validator/network_params_ethlambda_beacon.yaml +# +# Two lighthouse participants rather than one, because ethereum-package pins a +# lone lighthouse to `--target-peers=0`, which makes it refuse every inbound +# peer, ethlambda beacon included, and lighthouse rejects the flag given twice, +# so it cannot be overridden. `--subscribe-all-subnets` because ethlambda beacon +# publishes attestations through gossipsub fanout, which reaches only peers +# subscribed to the subnet, and a lighthouse with no validators of its own joins +# almost none. ethlambda beacon aggregates for the validator client itself, so +# lighthouse needs no `--import-all-attestations`: it receives the aggregates on +# `beacon_aggregate_and_proof` and packs them into the blocks it proposes. +participants: + - el_type: geth + cl_type: lighthouse + validator_count: 0 + supernode: true + count: 2 + cl_extra_params: + - --subscribe-all-subnets +network_params: + preset: mainnet + electra_fork_epoch: 0 + fulu_fork_epoch: 0 + preregistered_validator_count: 128 +additional_services: + - dora + +ethlambda_validator: + image: ghcr.io/lambdaclass/ethlambda:beacon-block-production + name: ethlambda-vc + suggested_fee_recipient: "0x000000000000000000000000000000000000dEaD" + +ethlambda_beacon: + enabled: true + fallback: false