From 03f42f4564f4cf432a0a74eeef0c497a9491da88 Mon Sep 17 00:00:00 2001 From: Nina Date: Thu, 3 Sep 2026 14:25:25 +0100 Subject: [PATCH 1/2] kurtosis setup --- .dockerignore | 19 ++ .github/workflows/docker.yaml | 60 ++++ Cargo.lock | 2 + Cargo.toml | 1 + Dockerfile | 37 +++ crates/beacon_api/src/config.rs | 6 +- crates/beacon_state/tile/Cargo.toml | 2 +- crates/beacon_state/tile/src/stf/gloas/bid.rs | 5 +- .../tile/src/stf/gloas/envelope.rs | 2 +- crates/beacon_state/tile/src/tile.rs | 44 +-- crates/beacon_state/tile/src/tile/tests.rs | 87 +----- crates/beacon_state/tile/src/validate.rs | 2 +- crates/bin/src/main.rs | 49 ++-- crates/columns/src/tile.rs | 3 +- crates/common/src/column_util.rs | 53 +--- crates/common/src/error.rs | 1 + crates/config/Cargo.toml | 1 + crates/config/chain_spec/Cargo.toml | 1 + crates/config/chain_spec/src/lib.rs | 185 +++++++++++- crates/config/src/chain_config.rs | 6 +- crates/config/src/lib.rs | 245 +++++++++++++++- crates/e2e/src/da_capture.rs | 4 +- crates/storage/src/tile.rs | 2 +- kurtosis/README.md | 268 ++++++++---------- kurtosis/net-participant.yaml | 74 +++++ kurtosis/setup.sh | 52 +--- 26 files changed, 799 insertions(+), 412 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/docker.yaml create mode 100644 Dockerfile create mode 100644 kurtosis/net-participant.yaml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..405d95ca --- /dev/null +++ b/.dockerignore @@ -0,0 +1,19 @@ +target/ +debug/ +.git/ +.github/ +.local/ +.claude/ +.cursor/ +.memsearch/ +.idea/ +.vscode/ +docs/ +*.md + +# Test fixtures only, and large: the spec-test corpus and the LFS perf/e2e +# data are both irrelevant to `cargo build --bin silver`. +crates/beacon_state/tile/consensus-spec-tests/ +crates/e2e/data/ +kurtosis/el/ +kurtosis/*.ssz diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml new file mode 100644 index 00000000..e9360a13 --- /dev/null +++ b/.github/workflows/docker.yaml @@ -0,0 +1,60 @@ +name: docker + +on: + push: + branches: [main] + tags: ["v*"] + pull_request: + workflow_dispatch: + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 60 + + permissions: + contents: read + packages: write + + steps: + - uses: actions/checkout@v4 + with: + lfs: false + + - uses: docker/setup-buildx-action@v3 + + # A pull request builds to prove the Dockerfile still works; only + # branch and tag pushes publish. + - name: Log in to the registry + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Derive tags + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=ref,event=tag + type=sha,format=long + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/Cargo.lock b/Cargo.lock index 5f4fe04d..2dabe9da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4524,6 +4524,7 @@ version = "0.0.1" dependencies = [ "hex", "serde", + "silver_ssz", "toml", ] @@ -4591,6 +4592,7 @@ dependencies = [ "hex", "secp256k1", "serde", + "serde_yml", "silver_chain_spec", "silver_common", "toml", diff --git a/Cargo.toml b/Cargo.toml index d9e11d59..f2f87441 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -145,6 +145,7 @@ rustls = "0.23.37" serde = { version = "1.0.228", features = ["derive"] } clap = { version = "4", features = ["derive"] } serde_json = "1.0.135" +serde_yml = "0.0.12" simd-json = "0.17.0" toml = "0.8" sha2 = "0.10" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..f70b53df --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +# syntax=docker/dockerfile:1 + +# `blst`, `hashtree-rs` and `mimalloc` compile C (and hashtree, assembly), and +# both `build.rs` scripts shell out to `buf`. +FROM rust:1.91-bookworm AS builder + +ARG BUF_VERSION=1.67.0 +RUN apt-get update && \ + apt-get install -y --no-install-recommends clang && \ + rm -rf /var/lib/apt/lists/* && \ + curl -fsSL -o /usr/local/bin/buf \ + "https://github.com/bufbuild/buf/releases/download/v${BUF_VERSION}/buf-Linux-$(uname -m)" && \ + chmod +x /usr/local/bin/buf + +WORKDIR /silver +COPY . . + +# `--locked`: the committed Cargo.lock is under a publish-age cooldown (see +# the justfile), so resolution must not drift in the image. +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/silver/target \ + cargo build --release --locked --no-default-features --bin silver && \ + cp target/release/silver /usr/local/bin/silver + +FROM debian:bookworm-slim + +RUN apt-get update && \ + apt-get install -y --no-install-recommends ca-certificates && \ + rm -rf /var/lib/apt/lists/* + +COPY --from=builder /usr/local/bin/silver /usr/local/bin/silver + +# Without this the tracing subscriber writes to rolling files under LOG_PATH, +# where `docker logs` never sees them. +ENV LOG_STDOUT=1 + +ENTRYPOINT ["/usr/local/bin/silver"] diff --git a/crates/beacon_api/src/config.rs b/crates/beacon_api/src/config.rs index 36f9d3d6..a6a7bd8a 100644 --- a/crates/beacon_api/src/config.rs +++ b/crates/beacon_api/src/config.rs @@ -349,10 +349,10 @@ fn configured(spec: &SpecConfig) -> impl IntoIterator max_blobs { return Err(E::TooManyBlobCommitments { got: bid.blob_kzg_commitments.len(), diff --git a/crates/beacon_state/tile/src/stf/gloas/envelope.rs b/crates/beacon_state/tile/src/stf/gloas/envelope.rs index b0c712b4..9ad8b4a3 100644 --- a/crates/beacon_state/tile/src/stf/gloas/envelope.rs +++ b/crates/beacon_state/tile/src/stf/gloas/envelope.rs @@ -68,7 +68,7 @@ pub fn verify_execution_payload_envelope( if *ExecutionPayloadView::parent_hash(payload) != state.latest_block_hash { return Err(E::PayloadMismatch { field: "parent_hash" }); } - let expected_timestamp = rv.imm.genesis_time + state.slot * cfg.seconds_per_slot; + let expected_timestamp = rv.imm.genesis_time + state.slot * cfg.seconds_per_slot(); if ExecutionPayloadView::timestamp(payload) != expected_timestamp { return Err(E::PayloadMismatch { field: "timestamp" }); } diff --git a/crates/beacon_state/tile/src/tile.rs b/crates/beacon_state/tile/src/tile.rs index 2367c6bb..011c5f25 100644 --- a/crates/beacon_state/tile/src/tile.rs +++ b/crates/beacon_state/tile/src/tile.rs @@ -6,8 +6,8 @@ use flux::{ }; use rustc_hash::FxHashMap; use silver_beacon_state_data::{ - B256, BeaconBlockHeader, BeaconState, BeaconStateOwner, BeaconStateReader, BlobParameters, - Checkpoint, Epoch, SLOTS_PER_EPOCH, Slot, SlotState, SpecConfig, StateId, Version, + B256, BeaconBlockHeader, BeaconState, BeaconStateOwner, BeaconStateReader, Checkpoint, Epoch, + SLOTS_PER_EPOCH, Slot, SlotState, SpecConfig, StateId, }; use silver_common::{ BeaconStateEvent, BlockSource, DataColumnsEvent, DataKind, EngineResp, GossipTopic, @@ -21,7 +21,7 @@ use silver_config::{PendingBounds, SyncingConfig}; use crate::{ bls, fork_choice::{ExecutionStatus, FORK_CHOICE_NODES_HINT, ForkChoice, PayloadStatus}, - merkle, ssz_hash, stf, + ssz_hash, stf, tile::{ attestation_pool::AttestationPool, attestation_root_memo::AttestationRootMemo, fork_data_roots::ForkDataRoots, orphan_pool::PendingBlock, seen_aggregates::SeenAggregates, @@ -372,9 +372,7 @@ impl BeaconStateTile { } let gvr = self.state.state().immutable.genesis_validators_root; - let bp = - get_blob_parameters(epoch, &self.spec.blob_schedule, self.spec.default_blob_params()); - let d = compute_fork_digest(self.spec.fork_version_at(epoch), &gvr, Some(bp)); + let d = self.spec.fork_digest_at(epoch, &gvr); self.cached_fork_digest = Some((epoch, d)); d } @@ -791,39 +789,5 @@ impl Tile for BeaconStateTile { /// current_slot >= aggregate.slot`. const ATTESTATION_PROPAGATION_SLOT_RANGE: u64 = 32; -/// Spec `compute_fork_digest` (Fulu EIP-7892). `blob_parameters` is `None` -/// pre-Fulu, `Some` from Fulu onward — the active BLOB_SCHEDULE entry. -fn compute_fork_digest( - fork_version: Version, - genesis_validators_root: &B256, - blob_parameters: Option, -) -> [u8; 4] { - let base = ssz_hash::hash_tree_root_fork_data(fork_version, genesis_validators_root); - let Some(bp) = blob_parameters else { - return base[..4].try_into().unwrap(); - }; - let mut input = [0u8; 16]; - input[..8].copy_from_slice(&bp.epoch.to_le_bytes()); - input[8..].copy_from_slice(&bp.max_blobs_per_block.to_le_bytes()); - let mix = merkle::sha256(&input); - [base[0] ^ mix[0], base[1] ^ mix[1], base[2] ^ mix[2], base[3] ^ mix[3]] -} - -/// Spec `get_blob_parameters`. `schedule` must be sorted ascending by epoch; -/// `default` is `BlobParameters(ELECTRA_FORK_EPOCH, -/// MAX_BLOBS_PER_BLOCK_ELECTRA)`. -pub(crate) fn get_blob_parameters( - epoch: Epoch, - schedule: &[BlobParameters], - default: BlobParameters, -) -> BlobParameters { - for entry in schedule.iter().rev() { - if epoch >= entry.epoch { - return *entry; - } - } - default -} - #[cfg(test)] mod tests; diff --git a/crates/beacon_state/tile/src/tile/tests.rs b/crates/beacon_state/tile/src/tile/tests.rs index b88eb6b9..faae7729 100644 --- a/crates/beacon_state/tile/src/tile/tests.rs +++ b/crates/beacon_state/tile/src/tile/tests.rs @@ -19,6 +19,7 @@ use silver_ssz::ssz_view::EXECUTION_PAYLOAD_ENVELOPE_MIN; use super::*; use crate::{ fork_choice::{BlockImport, PayloadStatus}, + merkle, stf::AttestationVote, test_signing, }; @@ -2191,89 +2192,3 @@ fn finalize_promotes_every_tier_into_checkpoint_encode() { ); } } - -// ── fork_digest (standalone `compute_fork_digest`, no tile state) ── - -const FD_SENTINEL: BlobParameters = BlobParameters { epoch: u64::MAX, max_blobs_per_block: 0 }; - -fn fd_genesis_validators_root(b: u8) -> B256 { - [b; 32] -} - -fn fd_ef_schedule() -> [BlobParameters; 6] { - [ - BlobParameters { epoch: 9, max_blobs_per_block: 9 }, - BlobParameters { epoch: 100, max_blobs_per_block: 100 }, - BlobParameters { epoch: 150, max_blobs_per_block: 175 }, - BlobParameters { epoch: 200, max_blobs_per_block: 200 }, - BlobParameters { epoch: 250, max_blobs_per_block: 275 }, - BlobParameters { epoch: 300, max_blobs_per_block: 300 }, - ] -} - -fn fd_ef_digest(epoch: Epoch, fork_version: Version, gvr: &B256) -> [u8; 4] { - let schedule = fd_ef_schedule(); - let bp = get_blob_parameters(epoch, &schedule, FD_SENTINEL); - compute_fork_digest(fork_version, gvr, Some(bp)) -} - -#[test] -fn ef_compute_fork_digest_vectors() { - let v6 = [0x06, 0x00, 0x00, 0x00]; - let v61 = [0x06, 0x00, 0x00, 0x01]; - let v7 = [0x07, 0x00, 0x00, 0x00]; - let v71 = [0x07, 0x00, 0x00, 0x01]; - - let cases: &[(Epoch, Version, B256, [u8; 4])] = &[ - (9, v6, fd_genesis_validators_root(0), [0xab, 0x3a, 0xe6, 0xc8]), - (10, v6, fd_genesis_validators_root(0), [0xab, 0x3a, 0xe6, 0xc8]), - (11, v6, fd_genesis_validators_root(0), [0xab, 0x3a, 0xe6, 0xc8]), - (99, v6, fd_genesis_validators_root(0), [0xab, 0x3a, 0xe6, 0xc8]), - (100, v6, fd_genesis_validators_root(0), [0xdf, 0x67, 0x55, 0x7b]), - (101, v6, fd_genesis_validators_root(0), [0xdf, 0x67, 0x55, 0x7b]), - (150, v6, fd_genesis_validators_root(0), [0x8a, 0xb3, 0x8b, 0x59]), - (199, v6, fd_genesis_validators_root(0), [0x8a, 0xb3, 0x8b, 0x59]), - (200, v6, fd_genesis_validators_root(0), [0xd9, 0xb8, 0x14, 0x38]), - (201, v6, fd_genesis_validators_root(0), [0xd9, 0xb8, 0x14, 0x38]), - (250, v6, fd_genesis_validators_root(0), [0x4e, 0xf3, 0x2a, 0x62]), - (299, v6, fd_genesis_validators_root(0), [0x4e, 0xf3, 0x2a, 0x62]), - (300, v6, fd_genesis_validators_root(0), [0xca, 0x10, 0x0d, 0x64]), - (301, v6, fd_genesis_validators_root(0), [0xca, 0x10, 0x0d, 0x64]), - (9, v6, fd_genesis_validators_root(1), [0x89, 0x67, 0x11, 0x11]), - (9, v6, fd_genesis_validators_root(2), [0xf4, 0x9b, 0x0e, 0x24]), - (9, v6, fd_genesis_validators_root(3), [0x86, 0x54, 0x4e, 0x4f]), - (100, v6, fd_genesis_validators_root(1), [0xfd, 0x3a, 0xa2, 0xa2]), - (100, v6, fd_genesis_validators_root(2), [0x80, 0xc6, 0xbd, 0x97]), - (100, v6, fd_genesis_validators_root(3), [0xf2, 0x09, 0xfd, 0xfc]), - (9, v61, fd_genesis_validators_root(0), [0x30, 0xf8, 0xc2, 0x5b]), - (9, v7, fd_genesis_validators_root(0), [0x04, 0x32, 0xf5, 0xa9]), - (9, v71, fd_genesis_validators_root(0), [0x6e, 0x69, 0xa6, 0x71]), - (100, v61, fd_genesis_validators_root(0), [0x44, 0xa5, 0x71, 0xe8]), - (100, v7, fd_genesis_validators_root(0), [0x70, 0x6f, 0x46, 0x1a]), - (100, v71, fd_genesis_validators_root(0), [0x1a, 0x34, 0x15, 0xc2]), - ]; - - for (epoch, fv, g, expected) in cases { - let got = fd_ef_digest(*epoch, *fv, g); - assert_eq!( - got, *expected, - "epoch={epoch} fv={fv:02x?} gvr[0]={:#04x}: got {got:02x?}, want {expected:02x?}", - g[0] - ); - } -} - -#[test] -fn mainnet_fulu_fork_digest_419072() { - let mainnet_gvr: B256 = [ - 0x4b, 0x36, 0x3d, 0xb9, 0x4e, 0x28, 0x61, 0x20, 0xd7, 0x6e, 0xb9, 0x05, 0x34, 0x0f, 0xdd, - 0x4e, 0x54, 0xbf, 0xe9, 0xf0, 0x6b, 0xf3, 0x3f, 0xf6, 0xcf, 0x5a, 0xd2, 0x7f, 0x51, 0x1b, - 0xfe, 0x95, - ]; - let spec = SpecConfig::mainnet(); - let bp = get_blob_parameters(419072, &spec.blob_schedule, spec.default_blob_params()); - assert_eq!(bp, BlobParameters { epoch: 419072, max_blobs_per_block: 21 }); - - let digest = compute_fork_digest(spec.fulu_fork_version, &mainnet_gvr, Some(bp)); - assert_eq!(digest, [0x8c, 0x9f, 0x62, 0xfe]); -} diff --git a/crates/beacon_state/tile/src/validate.rs b/crates/beacon_state/tile/src/validate.rs index 1c8f5b27..fa67c23f 100644 --- a/crates/beacon_state/tile/src/validate.rs +++ b/crates/beacon_state/tile/src/validate.rs @@ -175,7 +175,7 @@ pub fn validate_execution_payload( }); } - let expected_timestamp = view.imm.genesis_time + block_slot * cfg.seconds_per_slot; + let expected_timestamp = view.imm.genesis_time + block_slot * cfg.seconds_per_slot(); let got_timestamp = ExecutionPayloadView::timestamp(payload); if got_timestamp != expected_timestamp { return Err(ExecutionPayloadError::TimestampMismatch { diff --git a/crates/bin/src/main.rs b/crates/bin/src/main.rs index d7c56850..f44b5cf4 100644 --- a/crates/bin/src/main.rs +++ b/crates/bin/src/main.rs @@ -1,4 +1,10 @@ -use std::{error::Error, net::IpAddr, str::FromStr, sync::Arc, time::Instant}; +use std::{ + error::Error, + net::IpAddr, + str::FromStr, + sync::Arc, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; use flux::{ tile::{TileConfig, attach_tile}, @@ -34,6 +40,12 @@ static GLOBAL: MiMalloc = MiMalloc; #[global_allocator] static GLOBAL: CountingAllocator = CountingAllocator(MiMalloc); +const MAINNET_BOOTNODES: [&str; 3] = [ + "enr:-Ku4QG-2_Md3sZIAUebGYT6g0SMskIml77l6yR-M_JXc-UdNHCmHQeOiMLbylPejyJsdAPsTHJyjJB2sYGDLe0dn8uYBh2F0dG5ldHOIAAAAAAAAAACEZXRoMpC1MD8qAAAAAP__________gmlkgnY0gmlwhBLY-NyJc2VjcDI1NmsxoQORcM6e19T1T9gi7jxEZjk_sjVLGFscUNqAY9obgZaxbIN1ZHCCIyg", + "enr:-Le4QLHZDSvkLfqgEo8IWGG96h6mxwe_PsggC20CL3neLBjfXLGAQFOPSltZ7oP6ol54OvaNqO02Rnvb8YmDR274uq8ChGV0aDKQtTA_KgEAAAAAIgEAAAAAAIJpZIJ2NIJpcISLosQxg2lwNpAqAX4AAAAAAPA8kv_-ax65iXNlY3AyNTZrMaEDBJj7_dLFACaxBfaI8KZTh_SSJUjhyAyfshimvSqo22WDdWRwgiMohHVkcDaCI4I", + "enr:-Ku4QP2xDnEtUXIjzJ_DhlCRN9SN99RYQPJL92TMlSv7U5C1YnYLjwOQHgZIUXw6c-BvRg2Yc2QsZxxoS_pPRVe0yK8Bh2F0dG5ldHOIAAAAAAAAAACEZXRoMpD1pf1CAAAAAP__________gmlkgnY0gmlwhBLf22SJc2VjcDI1NmsxoQMeFF5GrS7UZpAH2Ly84aLK-TyvH-dRo0JM1i8yygH50YN1ZHCCJxA", +]; + fn main() -> Result<(), Box> { let _tracing = initialise_tracing_log("silver", 10, None, false); tracing::debug!("start"); @@ -106,6 +118,7 @@ fn main() -> Result<(), Box> { tracing::info!(enr = local_enr.to_base64(), "local ENR on startup"); let chain_config = config.chain_config(); + sleep_until_genesis(chain_config.genesis_unix_secs); let ticker = SlotTicker::new( chain_config.genesis_unix_secs, chain_config.slot_duration(), @@ -157,23 +170,9 @@ fn main() -> Result<(), Box> { let now = Instant::now(); - let bootnodes = if !config.chain_config().bootstrap_enrs.is_empty() { - config.chain_config().bootstrap_enrs.clone() - } else { - vec![ - Enr::from_str( - "enr:-Ku4QG-2_Md3sZIAUebGYT6g0SMskIml77l6yR-M_JXc-UdNHCmHQeOiMLbylPejyJsdAPsTHJyjJB2sYGDLe0dn8uYBh2F0dG5ldHOIAAAAAAAAAACEZXRoMpC1MD8qAAAAAP__________gmlkgnY0gmlwhBLY-NyJc2VjcDI1NmsxoQORcM6e19T1T9gi7jxEZjk_sjVLGFscUNqAY9obgZaxbIN1ZHCCIyg", - )?, - Enr::from_str( - "enr:-Le4QLHZDSvkLfqgEo8IWGG96h6mxwe_PsggC20CL3neLBjfXLGAQFOPSltZ7oP6ol54OvaNqO02Rnvb8YmDR274uq8ChGV0aDKQtTA_KgEAAAAAIgEAAAAAAIJpZIJ2NIJpcISLosQxg2lwNpAqAX4AAAAAAPA8kv_-ax65iXNlY3AyNTZrMaEDBJj7_dLFACaxBfaI8KZTh_SSJUjhyAyfshimvSqo22WDdWRwgiMohHVkcDaCI4I", - )?, - Enr::from_str( - "enr:-Ku4QP2xDnEtUXIjzJ_DhlCRN9SN99RYQPJL92TMlSv7U5C1YnYLjwOQHgZIUXw6c-BvRg2Yc2QsZxxoS_pPRVe0yK8Bh2F0dG5ldHOIAAAAAAAAAACEZXRoMpD1pf1CAAAAAP__________gmlkgnY0gmlwhBLf22SJc2VjcDI1NmsxoQMeFF5GrS7UZpAH2Ly84aLK-TyvH-dRo0JM1i8yygH50YN1ZHCCJxA", - )?, - ] - }; + let bootnodes = &config.chain_config().bootstrap_enrs; - for enr in &bootnodes { + for enr in bootnodes { discv5.add_enr(enr, now); } @@ -341,6 +340,12 @@ fn load_config() -> Result { .with_discovery_port(31133) .with_quic_port(31123); + let bootnodes = MAINNET_BOOTNODES + .iter() + .map(|enr| Enr::from_str(enr).expect("hardcoded mainnet bootnode ENR")) + .collect(); + config = config.with_bootstrap_enrs(bootnodes); + if let Some(ckpt) = args.get(1).filter(|a| !a.starts_with("--")) { config = config.with_checkpoint(ckpt.to_string()); if let Some(pk) = args.get(2).filter(|a| !a.starts_with("--")) { @@ -369,6 +374,16 @@ fn load_config() -> Result { Ok(config) } +fn sleep_until_genesis(genesis_unix_secs: u64) { + let genesis = UNIX_EPOCH + Duration::from_secs(genesis_unix_secs); + let Ok(remaining) = genesis.duration_since(SystemTime::now()) else { + return; + }; + + tracing::info!("waiting {}s for genesis at {genesis_unix_secs}", remaining.as_secs()); + std::thread::sleep(remaining); +} + /// List form for CLI flags whose config counterpart is a TOML array. A comma /// is neither valid in a `SocketAddr` nor sane in a socket path, so it can /// never be part of one value. diff --git a/crates/columns/src/tile.rs b/crates/columns/src/tile.rs index e61fd608..ab71f994 100644 --- a/crates/columns/src/tile.rs +++ b/crates/columns/src/tile.rs @@ -96,7 +96,8 @@ impl DataColumnsTile { engine_resp_consumer: TRandomAccess, el_column_producer: TProducer, ) -> Self { - let epoch_duration = Duration::from_secs(spec.seconds_per_slot) * SLOTS_PER_EPOCH as u32; + let epoch_duration = + Duration::from_millis(spec.slot_duration_ms()) * SLOTS_PER_EPOCH as u32; Self { consumers, spec, diff --git a/crates/common/src/column_util.rs b/crates/common/src/column_util.rs index 342cdb47..5c28ad40 100644 --- a/crates/common/src/column_util.rs +++ b/crates/common/src/column_util.rs @@ -2,7 +2,7 @@ use blst::{BLST_ERROR, min_pk::PublicKey}; use flux_profiler::timed; -use silver_beacon_state_data::{SLOTS_PER_EPOCH, SpecConfig}; +use silver_beacon_state_data::SLOTS_PER_EPOCH; use silver_common::{ merkle::{ B256, MerkleStack, hash_concat, hash_fixed_bytes, hash_list, is_valid_merkle_branch, @@ -19,29 +19,6 @@ use silver_common::{ /// EIP-4844 versioned-hash version byte (`VERSIONED_HASH_VERSION_KZG`). const VERSIONED_HASH_VERSION_KZG: u8 = 0x01; -/// Spec `compute_fork_digest` (EIP-7892) for the fork active at `slot`. Each -/// served RPC chunk must carry the context fork-digest for its own slot's -/// fork: a range/root request can span a -/// fork boundary, and post-fork the retention window still holds pre-fork -/// blocks/columns. -#[timed] -pub fn fork_digest_at(spec: &SpecConfig, slot: u64, genesis_validators_root: &B256) -> [u8; 4] { - let epoch = slot / SLOTS_PER_EPOCH; - let base = hash_tree_root_fork_data(spec.fork_version_at(epoch), genesis_validators_root); - let bp = spec - .blob_schedule - .iter() - .rev() - .find(|e| epoch >= e.epoch) - .copied() - .unwrap_or_else(|| spec.default_blob_params()); - let mut input = [0u8; 16]; - input[..8].copy_from_slice(&bp.epoch.to_le_bytes()); - input[8..].copy_from_slice(&bp.max_blobs_per_block.to_le_bytes()); - let mix = sha256(&input); - [base[0] ^ mix[0], base[1] ^ mix[1], base[2] ^ mix[2], base[3] ^ mix[3]] -} - /// Depth of the Merkle branch attaching `kzg_commitments` to /// `BeaconBlockBody.body_root`. Per Fulu spec: `floor(log2(gindex))` /// where `gindex` is the generalised index of `blob_kzg_commitments` @@ -440,34 +417,6 @@ mod tests { assert_eq!(body_root(&body), body_root(&body)); } - /// Known-answer for the served context fork-digest: mainnet Fulu at epoch - /// 419072 = `8c9f62fe` (same vector the beacon-state tile's - /// `compute_fork_digest` is checked against — guards this crate's copy). - #[test] - fn fork_digest_at_matches_mainnet_fulu() { - let mainnet_gvr: B256 = [ - 0x4b, 0x36, 0x3d, 0xb9, 0x4e, 0x28, 0x61, 0x20, 0xd7, 0x6e, 0xb9, 0x05, 0x34, 0x0f, - 0xdd, 0x4e, 0x54, 0xbf, 0xe9, 0xf0, 0x6b, 0xf3, 0x3f, 0xf6, 0xcf, 0x5a, 0xd2, 0x7f, - 0x51, 0x1b, 0xfe, 0x95, - ]; - let spec = SpecConfig::mainnet(); - assert_eq!(fork_digest_at(&spec, 419072 * SLOTS_PER_EPOCH, &mainnet_gvr), [ - 0x8c, 0x9f, 0x62, 0xfe - ],); - } - - /// A range/root request spanning the Gloas boundary must tag pre- and - /// post-fork chunks with different context digests (the bug this fixes). - #[test] - fn fork_digest_at_differs_across_gloas_boundary() { - let gvr = [7u8; 32]; - let mut spec = SpecConfig::mainnet(); - spec.gloas_fork_epoch = 10; - let pre = fork_digest_at(&spec, 9 * SLOTS_PER_EPOCH, &gvr); - let post = fork_digest_at(&spec, 10 * SLOTS_PER_EPOCH, &gvr); - assert_ne!(pre, post, "fulu-era and gloas-era digests must differ"); - } - /// Build a synthetic sidecar byte buffer with the given index and /// `n` parallel-list elements. Header + body_root + inclusion_proof /// are zero-filled; useful only for shape-check exercises. diff --git a/crates/common/src/error.rs b/crates/common/src/error.rs index 107c1326..d61ac11b 100644 --- a/crates/common/src/error.rs +++ b/crates/common/src/error.rs @@ -36,6 +36,7 @@ pub enum Error { EnrError(#[from] crate::enr::Error), KeyError(#[from] secp256k1::Error), InvalidStreamProtocol, + ConfigError(String), } impl fmt::Display for Error { diff --git a/crates/config/Cargo.toml b/crates/config/Cargo.toml index 94706ed7..6be5a89c 100644 --- a/crates/config/Cargo.toml +++ b/crates/config/Cargo.toml @@ -11,6 +11,7 @@ silver_chain_spec.workspace = true secp256k1.workspace = true serde.workspace = true toml.workspace = true +serde_yml.workspace = true hex.workspace = true tracing.workspace = true diff --git a/crates/config/chain_spec/Cargo.toml b/crates/config/chain_spec/Cargo.toml index 9478cba8..154ce805 100644 --- a/crates/config/chain_spec/Cargo.toml +++ b/crates/config/chain_spec/Cargo.toml @@ -8,6 +8,7 @@ version.workspace = true [dependencies] serde.workspace = true hex.workspace = true +silver_ssz.workspace = true [dev-dependencies] toml.workspace = true diff --git a/crates/config/chain_spec/src/lib.rs b/crates/config/chain_spec/src/lib.rs index 5992c4d2..7f18b1cb 100644 --- a/crates/config/chain_spec/src/lib.rs +++ b/crates/config/chain_spec/src/lib.rs @@ -1,4 +1,8 @@ use serde::{Deserialize, Deserializer, Serialize}; +use silver_ssz::{ + merkle::{B256, sha256}, + ssz_hash::hash_tree_root_fork_data, +}; const fn default_u64() -> u64 { V @@ -188,9 +192,14 @@ pub struct SpecConfig { pub deposit_network_id: u64, #[serde(default = "default_deposit_contract_address", with = "hex_0x")] pub deposit_contract_address: [u8; 20], - /// Seconds per beacon chain slot. 12 mainnet; testnets may use shorter. - #[serde(default = "default_u64::<12>")] - pub seconds_per_slot: u64, + /// Slot length, in whichever of the two spellings the network's config + /// uses: `SECONDS_PER_SLOT` is the older one, `SLOT_DURATION_MS` the one + /// ethereum-package now generates. Read them through `seconds_per_slot()` + /// and `slot_duration_ms()`, which agree whichever was written. + #[serde(default)] + pub seconds_per_slot: Option, + #[serde(default)] + pub slot_duration_ms: Option, /// Eth1 following parameters. Silver follows no eth1 deposit stream, so /// nothing here is used; they are carried so a client can tell which eth1 /// chain the network it joined votes on. @@ -345,6 +354,42 @@ impl SpecConfig { } } + pub fn seconds_per_slot(&self) -> u64 { + self.seconds_per_slot.unwrap_or_else(|| self.slot_duration_ms.unwrap_or(12_000) / 1000) + } + + pub fn slot_duration_ms(&self) -> u64 { + self.slot_duration_ms.unwrap_or_else(|| self.seconds_per_slot.unwrap_or(12) * 1000) + } + + /// Spec `get_blob_parameters`: the highest `blob_schedule` entry at or + /// below `epoch`, and `default_blob_params` when none covers it. + pub fn blob_params_at(&self, epoch: u64) -> BlobParameters { + for entry in self.blob_schedule.iter().rev() { + if epoch >= entry.epoch { + return *entry; + } + } + self.default_blob_params() + } + + /// Spec `compute_fork_digest`, including the Fulu EIP-7892 mix of the + /// active `BLOB_SCHEDULE` entry. Pre-Fulu the digest is the plain + /// fork-data root, which no blob schedule can alter. + pub fn fork_digest_at(&self, epoch: u64, genesis_validators_root: &B256) -> [u8; 4] { + let base = hash_tree_root_fork_data(self.fork_version_at(epoch), genesis_validators_root); + if self.fork_at(epoch) < ForkName::Fulu { + return base[..4].try_into().unwrap(); + } + + let bp = self.blob_params_at(epoch); + let mut input = [0u8; 16]; + input[..8].copy_from_slice(&bp.epoch.to_le_bytes()); + input[8..].copy_from_slice(&bp.max_blobs_per_block.to_le_bytes()); + let mix = sha256(&input); + [base[0] ^ mix[0], base[1] ^ mix[1], base[2] ^ mix[2], base[3] ^ mix[3]] + } + pub fn fork_at(&self, epoch: u64) -> ForkName { if epoch >= self.gloas_fork_epoch { ForkName::Gloas @@ -527,7 +572,8 @@ impl SpecConfig { deposit_chain_id: 560048, deposit_network_id: 560048, deposit_contract_address: default_deposit_contract_address(), - seconds_per_slot: 12, + seconds_per_slot: None, + slot_duration_ms: None, seconds_per_eth1_block: 12, eth1_follow_distance: 2048, // Identical to mainnet preset / config below this line. @@ -584,7 +630,8 @@ impl SpecConfig { deposit_chain_id: 1, deposit_network_id: 1, deposit_contract_address: default_deposit_contract_address(), - seconds_per_slot: 12, + seconds_per_slot: None, + slot_duration_ms: None, seconds_per_eth1_block: 14, eth1_follow_distance: 2048, shard_committee_period: 256, @@ -906,4 +953,132 @@ mod tests { let spec = SpecConfig { config_name: Some("Mainnet".to_owned()), ..SpecConfig::mainnet() }; assert_eq!(spec.misnamed_network(), Some("mainnet")); } + + /// EF `compute_fork_digest` vectors: the schedule entry active at each + /// epoch mixes into the digest, so a BPO boundary changes it while the + /// fork version stays put. + #[test] + fn ef_fork_digest_vectors() { + let v6 = [0x06, 0x00, 0x00, 0x00]; + let v61 = [0x06, 0x00, 0x00, 0x01]; + let v7 = [0x07, 0x00, 0x00, 0x00]; + let v71 = [0x07, 0x00, 0x00, 0x01]; + + let cases: &[(u64, [u8; 4], B256, [u8; 4])] = &[ + (9, v6, [0; 32], [0xab, 0x3a, 0xe6, 0xc8]), + (10, v6, [0; 32], [0xab, 0x3a, 0xe6, 0xc8]), + (11, v6, [0; 32], [0xab, 0x3a, 0xe6, 0xc8]), + (99, v6, [0; 32], [0xab, 0x3a, 0xe6, 0xc8]), + (100, v6, [0; 32], [0xdf, 0x67, 0x55, 0x7b]), + (101, v6, [0; 32], [0xdf, 0x67, 0x55, 0x7b]), + (150, v6, [0; 32], [0x8a, 0xb3, 0x8b, 0x59]), + (199, v6, [0; 32], [0x8a, 0xb3, 0x8b, 0x59]), + (200, v6, [0; 32], [0xd9, 0xb8, 0x14, 0x38]), + (201, v6, [0; 32], [0xd9, 0xb8, 0x14, 0x38]), + (250, v6, [0; 32], [0x4e, 0xf3, 0x2a, 0x62]), + (299, v6, [0; 32], [0x4e, 0xf3, 0x2a, 0x62]), + (300, v6, [0; 32], [0xca, 0x10, 0x0d, 0x64]), + (301, v6, [0; 32], [0xca, 0x10, 0x0d, 0x64]), + (9, v6, [1; 32], [0x89, 0x67, 0x11, 0x11]), + (9, v6, [2; 32], [0xf4, 0x9b, 0x0e, 0x24]), + (9, v6, [3; 32], [0x86, 0x54, 0x4e, 0x4f]), + (100, v6, [1; 32], [0xfd, 0x3a, 0xa2, 0xa2]), + (100, v6, [2; 32], [0x80, 0xc6, 0xbd, 0x97]), + (100, v6, [3; 32], [0xf2, 0x09, 0xfd, 0xfc]), + (9, v61, [0; 32], [0x30, 0xf8, 0xc2, 0x5b]), + (9, v7, [0; 32], [0x04, 0x32, 0xf5, 0xa9]), + (9, v71, [0; 32], [0x6e, 0x69, 0xa6, 0x71]), + (100, v61, [0; 32], [0x44, 0xa5, 0x71, 0xe8]), + (100, v7, [0; 32], [0x70, 0x6f, 0x46, 0x1a]), + (100, v71, [0; 32], [0x1a, 0x34, 0x15, 0xc2]), + ]; + + for (epoch, fork_version, gvr, expected) in cases { + let spec = SpecConfig { + fulu_fork_epoch: 0, + fulu_fork_version: *fork_version, + gloas_fork_epoch: unscheduled(), + blob_schedule: vec![ + BlobParameters { epoch: 9, max_blobs_per_block: 9 }, + BlobParameters { epoch: 100, max_blobs_per_block: 100 }, + BlobParameters { epoch: 150, max_blobs_per_block: 175 }, + BlobParameters { epoch: 200, max_blobs_per_block: 200 }, + BlobParameters { epoch: 250, max_blobs_per_block: 275 }, + BlobParameters { epoch: 300, max_blobs_per_block: 300 }, + ], + ..SpecConfig::mainnet() + }; + let got = spec.fork_digest_at(*epoch, gvr); + assert_eq!( + got, *expected, + "epoch={epoch} fork_version={fork_version:02x?} gvr[0]={:#04x}", + gvr[0] + ); + } + } + + const MAINNET_GVR: B256 = [ + 0x4b, 0x36, 0x3d, 0xb9, 0x4e, 0x28, 0x61, 0x20, 0xd7, 0x6e, 0xb9, 0x05, 0x34, 0x0f, 0xdd, + 0x4e, 0x54, 0xbf, 0xe9, 0xf0, 0x6b, 0xf3, 0x3f, 0xf6, 0xcf, 0x5a, 0xd2, 0x7f, 0x51, 0x1b, + 0xfe, 0x95, + ]; + + #[test] + fn mainnet_fulu_fork_digest_at_419072() { + let spec = SpecConfig::mainnet(); + assert_eq!(spec.blob_params_at(419072), BlobParameters { + epoch: 419072, + max_blobs_per_block: 21 + }); + assert_eq!(spec.fork_digest_at(419072, &MAINNET_GVR), [0x8c, 0x9f, 0x62, 0xfe]); + } + + /// A range/root request spanning the Gloas boundary must tag pre- and + /// post-fork chunks with different context digests. + #[test] + fn fork_digest_differs_across_the_gloas_boundary() { + let spec = SpecConfig { gloas_fork_epoch: 10, ..SpecConfig::mainnet() }; + assert_ne!(spec.fork_digest_at(9, &MAINNET_GVR), spec.fork_digest_at(10, &MAINNET_GVR)); + } + + /// Pre-Fulu the digest is the plain fork-data root: EIP-7892's blob mix + /// starts at Fulu, so no schedule entry may reach an earlier epoch. + #[test] + fn a_pre_fulu_digest_carries_no_blob_mix() { + let spec = SpecConfig { fulu_fork_epoch: 100, ..SpecConfig::mainnet() }; + let epoch = 99; + let expected: [u8; 4] = hash_tree_root_fork_data(spec.fork_version_at(epoch), &MAINNET_GVR) + [..4] + .try_into() + .unwrap(); + assert_eq!(spec.fork_digest_at(epoch, &MAINNET_GVR), expected); + } + + /// ethereum-package's generator writes `SLOT_DURATION_MS` and no + /// `SECONDS_PER_SLOT`; older configs write only the latter. Both spellings + /// have to reach the ticker, or a devnet's slot length silently becomes + /// mainnet's. + #[test] + fn either_spelling_of_the_slot_length_is_read() { + let ms: SpecConfig = toml::from_str("SLOT_DURATION_MS = 6000").unwrap(); + assert_eq!(ms.seconds_per_slot(), 6); + assert_eq!(ms.slot_duration_ms(), 6000); + + let secs: SpecConfig = toml::from_str("SECONDS_PER_SLOT = 6").unwrap(); + assert_eq!(secs.seconds_per_slot(), 6); + assert_eq!(secs.slot_duration_ms(), 6000); + + let neither: SpecConfig = toml::from_str("").unwrap(); + assert_eq!(neither.seconds_per_slot(), 12); + assert_eq!(neither.slot_duration_ms(), 12_000); + } + + /// A sub-second slot has no whole-second spelling, so the millisecond + /// value is the one the ticker must use. + #[test] + fn a_sub_second_slot_survives_as_milliseconds() { + let spec: SpecConfig = toml::from_str("SLOT_DURATION_MS = 500").unwrap(); + assert_eq!(spec.slot_duration_ms(), 500); + assert_eq!(spec.seconds_per_slot(), 0, "no whole second to report"); + } } diff --git a/crates/config/src/chain_config.rs b/crates/config/src/chain_config.rs index f9041157..2c71cd1d 100644 --- a/crates/config/src/chain_config.rs +++ b/crates/config/src/chain_config.rs @@ -17,6 +17,8 @@ pub struct ChainConfig { #[serde(default)] pub checkpoint_pubkeys_file: Option, #[serde(default)] + pub spec_file: Option, + #[serde(default)] pub bootstrap_enrs: Vec, #[serde(default)] pub spec: SpecConfig, @@ -29,6 +31,7 @@ impl Default for ChainConfig { prepare_payload_lookahead_millis: 4000, checkpoint_file: None, checkpoint_pubkeys_file: None, + spec_file: None, bootstrap_enrs: vec![], spec: SpecConfig::mainnet(), } @@ -36,9 +39,8 @@ impl Default for ChainConfig { } impl ChainConfig { - /// Single source of truth: `spec.seconds_per_slot` × 1s. pub fn slot_duration(&self) -> Duration { - Duration::from_secs(self.spec.seconds_per_slot) + Duration::from_millis(self.spec.slot_duration_ms()) } pub fn playload_lookahead(&self) -> Duration { diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 142d8880..acdd43fc 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -1,4 +1,6 @@ use std::{ + fs::File, + io::Read, net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, SocketAddrV4, SocketAddrV6}, time::{Duration, SystemTime, UNIX_EPOCH}, }; @@ -9,9 +11,10 @@ pub use engine_config::EngineConfig; pub use peer_score_params::ScoreParams; use secp256k1::PublicKey; use serde::{Deserialize, Serialize}; +use silver_chain_spec::ForkName; use silver_common::{ - Enr, Error, GossipTopic, Identify, Keypair, NodeId, PeerId, SAMPLES_PER_SLOT, SUBNETS_PER_NODE, - StreamProtocol, + Enr, Error, GossipTopic, Identify, Keypair, NodeId, PeerId, SAMPLES_PER_SLOT, SLOTS_PER_EPOCH, + SUBNETS_PER_NODE, StreamProtocol, }; pub use syncing_config::{PendingBounds, SyncingConfig}; @@ -37,6 +40,15 @@ const fn default_u64() -> u64 { V } +/// The mainnet Fulu digest, for the default run that names no config file. +const fn default_mainnet_fork_digest() -> [u8; 4] { + [0x8c, 0x9f, 0x62, 0xfe] +} + +const fn default_fork_version() -> [u8; 4] { + [6, 0, 0, 0] +} + fn default_beacon_api_bind() -> Vec { vec!["0.0.0.0:5051".into()] } @@ -79,13 +91,28 @@ fn default_gossip_topics() -> Vec { ] } +/// `BeaconState`'s first two fields are fixed-size, so its `genesis_time` +/// and `genesis_validators_root` sit at the head of any anchor state's SSZ. +fn anchor_genesis(path: &str) -> Result<(u64, [u8; 32]), Error> { + let mut head = [0u8; 40]; + let mut file = File::open(path)?; + file.read_exact(&mut head).map_err(|e| { + Error::ConfigError(format!("anchor state {path} is too short to read its genesis: {e}")) + })?; + let genesis_unix_secs = u64::from_le_bytes(head[..8].try_into().unwrap()); + Ok((genesis_unix_secs, head[8..].try_into().unwrap())) +} + #[derive(Debug, Deserialize, Serialize)] pub struct Config { #[serde(with = "hex::serde")] secret_key: [u8; 32], - #[serde(with = "hex::serde")] + /// Both of these are overwritten from `spec` and the anchor state's + /// `genesis_validators_root` whenever `checkpoint_file` names one, so a + /// literal here applies only to a run with no anchor. + #[serde(default = "default_mainnet_fork_digest", with = "hex::serde")] fork_digest: [u8; 4], - #[serde(with = "hex::serde")] + #[serde(default = "default_fork_version", with = "hex::serde")] next_fork_version: [u8; 4], // FAR_FUTURE (u64::MAX) by default — exceeds TOML's i64 range, so configs // for a network with no scheduled next fork simply omit it. @@ -204,7 +231,8 @@ impl Config { /// external IP, ports, secret key) here, so no source edits are needed. pub fn from_file>(path: P) -> Result { let text = std::fs::read_to_string(path)?; - let config: Self = toml::from_str(&text)?; + let mut config: Self = toml::from_str(&text)?; + config.resolve_from_network_files()?; let spec = &config.chain_config.spec; if let Some(network) = spec.misnamed_network() { @@ -219,6 +247,50 @@ impl Config { Ok(config) } + fn resolve_from_network_files(&mut self) -> Result<(), Error> { + if let Some(path) = &self.chain_config.spec_file { + let text = std::fs::read_to_string(path)?; + self.chain_config.spec = serde_yml::from_str(&text).map_err(|e| { + Error::ConfigError(format!("spec_file {path} is not a spec config: {e}")) + })?; + } + + let anchor_gvr = match &self.chain_config.checkpoint_file { + Some(anchor) => { + let (genesis_unix_secs, gvr) = anchor_genesis(anchor)?; + self.chain_config.genesis_unix_secs = genesis_unix_secs; + Some(gvr) + } + None => None, + }; + + let epoch = self.slots_since_genesis() / SLOTS_PER_EPOCH; + let spec = &self.chain_config.spec; + + let fork = spec.fork_at(epoch); + if fork < ForkName::Fulu { + return Err(Error::ConfigError(format!( + "chain_config.spec puts epoch {epoch} in {}; silver runs Fulu and Gloas only \ + (check FULU_FORK_EPOCH)", + fork.name() + ))); + } + + if let Some(gvr) = anchor_gvr { + self.fork_digest = spec.fork_digest_at(epoch, &gvr); + let (version, fork_epoch) = spec.next_fork(epoch); + self.next_fork_version = version; + self.next_fork_epoch = fork_epoch; + } + Ok(()) + } + + fn slots_since_genesis(&self) -> u64 { + let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(); + now.saturating_sub(self.chain_config.genesis_unix_secs) / + self.chain_config.spec.seconds_per_slot().max(1) + } + pub fn with_discovery_port(mut self, port: u16) -> Self { self.discovery_port = Some(port); self @@ -239,6 +311,11 @@ impl Config { self } + pub fn with_bootstrap_enrs(mut self, enrs: Vec) -> Self { + self.chain_config.bootstrap_enrs = enrs; + self + } + pub fn with_checkpoint(mut self, path: String) -> Self { self.chain_config.checkpoint_file = Some(path); self @@ -282,6 +359,10 @@ impl Config { self.fork_digest } + pub fn next_fork_version(&self) -> [u8; 4] { + self.next_fork_version + } + pub fn p2p_peer_id(&self) -> Result { Ok(PeerId::from_secp256k1_pubkey(self.keypair()?.public_key_compressed())) } @@ -571,4 +652,158 @@ mod tests { assert_eq!(cfg.external_ip_v4, Some(Ipv4Addr::new(172, 16, 0, 1))); assert_eq!(cfg.chain_config().genesis_unix_secs, 1234); } + + /// A `[u8; 32]` root and a `u64` genesis time at the head of an anchor + /// state, which is all `anchor_genesis` reads. + fn write_anchor(dir: &std::path::Path, genesis_unix_secs: u64, gvr: [u8; 32]) -> String { + let path = dir.join("anchor.ssz"); + let mut bytes = genesis_unix_secs.to_le_bytes().to_vec(); + bytes.extend_from_slice(&gvr); + bytes.extend_from_slice(&[0u8; 64]); + std::fs::write(&path, bytes).unwrap(); + path.to_str().unwrap().to_owned() + } + + fn write_file(dir: &std::path::Path, name: &str, body: &str) -> String { + let path = dir.join(name); + std::fs::write(&path, body).unwrap(); + path.to_str().unwrap().to_owned() + } + + fn temp_dir(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("silver-config-{name}")); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + /// The whole in-enclave contract: a config naming only the two files a + /// network publishes resolves to the same digest a hand-written one + /// spells out. + #[test] + fn spec_file_and_anchor_resolve_the_digest_and_genesis() { + let dir = temp_dir("spec-file"); + let gvr = [7u8; 32]; + let genesis = 1_600_000_000; + let anchor = write_anchor(&dir, genesis, gvr); + let spec_file = write_file( + &dir, + "config.yaml", + "CONFIG_NAME: kurtosis\n\ + GENESIS_FORK_VERSION: 0x10000038\n\ + FULU_FORK_VERSION: 0x70000038\n\ + FULU_FORK_EPOCH: 0\n\ + ELECTRA_FORK_EPOCH: 0\n\ + MAX_BLOBS_PER_BLOCK_ELECTRA: 9\n\ + SECONDS_PER_SLOT: 12\n\ + GLOAS_FORK_EPOCH: 18446744073709551615\n", + ); + let config_file = write_file( + &dir, + "silver.toml", + &format!( + "secret_key = \"{}\"\n\ + [chain_config]\n\ + spec_file = \"{spec_file}\"\n\ + checkpoint_file = \"{anchor}\"\n", + "11".repeat(32) + ), + ); + + let cfg = Config::from_file(&config_file).unwrap(); + let spec = &cfg.chain_config().spec; + assert_eq!(spec.network_name(), "kurtosis", "the spec came from the YAML"); + assert_eq!(spec.fulu_fork_version, [0x70, 0x00, 0x00, 0x38]); + assert_eq!(cfg.chain_config().genesis_unix_secs, genesis, "read from the anchor"); + + let epoch = cfg.slots_since_genesis() / SLOTS_PER_EPOCH; + assert_eq!(cfg.fork_digest(), spec.fork_digest_at(epoch, &gvr)); + assert_eq!(cfg.next_fork_version(), spec.next_fork(epoch).0); + assert_eq!(cfg.next_fork_epoch, u64::MAX, "no fork scheduled past Fulu here"); + } + + /// Literals lose to the network's own files: the anchor carries the + /// genesis time and the root the digest is built from, so a config cannot + /// assert a digest or a genesis that contradicts the state it boots on. + #[test] + fn the_anchor_outranks_the_files_literals() { + let dir = temp_dir("anchor-wins"); + let genesis = 1_600_000_000; + let gvr = [7u8; 32]; + let anchor = write_anchor(&dir, genesis, gvr); + let spec_file = write_file( + &dir, + "config.yaml", + "FULU_FORK_VERSION: 0x70000038\nFULU_FORK_EPOCH: 0\nELECTRA_FORK_EPOCH: 0\n", + ); + let config_file = write_file( + &dir, + "silver.toml", + &format!( + "secret_key = \"{}\"\n\ + fork_digest = \"8c9f62fe\"\n\ + [chain_config]\n\ + genesis_unix_secs = 42\n\ + spec_file = \"{spec_file}\"\n\ + checkpoint_file = \"{anchor}\"\n", + "11".repeat(32) + ), + ); + + let cfg = Config::from_file(&config_file).unwrap(); + assert_eq!(cfg.chain_config().genesis_unix_secs, genesis, "not the file's 42"); + let epoch = cfg.slots_since_genesis() / SLOTS_PER_EPOCH; + assert_eq!( + cfg.fork_digest(), + cfg.chain_config().spec.fork_digest_at(epoch, &gvr), + "not the file's 8c9f62fe" + ); + } + + /// silver has no pre-Fulu state transition, and an unstated + /// FULU_FORK_EPOCH silently inherits mainnet's — which is how a + /// Fulu-from-genesis devnet reads as Electra and derives a digest no peer + /// gossips on. + #[test] + fn spec_reading_earlier_than_fulu_is_refused() { + let dir = temp_dir("pre-fulu"); + // A genesis an hour ago, like a devnet's: the wall epoch is then far + // below mainnet's fulu_fork_epoch, which is the default in force here. + let recent = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() - 3600; + let anchor = write_anchor(&dir, recent, [7u8; 32]); + let spec_file = write_file( + &dir, + "config.yaml", + "FULU_FORK_VERSION: 0x70000038\nELECTRA_FORK_EPOCH: 0\n", + ); + let config_file = write_file( + &dir, + "silver.toml", + &format!( + "secret_key = \"{}\"\n\ + [chain_config]\n\ + spec_file = \"{spec_file}\"\n\ + checkpoint_file = \"{anchor}\"\n", + "11".repeat(32) + ), + ); + + let err = Config::from_file(&config_file).unwrap_err(); + let text = format!("{err}"); + assert!(text.contains("FULU_FORK_EPOCH"), "error should name the key: {text}"); + } + + /// With no anchor there is nothing to derive from, so the file's own + /// literals stand — this is the default mainnet run. + #[test] + fn without_an_anchor_the_files_literals_stand() { + let dir = temp_dir("no-anchor"); + let config_file = write_file( + &dir, + "silver.toml", + &format!("secret_key = \"{}\"\nfork_digest = \"8c9f62fe\"\n", "11".repeat(32)), + ); + let cfg = Config::from_file(&config_file).unwrap(); + assert_eq!(cfg.fork_digest(), [0x8c, 0x9f, 0x62, 0xfe]); + } } diff --git a/crates/e2e/src/da_capture.rs b/crates/e2e/src/da_capture.rs index 9dcac841..4ad87a19 100644 --- a/crates/e2e/src/da_capture.rs +++ b/crates/e2e/src/da_capture.rs @@ -53,7 +53,7 @@ impl SlotCapture { /// A slot whose columns turned up after it ended was served over RPC while /// the node caught up; its curve is not a gossip arrival curve. pub fn is_backfill(&self) -> bool { - let slot_us = SpecConfig::mainnet().seconds_per_slot * 1_000_000; + let slot_us = SpecConfig::mainnet().seconds_per_slot() * 1_000_000; self.arrivals.first().is_some_and(|a| a.at_us > slot_us) } } @@ -130,7 +130,7 @@ impl ColumnFixtures { seen |= 1 << a.index; first }); - let slot_start_us = (genesis + slot * spec.seconds_per_slot) * 1_000_000; + let slot_start_us = (genesis + slot * spec.seconds_per_slot()) * 1_000_000; for arrival in &mut cap.arrivals { let at = arrival.at_us; assert!( diff --git a/crates/storage/src/tile.rs b/crates/storage/src/tile.rs index 8bf4b1d6..860a21d2 100644 --- a/crates/storage/src/tile.rs +++ b/crates/storage/src/tile.rs @@ -455,7 +455,7 @@ impl Tile for StorageTile { let spec = &*self.spec; let gvr = self.genesis_validators_root; let fork_digest_at = move |slot: u64| match gvr { - Some(gvr) => column_util::fork_digest_at(spec, slot, &gvr), + Some(gvr) => spec.fork_digest_at(slot / SLOTS_PER_EPOCH, &gvr), None => [0u8; 4], }; if let Err(e) = self.store.file_io( diff --git a/kurtosis/README.md b/kurtosis/README.md index e03f137c..0ede69c9 100644 --- a/kurtosis/README.md +++ b/kurtosis/README.md @@ -1,211 +1,171 @@ -# Local kurtosis devnet for peer-connection and engine-API debugging +# Local kurtosis devnet -A controlled, multi-client ethereum devnet for testing silver's peer behaviour -against real CL clients **whose logs you can read**. The point is two-sided -visibility: every silver `connection lost` / `received goodbye` / eviction can -be matched against the peer's own account of why it dropped silver, same wall -clock. +A multi-client ethereum devnet for testing silver against real CL clients whose +logs you can read. Every silver disconnect or eviction can be matched against +the peer's own account of it, on the same clock. -silver also drives an EL over the engine API (`newPayload` / -`forkchoiceUpdated`); it gets its own dedicated **reth**, run as a second -host process via `run-reth.sh` (see "The execution layer" below). +Use it for protocol behaviour: handshakes, RPC framing, gossip scoring, +Status/Goodbye, engine-API payload verdicts. Do not use it for timeout or +peer-supply bugs — everything here is same-host, with no loss and no NAT. -Containers, not VMs — kurtosis runs the clients as Docker containers; silver -runs as a **bare host process** and joins the enclave over the Docker bridge. +Linux only. silver joins the enclave over the Docker bridge, and +`external_ip_v4` detection is bridge-specific. -## What this is (and isn't) for +There are two ways to run silver against it: -- **Good for:** protocol correctness — handshake, RPC framing, gossip scoring, - Status/Goodbye semantics, "why did silver drop a healthy peer", multi-client - behaviour differences. Engine-API wiring: payload verdicts (VALID / INVALID / - SYNCING), fork-choice updates against a real EL, JWT auth. -- **Not for:** the zombie-dial / timeout / peer-supply class. Everything here - is same-host: near-zero RTT, no loss, no NAT, always enough peers. Those bugs - only reproduce on the real internet from a clean source IP. Use a public - Fulu/PeerDAS devnet for that. - -Linux only — the host-process-joins-the-bridge model and `external_ip_v4` -detection are Docker-bridge specific. +- **as a host process**, with its own reth, wired up by `setup.sh`; +- **as a participant**, inside the enclave. See below. ## Prerequisites -`kurtosis`, `docker`, `curl`, `jq`, `reth` (silver's EL), and a Fulu-capable -silver build. +`kurtosis`, `docker`, `curl`, `jq`, `reth`, and a Fulu-capable silver build. -## Quick start +## Quick start (host process) ```bash -# 1. bring up the devnet + harvest config values into silver-devnet.toml -kurtosis/setup.sh # enclave name defaults to silver-dev +# 1. start the devnet and write silver-devnet.toml +kurtosis/setup.sh # enclave defaults to silver-dev -# 2. start silver's dedicated EL (separate terminal, keeps running) +# 2. start silver's reth (separate terminal, keeps running) kurtosis/run-reth.sh -# 3. run silver against it (zero source edits) +# 3. run silver RUST_LOG=info,silver_network=info,silver_peer=info \ cargo run --release --bin silver -- --config kurtosis/silver-devnet.toml - -# 4. (optional) watch live counters -cargo run -p silver_surfer -- ~/.local/share silver # counters-network / counters-peer ``` +`cargo run -p silver_surfer -- ~/.local/share silver` shows live counters. + ## Files -| File | Purpose | -|----------------------|---------------------------------------------------------------| -| `net.yaml` | kurtosis `ethereum-package` args: 4 participants, Fulu-from-genesis, CL at debug. | -| `setup.sh` | Spins up the enclave, harvests the network values, and writes the config TOML. | -| `silver-devnet.toml` | silver `Config` (TOML), generated by `setup.sh`. Static fields (`secret_key`, ports, `next_fork_version`) come from env-overridable vars in the script. | -| `genesis.ssz` | Genesis state, fetched by `setup.sh` as silver's sync anchor. Generated (gitignored via `*.ssz`); referenced by `chain_config.checkpoint_file`. | -| `run-reth.sh` | Runs silver's dedicated local reth from the harvested `el/` files. | -| `el/` | EL genesis, devnet enodes, JWT, and reth datadir for the local reth, harvested by `setup.sh`. Generated (gitignored). | - -## How `setup.sh` wires silver to the enclave - -silver's bin takes a full `Config` from `--config `. The script harvests -the network-specific values below and writes `silver-devnet.toml` (static -fields — `secret_key`, ports, `next_fork_version` — come from env-overridable -vars in the script; `next_fork_epoch` is omitted and defaults to FAR_FUTURE): - -| Field | Source | -|--------------------------------|----------------------------------------------------------------| -| `fork_digest` | dominant `/eth2//` topic in a CL's debug logs (client ground truth — avoids the Fulu modified-digest computation). | -| `chain_config.genesis_unix_secs` | `/eth/v1/beacon/genesis` | -| `chain_config.bootstrap_enrs` | `/eth/v1/node/identity` for each CL node | -| `external_ip_v4` | `kt-` Docker bridge gateway (so peers can dial silver back; non-fatal). | -| `chain_config.spec` | `/eth/v1/config/spec` — devnet fork versions + blob schedule. silver derives the fork digest **and** BLS signing domains from these; mainnet defaults mismatch (wrong digest → no peers; wrong domain → blocks fail sig verification). | -| `chain_config.checkpoint_file` | `genesis.ssz` fetched from `/eth/v2/debug/beacon/states/genesis` — silver's sync anchor (bootstraps fork choice so block 1's parent, the genesis block, resolves). | -| `engine_config.execution_endpoint` | `http://127.0.0.1:$ENGINE_PORT` — the local reth started by `run-reth.sh` (port from an env-overridable var, default 8551). | -| `engine_config.jwt_secret` | `el/jwt.hex`, written by `setup.sh` (fixed env-overridable value); `run-reth.sh` hands the same file to reth's `--authrpc.jwtsecret`. | -| `engine_config.unsafe_no_el` | Omitted by default. Set `UNSAFE_NO_EL=1` when running `setup.sh` to emit `unsafe_no_el = true`, or pass `--unsafe-no-el` to silver to force it without regenerating the config — see "Running without an EL" below. | - -On Linux the enclave's `172.x` container IPs are routable from the host, so -silver dials them directly (no port-publishing). `next_fork_version` / -`next_fork_epoch` are cosmetic — they decorate silver's own ENR but don't gate -peering. `secret_key` is any 32-byte hex; a fixed value gives silver a stable -peer-id/ENR across restarts. - -Re-run `setup.sh` after an enclave restart to re-sync (only the derived fields -change). +| File | Purpose | +|------|---------| +| `net.yaml` | ethereum-package args: 4 participants, Fulu from genesis, CL at debug. | +| `net-participant.yaml` | Same devnet, with silver inside it as a `cl_type: silver` participant. | +| `setup.sh` | Starts the enclave, harvests network values, writes the config TOML. | +| `run-reth.sh` | Runs silver's own reth from the harvested `el/` files. | +| `silver-devnet.toml` | silver's config, generated. Gitignored. | +| `genesis.ssz` | Genesis state, silver's sync anchor. Generated. | +| `el/` | EL genesis, enodes, JWT and datadir for the local reth. Generated. | -## The execution layer +## What `setup.sh` writes + +silver takes a whole `Config` from `--config `. The script harvests these +into `silver-devnet.toml`: -silver needs an EL to validate payloads. It gets a **dedicated local reth** -(`run-reth.sh`, a bare host process like silver itself) rather than borrowing -one inside the enclave: every enclave EL is already driven by its paired CL, -and two CLs steering one EL fight over its fork choice — while silver syncs, -its lagging `forkchoiceUpdated`s would yank the shared EL's head backwards. +| Field | Source | +|-------|--------| +| `chain_config.spec` | `/eth/v1/config/spec` — fork versions, fork epochs, blob schedule. | +| `chain_config.checkpoint_file` | `genesis.ssz`, or the finalized state with `--finalized-anchor`. | +| `chain_config.bootstrap_enrs` | `/eth/v1/node/identity` on each CL node. | +| `external_ip_v4` | the `kt-` bridge gateway, so peers can dial silver back. | +| `engine_config.*` | the local reth's endpoint and `el/jwt.hex`. | -`setup.sh` harvests what the local reth needs into `el/`: +`secret_key` and the ports come from env-overridable vars in the script. A fixed +`secret_key` keeps silver's peer id stable across restarts. -- the devnet **EL genesis** (`el_cl_genesis_data` artifact); -- the enclave ELs' **enodes** (via `admin_nodeInfo`), passed as both - bootnodes and trusted peers so reth syncs the EL chain and sees the tx - pool. The container IPs inside the enodes are host-routable on Linux, same - as the CL dialing; -- a **JWT** shared between silver and reth only (`el/jwt.hex`). +`fork_digest`, `next_fork_version` and `genesis_unix_secs` are **not** written. +silver derives them from `chain_config.spec` and the anchor state, so the config +cannot contradict the network. The spec must therefore be right: a wrong +`FULU_FORK_EPOCH` gives a wrong digest, and silver refuses a spec that reads +earlier than Fulu. -reth's datadir lives in `el/datadir` and survives restarts; `setup.sh` wipes -it automatically when the enclave (and hence the EL genesis) changes. reth's -log line for each `newPayload` / `forkchoiceUpdated` is the EL-side view to -correlate against silver's engine logs. +Re-run `setup.sh` after an enclave restart. -### Running without an EL (unsafe testing mode) +## silver as a participant (`net-participant.yaml`) -To exercise the CL in isolation, silver's engine tile can run with **no EL -attached**: it never connects to reth and answers every engine request -(`newPayload` / `forkchoiceUpdated`) with a synthetic `VALID`. Block import -proceeds without payload execution; **block proposal does not work** (the EL -payload can't be fabricated). Never enable this outside testing. +Here silver runs as a node in the enclave, not on the host. It gets its own +container and its own EL, and appears in `kurtosis enclave inspect`. -The quickest way is the `--unsafe-no-el` CLI flag, which forces no-EL mode on -top of any config — no regeneration needed: +Nothing is harvested. The launcher points silver at the enclave's own +`config.yaml` and `genesis.ssz`, and silver reads the rest from them. + +The launcher is not upstream yet. It lives on +[`ninaiiad/ethereum-package@ng/silver-cl`](https://github.com/ninaiiad/ethereum-package/tree/ng/silver-cl). ```bash -# run silver against an existing config — no run-reth.sh needed -RUST_LOG=info cargo run --release --bin silver -- \ - --config kurtosis/silver-devnet.toml --unsafe-no-el +docker build -t silver:local . # from the repo root +git clone -b ng/silver-cl https://github.com/ninaiiad/ethereum-package +kurtosis run ./ethereum-package \ + --args-file kurtosis/net-participant.yaml \ + --enclave silver-participant --image-download always ``` -Alternatively, bake it into the generated config via the env var: +Kurtosis 1.15.2 cannot load the package: it fails with `undefined: GpuConfig`. +To run on it, delete the `gpu=GpuConfig(...)` kwarg in +`src/zkboost/zkboost_launcher.star` first. -```bash -# generate the config with the flag baked in... -UNSAFE_NO_EL=1 kurtosis/setup.sh +You can run the branch straight from GitHub instead of cloning it, as +`kurtosis run github.com/ninaiiad/ethereum-package@ng/silver-cl`. That form also +needs the branch's `kurtosis.yml` to name the fork, and gives you nowhere to +apply the `GpuConfig` edit. -# ...then run silver directly -RUST_LOG=info cargo run --release --bin silver -- --config kurtosis/silver-devnet.toml +Once the launcher is upstream, pin a release that has it: + +```bash +kurtosis run github.com/ethpandaops/ethereum-package@ \ + --args-file kurtosis/net-participant.yaml \ + --enclave silver-participant ``` -Either way sets `unsafe_no_el = true` (the CLI flag overrides the config file); -`execution_endpoint` and `jwt_secret` are then ignored. Editing -`silver-devnet.toml` by hand works too, but `setup.sh` overwrites it on the next -run — hence the flag and env var. +Then drop the `cl_image` and `el_image` pins from `net-participant.yaml`. They +are there because HEAD's genesis needs client builds that track HEAD, and +because silver has no published image yet. -## Viewing node logs +## The execution layer -```bash -# list services (and their names) in the enclave -kurtosis enclave inspect silver-dev +silver needs an EL to validate payloads, and gets its own reth rather than one +from the enclave. Every enclave EL is already driven by its paired CL, and two +CLs steering one EL fight over its fork choice. -# follow one node's logs (-f = tail; drop it to dump the full buffer) -kurtosis service logs -f silver-dev cl-1-lighthouse-geth +`setup.sh` harvests the EL genesis, the enclave ELs' enodes, and a shared JWT +into `el/`. reth's datadir is `el/datadir`; the script wipes it when the EL +genesis changes. -# the matching execution-layer client -kurtosis service logs -f silver-dev el-1-geth-lighthouse +### Running without an EL -# all services at once -kurtosis service logs -f silver-dev +silver's engine tile can run with no EL at all. It answers every engine request +`VALID`, so blocks import without payload execution. Block proposal does not +work. Testing only. + +```bash +cargo run --release --bin silver -- \ + --config kurtosis/silver-devnet.toml --unsafe-no-el ``` -Service names follow `cl---` (consensus) and `el---` -(execution) — confirm the exact names with `enclave inspect`. With the default -`net.yaml` the CL nodes are `cl-1-lighthouse-geth`, `cl-2-lighthouse-reth`, -`cl-3-lighthouse-nethermind`, `cl-4-prysm-geth`. +`UNSAFE_NO_EL=1 kurtosis/setup.sh` bakes it into the config instead. The flag +overrides the file. -## Correlating both sides +## Logs ```bash -# silver's view: dial/accept outcomes, disconnect reasons, eviction breakdowns -RUST_LOG=...silver_network=info,silver_peer=info cargo run ... --config ... - -# the peer's view of the same events +kurtosis enclave inspect silver-dev # list service names kurtosis service logs -f silver-dev cl-1-lighthouse-geth +kurtosis service logs -f silver-dev # everything ``` -`dora` (block explorer URL printed by `setup.sh` / `kurtosis enclave inspect`) -confirms the chain is producing and finalizing. +Names are `cl---` and `el---`. `dora`, whose URL +`enclave inspect` prints, shows whether the chain is producing and finalizing. ## Data columns (PeerDAS) -Data column sidecars only exist for blocks carrying blob commitments. A vanilla -devnet produces none, so `net.yaml` runs `spamoor` with the `blobs` scenario to -submit EIP-4844 blob txs continuously (`throughput`/`sidecars` knobs there). -Verify columns are flowing: +Column sidecars exist only for blocks carrying blob commitments, so `net.yaml` +runs `spamoor` to submit blob transactions continuously. Check they are flowing +with `kurtosis service logs -f silver-dev el-1-geth-lighthouse | grep -i blob`, +or per-block blob counts in dora. -```bash -# EL: blocks should show non-zero blobGasUsed -kurtosis service logs -f silver-dev el-1-geth-lighthouse | grep -i blob -# dora: per-block blob count in the explorer UI -# silver: data-column gossip/RPC traffic once synced -``` +For backfill testing, let the spammer run a while before launching silver. + +## Notes -For backfill testing let the spammer run a while pre-launch so history -accumulates blob blocks (hence columns) to sync/validate against. - -## Compatibility notes - -- **QUIC-only:** silver dials a peer only if its ENR advertises a `quic` - socket. Lighthouse enables QUIC by default — hence the lighthouse-heavy - participant set in `net.yaml`. A non-QUIC client is skipped (silver logs - `Peer does not support quic`). -- **`preset: mainnet`** in `net.yaml` matches silver's default - `SpecConfig::mainnet()`. Minimal preset would desync slot/epoch math. -- **Version-sensitive (upstream, not silver):** the `*_fork_epoch` keys in - `net.yaml`, the `http` / `rpc` port-ids, and the `el_cl_genesis_data` - artifact name used by `setup.sh` track `ethereum-package` churn. If - `setup.sh` fails resolving a URL or the chain won't start, check these - against the package version you pulled. +- **QUIC only.** silver dials a peer only if its ENR advertises a `quic` + socket, and logs `Peer does not support quic` otherwise. Lighthouse enables + QUIC by default, hence the lighthouse-heavy participant set. +- **`preset: mainnet`** matches silver's `SpecConfig` defaults. The minimal + preset would break slot and epoch maths. +- **Upstream churn.** The `*_fork_epoch` keys, the `http` and `rpc` port ids, + and the `el_cl_genesis_data` artifact name all track ethereum-package. Check + them against the version you pulled when `setup.sh` fails. ## Teardown diff --git a/kurtosis/net-participant.yaml b/kurtosis/net-participant.yaml new file mode 100644 index 00000000..890ef4a7 --- /dev/null +++ b/kurtosis/net-participant.yaml @@ -0,0 +1,74 @@ +# Kurtosis ethereum-package args for a devnet in which silver is a +# PARTICIPANT, not a host process: +# +# kurtosis run github.com/ninaiiad/ethereum-package@ng/silver-cl \ +# --args-file kurtosis/net-participant.yaml \ +# --enclave silver-participant --image-download always + +participants: + # silver first, so it is the enclave's bootnode and every other CL is handed + # its ENR. validator_count 0 because silver attests nothing: its share of the + # validator set would otherwise be assigned and then never vote, and the + # chain would struggle to finalize. + - el_type: reth + cl_type: silver + # Drop this once the docker workflow publishes: the launcher already + # defaults to ghcr.io/gattaca-com/silver:latest. Keep it to test a local + # build. + cl_image: silver:local + validator_count: 0 + cl_extra_env_vars: + RUST_LOG: "info,silver_network=info,silver_peer=info" + + # Images are pinned to the ethpandaops builds that track ethereum-package's + # own HEAD, because that is the only checkout this file can run against. + # `ethereum/client-go:latest` and `sigp/lighthouse:latest` both reject the + # genesis HEAD's generator produces (geth: `missing entry for fork "osaka" + # in blobSchedule`; lighthouse: `missing field SECONDS_PER_SLOT`, since the + # generator now writes SLOT_DURATION_MS instead). + - el_type: geth + el_image: ethpandaops/geth:master + cl_type: lighthouse + cl_image: ethpandaops/lighthouse:unstable + cl_extra_params: + - --target-peers=64 + count: 1 + - el_type: reth + cl_type: lighthouse + cl_image: ethpandaops/lighthouse:unstable + cl_extra_params: + - --target-peers=64 + count: 1 + - el_type: nethermind + cl_type: lighthouse + cl_image: ethpandaops/lighthouse:unstable + cl_extra_params: + - --target-peers=64 + count: 1 + +network_params: + preset: mainnet + seconds_per_slot: 12 + # Full custody on every node, so a non-custodying participant like silver can + # always source the columns a Fulu block needs. See net.yaml. + num_validator_keys_per_node: 128 + # silver waits for genesis rather than starting pre-genesis, so the delay + # only has to cover image pulls and EL init. + genesis_delay: 60 + deneb_fork_epoch: 0 + electra_fork_epoch: 0 + fulu_fork_epoch: 0 + +global_log_level: debug + +additional_services: + - dora + - spamoor + +spamoor_params: + spammers: + - scenario: blobs + name: blob-spammer + config: + throughput: 2 + sidecars: 3 diff --git a/kurtosis/setup.sh b/kurtosis/setup.sh index 18e1a816..50f353d8 100755 --- a/kurtosis/setup.sh +++ b/kurtosis/setup.sh @@ -18,8 +18,8 @@ # finalized yet, and genesis is already a Gloas state. # # Generates silver-devnet.toml from scratch each run (derived fields harvested -# from the CL; static fields — secret_key, ports, next_fork_version — taken -# from the env-overridable vars below). Also writes the anchor state and el/ +# from the CL; static fields — secret_key, ports — taken from the +# env-overridable vars below). Also writes the anchor state and el/ # (genesis + bootnodes + JWT for silver's dedicated local reth — start it with # run-reth.sh). # @@ -65,12 +65,10 @@ fi # Static (non-harvested) config values — override via env if needed. A fixed # secret_key gives silver a stable peer-id/ENR across restarts; rotate it per -# run to shed a peer's accumulated RPC penalties. NEXT_FORK_VERSION applies to -# Fulu runs only — Gloas runs harvest the pair from the spec. +# run to shed a peer's accumulated RPC penalties. SECRET_KEY="${SECRET_KEY:-1111111111111111111111111111111111111111111111111111111111111111}" DISCOVERY_PORT="${DISCOVERY_PORT:-31133}" QUIC_PORT="${QUIC_PORT:-31123}" -NEXT_FORK_VERSION="${NEXT_FORK_VERSION:-06000000}" # Custody groups silver subscribes to / advertises (ENR cgc). 8 = silver's # floor (SAMPLES_PER_SLOT): custody set covers the full sample set, so values # below 8 are raised to 8. Bump to custody/serve more columns. @@ -139,28 +137,7 @@ done GENESIS="$(curl -fsS "$PRIMARY_URL/eth/v1/beacon/genesis" | jq -r .data.genesis_time)" echo "genesis_unix_secs: $GENESIS" -# 5. fork_digest — ground truth from the client's own gossip topics -# (/eth2//...). Computing it risks the Fulu modified-digest rules; -# reading the client's topics avoids that. Pick the dominant digest. -echo "resolving fork_digest from $PRIMARY logs ..." -FORK_DIGEST="" -for _ in $(seq 1 30); do - # Bounded tail, captured whole: piping kurtosis straight into `head` kills it - # with SIGPIPE once the buffer is large, which `pipefail` turns into an abort - # — and a long-lived enclave is the normal case for --finalized-anchor. - LOGS="$(kurtosis service logs -n 4000 "$ENCLAVE" "$PRIMARY" 2>&1 || true)" - FORK_DIGEST="$(grep -oE '/eth2/[0-9a-f]{8}/' <<<"$LOGS" | grep -oE '[0-9a-f]{8}' \ - | sort | uniq -c | sort -rn | head -1 | awk '{print $2}')" - [ -n "$FORK_DIGEST" ] && break - sleep 2 -done -[ -n "$FORK_DIGEST" ] || { - echo "could not resolve fork_digest from logs (is global_log_level debug?)" >&2 - exit 1 -} -echo "fork_digest: $FORK_DIGEST" - -# 6. bootstrap_enrs — one per CL node. +# 5. bootstrap_enrs — one per CL node. ENRS=() for svc in "${CL_SVCS[@]}"; do url="$(beacon_url "$svc")" @@ -208,6 +185,12 @@ ffv="$(jq -r '.FULU_FORK_VERSION' <<<"$SPEC")" gloasfv="$(jq -r '.GLOAS_FORK_VERSION' <<<"$SPEC")" gloasfe="$(jq -r '.GLOAS_FORK_EPOCH | tonumber' <<<"$SPEC")" efe="$(jq -r '.ELECTRA_FORK_EPOCH | tonumber' <<<"$SPEC")" +# The Fulu *epoch*, not just its version: `fork_at(epoch)` picks the active +# fork from the epochs, and an unstated FULU_FORK_EPOCH falls back to +# mainnet's 411392 -- so a Fulu-from-genesis devnet reads as Electra, which +# silver does not run, and every fork version derived from the schedule +# (signing domains, the fork digest) is the wrong one. +ffe="$(jq -r '.FULU_FORK_EPOCH | tonumber' <<<"$SPEC")" mbe="$(jq -r '.MAX_BLOBS_PER_BLOCK_ELECTRA | tonumber' <<<"$SPEC")" sps="$(jq -r '.SECONDS_PER_SLOT | tonumber' <<<"$SPEC")" WALL_EPOCH=$(( ( $(date +%s) - GENESIS ) / sps / 32 )) @@ -261,16 +244,10 @@ echo "$JWT_SECRET" > "$EL_DIR/jwt.hex" { echo "# Generated by kurtosis/setup.sh — re-run to refresh. Loaded via --config." echo "secret_key = \"$SECRET_KEY\"" - echo "fork_digest = \"$FORK_DIGEST\"" - # ENRForkID's next_fork pair. On a Gloas devnet it is the harvested Gloas - # activation while that is still ahead of the wall epoch; once it passes, - # next_fork_epoch is FAR_FUTURE, which exceeds TOML's i64 and is omitted. - if [ "$GLOAS" -eq 1 ]; then - echo "next_fork_version = \"${gloasfv#0x}\"" - [ "$gloasfe" -gt "$WALL_EPOCH" ] && echo "next_fork_epoch = $gloasfe" - else - echo "next_fork_version = \"$NEXT_FORK_VERSION\"" - fi + # No fork_digest / next_fork_version: silver derives both from + # [chain_config.spec] and the anchor state's genesis_validators_root, and + # ignores a literal whenever checkpoint_file names an anchor. The spec keys + # below are what has to be right. [ -n "$GATEWAY" ] && echo "external_ip_v4 = \"$GATEWAY\"" echo "discovery_port = $DISCOVERY_PORT" echo "quic_port = $QUIC_PORT" @@ -301,6 +278,7 @@ echo "$JWT_SECRET" > "$EL_DIR/jwt.hex" echo "GLOAS_FORK_EPOCH = $gloasfe" fi echo "ELECTRA_FORK_EPOCH = $efe" + echo "FULU_FORK_EPOCH = $ffe" echo "MAX_BLOBS_PER_BLOCK_ELECTRA = $mbe" echo "SECONDS_PER_SLOT = $sps" # BLOB_SCHEDULE entries (array-of-tables) must follow the scalar keys above. From 91711db11342943390882239f3704a65b1d6985b Mon Sep 17 00:00:00 2001 From: Nina Date: Thu, 3 Sep 2026 14:34:13 +0100 Subject: [PATCH 2/2] delete docker workflow --- .github/workflows/docker.yaml | 60 ----------------------------------- 1 file changed, 60 deletions(-) delete mode 100644 .github/workflows/docker.yaml diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml deleted file mode 100644 index e9360a13..00000000 --- a/.github/workflows/docker.yaml +++ /dev/null @@ -1,60 +0,0 @@ -name: docker - -on: - push: - branches: [main] - tags: ["v*"] - pull_request: - workflow_dispatch: - -env: - REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository }} - -jobs: - build: - runs-on: ubuntu-latest - timeout-minutes: 60 - - permissions: - contents: read - packages: write - - steps: - - uses: actions/checkout@v4 - with: - lfs: false - - - uses: docker/setup-buildx-action@v3 - - # A pull request builds to prove the Dockerfile still works; only - # branch and tag pushes publish. - - name: Log in to the registry - if: github.event_name != 'pull_request' - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Derive tags - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - tags: | - type=ref,event=branch - type=ref,event=pr - type=ref,event=tag - type=sha,format=long - type=raw,value=latest,enable={{is_default_branch}} - - - name: Build and push - uses: docker/build-push-action@v6 - with: - context: . - push: ${{ github.event_name != 'pull_request' }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max