From 2721b0936fd2e7e544501e1a55684418d057121c Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> Date: Thu, 14 May 2026 18:46:56 +0200 Subject: [PATCH 01/21] feat(testutil): add beaconmock baseline Initial port of charon/testutil/beaconmock: BeaconMock with wiremock server, builder API, static endpoints, and deterministic attester/ proposer duties. Migrate eth2util, app/eth2wrap, core/deadline, and cli test code to the shared mock. --- Cargo.lock | 8 + crates/app/Cargo.toml | 1 + crates/app/src/eth2wrap/valcache.rs | 60 ++- crates/cli/Cargo.toml | 1 + crates/cli/src/commands/test/beacon.rs | 26 +- crates/core/src/deadline.rs | 60 +-- crates/eth2util/src/eth2exp.rs | 36 +- crates/eth2util/src/signing.rs | 83 ++- crates/testutil/Cargo.toml | 6 + crates/testutil/src/beaconmock.rs | 714 +++++++++++++++++++++++++ crates/testutil/src/lib.rs | 4 + 11 files changed, 843 insertions(+), 156 deletions(-) create mode 100644 crates/testutil/src/beaconmock.rs diff --git a/Cargo.lock b/Cargo.lock index 517f2478..ad155075 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5458,6 +5458,7 @@ dependencies = [ "pluto-eth2api", "pluto-k1util", "pluto-ssz", + "pluto-testutil", "prost 0.14.3", "prost-types 0.14.3", "regex", @@ -5506,6 +5507,7 @@ dependencies = [ "pluto-p2p", "pluto-relay-server", "pluto-ssz", + "pluto-testutil", "pluto-tracing", "quick-xml", "rand 0.8.6", @@ -5897,11 +5899,17 @@ dependencies = [ name = "pluto-testutil" version = "1.7.1" dependencies = [ + "anyhow", + "bon", + "chrono", "hex", "k256", "pluto-crypto", "pluto-eth2api", "rand 0.8.6", + "serde_json", + "thiserror 2.0.18", + "wiremock", ] [[package]] diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 5d746d50..77f14edf 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -37,6 +37,7 @@ pluto-ssz.workspace = true pluto-build-proto.workspace = true [dev-dependencies] +pluto-testutil.workspace = true wiremock.workspace = true test-case.workspace = true diff --git a/crates/app/src/eth2wrap/valcache.rs b/crates/app/src/eth2wrap/valcache.rs index 1fb2ceea..3ec4d6b8 100644 --- a/crates/app/src/eth2wrap/valcache.rs +++ b/crates/app/src/eth2wrap/valcache.rs @@ -227,8 +227,9 @@ mod tests { BlindedBlock400Response, GetStateValidatorsResponseResponseDatum, ValidatorResponseValidator, ValidatorStatus, }; + use pluto_testutil::BeaconMock; use wiremock::{ - Mock, MockServer, ResponseTemplate, + Mock, ResponseTemplate, matchers::{method, path}, }; @@ -267,17 +268,17 @@ mod tests { .collect::>(); // Create a mock server that tracks request count - let mock_server = MockServer::start().await; + let mock = BeaconMock::builder() + .build() + .await + .expect("should create beacon mock"); post_state_validators_success("head", datums.to_vec()) .expect(2) // Should be called exactly twice (once before trim, once after) - .mount(&mock_server) + .mount(mock.server()) .await; - let eth2_cl = EthBeaconNodeApiClient::with_base_url(mock_server.uri()) - .expect("Failed to create client"); - // Create a cache. - let cache = ValidatorCache::new(eth2_cl, pubkeys); + let cache = ValidatorCache::new(mock.client().clone(), pubkeys); // Check cache is populated. let (actual_active, actual_complete) = @@ -310,15 +311,16 @@ mod tests { #[tokio::test] async fn get_by_head_fail_fetch() { // Create a mock server that returns a 404 error - let mock_server = MockServer::start().await; + let mock = BeaconMock::builder() + .build() + .await + .expect("should create beacon mock"); post_state_validators_not_found("head") .expect(1) - .mount(&mock_server) + .mount(mock.server()) .await; - let eth2_cl = EthBeaconNodeApiClient::with_base_url(mock_server.uri()) - .expect("Failed to create client"); - let cache = ValidatorCache::new(eth2_cl, vec![test_pubkey(1)]); + let cache = ValidatorCache::new(mock.client().clone(), vec![test_pubkey(1)]); // Verify cache is initially empty { @@ -344,7 +346,10 @@ mod tests { let pubkeys = vec![test_pubkey(0), test_pubkey(1)]; // Set up mock server with different responses based on slot - let mock_server = MockServer::start().await; + let mock = BeaconMock::builder() + .build() + .await + .expect("should create beacon mock"); post_state_validators_success( "1", @@ -353,7 +358,7 @@ mod tests { test_validator_datum(1, &pubkeys[1], ValidatorStatus::ActiveOngoing), ], ) - .mount(&mock_server) + .mount(mock.server()) .await; post_state_validators_success( @@ -363,7 +368,7 @@ mod tests { test_validator_datum(1, &pubkeys[1], ValidatorStatus::ActiveOngoing), ], ) - .mount(&mock_server) + .mount(mock.server()) .await; post_state_validators_success( @@ -373,21 +378,18 @@ mod tests { test_validator_datum(1, &pubkeys[1], ValidatorStatus::PendingQueued), ], ) - .mount(&mock_server) + .mount(mock.server()) .await; post_state_validators_not_found("3") - .mount(&mock_server) + .mount(mock.server()) .await; post_state_validators_not_found("head") - .mount(&mock_server) + .mount(mock.server()) .await; - let eth2_cl = EthBeaconNodeApiClient::with_base_url(mock_server.uri()) - .expect("Failed to create client"); - // Create a cache. - let cache = ValidatorCache::new(eth2_cl, pubkeys.clone()); + let cache = ValidatorCache::new(mock.client().clone(), pubkeys.clone()); // Test slot 1: 1 active validator (index 1), 2 complete, refreshed_by_slot=true let (active, complete, refreshed_by_slot) = cache @@ -429,10 +431,13 @@ mod tests { let pubkeys = vec![test_pubkey(0), test_pubkey(1)]; // Set up mock server: slot requests fail, but head succeeds - let mock_server = MockServer::start().await; + let mock = BeaconMock::builder() + .build() + .await + .expect("should create beacon mock"); post_state_validators_not_found("1") - .mount(&mock_server) + .mount(mock.server()) .await; post_state_validators_success( @@ -442,13 +447,10 @@ mod tests { test_validator_datum(1, &pubkeys[1], ValidatorStatus::ActiveOngoing), ], ) - .mount(&mock_server) + .mount(mock.server()) .await; - let eth2_cl = EthBeaconNodeApiClient::with_base_url(mock_server.uri()) - .expect("Failed to create client"); - - let cache = ValidatorCache::new(eth2_cl, pubkeys); + let cache = ValidatorCache::new(mock.client().clone(), pubkeys); // Test slot 1: fails, falls back to head, returns 2 active, 2 complete, // refreshed_by_slot=false diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 738f2477..959f6163 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -52,6 +52,7 @@ test-case.workspace = true backon.workspace = true wiremock.workspace = true pluto-cluster = { workspace = true, features = ["test-cluster"] } +pluto-testutil.workspace = true [lints] workspace = true diff --git a/crates/cli/src/commands/test/beacon.rs b/crates/cli/src/commands/test/beacon.rs index f428ed35..6635fe43 100644 --- a/crates/cli/src/commands/test/beacon.rs +++ b/crates/cli/src/commands/test/beacon.rs @@ -2131,6 +2131,7 @@ async fn req_submit_sync_committee_contribution(target: &str) -> CliResult MockServer { - let server = MockServer::start().await; + async fn start_healthy_mocked_beacon_node() -> BeaconMock { + let mock = BeaconMock::builder() + .build() + .await + .expect("should create beacon mock"); Mock::given(method("GET")) .and(path("/eth/v1/node/health")) .respond_with(ResponseTemplate::new(200)) - .mount(&server) - .await; - - Mock::given(method("GET")) - .and(path("/eth/v1/node/syncing")) - .respond_with( - ResponseTemplate::new(200).set_body_string( - r#"{"data":{"head_slot":"0","sync_distance":"0","is_optimistic":false,"is_syncing":false}}"#, - ), - ) - .mount(&server) + .mount(mock.server()) .await; Mock::given(method("GET")) .and(path("/eth/v1/node/peers")) .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"meta":{"count":500}}"#)) - .mount(&server) + .mount(mock.server()) .await; Mock::given(method("GET")) @@ -2191,10 +2185,10 @@ mod tests { .respond_with(ResponseTemplate::new(200).set_body_string( r#"{"data":{"version":"BeaconNodeProvider/v1.0.0/linux_x86_64"}}"#, )) - .mount(&server) + .mount(mock.server()) .await; - server + mock } fn expected_results_for_healthy_node() -> Vec<(&'static str, TestVerdict)> { diff --git a/crates/core/src/deadline.rs b/crates/core/src/deadline.rs index ae820064..78330ad1 100644 --- a/crates/core/src/deadline.rs +++ b/crates/core/src/deadline.rs @@ -449,52 +449,22 @@ mod tests { use super::*; use crate::types::SlotNumber; - use wiremock::{ - Mock, MockServer, ResponseTemplate, - matchers::{method, path}, - }; + use pluto_testutil::BeaconMock; /// Creates a mock beacon node API server and returns the client. async fn create_mock_beacon_client( genesis_time: DateTime, slot_duration_secs: u64, slots_per_epoch: u64, - ) -> (MockServer, EthBeaconNodeApiClient) { - let mock_server = MockServer::start().await; - - // Mock /eth/v1/beacon/genesis - let genesis_response = serde_json::json!({ - "data": { - "genesis_time": genesis_time.timestamp().to_string(), - "genesis_validators_root": "0x0000000000000000000000000000000000000000000000000000000000000000", - "genesis_fork_version": "0x00000000" - } - }); - - Mock::given(method("GET")) - .and(path("/eth/v1/beacon/genesis")) - .respond_with(ResponseTemplate::new(200).set_body_json(genesis_response)) - .mount(&mock_server) - .await; - - // Mock /eth/v1/config/spec - let spec_response = serde_json::json!({ - "data": { - "SECONDS_PER_SLOT": slot_duration_secs.to_string(), - "SLOTS_PER_EPOCH": slots_per_epoch.to_string() - } - }); - - Mock::given(method("GET")) - .and(path("/eth/v1/config/spec")) - .respond_with(ResponseTemplate::new(200).set_body_json(spec_response)) - .mount(&mock_server) - .await; - - let client = EthBeaconNodeApiClient::with_base_url(mock_server.uri()) - .expect("Failed to create client"); - - (mock_server, client) + ) -> BeaconMock { + BeaconMock::builder() + .genesis_time(genesis_time) + .genesis_validators_root([0; 32]) + .slot_duration(Duration::from_secs(slot_duration_secs)) + .slots_per_epoch(slots_per_epoch) + .build() + .await + .expect("should create beacon mock") } /// Helper function to create expired duties, non-expired duties, and @@ -660,10 +630,11 @@ mod tests { let slot_duration_secs = 12; let slots_per_epoch = 32; - let (_mock_server, client) = + let mock = create_mock_beacon_client(genesis_time, slot_duration_secs, slots_per_epoch).await; + let client = mock.client(); - let deadline_func = new_duty_deadline_func(&client) + let deadline_func = new_duty_deadline_func(client) .await .expect("should create deadline func"); @@ -688,8 +659,9 @@ mod tests { let slot_duration_secs = 12; let slots_per_epoch = 32; - let (_mock_server, client) = + let mock = create_mock_beacon_client(genesis_time, slot_duration_secs, slots_per_epoch).await; + let client = mock.client(); let slot_duration = Duration::from_secs(slot_duration_secs); let margin = slot_duration @@ -712,7 +684,7 @@ mod tests { .expect("slot start should not overflow") }; - let deadline_func = new_duty_deadline_func(&client) + let deadline_func = new_duty_deadline_func(client) .await .expect("should create deadline func"); diff --git a/crates/eth2util/src/eth2exp.rs b/crates/eth2util/src/eth2exp.rs index 0eda27ca..e9d18b6a 100644 --- a/crates/eth2util/src/eth2exp.rs +++ b/crates/eth2util/src/eth2exp.rs @@ -117,22 +117,19 @@ fn hash_modulo(sig: &BLSSignature, modulo: u64) -> bool { #[cfg(test)] mod tests { use super::*; + use pluto_testutil::BeaconMock; use serde_json::json; use test_case::test_case; - use wiremock::{Mock, MockServer, ResponseTemplate, matchers}; - - async fn mock_client(spec_fields: serde_json::Value) -> (MockServer, EthBeaconNodeApiClient) { - let server = MockServer::start().await; - Mock::given(matchers::method("GET")) - .and(matchers::path("/eth/v1/config/spec")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "data": spec_fields }))) - .mount(&server) - .await; - let client = EthBeaconNodeApiClient::with_base_url(server.uri()).unwrap(); - (server, client) + + async fn mock_client(spec_fields: serde_json::Value) -> BeaconMock { + BeaconMock::builder() + .spec(spec_fields) + .build() + .await + .unwrap() } - async fn default_client() -> (MockServer, EthBeaconNodeApiClient) { + async fn default_client() -> BeaconMock { mock_client(json!({ "TARGET_AGGREGATORS_PER_COMMITTEE": "16", "SYNC_COMMITTEE_SIZE": "512", @@ -154,11 +151,12 @@ mod tests { #[tokio::test] async fn is_att_aggregator() { - let (_server, client) = default_client().await; + let mock = default_client().await; + let client = mock.client(); // comm_len=3, TARGET_AGGREGATORS_PER_COMMITTEE=16 → modulo=max(3/16,1)=1 → // always true assert!( - super::is_att_aggregator(&client, 3, decode_sig(ATT_SIG_HEX)) + super::is_att_aggregator(client, 3, decode_sig(ATT_SIG_HEX)) .await .unwrap() ); @@ -166,10 +164,11 @@ mod tests { #[tokio::test] async fn is_not_att_aggregator() { - let (_server, client) = default_client().await; + let mock = default_client().await; + let client = mock.client(); // comm_len=64, TARGET_AGGREGATORS_PER_COMMITTEE=16 → modulo=4 → false assert!( - !super::is_att_aggregator(&client, 64, decode_sig(ATT_SIG_HEX)) + !super::is_att_aggregator(client, 64, decode_sig(ATT_SIG_HEX)) .await .unwrap() ); @@ -187,8 +186,9 @@ mod tests { #[test_case("99e60f20dde4d4872b048d703f1943071c20213d504012e7e520c229da87661803b9f139b9a0c5be31de3cef6821c080125aed38ebaf51ba9a2e9d21d7fbf2903577983109d097a8599610a92c0305408d97c1fd4b0b2d1743fb4eedf5443f99", true ; "aggregator_3")] #[tokio::test] async fn is_sync_comm_aggregator(sig_hex: &str, expected: bool) { - let (_server, client) = default_client().await; - let result = super::is_sync_comm_aggregator(&client, decode_sig(sig_hex)) + let mock = default_client().await; + let client = mock.client(); + let result = super::is_sync_comm_aggregator(client, decode_sig(sig_hex)) .await .unwrap(); assert_eq!(result, expected); diff --git a/crates/eth2util/src/signing.rs b/crates/eth2util/src/signing.rs index 60639a8e..de51b890 100644 --- a/crates/eth2util/src/signing.rs +++ b/crates/eth2util/src/signing.rs @@ -179,17 +179,15 @@ pub async fn verify_aggregate_and_proof_selection( #[cfg(test)] mod tests { use super::*; + use chrono::DateTime; use pluto_crypto::tbls::Tbls; use pluto_eth2api::{ compute_builder_domain, compute_domain, spec::{bellatrix::ExecutionAddress, phase0::Version}, v1::ValidatorRegistration, }; + use pluto_testutil::BeaconMock; use serde_json::json; - use wiremock::{ - Mock, MockServer, ResponseTemplate, - matchers::{method, path}, - }; const BUILDER_DOMAIN_TYPE: [u8; 4] = [0x00, 0x00, 0x00, 0x01]; @@ -218,34 +216,14 @@ mod tests { }) } - async fn mock_beacon_client() -> (MockServer, EthBeaconNodeApiClient) { - let server = MockServer::start().await; - let base_url = server.uri(); - - Mock::given(method("GET")) - .and(path("/eth/v1/config/spec")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "data": spec_fixture(), - }))) - .mount(&server) - .await; - - Mock::given(method("GET")) - .and(path("/eth/v1/beacon/genesis")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "data": { - "genesis_time": "0", - "genesis_validators_root": "0x0000000000000000000000000000000000000000000000000000000000000000", - "genesis_fork_version": "0x01017000", - } - }))) - .mount(&server) - .await; - - ( - server, - EthBeaconNodeApiClient::with_base_url(base_url).unwrap(), - ) + async fn mock_beacon_client() -> BeaconMock { + BeaconMock::builder() + .spec(spec_fixture()) + .genesis_time(DateTime::from_timestamp(0, 0).unwrap()) + .genesis_validators_root([0; 32]) + .build() + .await + .unwrap() } #[test] @@ -282,9 +260,10 @@ mod tests { #[tokio::test] async fn get_domain_matches_builder_vector() { - let (_server, client) = mock_beacon_client().await; + let mock = mock_beacon_client().await; + let client = mock.client(); - let domain = get_domain(&client, DomainName::ApplicationBuilder, 1_000) + let domain = get_domain(client, DomainName::ApplicationBuilder, 1_000) .await .unwrap(); @@ -296,9 +275,10 @@ mod tests { #[tokio::test] async fn get_domain_uses_capella_for_voluntary_exit() { - let (_server, client) = mock_beacon_client().await; + let mock = mock_beacon_client().await; + let client = mock.client(); - let domain = get_domain(&client, DomainName::VoluntaryExit, 1_000) + let domain = get_domain(client, DomainName::VoluntaryExit, 1_000) .await .unwrap(); @@ -310,7 +290,8 @@ mod tests { #[tokio::test] async fn get_data_root_matches_registration_vector() { - let (_server, client) = mock_beacon_client().await; + let mock = mock_beacon_client().await; + let client = mock.client(); let fee_recipient: ExecutionAddress = hex::decode("000000000000000000000000000000000000dead") @@ -333,7 +314,7 @@ mod tests { }; let signing_root = get_data_root( - &client, + client, DomainName::ApplicationBuilder, 0, message.message_root(), @@ -349,7 +330,8 @@ mod tests { #[tokio::test] async fn verify_accepts_valid_signature() { - let (_server, client) = mock_beacon_client().await; + let mock = mock_beacon_client().await; + let client = mock.client(); let secret = secret_key("345768c0245f1dc702df9e50e811002f61ebb2680b3d5931527ef59f96cbaf9b"); let pubkey = BlstImpl.secret_to_public_key(&secret).unwrap(); @@ -366,13 +348,13 @@ mod tests { pubkey, }; let message_root = message.message_root(); - let signing_root = get_data_root(&client, DomainName::ApplicationBuilder, 0, message_root) + let signing_root = get_data_root(client, DomainName::ApplicationBuilder, 0, message_root) .await .unwrap(); let signature = BlstImpl.sign(&secret, &signing_root).unwrap(); verify( - &client, + client, DomainName::ApplicationBuilder, 0, message_root, @@ -385,10 +367,11 @@ mod tests { #[tokio::test] async fn verify_rejects_zero_signature() { - let (_server, client) = mock_beacon_client().await; + let mock = mock_beacon_client().await; + let client = mock.client(); let pubkey = [0x11; 48]; let err = verify( - &client, + client, DomainName::ApplicationBuilder, 0, [0x22; 32], @@ -403,20 +386,21 @@ mod tests { #[tokio::test] async fn verify_rejects_wrong_pubkey() { - let (_server, client) = mock_beacon_client().await; + let mock = mock_beacon_client().await; + let client = mock.client(); let secret = secret_key("345768c0245f1dc702df9e50e811002f61ebb2680b3d5931527ef59f96cbaf9b"); let wrong_secret = secret_key("01477d4bfbbcebe1fef8d4d6f624ecbb6e3178558bb1b0d6286c816c66842a6d"); let pubkey = BlstImpl.secret_to_public_key(&wrong_secret).unwrap(); let message_root = [0x55; 32]; - let signing_root = get_data_root(&client, DomainName::ApplicationBuilder, 0, message_root) + let signing_root = get_data_root(client, DomainName::ApplicationBuilder, 0, message_root) .await .unwrap(); let signature = BlstImpl.sign(&secret, &signing_root).unwrap(); let err = verify( - &client, + client, DomainName::ApplicationBuilder, 0, message_root, @@ -431,14 +415,15 @@ mod tests { #[tokio::test] async fn verify_rejects_wrong_message_root() { - let (_server, client) = mock_beacon_client().await; + let mock = mock_beacon_client().await; + let client = mock.client(); let secret = secret_key("345768c0245f1dc702df9e50e811002f61ebb2680b3d5931527ef59f96cbaf9b"); let pubkey = BlstImpl.secret_to_public_key(&secret).unwrap(); let signed_message_root = [0x55; 32]; let verified_message_root = [0x66; 32]; let signing_root = get_data_root( - &client, + client, DomainName::ApplicationBuilder, 0, signed_message_root, @@ -448,7 +433,7 @@ mod tests { let signature = BlstImpl.sign(&secret, &signing_root).unwrap(); let err = verify( - &client, + client, DomainName::ApplicationBuilder, 0, verified_message_root, diff --git a/crates/testutil/Cargo.toml b/crates/testutil/Cargo.toml index 7e59618b..52a6f0bc 100644 --- a/crates/testutil/Cargo.toml +++ b/crates/testutil/Cargo.toml @@ -7,11 +7,17 @@ license.workspace = true publish.workspace = true [dependencies] +anyhow.workspace = true +bon.workspace = true +chrono.workspace = true k256.workspace = true pluto-crypto.workspace = true pluto-eth2api.workspace = true rand.workspace = true hex.workspace = true +serde_json.workspace = true +thiserror.workspace = true +wiremock.workspace = true [lints] workspace = true diff --git a/crates/testutil/src/beaconmock.rs b/crates/testutil/src/beaconmock.rs new file mode 100644 index 00000000..5de3ca68 --- /dev/null +++ b/crates/testutil/src/beaconmock.rs @@ -0,0 +1,714 @@ +//! Beacon node API mocks for tests. +//! +//! `BeaconMock` owns the backing `wiremock::MockServer`, so keep the mock alive +//! for as long as clients use `BeaconMock::client()`. + +use std::{ + collections::BTreeMap, + sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard}, + time::Duration, +}; + +use bon::bon; +use chrono::{DateTime, TimeZone, Utc}; +use pluto_eth2api::{ + EthBeaconNodeApiClient, ValidatorResponseValidator, ValidatorStatus, + spec::phase0::{BLSPubKey, Epoch, Root, ValidatorIndex}, +}; +use serde_json::{Value, json}; +use wiremock::{ + Mock, MockServer, Request, ResponseTemplate, + matchers::{method, path, path_regex}, +}; + +const ZERO_ROOT: &str = "0x0000000000000000000000000000000000000000000000000000000000000000"; +const DEFAULT_GENESIS_VALIDATORS_ROOT: &str = + "0x9143aa7c615a7f7115e2b6aac319c03529df8242ae705fba9df39b79c59fa8b1"; +const DEFAULT_GENESIS_FORK_VERSION: &str = "0x01017000"; +const DEFAULT_WITHDRAWAL_CREDENTIALS: &str = + "0x3132333435363738393031323334353637383930313233343536373839303132"; +const DEFAULT_MOCK_PRIORITY: u8 = 255; + +/// Errors returned while configuring `BeaconMock`. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// The generated beacon API client could not be created for the mock URL. + #[error("create beacon node api client: {0}")] + Client(#[source] anyhow::Error), +} + +/// Result type for beacon mock setup. +pub type Result = std::result::Result; + +/// Minimal validator representation used by the beacon mock. +#[derive(Debug, Clone, PartialEq)] +pub struct Validator { + /// Validator index in the beacon registry. + pub index: ValidatorIndex, + /// Current balance in gwei. + pub balance: u64, + /// Current validator status. + pub status: ValidatorStatus, + /// Validator details returned by the beacon API. + pub validator: ValidatorResponseValidator, +} + +impl Validator { + /// Creates an active validator with the provided index and public key. + #[must_use] + pub fn active(index: ValidatorIndex, pubkey: BLSPubKey) -> Self { + let pubkey = hex_0x(pubkey); + + Self { + index, + balance: index, + status: ValidatorStatus::ActiveOngoing, + validator: ValidatorResponseValidator { + activation_eligibility_epoch: index.to_string(), + activation_epoch: index.checked_add(1).unwrap_or(index).to_string(), + effective_balance: index.to_string(), + exit_epoch: u64::MAX.to_string(), + pubkey, + slashed: false, + withdrawable_epoch: u64::MAX.to_string(), + withdrawal_credentials: DEFAULT_WITHDRAWAL_CREDENTIALS.to_string(), + }, + } + } +} + +/// Validator set used to seed validator and duty endpoints. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct ValidatorSet(BTreeMap); + +impl ValidatorSet { + /// Returns the small deterministic validator set from Charon's Go + /// beaconmock. + #[must_use] + pub fn validator_set_a() -> Self { + [ + ( + 1, + "0x914cff835a769156ba43ad50b931083c2dadd94e8359ce394bc7a3e06424d0214922ddf15f81640530b9c25c0bc0d490", + ), + ( + 2, + "0x8dae41352b69f2b3a1c0b05330c1bf65f03730c520273028864b11fcb94d8ce8f26d64f979a0ee3025467f45fd2241ea", + ), + ( + 3, + "0x8ee91545183c8c2db86633626f5074fd8ef93c4c9b7a2879ad1768f600c5b5906c3af20d47de42c3b032956fa8db1a76", + ), + ] + .into_iter() + .filter_map(|(index, pubkey)| { + parse_pubkey(pubkey).map(|pubkey| (index, Validator::active(index, pubkey))) + }) + .collect() + } + + /// Inserts or replaces a validator. + pub fn insert(&mut self, validator: Validator) { + self.0.insert(validator.index, validator); + } + + /// Returns all validators in index order. + #[must_use] + pub fn validators(&self) -> Vec { + self.0.values().cloned().collect() + } + + /// Returns the validator for an index. + #[must_use] + pub fn by_index(&self, index: ValidatorIndex) -> Option { + self.0.get(&index).cloned() + } + + /// Returns true if the set contains no validators. + #[must_use] + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +impl FromIterator<(ValidatorIndex, Validator)> for ValidatorSet { + fn from_iter>(iter: T) -> Self { + Self(iter.into_iter().collect()) + } +} + +/// Shared mock state used by mounted HTTP handlers. +#[derive(Debug)] +pub struct MockState { + spec: RwLock, + genesis: RwLock, + validator_set: RwLock, + deterministic_attester_duties: RwLock>, + deterministic_proposer_duties: RwLock>, +} + +impl MockState { + fn new(spec: Value, genesis: Value, validator_set: ValidatorSet) -> Self { + Self { + spec: RwLock::new(spec), + genesis: RwLock::new(genesis), + validator_set: RwLock::new(validator_set), + deterministic_attester_duties: RwLock::new(None), + deterministic_proposer_duties: RwLock::new(None), + } + } + + /// Returns a clone of the spec map served by `/eth/v1/config/spec`. + #[must_use] + pub fn spec(&self) -> Value { + read_lock(&self.spec).clone() + } + + /// Replaces one spec key. + pub fn set_spec_field(&self, key: impl Into, value: impl Into) { + let key = key.into(); + let value = value.into(); + if let Some(spec) = write_lock(&self.spec).as_object_mut() { + spec.insert(key, value); + } + } + + /// Returns a clone of the genesis data served by `/eth/v1/beacon/genesis`. + #[must_use] + pub fn genesis(&self) -> Value { + read_lock(&self.genesis).clone() + } + + /// Replaces one genesis field. + pub fn set_genesis_field(&self, key: impl Into, value: impl Into) { + let key = key.into(); + let value = value.into(); + if let Some(genesis) = write_lock(&self.genesis).as_object_mut() { + genesis.insert(key, value); + } + } + + /// Replaces the validator set served by validator-related endpoints. + pub fn set_validator_set(&self, validator_set: ValidatorSet) { + *write_lock(&self.validator_set) = validator_set; + } +} + +/// Wire-level beacon node mock with a generated client pre-dialed to the +/// server. +#[derive(Debug)] +pub struct BeaconMock { + server: MockServer, + client: EthBeaconNodeApiClient, + state: Arc, +} + +#[bon] +impl BeaconMock { + /// Builds a beacon mock with Charon-compatible defaults, overriding any + /// provided fields. + #[builder] + pub async fn new( + validator_set: Option, + slot_duration: Option, + slots_per_epoch: Option, + genesis_time: Option>, + genesis_validators_root: Option, + spec: Option, + deterministic_attester_duties: Option, + deterministic_proposer_duties: Option, + ) -> Result { + let mut spec = spec.unwrap_or_else(default_spec); + let mut genesis = default_genesis(); + let validator_set = validator_set.unwrap_or_default(); + + if let Some(slot_duration) = slot_duration { + set_object_field( + &mut spec, + "SECONDS_PER_SLOT", + slot_duration.as_secs().to_string(), + ); + } + + if let Some(slots_per_epoch) = slots_per_epoch { + set_object_field(&mut spec, "SLOTS_PER_EPOCH", slots_per_epoch.to_string()); + } + + if let Some(genesis_time) = genesis_time { + let timestamp = genesis_time.timestamp().to_string(); + set_object_field(&mut genesis, "genesis_time", timestamp.clone()); + set_object_field(&mut spec, "MIN_GENESIS_TIME", timestamp); + } + + if let Some(genesis_validators_root) = genesis_validators_root { + set_object_field( + &mut genesis, + "genesis_validators_root", + hex_0x(genesis_validators_root), + ); + } + + let state = Arc::new(MockState::new(spec, genesis, validator_set)); + *write_lock(&state.deterministic_attester_duties) = deterministic_attester_duties; + *write_lock(&state.deterministic_proposer_duties) = deterministic_proposer_duties; + + let server = MockServer::start().await; + mount_defaults(&server, Arc::clone(&state)).await; + + let client = EthBeaconNodeApiClient::with_base_url(server.uri()).map_err(Error::Client)?; + + Ok(Self { + server, + client, + state, + }) + } + + /// Returns the generated beacon node API client connected to this mock. + #[must_use] + pub fn client(&self) -> &EthBeaconNodeApiClient { + &self.client + } + + /// Returns the backing mock server for mounting test-specific endpoints. + #[must_use] + pub fn server(&self) -> &MockServer { + &self.server + } + + /// Returns the mock server base URI. + #[must_use] + pub fn uri(&self) -> String { + self.server.uri() + } + + /// Returns shared state used by the mounted HTTP handlers. + #[must_use] + pub fn state(&self) -> Arc { + Arc::clone(&self.state) + } +} + +async fn mount_defaults(server: &MockServer, state: Arc) { + Mock::given(method("GET")) + .and(path("/up")) + .respond_with(ResponseTemplate::new(200)) + .with_priority(DEFAULT_MOCK_PRIORITY) + .mount(server) + .await; + + mount_json(server, "GET", "/eth/v1/config/spec", { + let state = Arc::clone(&state); + move |_| json!({ "data": state.spec() }) + }) + .await; + + mount_json(server, "GET", "/eth/v1/beacon/genesis", { + let state = Arc::clone(&state); + move |_| json!({ "data": state.genesis() }) + }) + .await; + + mount_json(server, "GET", "/eth/v1/config/fork_schedule", |_| { + json!({ + "data": [ + { "previous_version": "0x01017000", "current_version": "0x01017000", "epoch": "0" }, + { "previous_version": "0x01017000", "current_version": "0x02017000", "epoch": "0" }, + { "previous_version": "0x02017000", "current_version": "0x03017000", "epoch": "0" }, + { "previous_version": "0x03017000", "current_version": "0x04017000", "epoch": "0" }, + { "previous_version": "0x04017000", "current_version": "0x05017000", "epoch": "0" } + ] + }) + }) + .await; + + mount_json( + server, + "GET", + "/eth/v1/node/version", + |_| json!({ "data": { "version": "charon/static_beacon_mock" } }), + ) + .await; + + mount_json(server, "GET", "/eth/v1/node/syncing", |_| { + json!({ + "data": { + "head_slot": "1", + "sync_distance": "0", + "is_syncing": false, + "is_optimistic": false, + "el_offline": false + } + }) + }) + .await; + + mount_json(server, "GET", "/eth/v1/beacon/headers/head", |_| { + json!({ + "data": { + "root": ZERO_ROOT, + "canonical": true, + "header": { + "message": { + "slot": "1", + "proposer_index": "0", + "parent_root": ZERO_ROOT, + "state_root": ZERO_ROOT, + "body_root": ZERO_ROOT + }, + "signature": format!("0x{}", "00".repeat(96)) + } + }, + "execution_optimistic": false, + "finalized": false + }) + }) + .await; + + mount_json(server, "GET", "/eth/v1/config/deposit_contract", |_| { + json!({ + "data": { + "chain_id": "17000", + "address": "0x4242424242424242424242424242424242424242" + } + }) + }) + .await; + + mount_status( + server, + "POST", + "/eth/v1/validator/sync_committee_subscriptions", + 200, + ) + .await; + mount_status( + server, + "POST", + "/eth/v1/validator/beacon_committee_subscriptions", + 200, + ) + .await; + mount_status( + server, + "POST", + "/eth/v1/validator/prepare_beacon_proposer", + 200, + ) + .await; + + mount_json_with_status( + server, + "GET", + "/eth/v2/validator/aggregate_attestation", + 400, + |_| { + json!({ + "code": 403, + "message": "Beacon node was not assigned to aggregate on that subnet." + }) + }, + ) + .await; + + mount_json(server, "GET", "/eth/v1/beacon/states/head/validators", { + let state = Arc::clone(&state); + move |_| validators_response(&state) + }) + .await; + + mount_json( + server, + "POST", + r"^/eth/v1/validator/duties/attester/[0-9]+$", + { + let state = Arc::clone(&state); + move |request| attester_duties_response(&state, request) + }, + ) + .await; + + mount_json( + server, + "GET", + r"^/eth/v1/validator/duties/proposer/[0-9]+$", + { + let state = Arc::clone(&state); + move |request| proposer_duties_response(&state, request) + }, + ) + .await; +} + +async fn mount_json(server: &MockServer, http_method: &'static str, endpoint: &'static str, f: F) +where + F: Send + Sync + 'static + Fn(&Request) -> Value, +{ + mount_json_with_status(server, http_method, endpoint, 200, f).await; +} + +async fn mount_json_with_status( + server: &MockServer, + http_method: &'static str, + endpoint: &'static str, + status: u16, + f: F, +) where + F: Send + Sync + 'static + Fn(&Request) -> Value, +{ + let route = Mock::given(method(http_method)); + let route = if endpoint.starts_with('^') { + route.and(path_regex(endpoint)) + } else { + route.and(path(endpoint)) + }; + + route + .respond_with(move |request: &Request| { + ResponseTemplate::new(status).set_body_json(f(request)) + }) + .with_priority(DEFAULT_MOCK_PRIORITY) + .mount(server) + .await; +} + +async fn mount_status( + server: &MockServer, + http_method: &'static str, + endpoint: &'static str, + status: u16, +) { + Mock::given(method(http_method)) + .and(path(endpoint)) + .respond_with(ResponseTemplate::new(status)) + .with_priority(DEFAULT_MOCK_PRIORITY) + .mount(server) + .await; +} + +fn validators_response(state: &MockState) -> Value { + let data: Vec = read_lock(&state.validator_set) + .validators() + .into_iter() + .map(|validator| { + json!({ + "index": validator.index.to_string(), + "balance": validator.balance.to_string(), + "status": validator.status, + "validator": validator.validator, + }) + }) + .collect(); + + json!({ + "data": data, + "execution_optimistic": false, + "finalized": false + }) +} + +fn attester_duties_response(state: &MockState, request: &Request) -> Value { + let Some(factor) = *read_lock(&state.deterministic_attester_duties) else { + return duties_response(Vec::new()); + }; + + let epoch = epoch_from_path(request.url.path()); + let mut indices = indices_from_body(request); + indices.sort_unstable(); + + let validator_set = read_lock(&state.validator_set).clone(); + let slots_per_epoch = slots_per_epoch(state); + let committee_length = factor.max(1); + let validator_committee_index = committee_length.saturating_sub(1); + + let data = indices + .into_iter() + .enumerate() + .filter_map(|(position, index)| { + let validator = validator_set.by_index(index)?; + let position = u64::try_from(position).ok()?; + let slot_offset = position.checked_mul(factor)?.checked_rem(slots_per_epoch)?; + let slot = slots_per_epoch + .checked_mul(epoch)? + .checked_add(slot_offset)?; + + Some(json!({ + "pubkey": validator.validator.pubkey, + "slot": slot.to_string(), + "validator_index": index.to_string(), + "committee_index": index.to_string(), + "committee_length": committee_length.to_string(), + "committees_at_slot": slots_per_epoch.to_string(), + "validator_committee_index": validator_committee_index.to_string(), + })) + }) + .collect(); + + duties_response(data) +} + +fn proposer_duties_response(state: &MockState, request: &Request) -> Value { + let Some(factor) = *read_lock(&state.deterministic_proposer_duties) else { + return duties_response(Vec::new()); + }; + + let epoch = epoch_from_path(request.url.path()); + let slots_per_epoch = slots_per_epoch(state); + let validators = read_lock(&state.validator_set).validators(); + let mut assigned_slots = BTreeMap::new(); + let mut data = Vec::new(); + + for (position, validator) in validators.into_iter().enumerate() { + let Ok(position) = u64::try_from(position) else { + continue; + }; + let Some(slot_offset) = position + .checked_mul(factor) + .and_then(|offset| offset.checked_rem(slots_per_epoch)) + else { + continue; + }; + if assigned_slots.contains_key(&slot_offset) { + break; + } + + assigned_slots.insert(slot_offset, ()); + + let Some(slot) = slots_per_epoch + .checked_mul(epoch) + .and_then(|base| base.checked_add(slot_offset)) + else { + continue; + }; + + data.push(json!({ + "pubkey": validator.validator.pubkey, + "slot": slot.to_string(), + "validator_index": validator.index.to_string(), + })); + + if factor == 0 { + break; + } + } + + duties_response(data) +} + +fn duties_response(data: Vec) -> Value { + json!({ + "data": data, + "dependent_root": ZERO_ROOT, + "execution_optimistic": false + }) +} + +fn indices_from_body(request: &Request) -> Vec { + serde_json::from_slice::>(&request.body) + .map(|indices| { + indices + .into_iter() + .filter_map(|index| index.parse::().ok()) + .collect() + }) + .unwrap_or_default() +} + +fn epoch_from_path(path: &str) -> Epoch { + path.rsplit('/') + .next() + .and_then(|epoch| epoch.parse::().ok()) + .unwrap_or_default() +} + +fn slots_per_epoch(state: &MockState) -> u64 { + read_lock(&state.spec) + .get("SLOTS_PER_EPOCH") + .and_then(Value::as_str) + .and_then(|value| value.parse().ok()) + .filter(|slots| *slots > 0) + .unwrap_or(16) +} + +fn default_spec() -> Value { + json!({ + "CONFIG_NAME": "charon-simnet", + "SLOTS_PER_EPOCH": "16", + "SECONDS_PER_SLOT": "12", + "MIN_GENESIS_TIME": default_genesis_time().timestamp().to_string(), + "GENESIS_FORK_VERSION": DEFAULT_GENESIS_FORK_VERSION, + "ALTAIR_FORK_VERSION": "0x20000910", + "ALTAIR_FORK_EPOCH": "0", + "BELLATRIX_FORK_VERSION": "0x30000910", + "BELLATRIX_FORK_EPOCH": "0", + "CAPELLA_FORK_VERSION": "0x40000910", + "CAPELLA_FORK_EPOCH": "0", + "DENEB_FORK_VERSION": "0x50000910", + "DENEB_FORK_EPOCH": "0", + "ELECTRA_FORK_VERSION": "0x60000910", + "ELECTRA_FORK_EPOCH": "2048", + "FULU_FORK_VERSION": "0x70000910", + "FULU_FORK_EPOCH": u64::MAX.to_string(), + "DOMAIN_BEACON_PROPOSER": "0x00000000", + "DOMAIN_BEACON_ATTESTER": "0x01000000", + "DOMAIN_RANDAO": "0x02000000", + "DOMAIN_DEPOSIT": "0x03000000", + "DOMAIN_VOLUNTARY_EXIT": "0x04000000", + "DOMAIN_SELECTION_PROOF": "0x05000000", + "DOMAIN_AGGREGATE_AND_PROOF": "0x06000000", + "DOMAIN_SYNC_COMMITTEE": "0x07000000", + "DOMAIN_SYNC_COMMITTEE_SELECTION_PROOF": "0x08000000", + "DOMAIN_CONTRIBUTION_AND_PROOF": "0x09000000", + "DOMAIN_APPLICATION_BUILDER": "0x00000001", + "TARGET_AGGREGATORS_PER_COMMITTEE": "16", + "SYNC_COMMITTEE_SIZE": "512", + "SYNC_COMMITTEE_SUBNET_COUNT": "4", + "TARGET_AGGREGATORS_PER_SYNC_SUBCOMMITTEE": "16", + "EPOCHS_PER_SYNC_COMMITTEE_PERIOD": "256" + }) +} + +fn default_genesis() -> Value { + json!({ + "genesis_time": default_genesis_time().timestamp().to_string(), + "genesis_validators_root": DEFAULT_GENESIS_VALIDATORS_ROOT, + "genesis_fork_version": DEFAULT_GENESIS_FORK_VERSION, + }) +} + +fn default_genesis_time() -> DateTime { + match Utc.with_ymd_and_hms(2022, 3, 1, 0, 0, 0).single() { + Some(time) => time, + None => Utc::now(), + } +} + +fn set_object_field(target: &mut Value, key: &'static str, value: impl Into) { + if let Some(target) = target.as_object_mut() { + target.insert(key.to_string(), value.into()); + } +} + +fn hex_0x(bytes: impl AsRef<[u8]>) -> String { + format!("0x{}", hex::encode(bytes.as_ref())) +} + +fn parse_pubkey(pubkey: &str) -> Option { + let pubkey = pubkey.strip_prefix("0x").unwrap_or(pubkey); + let bytes = hex::decode(pubkey).ok()?; + bytes.try_into().ok() +} + +fn read_lock(lock: &RwLock) -> RwLockReadGuard<'_, T> { + match lock.read() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + } +} + +fn write_lock(lock: &RwLock) -> RwLockWriteGuard<'_, T> { + match lock.write() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + } +} diff --git a/crates/testutil/src/lib.rs b/crates/testutil/src/lib.rs index 686c8c7a..3290722c 100644 --- a/crates/testutil/src/lib.rs +++ b/crates/testutil/src/lib.rs @@ -7,6 +7,10 @@ /// Random utilities. pub mod random; +/// Beacon node API mock utilities. +pub mod beaconmock; + +pub use beaconmock::{BeaconMock, MockState, Validator, ValidatorSet}; pub use random::{ random_deneb_versioned_attestation, random_eth2_signature, random_eth2_signature_bytes, random_root, random_root_bytes, random_slot, random_v_idx, From 047c67f4401fb289e9643b1c718a1142c49b1f97 Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> Date: Thu, 14 May 2026 19:24:45 +0200 Subject: [PATCH 02/21] refactor(testutil): split beaconmock into directory module Prep for parallel work on headproducer, attestation store, options, validator-set API, and fuzzer. Public API (BeaconMock, MockState, Validator, ValidatorSet) unchanged. --- .../{beaconmock.rs => beaconmock/defaults.rs} | 342 ++---------------- crates/testutil/src/beaconmock/mod.rs | 126 +++++++ crates/testutil/src/beaconmock/state.rs | 199 ++++++++++ 3 files changed, 348 insertions(+), 319 deletions(-) rename crates/testutil/src/{beaconmock.rs => beaconmock/defaults.rs} (52%) create mode 100644 crates/testutil/src/beaconmock/mod.rs create mode 100644 crates/testutil/src/beaconmock/state.rs diff --git a/crates/testutil/src/beaconmock.rs b/crates/testutil/src/beaconmock/defaults.rs similarity index 52% rename from crates/testutil/src/beaconmock.rs rename to crates/testutil/src/beaconmock/defaults.rs index 5de3ca68..9db0b399 100644 --- a/crates/testutil/src/beaconmock.rs +++ b/crates/testutil/src/beaconmock/defaults.rs @@ -1,295 +1,25 @@ -//! Beacon node API mocks for tests. -//! -//! `BeaconMock` owns the backing `wiremock::MockServer`, so keep the mock alive -//! for as long as clients use `BeaconMock::client()`. - -use std::{ - collections::BTreeMap, - sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard}, - time::Duration, -}; +//! Default spec/genesis and mount logic for the beacon mock HTTP handlers. + +use std::{collections::BTreeMap, sync::Arc}; -use bon::bon; use chrono::{DateTime, TimeZone, Utc}; -use pluto_eth2api::{ - EthBeaconNodeApiClient, ValidatorResponseValidator, ValidatorStatus, - spec::phase0::{BLSPubKey, Epoch, Root, ValidatorIndex}, -}; +use pluto_eth2api::spec::phase0::{Epoch, ValidatorIndex}; use serde_json::{Value, json}; use wiremock::{ Mock, MockServer, Request, ResponseTemplate, matchers::{method, path, path_regex}, }; -const ZERO_ROOT: &str = "0x0000000000000000000000000000000000000000000000000000000000000000"; -const DEFAULT_GENESIS_VALIDATORS_ROOT: &str = - "0x9143aa7c615a7f7115e2b6aac319c03529df8242ae705fba9df39b79c59fa8b1"; -const DEFAULT_GENESIS_FORK_VERSION: &str = "0x01017000"; -const DEFAULT_WITHDRAWAL_CREDENTIALS: &str = - "0x3132333435363738393031323334353637383930313233343536373839303132"; -const DEFAULT_MOCK_PRIORITY: u8 = 255; - -/// Errors returned while configuring `BeaconMock`. -#[derive(Debug, thiserror::Error)] -pub enum Error { - /// The generated beacon API client could not be created for the mock URL. - #[error("create beacon node api client: {0}")] - Client(#[source] anyhow::Error), -} - -/// Result type for beacon mock setup. -pub type Result = std::result::Result; - -/// Minimal validator representation used by the beacon mock. -#[derive(Debug, Clone, PartialEq)] -pub struct Validator { - /// Validator index in the beacon registry. - pub index: ValidatorIndex, - /// Current balance in gwei. - pub balance: u64, - /// Current validator status. - pub status: ValidatorStatus, - /// Validator details returned by the beacon API. - pub validator: ValidatorResponseValidator, -} - -impl Validator { - /// Creates an active validator with the provided index and public key. - #[must_use] - pub fn active(index: ValidatorIndex, pubkey: BLSPubKey) -> Self { - let pubkey = hex_0x(pubkey); - - Self { - index, - balance: index, - status: ValidatorStatus::ActiveOngoing, - validator: ValidatorResponseValidator { - activation_eligibility_epoch: index.to_string(), - activation_epoch: index.checked_add(1).unwrap_or(index).to_string(), - effective_balance: index.to_string(), - exit_epoch: u64::MAX.to_string(), - pubkey, - slashed: false, - withdrawable_epoch: u64::MAX.to_string(), - withdrawal_credentials: DEFAULT_WITHDRAWAL_CREDENTIALS.to_string(), - }, - } - } -} +use super::state::{MockState, read_lock}; -/// Validator set used to seed validator and duty endpoints. -#[derive(Debug, Clone, Default, PartialEq)] -pub struct ValidatorSet(BTreeMap); - -impl ValidatorSet { - /// Returns the small deterministic validator set from Charon's Go - /// beaconmock. - #[must_use] - pub fn validator_set_a() -> Self { - [ - ( - 1, - "0x914cff835a769156ba43ad50b931083c2dadd94e8359ce394bc7a3e06424d0214922ddf15f81640530b9c25c0bc0d490", - ), - ( - 2, - "0x8dae41352b69f2b3a1c0b05330c1bf65f03730c520273028864b11fcb94d8ce8f26d64f979a0ee3025467f45fd2241ea", - ), - ( - 3, - "0x8ee91545183c8c2db86633626f5074fd8ef93c4c9b7a2879ad1768f600c5b5906c3af20d47de42c3b032956fa8db1a76", - ), - ] - .into_iter() - .filter_map(|(index, pubkey)| { - parse_pubkey(pubkey).map(|pubkey| (index, Validator::active(index, pubkey))) - }) - .collect() - } - - /// Inserts or replaces a validator. - pub fn insert(&mut self, validator: Validator) { - self.0.insert(validator.index, validator); - } - - /// Returns all validators in index order. - #[must_use] - pub fn validators(&self) -> Vec { - self.0.values().cloned().collect() - } - - /// Returns the validator for an index. - #[must_use] - pub fn by_index(&self, index: ValidatorIndex) -> Option { - self.0.get(&index).cloned() - } - - /// Returns true if the set contains no validators. - #[must_use] - pub fn is_empty(&self) -> bool { - self.0.is_empty() - } -} - -impl FromIterator<(ValidatorIndex, Validator)> for ValidatorSet { - fn from_iter>(iter: T) -> Self { - Self(iter.into_iter().collect()) - } -} - -/// Shared mock state used by mounted HTTP handlers. -#[derive(Debug)] -pub struct MockState { - spec: RwLock, - genesis: RwLock, - validator_set: RwLock, - deterministic_attester_duties: RwLock>, - deterministic_proposer_duties: RwLock>, -} - -impl MockState { - fn new(spec: Value, genesis: Value, validator_set: ValidatorSet) -> Self { - Self { - spec: RwLock::new(spec), - genesis: RwLock::new(genesis), - validator_set: RwLock::new(validator_set), - deterministic_attester_duties: RwLock::new(None), - deterministic_proposer_duties: RwLock::new(None), - } - } - - /// Returns a clone of the spec map served by `/eth/v1/config/spec`. - #[must_use] - pub fn spec(&self) -> Value { - read_lock(&self.spec).clone() - } - - /// Replaces one spec key. - pub fn set_spec_field(&self, key: impl Into, value: impl Into) { - let key = key.into(); - let value = value.into(); - if let Some(spec) = write_lock(&self.spec).as_object_mut() { - spec.insert(key, value); - } - } - - /// Returns a clone of the genesis data served by `/eth/v1/beacon/genesis`. - #[must_use] - pub fn genesis(&self) -> Value { - read_lock(&self.genesis).clone() - } - - /// Replaces one genesis field. - pub fn set_genesis_field(&self, key: impl Into, value: impl Into) { - let key = key.into(); - let value = value.into(); - if let Some(genesis) = write_lock(&self.genesis).as_object_mut() { - genesis.insert(key, value); - } - } - - /// Replaces the validator set served by validator-related endpoints. - pub fn set_validator_set(&self, validator_set: ValidatorSet) { - *write_lock(&self.validator_set) = validator_set; - } -} - -/// Wire-level beacon node mock with a generated client pre-dialed to the -/// server. -#[derive(Debug)] -pub struct BeaconMock { - server: MockServer, - client: EthBeaconNodeApiClient, - state: Arc, -} - -#[bon] -impl BeaconMock { - /// Builds a beacon mock with Charon-compatible defaults, overriding any - /// provided fields. - #[builder] - pub async fn new( - validator_set: Option, - slot_duration: Option, - slots_per_epoch: Option, - genesis_time: Option>, - genesis_validators_root: Option, - spec: Option, - deterministic_attester_duties: Option, - deterministic_proposer_duties: Option, - ) -> Result { - let mut spec = spec.unwrap_or_else(default_spec); - let mut genesis = default_genesis(); - let validator_set = validator_set.unwrap_or_default(); - - if let Some(slot_duration) = slot_duration { - set_object_field( - &mut spec, - "SECONDS_PER_SLOT", - slot_duration.as_secs().to_string(), - ); - } - - if let Some(slots_per_epoch) = slots_per_epoch { - set_object_field(&mut spec, "SLOTS_PER_EPOCH", slots_per_epoch.to_string()); - } - - if let Some(genesis_time) = genesis_time { - let timestamp = genesis_time.timestamp().to_string(); - set_object_field(&mut genesis, "genesis_time", timestamp.clone()); - set_object_field(&mut spec, "MIN_GENESIS_TIME", timestamp); - } - - if let Some(genesis_validators_root) = genesis_validators_root { - set_object_field( - &mut genesis, - "genesis_validators_root", - hex_0x(genesis_validators_root), - ); - } - - let state = Arc::new(MockState::new(spec, genesis, validator_set)); - *write_lock(&state.deterministic_attester_duties) = deterministic_attester_duties; - *write_lock(&state.deterministic_proposer_duties) = deterministic_proposer_duties; - - let server = MockServer::start().await; - mount_defaults(&server, Arc::clone(&state)).await; - - let client = EthBeaconNodeApiClient::with_base_url(server.uri()).map_err(Error::Client)?; - - Ok(Self { - server, - client, - state, - }) - } - - /// Returns the generated beacon node API client connected to this mock. - #[must_use] - pub fn client(&self) -> &EthBeaconNodeApiClient { - &self.client - } - - /// Returns the backing mock server for mounting test-specific endpoints. - #[must_use] - pub fn server(&self) -> &MockServer { - &self.server - } - - /// Returns the mock server base URI. - #[must_use] - pub fn uri(&self) -> String { - self.server.uri() - } - - /// Returns shared state used by the mounted HTTP handlers. - #[must_use] - pub fn state(&self) -> Arc { - Arc::clone(&self.state) - } -} +pub(crate) const ZERO_ROOT: &str = + "0x0000000000000000000000000000000000000000000000000000000000000000"; +pub(crate) const DEFAULT_GENESIS_VALIDATORS_ROOT: &str = + "0x9143aa7c615a7f7115e2b6aac319c03529df8242ae705fba9df39b79c59fa8b1"; +pub(crate) const DEFAULT_GENESIS_FORK_VERSION: &str = "0x01017000"; +pub(crate) const DEFAULT_MOCK_PRIORITY: u8 = 255; -async fn mount_defaults(server: &MockServer, state: Arc) { +pub(crate) async fn mount_defaults(server: &MockServer, state: Arc) { Mock::given(method("GET")) .and(path("/up")) .respond_with(ResponseTemplate::new(200)) @@ -440,14 +170,18 @@ async fn mount_defaults(server: &MockServer, state: Arc) { .await; } -async fn mount_json(server: &MockServer, http_method: &'static str, endpoint: &'static str, f: F) -where +pub(crate) async fn mount_json( + server: &MockServer, + http_method: &'static str, + endpoint: &'static str, + f: F, +) where F: Send + Sync + 'static + Fn(&Request) -> Value, { mount_json_with_status(server, http_method, endpoint, 200, f).await; } -async fn mount_json_with_status( +pub(crate) async fn mount_json_with_status( server: &MockServer, http_method: &'static str, endpoint: &'static str, @@ -472,7 +206,7 @@ async fn mount_json_with_status( .await; } -async fn mount_status( +pub(crate) async fn mount_status( server: &MockServer, http_method: &'static str, endpoint: &'static str, @@ -630,7 +364,7 @@ fn slots_per_epoch(state: &MockState) -> u64 { .unwrap_or(16) } -fn default_spec() -> Value { +pub(crate) fn default_spec() -> Value { json!({ "CONFIG_NAME": "charon-simnet", "SLOTS_PER_EPOCH": "16", @@ -668,7 +402,7 @@ fn default_spec() -> Value { }) } -fn default_genesis() -> Value { +pub(crate) fn default_genesis() -> Value { json!({ "genesis_time": default_genesis_time().timestamp().to_string(), "genesis_validators_root": DEFAULT_GENESIS_VALIDATORS_ROOT, @@ -676,39 +410,9 @@ fn default_genesis() -> Value { }) } -fn default_genesis_time() -> DateTime { +pub(crate) fn default_genesis_time() -> DateTime { match Utc.with_ymd_and_hms(2022, 3, 1, 0, 0, 0).single() { Some(time) => time, None => Utc::now(), } } - -fn set_object_field(target: &mut Value, key: &'static str, value: impl Into) { - if let Some(target) = target.as_object_mut() { - target.insert(key.to_string(), value.into()); - } -} - -fn hex_0x(bytes: impl AsRef<[u8]>) -> String { - format!("0x{}", hex::encode(bytes.as_ref())) -} - -fn parse_pubkey(pubkey: &str) -> Option { - let pubkey = pubkey.strip_prefix("0x").unwrap_or(pubkey); - let bytes = hex::decode(pubkey).ok()?; - bytes.try_into().ok() -} - -fn read_lock(lock: &RwLock) -> RwLockReadGuard<'_, T> { - match lock.read() { - Ok(guard) => guard, - Err(poisoned) => poisoned.into_inner(), - } -} - -fn write_lock(lock: &RwLock) -> RwLockWriteGuard<'_, T> { - match lock.write() { - Ok(guard) => guard, - Err(poisoned) => poisoned.into_inner(), - } -} diff --git a/crates/testutil/src/beaconmock/mod.rs b/crates/testutil/src/beaconmock/mod.rs new file mode 100644 index 00000000..84306991 --- /dev/null +++ b/crates/testutil/src/beaconmock/mod.rs @@ -0,0 +1,126 @@ +//! Beacon node API mocks for tests. +//! +//! `BeaconMock` owns the backing `wiremock::MockServer`, so keep the mock alive +//! for as long as clients use `BeaconMock::client()`. + +mod defaults; +mod state; + +use std::{sync::Arc, time::Duration}; + +use bon::bon; +use chrono::{DateTime, Utc}; +use pluto_eth2api::{EthBeaconNodeApiClient, spec::phase0::Root}; +use serde_json::Value; +use wiremock::MockServer; + +use defaults::{default_genesis, default_spec, mount_defaults}; +use state::{hex_0x, set_object_field, write_lock}; + +pub use state::{MockState, Validator, ValidatorSet}; + +/// Errors returned while configuring `BeaconMock`. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// The generated beacon API client could not be created for the mock URL. + #[error("create beacon node api client: {0}")] + Client(#[source] anyhow::Error), +} + +/// Result type for beacon mock setup. +pub type Result = std::result::Result; + +/// Wire-level beacon node mock with a generated client pre-dialed to the +/// server. +#[derive(Debug)] +pub struct BeaconMock { + server: MockServer, + client: EthBeaconNodeApiClient, + state: Arc, +} + +#[bon] +impl BeaconMock { + /// Builds a beacon mock with Charon-compatible defaults, overriding any + /// provided fields. + #[builder] + pub async fn new( + validator_set: Option, + slot_duration: Option, + slots_per_epoch: Option, + genesis_time: Option>, + genesis_validators_root: Option, + spec: Option, + deterministic_attester_duties: Option, + deterministic_proposer_duties: Option, + ) -> Result { + let mut spec = spec.unwrap_or_else(default_spec); + let mut genesis = default_genesis(); + let validator_set = validator_set.unwrap_or_default(); + + if let Some(slot_duration) = slot_duration { + set_object_field( + &mut spec, + "SECONDS_PER_SLOT", + slot_duration.as_secs().to_string(), + ); + } + + if let Some(slots_per_epoch) = slots_per_epoch { + set_object_field(&mut spec, "SLOTS_PER_EPOCH", slots_per_epoch.to_string()); + } + + if let Some(genesis_time) = genesis_time { + let timestamp = genesis_time.timestamp().to_string(); + set_object_field(&mut genesis, "genesis_time", timestamp.clone()); + set_object_field(&mut spec, "MIN_GENESIS_TIME", timestamp); + } + + if let Some(genesis_validators_root) = genesis_validators_root { + set_object_field( + &mut genesis, + "genesis_validators_root", + hex_0x(genesis_validators_root), + ); + } + + let state = Arc::new(MockState::new(spec, genesis, validator_set)); + *write_lock(&state.deterministic_attester_duties) = deterministic_attester_duties; + *write_lock(&state.deterministic_proposer_duties) = deterministic_proposer_duties; + + let server = MockServer::start().await; + mount_defaults(&server, Arc::clone(&state)).await; + + let client = EthBeaconNodeApiClient::with_base_url(server.uri()).map_err(Error::Client)?; + + Ok(Self { + server, + client, + state, + }) + } + + /// Returns the generated beacon node API client connected to this mock. + #[must_use] + pub fn client(&self) -> &EthBeaconNodeApiClient { + &self.client + } + + /// Returns the backing mock server for mounting test-specific endpoints. + #[must_use] + pub fn server(&self) -> &MockServer { + &self.server + } + + /// Returns the mock server base URI. + #[must_use] + pub fn uri(&self) -> String { + self.server.uri() + } + + /// Returns shared state used by the mounted HTTP handlers. + #[must_use] + pub fn state(&self) -> Arc { + Arc::clone(&self.state) + } +} diff --git a/crates/testutil/src/beaconmock/state.rs b/crates/testutil/src/beaconmock/state.rs new file mode 100644 index 00000000..6c7dd1b8 --- /dev/null +++ b/crates/testutil/src/beaconmock/state.rs @@ -0,0 +1,199 @@ +//! Shared state, validator types, and helpers for `BeaconMock`. + +use std::{ + collections::BTreeMap, + sync::{RwLock, RwLockReadGuard, RwLockWriteGuard}, +}; + +use pluto_eth2api::{ + ValidatorResponseValidator, ValidatorStatus, + spec::phase0::{BLSPubKey, ValidatorIndex}, +}; +use serde_json::Value; + +pub(crate) const DEFAULT_WITHDRAWAL_CREDENTIALS: &str = + "0x3132333435363738393031323334353637383930313233343536373839303132"; + +/// Minimal validator representation used by the beacon mock. +#[derive(Debug, Clone, PartialEq)] +pub struct Validator { + /// Validator index in the beacon registry. + pub index: ValidatorIndex, + /// Current balance in gwei. + pub balance: u64, + /// Current validator status. + pub status: ValidatorStatus, + /// Validator details returned by the beacon API. + pub validator: ValidatorResponseValidator, +} + +impl Validator { + /// Creates an active validator with the provided index and public key. + #[must_use] + pub fn active(index: ValidatorIndex, pubkey: BLSPubKey) -> Self { + let pubkey = hex_0x(pubkey); + + Self { + index, + balance: index, + status: ValidatorStatus::ActiveOngoing, + validator: ValidatorResponseValidator { + activation_eligibility_epoch: index.to_string(), + activation_epoch: index.checked_add(1).unwrap_or(index).to_string(), + effective_balance: index.to_string(), + exit_epoch: u64::MAX.to_string(), + pubkey, + slashed: false, + withdrawable_epoch: u64::MAX.to_string(), + withdrawal_credentials: DEFAULT_WITHDRAWAL_CREDENTIALS.to_string(), + }, + } + } +} + +/// Validator set used to seed validator and duty endpoints. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct ValidatorSet(BTreeMap); + +impl ValidatorSet { + /// Returns the small deterministic validator set from Charon's Go + /// beaconmock. + #[must_use] + pub fn validator_set_a() -> Self { + [ + ( + 1, + "0x914cff835a769156ba43ad50b931083c2dadd94e8359ce394bc7a3e06424d0214922ddf15f81640530b9c25c0bc0d490", + ), + ( + 2, + "0x8dae41352b69f2b3a1c0b05330c1bf65f03730c520273028864b11fcb94d8ce8f26d64f979a0ee3025467f45fd2241ea", + ), + ( + 3, + "0x8ee91545183c8c2db86633626f5074fd8ef93c4c9b7a2879ad1768f600c5b5906c3af20d47de42c3b032956fa8db1a76", + ), + ] + .into_iter() + .filter_map(|(index, pubkey)| { + parse_pubkey(pubkey).map(|pubkey| (index, Validator::active(index, pubkey))) + }) + .collect() + } + + /// Inserts or replaces a validator. + pub fn insert(&mut self, validator: Validator) { + self.0.insert(validator.index, validator); + } + + /// Returns all validators in index order. + #[must_use] + pub fn validators(&self) -> Vec { + self.0.values().cloned().collect() + } + + /// Returns the validator for an index. + #[must_use] + pub fn by_index(&self, index: ValidatorIndex) -> Option { + self.0.get(&index).cloned() + } + + /// Returns true if the set contains no validators. + #[must_use] + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +impl FromIterator<(ValidatorIndex, Validator)> for ValidatorSet { + fn from_iter>(iter: T) -> Self { + Self(iter.into_iter().collect()) + } +} + +/// Shared mock state used by mounted HTTP handlers. +#[derive(Debug)] +pub struct MockState { + pub(crate) spec: RwLock, + pub(crate) genesis: RwLock, + pub(crate) validator_set: RwLock, + pub(crate) deterministic_attester_duties: RwLock>, + pub(crate) deterministic_proposer_duties: RwLock>, +} + +impl MockState { + pub(crate) fn new(spec: Value, genesis: Value, validator_set: ValidatorSet) -> Self { + Self { + spec: RwLock::new(spec), + genesis: RwLock::new(genesis), + validator_set: RwLock::new(validator_set), + deterministic_attester_duties: RwLock::new(None), + deterministic_proposer_duties: RwLock::new(None), + } + } + + /// Returns a clone of the spec map served by `/eth/v1/config/spec`. + #[must_use] + pub fn spec(&self) -> Value { + read_lock(&self.spec).clone() + } + + /// Replaces one spec key. + pub fn set_spec_field(&self, key: impl Into, value: impl Into) { + let key = key.into(); + let value = value.into(); + if let Some(spec) = write_lock(&self.spec).as_object_mut() { + spec.insert(key, value); + } + } + + /// Returns a clone of the genesis data served by `/eth/v1/beacon/genesis`. + #[must_use] + pub fn genesis(&self) -> Value { + read_lock(&self.genesis).clone() + } + + /// Replaces one genesis field. + pub fn set_genesis_field(&self, key: impl Into, value: impl Into) { + let key = key.into(); + let value = value.into(); + if let Some(genesis) = write_lock(&self.genesis).as_object_mut() { + genesis.insert(key, value); + } + } + + /// Replaces the validator set served by validator-related endpoints. + pub fn set_validator_set(&self, validator_set: ValidatorSet) { + *write_lock(&self.validator_set) = validator_set; + } +} + +pub(crate) fn hex_0x(bytes: impl AsRef<[u8]>) -> String { + format!("0x{}", hex::encode(bytes.as_ref())) +} + +pub(crate) fn parse_pubkey(pubkey: &str) -> Option { + let pubkey = pubkey.strip_prefix("0x").unwrap_or(pubkey); + let bytes = hex::decode(pubkey).ok()?; + bytes.try_into().ok() +} + +pub(crate) fn read_lock(lock: &RwLock) -> RwLockReadGuard<'_, T> { + match lock.read() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + } +} + +pub(crate) fn write_lock(lock: &RwLock) -> RwLockWriteGuard<'_, T> { + match lock.write() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + } +} + +pub(crate) fn set_object_field(target: &mut Value, key: &'static str, value: impl Into) { + if let Some(target) = target.as_object_mut() { + target.insert(key.to_string(), value.into()); + } +} From 9765e20401938d54d2ede3eb1285e2746c968687 Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> Date: Thu, 14 May 2026 20:28:31 +0200 Subject: [PATCH 03/21] feat(testutil/beaconmock): port head producer, attestation store, options, fuzzer Brings the Rust beaconmock to functional parity with charon/testutil/beaconmock: - head producer: slot ticker + SSE on /eth/v1/events + deterministic block-root endpoint (headproducer.rs). - attestation store: deterministic AttestationData per (slot, committee), by-root aggregate lookup, 32-slot trim (attestation.rs). - builder options: endpoint_overrides, fork_version, sync committee size/subnet count, no_proposer/attester/sync duties, deterministic sync committee duties (options.rs + sync duties endpoint). - fuzzer mode: random JSON for proposal, attestation, duties endpoints behind a builder flag (fuzzer.rs). - ValidatorSet: by_public_key, public_keys. - default spec extended with mainnet keys real validator clients read. - /eth/v2/beacon/blocks/{id} default endpoint. --- Cargo.lock | 3 + crates/testutil/Cargo.toml | 5 + crates/testutil/src/beaconmock/attestation.rs | 351 +++++++++++++ crates/testutil/src/beaconmock/defaults.rs | 312 ++++++++++-- crates/testutil/src/beaconmock/fuzzer.rs | 469 ++++++++++++++++++ .../testutil/src/beaconmock/headproducer.rs | 457 +++++++++++++++++ crates/testutil/src/beaconmock/mod.rs | 71 ++- crates/testutil/src/beaconmock/options.rs | 316 ++++++++++++ crates/testutil/src/beaconmock/state.rs | 73 +++ crates/testutil/src/lib.rs | 4 + 10 files changed, 2025 insertions(+), 36 deletions(-) create mode 100644 crates/testutil/src/beaconmock/attestation.rs create mode 100644 crates/testutil/src/beaconmock/fuzzer.rs create mode 100644 crates/testutil/src/beaconmock/headproducer.rs create mode 100644 crates/testutil/src/beaconmock/options.rs diff --git a/Cargo.lock b/Cargo.lock index ad155075..6c364bc4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5907,8 +5907,11 @@ dependencies = [ "pluto-crypto", "pluto-eth2api", "rand 0.8.6", + "reqwest 0.13.3", "serde_json", "thiserror 2.0.18", + "tokio", + "tree_hash", "wiremock", ] diff --git a/crates/testutil/Cargo.toml b/crates/testutil/Cargo.toml index 52a6f0bc..65d23978 100644 --- a/crates/testutil/Cargo.toml +++ b/crates/testutil/Cargo.toml @@ -17,7 +17,12 @@ rand.workspace = true hex.workspace = true serde_json.workspace = true thiserror.workspace = true +tokio.workspace = true +tree_hash.workspace = true wiremock.workspace = true +[dev-dependencies] +reqwest.workspace = true + [lints] workspace = true diff --git a/crates/testutil/src/beaconmock/attestation.rs b/crates/testutil/src/beaconmock/attestation.rs new file mode 100644 index 00000000..8c2aa382 --- /dev/null +++ b/crates/testutil/src/beaconmock/attestation.rs @@ -0,0 +1,351 @@ +//! Attestation data store and HTTP endpoints used by `BeaconMock`. +//! +//! Mirrors Charon's Go `attestationStore` (testutil/beaconmock/attestation.go): +//! generates deterministic `AttestationData` for a `(slot, committee_index)` +//! pair, keyed by the SSZ hash-tree-root of the generated data, and serves it +//! back through the `aggregate_attestation` endpoint when queried by root. + +use std::{ + collections::BTreeMap, + sync::{Arc, RwLock}, +}; + +use pluto_eth2api::spec::phase0::{AttestationData, Checkpoint, Epoch, Root, Slot}; +use serde_json::{Value, json}; +use tree_hash::TreeHash; +use wiremock::{ + Mock, MockServer, Request, ResponseTemplate, + matchers::{method, path}, +}; + +use super::state::{MockState, hex_0x, read_lock, write_lock}; + +/// Priority used by attestation routes; lower than the default fallback so +/// these handlers override the static 400 mounted in `defaults.rs`. +const ATTESTATION_PRIORITY: u8 = 100; + +/// Number of slots after which previously generated entries are pruned. +const PRUNE_AFTER_SLOTS: u64 = 32; + +/// Tracks attestation data generated on demand and indexed by SSZ hash root. +/// +/// Mirrors Charon's `attestationStore`. +#[derive(Debug, Default)] +pub(crate) struct AttestationStore { + entries: RwLock>, +} + +impl AttestationStore { + /// Generates a deterministic `AttestationData` for the requested + /// `(slot, committee_index)`, stores it keyed by its SSZ hash-tree-root, + /// and returns the data alongside the computed root. + pub(crate) fn new_attestation_data( + &self, + slot: Slot, + committee_index: u64, + slots_per_epoch: u64, + ) -> (AttestationData, Root) { + let epoch = epoch_from_slot(slot, slots_per_epoch); + let data = build_attestation_data(epoch, slot, committee_index); + let root = data.tree_hash_root().0; + self.set_data(data.clone(), root); + (data, root) + } + + /// Returns a previously generated `AttestationData` for `root`, if any. + pub(crate) fn get_by_root(&self, root: &Root) -> Option { + read_lock(&self.entries).get(root).cloned() + } + + fn set_data(&self, data: AttestationData, root: Root) { + let mut entries = write_lock(&self.entries); + // Drop entries older than `PRUNE_AFTER_SLOTS` relative to the new data. + entries.retain(|_, old| old.slot.saturating_add(PRUNE_AFTER_SLOTS) >= data.slot); + entries.insert(root, data); + } +} + +/// Computes the epoch for `slot` given `slots_per_epoch`, mirroring +/// `eth2util.EpochFromSlot` in Charon's Go code. +fn epoch_from_slot(slot: Slot, slots_per_epoch: u64) -> Epoch { + slot.checked_div(slots_per_epoch).unwrap_or(0) +} + +/// Returns the SSZ hash root of a slot number (little-endian u64, right padded +/// to 32 bytes), matching `eth2util.SlotHashRoot` in Charon's Go code. +fn slot_hash_root(num: u64) -> Root { + num.tree_hash_root().0 +} + +fn build_attestation_data(epoch: Epoch, slot: Slot, committee_index: u64) -> AttestationData { + let previous_epoch = epoch.saturating_sub(1); + AttestationData { + slot, + index: committee_index, + beacon_block_root: slot_hash_root(slot), + source: Checkpoint { + epoch: previous_epoch, + root: slot_hash_root(previous_epoch), + }, + target: Checkpoint { + epoch, + root: slot_hash_root(epoch), + }, + } +} + +/// Mounts the attestation-data and aggregate-attestation handlers on `server`. +/// +/// These routes use a higher priority than `mount_defaults`, so a successful +/// lookup overrides the static 400 served by the default +/// `aggregate_attestation` route; unknown roots fall through to the default +/// 400 response. +pub(crate) async fn mount(server: &MockServer, state: Arc) { + mount_json_with_priority( + server, + "GET", + "/eth/v1/validator/attestation_data", + ATTESTATION_PRIORITY, + { + let state = Arc::clone(&state); + move |request| attestation_data_response(&state, request) + }, + ) + .await; + + Mock::given(method("GET")) + .and(path("/eth/v2/validator/aggregate_attestation")) + .and(query_param_present("attestation_data_root")) + .respond_with({ + let state = Arc::clone(&state); + move |request: &Request| aggregate_attestation_response(&state, request) + }) + .with_priority(ATTESTATION_PRIORITY) + .mount(server) + .await; +} + +fn attestation_data_response(state: &MockState, request: &Request) -> Value { + let slot = query_value(request, "slot") + .and_then(|value| value.parse::().ok()) + .unwrap_or(0); + let committee_index = query_value(request, "committee_index") + .and_then(|value| value.parse::().ok()) + .unwrap_or(0); + + let slots_per_epoch = read_lock(&state.spec) + .get("SLOTS_PER_EPOCH") + .and_then(Value::as_str) + .and_then(|value| value.parse().ok()) + .filter(|slots| *slots > 0) + .unwrap_or(16); + + let (data, _root) = + state + .attestation_store + .new_attestation_data(slot, committee_index, slots_per_epoch); + + json!({ "data": attestation_data_json(&data) }) +} + +fn aggregate_attestation_response(state: &MockState, request: &Request) -> ResponseTemplate { + let root_param = query_value(request, "attestation_data_root").unwrap_or_default(); + let Some(root) = parse_root(&root_param) else { + return ResponseTemplate::new(400).set_body_json(unknown_root_body()); + }; + + let Some(data) = state.attestation_store.get_by_root(&root) else { + return ResponseTemplate::new(400).set_body_json(unknown_root_body()); + }; + + ResponseTemplate::new(200).set_body_json(aggregate_attestation_body(&data)) +} + +fn aggregate_attestation_body(data: &AttestationData) -> Value { + // Charon's defaultMock returns a Fulu (Electra-shaped) attestation with a + // single committee bit set, an empty aggregation bitlist and a zeroed + // signature. + let mut committee_bits = [0u8; 8]; + committee_bits[0] = 0x01; + + json!({ + "version": "fulu", + "data": { + "aggregation_bits": "0x01", + "data": attestation_data_json(data), + "signature": format!("0x{}", "00".repeat(96)), + "committee_bits": hex_0x(committee_bits), + } + }) +} + +fn attestation_data_json(data: &AttestationData) -> Value { + json!({ + "slot": data.slot.to_string(), + "index": data.index.to_string(), + "beacon_block_root": hex_0x(data.beacon_block_root), + "source": { + "epoch": data.source.epoch.to_string(), + "root": hex_0x(data.source.root), + }, + "target": { + "epoch": data.target.epoch.to_string(), + "root": hex_0x(data.target.root), + } + }) +} + +fn unknown_root_body() -> Value { + json!({ + "code": 400, + "message": "unknown aggregate attestation root" + }) +} + +fn parse_root(value: &str) -> Option { + let stripped = value.strip_prefix("0x").unwrap_or(value); + let bytes = hex::decode(stripped).ok()?; + bytes.try_into().ok() +} + +fn query_value(request: &Request, key: &str) -> Option { + request + .url + .query_pairs() + .find_map(|(k, v)| (k == key).then(|| v.into_owned())) +} + +fn query_param_present(key: &'static str) -> impl wiremock::Match { + QueryParamPresent { key } +} + +struct QueryParamPresent { + key: &'static str, +} + +impl wiremock::Match for QueryParamPresent { + fn matches(&self, request: &Request) -> bool { + request.url.query_pairs().any(|(k, _)| k == self.key) + } +} + +async fn mount_json_with_priority( + server: &MockServer, + http_method: &'static str, + endpoint: &'static str, + priority: u8, + f: F, +) where + F: Send + Sync + 'static + Fn(&Request) -> Value, +{ + Mock::given(method(http_method)) + .and(path(endpoint)) + .respond_with(move |request: &Request| ResponseTemplate::new(200).set_body_json(f(request))) + .with_priority(priority) + .mount(server) + .await; +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::beaconmock::BeaconMock; + use pluto_eth2api::spec::phase0::AttestationData; + + #[tokio::test] + async fn attestation_round_trip() { + let mock = BeaconMock::builder() + .build() + .await + .expect("build beacon mock"); + + let base = mock.uri(); + let http = reqwest::Client::new(); + + // 1. Fetch attestation data for slot=10, committee_index=2. + let resp = http + .get(format!( + "{base}/eth/v1/validator/attestation_data?slot=10&committee_index=2" + )) + .send() + .await + .expect("attestation_data request"); + assert_eq!(resp.status(), 200, "attestation_data should succeed"); + let body: Value = resp.json().await.expect("attestation_data json"); + let data_json = body.get("data").expect("data field").clone(); + let data: AttestationData = + serde_json::from_value(data_json).expect("deserialize attestation data"); + + assert_eq!(data.slot, 10); + assert_eq!(data.index, 2); + + // 2. Compute the SSZ HTR of the returned data. + let root = data.tree_hash_root().0; + let root_hex = format!("0x{}", hex::encode(root)); + + // 3. Fetch aggregate_attestation for the matching root. + let resp = http + .get(format!( + "{base}/eth/v2/validator/aggregate_attestation?slot=10&attestation_data_root={root_hex}" + )) + .send() + .await + .expect("aggregate_attestation request"); + assert_eq!(resp.status(), 200, "aggregate_attestation should match"); + let body: Value = resp.json().await.expect("aggregate_attestation json"); + assert_eq!(body.get("version").and_then(Value::as_str), Some("fulu")); + let returned = body + .get("data") + .and_then(|d| d.get("data")) + .cloned() + .expect("nested data"); + let returned: AttestationData = + serde_json::from_value(returned).expect("deserialize aggregated data"); + assert_eq!(returned, data, "returned data should match generated data"); + + // 4. Unknown root falls through to 400. + let zero_root = format!("0x{}", "00".repeat(32)); + let resp = http + .get(format!( + "{base}/eth/v2/validator/aggregate_attestation?slot=10&attestation_data_root={zero_root}" + )) + .send() + .await + .expect("aggregate_attestation unknown root request"); + assert_eq!( + resp.status(), + 400, + "aggregate_attestation should 400 on unknown root" + ); + } + + #[test] + fn slot_hash_root_matches_charon() { + // Mirrors charon/eth2util/hash_test.go: SSZ hash of slot 2 is the + // little-endian uint64 right-padded to 32 bytes. + assert_eq!( + hex::encode(slot_hash_root(2)), + "0200000000000000000000000000000000000000000000000000000000000000", + ); + } + + #[test] + fn epoch_from_slot_handles_zero() { + assert_eq!(epoch_from_slot(10, 0), 0); + assert_eq!(epoch_from_slot(0, 16), 0); + assert_eq!(epoch_from_slot(32, 16), 2); + } + + #[test] + fn store_prunes_old_entries() { + let store = AttestationStore::default(); + let (_, root_old) = store.new_attestation_data(1, 0, 16); + let (_, root_recent) = store.new_attestation_data(100, 0, 16); + + assert!( + store.get_by_root(&root_old).is_none(), + "entries older than 32 slots should be pruned" + ); + assert!(store.get_by_root(&root_recent).is_some()); + } +} diff --git a/crates/testutil/src/beaconmock/defaults.rs b/crates/testutil/src/beaconmock/defaults.rs index 9db0b399..08559772 100644 --- a/crates/testutil/src/beaconmock/defaults.rs +++ b/crates/testutil/src/beaconmock/defaults.rs @@ -168,6 +168,17 @@ pub(crate) async fn mount_defaults(server: &MockServer, state: Arc) { }, ) .await; + + mount_json(server, "GET", r"^/eth/v2/beacon/blocks/[^/]+$", |_| { + bellatrix_signed_block_response() + }) + .await; + + mount_json(server, "POST", r"^/eth/v1/validator/duties/sync/[0-9]+$", { + let state = Arc::clone(&state); + move |request| sync_committee_duties_response(&state, request) + }) + .await; } pub(crate) async fn mount_json( @@ -329,6 +340,65 @@ fn proposer_duties_response(state: &MockState, request: &Request) -> Value { duties_response(data) } +fn bellatrix_signed_block_response() -> Value { + use crate::random::{random_eth2_signature, random_root, random_slot, random_v_idx}; + + let zero_sig = format!("0x{}", "00".repeat(96)); + let zero_bytes32 = format!("0x{}", "00".repeat(32)); + let zero_bytes20 = format!("0x{}", "00".repeat(20)); + let zero_logs_bloom = format!("0x{}", "00".repeat(256)); + let sync_committee_bits = format!("0x{}", "00".repeat(64)); + + let body = json!({ + "randao_reveal": random_eth2_signature(), + "eth1_data": { + "deposit_root": random_root(), + "deposit_count": "0", + "block_hash": zero_bytes32, + }, + "graffiti": zero_bytes32, + "proposer_slashings": [], + "attester_slashings": [], + "attestations": [], + "deposits": [], + "voluntary_exits": [], + "sync_aggregate": { + "sync_committee_bits": sync_committee_bits, + "sync_committee_signature": zero_sig, + }, + "execution_payload": { + "parent_hash": zero_bytes32, + "fee_recipient": zero_bytes20, + "state_root": zero_bytes32, + "receipts_root": zero_bytes32, + "logs_bloom": zero_logs_bloom, + "prev_randao": zero_bytes32, + "block_number": "0", + "gas_limit": "0", + "gas_used": "0", + "timestamp": "0", + "extra_data": "0x", + "base_fee_per_gas": "0", + "block_hash": zero_bytes32, + "transactions": [], + } + }); + + json!({ + "version": "bellatrix", + "data": { + "message": { + "slot": random_slot().to_string(), + "proposer_index": random_v_idx().to_string(), + "parent_root": random_root(), + "state_root": random_root(), + "body": body, + }, + "signature": random_eth2_signature(), + } + }) +} + fn duties_response(data: Vec) -> Value { json!({ "data": data, @@ -337,6 +407,45 @@ fn duties_response(data: Vec) -> Value { }) } +fn sync_committee_duties_response(state: &MockState, request: &Request) -> Value { + let Some((n, k)) = *read_lock(&state.deterministic_sync_comm_duties) else { + return sync_duties_response(Vec::new()); + }; + + let epoch = epoch_from_path(request.url.path()); + let Some(remainder) = epoch.checked_rem(k) else { + return sync_duties_response(Vec::new()); + }; + if remainder >= n { + return sync_duties_response(Vec::new()); + } + + let indices = indices_from_body(request); + let validator_set = read_lock(&state.validator_set).clone(); + + let data = indices + .into_iter() + .enumerate() + .filter_map(|(position, index)| { + let validator = validator_set.by_index(index)?; + Some(json!({ + "pubkey": validator.validator.pubkey, + "validator_index": index.to_string(), + "validator_sync_committee_indices": [position.to_string()], + })) + }) + .collect(); + + sync_duties_response(data) +} + +fn sync_duties_response(data: Vec) -> Value { + json!({ + "data": data, + "execution_optimistic": false + }) +} + fn indices_from_body(request: &Request) -> Vec { serde_json::from_slice::>(&request.body) .map(|indices| { @@ -365,41 +474,109 @@ fn slots_per_epoch(state: &MockState) -> u64 { } pub(crate) fn default_spec() -> Value { - json!({ - "CONFIG_NAME": "charon-simnet", - "SLOTS_PER_EPOCH": "16", - "SECONDS_PER_SLOT": "12", - "MIN_GENESIS_TIME": default_genesis_time().timestamp().to_string(), - "GENESIS_FORK_VERSION": DEFAULT_GENESIS_FORK_VERSION, - "ALTAIR_FORK_VERSION": "0x20000910", - "ALTAIR_FORK_EPOCH": "0", - "BELLATRIX_FORK_VERSION": "0x30000910", - "BELLATRIX_FORK_EPOCH": "0", - "CAPELLA_FORK_VERSION": "0x40000910", - "CAPELLA_FORK_EPOCH": "0", - "DENEB_FORK_VERSION": "0x50000910", - "DENEB_FORK_EPOCH": "0", - "ELECTRA_FORK_VERSION": "0x60000910", - "ELECTRA_FORK_EPOCH": "2048", - "FULU_FORK_VERSION": "0x70000910", - "FULU_FORK_EPOCH": u64::MAX.to_string(), - "DOMAIN_BEACON_PROPOSER": "0x00000000", - "DOMAIN_BEACON_ATTESTER": "0x01000000", - "DOMAIN_RANDAO": "0x02000000", - "DOMAIN_DEPOSIT": "0x03000000", - "DOMAIN_VOLUNTARY_EXIT": "0x04000000", - "DOMAIN_SELECTION_PROOF": "0x05000000", - "DOMAIN_AGGREGATE_AND_PROOF": "0x06000000", - "DOMAIN_SYNC_COMMITTEE": "0x07000000", - "DOMAIN_SYNC_COMMITTEE_SELECTION_PROOF": "0x08000000", - "DOMAIN_CONTRIBUTION_AND_PROOF": "0x09000000", - "DOMAIN_APPLICATION_BUILDER": "0x00000001", - "TARGET_AGGREGATORS_PER_COMMITTEE": "16", - "SYNC_COMMITTEE_SIZE": "512", - "SYNC_COMMITTEE_SUBNET_COUNT": "4", - "TARGET_AGGREGATORS_PER_SYNC_SUBCOMMITTEE": "16", - "EPOCHS_PER_SYNC_COMMITTEE_PERIOD": "256" - }) + // Build via discrete (key, value) entries to avoid blowing the `json!` macro + // recursion limit; mainnet specs contain ~100 keys. + let entries: &[(&str, &str)] = &[ + ("CONFIG_NAME", "charon-simnet"), + ("PRESET_BASE", "mainnet"), + ("SLOTS_PER_EPOCH", "16"), + ("SECONDS_PER_SLOT", "12"), + ("GENESIS_DELAY", "300"), + ("GENESIS_FORK_VERSION", DEFAULT_GENESIS_FORK_VERSION), + ("ALTAIR_FORK_VERSION", "0x20000910"), + ("ALTAIR_FORK_EPOCH", "0"), + ("BELLATRIX_FORK_VERSION", "0x30000910"), + ("BELLATRIX_FORK_EPOCH", "0"), + ("CAPELLA_FORK_VERSION", "0x40000910"), + ("CAPELLA_FORK_EPOCH", "0"), + ("DENEB_FORK_VERSION", "0x50000910"), + ("DENEB_FORK_EPOCH", "0"), + ("ELECTRA_FORK_VERSION", "0x60000910"), + ("ELECTRA_FORK_EPOCH", "2048"), + ("FULU_FORK_VERSION", "0x70000910"), + ("DOMAIN_BEACON_PROPOSER", "0x00000000"), + ("DOMAIN_BEACON_ATTESTER", "0x01000000"), + ("DOMAIN_RANDAO", "0x02000000"), + ("DOMAIN_DEPOSIT", "0x03000000"), + ("DOMAIN_VOLUNTARY_EXIT", "0x04000000"), + ("DOMAIN_SELECTION_PROOF", "0x05000000"), + ("DOMAIN_AGGREGATE_AND_PROOF", "0x06000000"), + ("DOMAIN_SYNC_COMMITTEE", "0x07000000"), + ("DOMAIN_SYNC_COMMITTEE_SELECTION_PROOF", "0x08000000"), + ("DOMAIN_CONTRIBUTION_AND_PROOF", "0x09000000"), + ("DOMAIN_APPLICATION_BUILDER", "0x00000001"), + ("TARGET_AGGREGATORS_PER_COMMITTEE", "16"), + ("SYNC_COMMITTEE_SIZE", "512"), + ("SYNC_COMMITTEE_SUBNET_COUNT", "4"), + ("TARGET_AGGREGATORS_PER_SYNC_SUBCOMMITTEE", "16"), + ("EPOCHS_PER_SYNC_COMMITTEE_PERIOD", "256"), + // Committee / shuffling shape. + ("MAX_VALIDATORS_PER_COMMITTEE", "2048"), + ("MAX_COMMITTEES_PER_SLOT", "64"), + ("TARGET_COMMITTEE_SIZE", "128"), + ("SHUFFLE_ROUND_COUNT", "90"), + ("SHARD_COMMITTEE_PERIOD", "256"), + // Historical state limits. + ("EPOCHS_PER_HISTORICAL_VECTOR", "65536"), + ("EPOCHS_PER_SLASHINGS_VECTOR", "8192"), + ("SLOTS_PER_HISTORICAL_ROOT", "8192"), + ("HISTORICAL_ROOTS_LIMIT", "16777216"), + ("VALIDATOR_REGISTRY_LIMIT", "1099511627776"), + // Churn / activation queue. + ("MIN_PER_EPOCH_CHURN_LIMIT", "4"), + ("CHURN_LIMIT_QUOTIENT", "65536"), + ("MAX_PER_EPOCH_ACTIVATION_CHURN_LIMIT", "8"), + // Balances. + ("MAX_EFFECTIVE_BALANCE", "32000000000"), + ("MAX_EFFECTIVE_BALANCE_ELECTRA", "2048000000000"), + ("MIN_ACTIVATION_BALANCE", "32000000000"), + ("EFFECTIVE_BALANCE_INCREMENT", "1000000000"), + ("EJECTION_BALANCE", "28000000000"), + ("MIN_DEPOSIT_AMOUNT", "1000000000"), + // Attestation / fork-choice timing. + ("MIN_ATTESTATION_INCLUSION_DELAY", "1"), + ("MIN_SEED_LOOKAHEAD", "1"), + ("MAX_SEED_LOOKAHEAD", "4"), + ("ATTESTATION_PROPAGATION_SLOT_RANGE", "32"), + ("ATTESTATION_SUBNET_COUNT", "64"), + ("MIN_VALIDATOR_WITHDRAWABILITY_DELAY", "256"), + ("PROPOSER_SCORE_BOOST", "40"), + // Block body operation limits. + ("MAX_ATTESTATIONS", "128"), + ("MAX_ATTESTATIONS_ELECTRA", "8"), + ("MAX_ATTESTER_SLASHINGS", "2"), + ("MAX_PROPOSER_SLASHINGS", "16"), + ("MAX_VOLUNTARY_EXITS", "16"), + ("MAX_DEPOSITS", "16"), + ("MAX_BLS_TO_EXECUTION_CHANGES", "16"), + ("MAX_WITHDRAWALS_PER_PAYLOAD", "16"), + // Deposit / chain identity. + ("DEPOSIT_CHAIN_ID", "17000"), + ("DEPOSIT_NETWORK_ID", "17000"), + ( + "DEPOSIT_CONTRACT_ADDRESS", + "0x4242424242424242424242424242424242424242", + ), + // Inactivity leak parameters. + ("INACTIVITY_SCORE_BIAS", "4"), + ("INACTIVITY_SCORE_RECOVERY_RATE", "16"), + ]; + + let mut spec = serde_json::Map::new(); + for (key, value) in entries { + spec.insert((*key).to_string(), Value::String((*value).to_string())); + } + // Fields that require non-`&'static str` values. + spec.insert( + "MIN_GENESIS_TIME".to_string(), + Value::String(default_genesis_time().timestamp().to_string()), + ); + spec.insert( + "FULU_FORK_EPOCH".to_string(), + Value::String(u64::MAX.to_string()), + ); + + Value::Object(spec) } pub(crate) fn default_genesis() -> Value { @@ -416,3 +593,68 @@ pub(crate) fn default_genesis_time() -> DateTime { None => Utc::now(), } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::beaconmock::BeaconMock; + + #[test] + fn default_spec_contains_load_bearing_keys() { + let spec = default_spec(); + for key in [ + "MAX_VALIDATORS_PER_COMMITTEE", + "EPOCHS_PER_HISTORICAL_VECTOR", + "MIN_PER_EPOCH_CHURN_LIMIT", + "MAX_EFFECTIVE_BALANCE", + "MAX_EFFECTIVE_BALANCE_ELECTRA", + "DEPOSIT_CHAIN_ID", + "PRESET_BASE", + "MAX_COMMITTEES_PER_SLOT", + ] { + assert!( + spec.get(key).is_some(), + "default_spec is missing load-bearing key {key}" + ); + } + } + + #[tokio::test] + async fn bellatrix_signed_block_endpoint_returns_versioned_block() { + let mock = BeaconMock::builder() + .build() + .await + .expect("build beacon mock"); + + let base = mock.uri(); + let http = reqwest::Client::new(); + + // The `block_id` segment is opaque to the mock; "head" exercises the + // path_regex match. + let resp = http + .get(format!("{base}/eth/v2/beacon/blocks/head")) + .send() + .await + .expect("blocks request"); + assert_eq!(resp.status(), 200, "blocks endpoint should succeed"); + + let body: Value = resp.json().await.expect("blocks json"); + assert_eq!( + body.get("version").and_then(Value::as_str), + Some("bellatrix"), + "version field should be bellatrix" + ); + assert!( + body.get("data").and_then(Value::as_object).is_some(), + "data field should be a JSON object" + ); + + // Same endpoint should also match a numeric block_id. + let resp = http + .get(format!("{base}/eth/v2/beacon/blocks/123")) + .send() + .await + .expect("blocks request (numeric)"); + assert_eq!(resp.status(), 200); + } +} diff --git a/crates/testutil/src/beaconmock/fuzzer.rs b/crates/testutil/src/beaconmock/fuzzer.rs new file mode 100644 index 00000000..259938c9 --- /dev/null +++ b/crates/testutil/src/beaconmock/fuzzer.rs @@ -0,0 +1,469 @@ +//! Optional fuzz handlers that override default beacon endpoints with random +//! JSON responses. +//! +//! Mirrors `WithBeaconMockFuzzer` from Charon's Go beaconmock +//! (`testutil/beaconmock/beaconmock_fuzz.go`). Pluto's mock is HTTP-only, so +//! instead of swapping out function dispatch fields we mount higher-priority +//! wiremock routes that produce randomly-generated, schema-shaped JSON for the +//! same set of endpoints consumed by Charon during fuzz testing. +//! +//! Mounted routes use a numerically lower priority than `mount_defaults` so +//! they take precedence when both are registered on the same `MockServer`. + +use rand::{Rng, seq::SliceRandom}; +use serde_json::{Value, json}; +use wiremock::{ + Mock, MockServer, Request, ResponseTemplate, + matchers::{method, path, path_regex}, +}; + +use crate::random::{ + random_bit_list, random_eth2_signature, random_phase0_attestation, random_root, +}; + +/// Priority for fuzzer routes; must be numerically lower (= higher priority) +/// than `defaults::DEFAULT_MOCK_PRIORITY` so it overrides default mounts. +const FUZZ_MOCK_PRIORITY: u8 = 10; + +/// Mounts random-response handlers for the endpoints fuzzed in the Go +/// `WithBeaconMockFuzzer` option. +/// +/// The mounted handlers return JSON-shaped responses with random values. Tests +/// should not rely on any specific field values. +pub(super) async fn mount_fuzzer(server: &MockServer) { + mount_fuzz_json( + server, + "GET", + "/eth/v2/validator/aggregate_attestation", + |_| aggregate_attestation_response(), + ) + .await; + + mount_fuzz_json(server, "GET", "/eth/v1/validator/attestation_data", |_| { + attestation_data_response() + }) + .await; + + // Both v2 and v3 endpoints for block production exist in Charon's flows. + mount_fuzz_json( + server, + "GET", + r"^/eth/v2/validator/blocks/[0-9]+$", + |request| proposal_response(slot_from_path(request.url.path())), + ) + .await; + + mount_fuzz_json( + server, + "GET", + r"^/eth/v3/validator/blocks/[0-9]+$", + |request| proposal_response(slot_from_path(request.url.path())), + ) + .await; + + mount_fuzz_json(server, "GET", r"^/eth/v2/beacon/blocks/.+$", |_| { + signed_beacon_block_response() + }) + .await; + + mount_fuzz_json( + server, + "GET", + "/eth/v1/beacon/states/head/validators", + |_| validators_response(), + ) + .await; + + mount_fuzz_json( + server, + "POST", + r"^/eth/v1/validator/duties/attester/[0-9]+$", + |request| attester_duties_response(epoch_from_path(request.url.path())), + ) + .await; + + mount_fuzz_json( + server, + "GET", + r"^/eth/v1/validator/duties/proposer/[0-9]+$", + |request| proposer_duties_response(epoch_from_path(request.url.path())), + ) + .await; + + mount_fuzz_json( + server, + "POST", + r"^/eth/v1/validator/duties/sync/[0-9]+$", + |request| sync_committee_duties_response(epoch_from_path(request.url.path())), + ) + .await; +} + +async fn mount_fuzz_json( + server: &MockServer, + http_method: &'static str, + endpoint: &'static str, + f: F, +) where + F: Send + Sync + 'static + Fn(&Request) -> Value, +{ + let route = Mock::given(method(http_method)); + let route = if endpoint.starts_with('^') { + route.and(path_regex(endpoint)) + } else { + route.and(path(endpoint)) + }; + + route + .respond_with(move |request: &Request| ResponseTemplate::new(200).set_body_json(f(request))) + .with_priority(FUZZ_MOCK_PRIORITY) + .mount(server) + .await; +} + +fn aggregate_attestation_response() -> Value { + json!({ + "version": "deneb", + "data": random_phase0_attestation(), + }) +} + +fn attestation_data_response() -> Value { + let mut rng = rand::thread_rng(); + json!({ + "data": { + "slot": rng.r#gen::().to_string(), + "index": rng.r#gen::().to_string(), + "beacon_block_root": random_root(), + "source": random_checkpoint(), + "target": random_checkpoint(), + } + }) +} + +fn proposal_response(slot: u64) -> Value { + json!({ + "version": "deneb", + "execution_payload_blinded": false, + "execution_payload_value": "0", + "consensus_block_value": "0", + "data": { + "block": random_beacon_block(slot), + "kzg_proofs": [], + "blobs": [], + } + }) +} + +fn signed_beacon_block_response() -> Value { + let mut rng = rand::thread_rng(); + let slot = rng.r#gen::(); + json!({ + "version": "deneb", + "execution_optimistic": false, + "finalized": false, + "data": { + "message": random_beacon_block(slot), + "signature": random_eth2_signature(), + } + }) +} + +fn validators_response() -> Value { + let mut rng = rand::thread_rng(); + let count = rng.gen_range(0..=4u64); + let data: Vec = (0..count) + .map(|index| { + json!({ + "index": index.to_string(), + "balance": rng.r#gen::().to_string(), + "status": random_validator_status(&mut rng), + "validator": { + "pubkey": format!("0x{}", hex::encode([rng.r#gen::(); 48])), + "withdrawal_credentials": random_root(), + "effective_balance": rng.r#gen::().to_string(), + "slashed": rng.r#gen::(), + "activation_eligibility_epoch": rng.r#gen::().to_string(), + "activation_epoch": rng.r#gen::().to_string(), + "exit_epoch": rng.r#gen::().to_string(), + "withdrawable_epoch": rng.r#gen::().to_string(), + } + }) + }) + .collect(); + + json!({ + "data": data, + "execution_optimistic": false, + "finalized": false, + }) +} + +fn attester_duties_response(epoch: u64) -> Value { + let mut rng = rand::thread_rng(); + let slots_per_epoch = 16u64; + let count = rng.gen_range(0..=4u64); + let data: Vec = (0..count) + .map(|i| { + let slot_offset = rng.gen_range(0..slots_per_epoch); + let slot = epoch + .saturating_mul(slots_per_epoch) + .saturating_add(slot_offset); + json!({ + "pubkey": format!("0x{}", hex::encode([rng.r#gen::(); 48])), + "validator_index": i.to_string(), + "committee_index": rng.r#gen::().to_string(), + "committee_length": rng.r#gen::().to_string(), + "committees_at_slot": slots_per_epoch.to_string(), + "validator_committee_index": rng.r#gen::().to_string(), + "slot": slot.to_string(), + }) + }) + .collect(); + + json!({ + "data": data, + "dependent_root": random_root(), + "execution_optimistic": false, + }) +} + +fn proposer_duties_response(epoch: u64) -> Value { + let mut rng = rand::thread_rng(); + let slots_per_epoch = 16u64; + let count = rng.gen_range(0..=4u64); + let data: Vec = (0..count) + .map(|i| { + let slot_offset = rng.gen_range(0..slots_per_epoch); + let slot = epoch + .saturating_mul(slots_per_epoch) + .saturating_add(slot_offset); + json!({ + "pubkey": format!("0x{}", hex::encode([rng.r#gen::(); 48])), + "validator_index": i.to_string(), + "slot": slot.to_string(), + }) + }) + .collect(); + + json!({ + "data": data, + "dependent_root": random_root(), + "execution_optimistic": false, + }) +} + +fn sync_committee_duties_response(_epoch: u64) -> Value { + let mut rng = rand::thread_rng(); + let count = rng.gen_range(0..=4u64); + let data: Vec = (0..count) + .map(|i| { + let subnet_count = rng.gen_range(0..=4u64); + let subnets: Vec = (0..subnet_count).map(|s| s.to_string()).collect(); + json!({ + "pubkey": format!("0x{}", hex::encode([rng.r#gen::(); 48])), + "validator_index": i.to_string(), + "validator_sync_committee_indices": subnets, + }) + }) + .collect(); + + json!({ + "data": data, + "execution_optimistic": false, + }) +} + +fn random_checkpoint() -> Value { + let mut rng = rand::thread_rng(); + json!({ + "epoch": rng.r#gen::().to_string(), + "root": random_root(), + }) +} + +fn random_validator_status(rng: &mut impl Rng) -> &'static str { + const STATUSES: &[&str] = &[ + "pending_initialized", + "pending_queued", + "active_ongoing", + "active_exiting", + "active_slashed", + "exited_unslashed", + "exited_slashed", + "withdrawal_possible", + "withdrawal_done", + ]; + STATUSES.choose(rng).copied().unwrap_or("active_ongoing") +} + +fn random_beacon_block(slot: u64) -> Value { + let mut rng = rand::thread_rng(); + json!({ + "slot": slot.to_string(), + "proposer_index": rng.r#gen::().to_string(), + "parent_root": random_root(), + "state_root": random_root(), + "body": random_beacon_block_body(), + }) +} + +fn random_beacon_block_body() -> Value { + json!({ + "randao_reveal": random_eth2_signature(), + "eth1_data": { + "deposit_root": random_root(), + "deposit_count": rand::thread_rng().r#gen::().to_string(), + "block_hash": random_root(), + }, + "graffiti": random_root(), + "proposer_slashings": [], + "attester_slashings": [], + "attestations": [random_phase0_attestation()], + "deposits": [], + "voluntary_exits": [], + "sync_aggregate": { + "sync_committee_bits": random_bit_list(0), + "sync_committee_signature": random_eth2_signature(), + }, + "execution_payload": { + "parent_hash": random_root(), + "fee_recipient": format!("0x{}", hex::encode([0u8; 20])), + "state_root": random_root(), + "receipts_root": random_root(), + "logs_bloom": format!("0x{}", hex::encode([0u8; 256])), + "prev_randao": random_root(), + "block_number": "0", + "gas_limit": "0", + "gas_used": "0", + "timestamp": "0", + "extra_data": "0x", + "base_fee_per_gas": "0", + "block_hash": random_root(), + "transactions": [], + "withdrawals": [], + "blob_gas_used": "0", + "excess_blob_gas": "0", + }, + "bls_to_execution_changes": [], + "blob_kzg_commitments": [], + }) +} + +fn slot_from_path(path: &str) -> u64 { + path.rsplit('/') + .next() + .and_then(|slot| slot.parse::().ok()) + .unwrap_or_default() +} + +fn epoch_from_path(path: &str) -> u64 { + path.rsplit('/') + .next() + .and_then(|epoch| epoch.parse::().ok()) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use crate::beaconmock::BeaconMock; + use reqwest::{Client, Method, StatusCode}; + use serde_json::Value; + + struct Endpoint { + method: Method, + path: &'static str, + body: Option<&'static str>, + } + + #[tokio::test] + async fn fuzzer_returns_random_json_for_each_endpoint() { + let mock = BeaconMock::builder() + .fuzzer(true) + .build() + .await + .expect("build beacon mock"); + + let base = mock.uri(); + let http = Client::new(); + + let endpoints = [ + Endpoint { + method: Method::GET, + path: "/eth/v2/validator/aggregate_attestation?slot=1&attestation_data_root=0x0000000000000000000000000000000000000000000000000000000000000000", + body: None, + }, + Endpoint { + method: Method::GET, + path: "/eth/v1/validator/attestation_data?slot=1&committee_index=0", + body: None, + }, + Endpoint { + method: Method::GET, + path: "/eth/v2/validator/blocks/123", + body: None, + }, + Endpoint { + method: Method::GET, + path: "/eth/v3/validator/blocks/123", + body: None, + }, + Endpoint { + method: Method::GET, + path: "/eth/v2/beacon/blocks/head", + body: None, + }, + Endpoint { + method: Method::GET, + path: "/eth/v1/beacon/states/head/validators", + body: None, + }, + Endpoint { + method: Method::POST, + path: "/eth/v1/validator/duties/attester/7", + body: Some(r#"["0","1"]"#), + }, + Endpoint { + method: Method::GET, + path: "/eth/v1/validator/duties/proposer/7", + body: None, + }, + Endpoint { + method: Method::POST, + path: "/eth/v1/validator/duties/sync/7", + body: Some(r#"["0","1"]"#), + }, + ]; + + for endpoint in endpoints { + let url = format!("{base}{}", endpoint.path); + let mut req = http.request(endpoint.method.clone(), &url); + if let Some(body) = endpoint.body { + req = req + .header(reqwest::header::CONTENT_TYPE, "application/json") + .body(body); + } + + let resp = req + .send() + .await + .unwrap_or_else(|err| panic!("request to {url} failed: {err}")); + + assert_eq!( + resp.status(), + StatusCode::OK, + "fuzzed endpoint {url} should return 200", + ); + + // Response must be JSON-parseable. + let body: Value = resp + .json() + .await + .unwrap_or_else(|err| panic!("response from {url} not JSON: {err}")); + assert!( + body.get("data").is_some(), + "fuzzed endpoint {url} should return a `data` field; got {body}", + ); + } + } +} diff --git a/crates/testutil/src/beaconmock/headproducer.rs b/crates/testutil/src/beaconmock/headproducer.rs new file mode 100644 index 00000000..bc117177 --- /dev/null +++ b/crates/testutil/src/beaconmock/headproducer.rs @@ -0,0 +1,457 @@ +//! Slot-driven head producer for the beacon mock. +//! +//! Mirrors Charon's `headProducer` (Go) — see +//! `charon/testutil/beaconmock/headproducer.go` — by ticking on every slot, +//! generating deterministic block/state roots, and exposing the resulting +//! head over `/eth/v1/events` (SSE) and +//! `/eth/v1/beacon/blocks/{block_id}/root`. +//! +//! Note on SSE: wiremock buffers a response body before sending, so events +//! cannot be streamed continuously. Each request to `/eth/v1/events` waits up +//! to ~one slot for the producer to have a current head and then returns a +//! single, well-formed SSE record (`event: \ndata: \n\n`). +//! Subscribers should poll the endpoint to keep receiving events. +//! +//! The block-root endpoint matches Charon: it answers with the current head's +//! block root when `block_id` is `head` or matches the current head's slot, +//! and 400 otherwise. +//! +//! The ticker is shut down when the returned [`HeadProducer`] is dropped. + +use std::{ + sync::{Arc, RwLock}, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use chrono::{DateTime, Utc}; +use pluto_eth2api::spec::phase0::{Root, Slot}; +use rand::{RngCore, SeedableRng, rngs::StdRng}; +use serde_json::{Value, json}; +use tokio::sync::{Notify, watch}; +use wiremock::{ + Mock, MockServer, Request, ResponseTemplate, + matchers::{method, path, path_regex}, +}; + +use super::defaults::DEFAULT_MOCK_PRIORITY; + +const TOPIC_HEAD: &str = "head"; +const TOPIC_BLOCK: &str = "block"; + +/// Deterministic head event derived from a slot. +#[derive(Clone, Debug)] +struct HeadEvent { + slot: Slot, + block: Root, + state: Root, + current_duty_dependent_root: Root, + previous_duty_dependent_root: Root, +} + +/// Owns the slot ticker driving the head producer. Drop to stop the ticker. +#[derive(Debug)] +pub(crate) struct HeadProducer { + shutdown: Arc, +} + +impl HeadProducer { + /// Spawns the slot ticker and mounts SSE/block-root handlers on `server`. + pub(crate) async fn spawn( + server: &MockServer, + genesis_time: DateTime, + slot_duration: Duration, + ) -> Self { + let state = Arc::new(SharedState::new()); + let shutdown = Arc::new(Notify::new()); + + mount_events(server, Arc::clone(&state), slot_duration).await; + mount_block_root(server, Arc::clone(&state)).await; + + spawn_slot_ticker( + Arc::clone(&state), + Arc::clone(&shutdown), + genesis_time, + slot_duration, + ); + + Self { shutdown } + } +} + +impl Drop for HeadProducer { + fn drop(&mut self) { + self.shutdown.notify_waiters(); + } +} + +struct SharedState { + current_head: RwLock>, + head_tx: watch::Sender, + head_rx: watch::Receiver, +} + +impl SharedState { + fn new() -> Self { + let (head_tx, head_rx) = watch::channel(0u64); + Self { + current_head: RwLock::new(None), + head_tx, + head_rx, + } + } + + fn set_current_head(&self, event: HeadEvent) { + match self.current_head.write() { + Ok(mut guard) => *guard = Some(event), + Err(poisoned) => *poisoned.into_inner() = Some(event), + } + // Bump the generation counter so listeners wake up. + let next = self.head_tx.borrow().wrapping_add(1); + let _ = self.head_tx.send(next); + } + + fn current_head(&self) -> Option { + match self.current_head.read() { + Ok(guard) => guard.clone(), + Err(poisoned) => poisoned.into_inner().clone(), + } + } + + fn subscribe(&self) -> watch::Receiver { + self.head_rx.clone() + } +} + +fn spawn_slot_ticker( + state: Arc, + shutdown: Arc, + genesis_time: DateTime, + slot_duration: Duration, +) { + // Mirror Go's startSlotTicker: compute current slot from chain age, then + // tick once per slot until shutdown. + let genesis = system_time_from(genesis_time); + let slot_duration = if slot_duration.is_zero() { + Duration::from_millis(1) + } else { + slot_duration + }; + + tokio::spawn(async move { + let (mut height, mut next_tick) = initial_slot(genesis, slot_duration); + let shutdown_fut = shutdown.notified(); + tokio::pin!(shutdown_fut); + + loop { + update_head(&state, height); + + height = height.wrapping_add(1); + next_tick = next_tick.checked_add(slot_duration).unwrap_or_else(|| { + SystemTime::now() + .checked_add(slot_duration) + .unwrap_or(SystemTime::now()) + }); + let delay = next_tick + .duration_since(SystemTime::now()) + .unwrap_or_default(); + + tokio::select! { + _ = &mut shutdown_fut => return, + _ = tokio::time::sleep(delay) => {} + } + } + }); +} + +fn initial_slot(genesis: SystemTime, slot_duration: Duration) -> (Slot, SystemTime) { + let now = SystemTime::now(); + let chain_age = now.duration_since(genesis).unwrap_or_default(); + let nanos = u64::try_from(slot_duration.as_nanos()) + .unwrap_or(u64::MAX) + .max(1); + let height = u64::try_from(chain_age.as_nanos()) + .unwrap_or(0) + .checked_div(nanos) + .unwrap_or(0); + let multiplier = u32::try_from(height).unwrap_or(u32::MAX); + let start = genesis + .checked_add(slot_duration.saturating_mul(multiplier)) + .unwrap_or(now); + (height, start) +} + +fn system_time_from(dt: DateTime) -> SystemTime { + let secs = dt.timestamp(); + if secs >= 0 { + let secs_u64 = u64::try_from(secs).unwrap_or(0); + UNIX_EPOCH + .checked_add(Duration::from_secs(secs_u64)) + .unwrap_or(UNIX_EPOCH) + } else { + UNIX_EPOCH + .checked_sub(Duration::from_secs(secs.unsigned_abs())) + .unwrap_or(UNIX_EPOCH) + } +} + +fn update_head(state: &SharedState, slot: Slot) { + state.set_current_head(pseudo_random_head_event(slot)); +} + +fn pseudo_random_head_event(slot: Slot) -> HeadEvent { + let mut rng = StdRng::seed_from_u64(slot); + HeadEvent { + slot, + block: random_root(&mut rng), + state: random_root(&mut rng), + current_duty_dependent_root: random_root(&mut rng), + previous_duty_dependent_root: random_root(&mut rng), + } +} + +fn random_root(rng: &mut StdRng) -> Root { + let mut root = Root::default(); + rng.fill_bytes(&mut root); + root +} + +async fn mount_events(server: &MockServer, state: Arc, slot_duration: Duration) { + let wait_budget = slot_duration + .saturating_mul(2) + .max(Duration::from_millis(50)); + + Mock::given(method("GET")) + .and(path("/eth/v1/events")) + .respond_with(move |request: &Request| { + let topics = parse_topics(request); + if let Some(invalid) = topics.iter().find(|topic| !is_supported_topic(topic)) { + return error_response(500, format!("unknown topic: {invalid}")); + } + + // Wait synchronously (bounded) until at least one head is produced + // so the buffered SSE body is non-empty. + wait_for_first_head(&state, wait_budget); + let Some(head) = state.current_head() else { + return error_response(500, "head producer not ready".into()); + }; + + let mut body = String::new(); + if topics.is_empty() || topics.iter().any(|t| t == TOPIC_HEAD) { + push_sse_event(&mut body, TOPIC_HEAD, &head_event_json(&head)); + } + if topics.iter().any(|t| t == TOPIC_BLOCK) { + push_sse_event(&mut body, TOPIC_BLOCK, &block_event_json(&head)); + } + + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .insert_header("cache-control", "no-cache") + .set_body_raw(body.into_bytes(), "text/event-stream") + }) + .with_priority(DEFAULT_MOCK_PRIORITY - 1) + .mount(server) + .await; +} + +async fn mount_block_root(server: &MockServer, state: Arc) { + Mock::given(method("GET")) + .and(path_regex(r"^/eth/v1/beacon/blocks/[^/]+/root$")) + .respond_with(move |request: &Request| { + let Some(head) = state.current_head() else { + return error_response(500, "head producer not ready".into()); + }; + + let block_id = extract_block_id(request.url.path()); + if block_id != "head" && block_id != head.slot.to_string() { + return error_response(400, format!("Invalid block ID: {block_id}")); + } + + ResponseTemplate::new(200).set_body_json(json!({ + "execution_optimistic": false, + "data": { "root": hex_0x(head.block) } + })) + }) + .with_priority(DEFAULT_MOCK_PRIORITY - 1) + .mount(server) + .await; +} + +fn parse_topics(request: &Request) -> Vec { + request + .url + .query_pairs() + .filter_map(|(k, v)| (k == "topics").then(|| v.into_owned())) + .collect() +} + +fn is_supported_topic(topic: &str) -> bool { + topic == TOPIC_HEAD || topic == TOPIC_BLOCK +} + +fn extract_block_id(path: &str) -> String { + // Path matched by the regex above: ".../blocks/{block_id}/root". + let mut parts = path.rsplit('/'); + let _ = parts.next(); // "root" + parts.next().unwrap_or_default().to_string() +} + +fn wait_for_first_head(state: &SharedState, budget: Duration) { + if state.current_head().is_some() { + return; + } + + // Drive a short blocking wait without blocking the runtime worker for long: + // poll the shared state with small sleeps until the budget elapses. + let start = std::time::Instant::now(); + let mut rx = state.subscribe(); + while start.elapsed() < budget { + if rx.has_changed().unwrap_or(false) { + let _ = rx.borrow_and_update(); + } + if state.current_head().is_some() { + return; + } + std::thread::sleep(Duration::from_millis(1)); + } +} + +fn push_sse_event(body: &mut String, topic: &str, data: &Value) { + body.push_str("event: "); + body.push_str(topic); + body.push('\n'); + body.push_str("data: "); + body.push_str(&data.to_string()); + body.push_str("\n\n"); +} + +fn head_event_json(head: &HeadEvent) -> Value { + json!({ + "slot": head.slot.to_string(), + "block": hex_0x(head.block), + "state": hex_0x(head.state), + "epoch_transition": false, + "current_duty_dependent_root": hex_0x(head.current_duty_dependent_root), + "previous_duty_dependent_root": hex_0x(head.previous_duty_dependent_root), + "execution_optimistic": false, + }) +} + +fn block_event_json(head: &HeadEvent) -> Value { + json!({ + "slot": head.slot.to_string(), + "block": hex_0x(head.block), + "execution_optimistic": false, + }) +} + +fn hex_0x(bytes: impl AsRef<[u8]>) -> String { + format!("0x{}", hex::encode(bytes.as_ref())) +} + +fn error_response(status: u16, message: String) -> ResponseTemplate { + ResponseTemplate::new(status).set_body_json(json!({ + "code": status, + "message": message, + })) +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use chrono::Utc; + + use crate::beaconmock::BeaconMock; + + #[tokio::test] + async fn publishes_head_event_via_sse() { + let mock = BeaconMock::builder() + .slot_duration(Duration::from_millis(100)) + .genesis_time(Utc::now()) + .build() + .await + .expect("beacon mock"); + + let url = format!("{}/eth/v1/events?topics=head", mock.uri()); + let client = reqwest::Client::new(); + + // Poll the endpoint with a short timeout — the responder buffers a + // single SSE event per request, so the test reads the body once a + // head event has been produced. + let deadline = std::time::Instant::now() + Duration::from_secs(2); + let body = loop { + assert!( + std::time::Instant::now() < deadline, + "no head event in time" + ); + let resp = client + .get(&url) + .timeout(Duration::from_secs(1)) + .send() + .await + .expect("send"); + assert_eq!(resp.status().as_u16(), 200); + let text = resp.text().await.expect("body"); + if text.contains("event: head") { + break text; + } + tokio::time::sleep(Duration::from_millis(20)).await; + }; + + assert!(body.contains("event: head")); + assert!(body.contains("\"slot\"")); + assert!(body.contains("\"block\"")); + } + + #[tokio::test] + async fn rejects_unknown_topic() { + let mock = BeaconMock::builder() + .slot_duration(Duration::from_millis(100)) + .genesis_time(Utc::now()) + .build() + .await + .expect("beacon mock"); + + let url = format!("{}/eth/v1/events?topics=bogus", mock.uri()); + let resp = reqwest::get(&url).await.expect("send"); + assert_eq!(resp.status().as_u16(), 500); + let text = resp.text().await.expect("body"); + assert!(text.contains("unknown topic")); + } + + #[tokio::test] + async fn block_root_for_head() { + let mock = BeaconMock::builder() + .slot_duration(Duration::from_millis(100)) + .genesis_time(Utc::now()) + .build() + .await + .expect("beacon mock"); + + // Wait for the ticker to publish at least one head. + tokio::time::sleep(Duration::from_millis(150)).await; + + let url = format!("{}/eth/v1/beacon/blocks/head/root", mock.uri()); + let resp = reqwest::get(&url).await.expect("send"); + assert_eq!(resp.status().as_u16(), 200); + let body: serde_json::Value = resp.json().await.expect("json"); + let root = body["data"]["root"].as_str().expect("root"); + assert!(root.starts_with("0x") && root.len() == 2 + 64); + } + + #[tokio::test] + async fn block_root_rejects_stale_id() { + let mock = BeaconMock::builder() + .slot_duration(Duration::from_millis(100)) + .genesis_time(Utc::now()) + .build() + .await + .expect("beacon mock"); + + tokio::time::sleep(Duration::from_millis(150)).await; + + let url = format!("{}/eth/v1/beacon/blocks/999999/root", mock.uri()); + let resp = reqwest::get(&url).await.expect("send"); + assert_eq!(resp.status().as_u16(), 400); + } +} diff --git a/crates/testutil/src/beaconmock/mod.rs b/crates/testutil/src/beaconmock/mod.rs index 84306991..b07aa2f3 100644 --- a/crates/testutil/src/beaconmock/mod.rs +++ b/crates/testutil/src/beaconmock/mod.rs @@ -3,7 +3,11 @@ //! `BeaconMock` owns the backing `wiremock::MockServer`, so keep the mock alive //! for as long as clients use `BeaconMock::client()`. +mod attestation; mod defaults; +mod fuzzer; +mod headproducer; +mod options; mod state; use std::{sync::Arc, time::Duration}; @@ -14,7 +18,13 @@ use pluto_eth2api::{EthBeaconNodeApiClient, spec::phase0::Root}; use serde_json::Value; use wiremock::MockServer; -use defaults::{default_genesis, default_spec, mount_defaults}; +use defaults::{default_genesis, default_genesis_time, default_spec, mount_defaults}; +use fuzzer::mount_fuzzer; +use headproducer::HeadProducer; +use options::{ + mount_endpoint_override, mount_no_attester_duties, mount_no_proposer_duties, + mount_no_sync_committee_duties, +}; use state::{hex_0x, set_object_field, write_lock}; pub use state::{MockState, Validator, ValidatorSet}; @@ -37,12 +47,15 @@ pub struct BeaconMock { server: MockServer, client: EthBeaconNodeApiClient, state: Arc, + // Held to keep the slot ticker alive; dropped with `BeaconMock`. + _head_producer: HeadProducer, } #[bon] impl BeaconMock { /// Builds a beacon mock with Charon-compatible defaults, overriding any /// provided fields. + #[allow(clippy::too_many_arguments)] #[builder] pub async fn new( validator_set: Option, @@ -53,11 +66,23 @@ impl BeaconMock { spec: Option, deterministic_attester_duties: Option, deterministic_proposer_duties: Option, + fuzzer: Option, + #[builder(default)] endpoint_overrides: Vec<(String, Value)>, + fork_version: Option<[u8; 4]>, + sync_committee_size: Option, + sync_committee_subnet_count: Option, + #[builder(default)] no_proposer_duties: bool, + #[builder(default)] no_attester_duties: bool, + #[builder(default)] no_sync_committee_duties: bool, + deterministic_sync_comm_duties: Option<(u64, u64)>, ) -> Result { let mut spec = spec.unwrap_or_else(default_spec); let mut genesis = default_genesis(); let validator_set = validator_set.unwrap_or_default(); + let effective_slot_duration = slot_duration.unwrap_or(Duration::from_secs(12)); + let effective_genesis_time = genesis_time.unwrap_or_else(default_genesis_time); + if let Some(slot_duration) = slot_duration { set_object_field( &mut spec, @@ -84,12 +109,55 @@ impl BeaconMock { ); } + if let Some(fork_version) = fork_version { + let formatted = hex_0x(fork_version); + set_object_field(&mut spec, "GENESIS_FORK_VERSION", formatted.clone()); + set_object_field(&mut genesis, "genesis_fork_version", formatted); + } + + if let Some(size) = sync_committee_size { + set_object_field(&mut spec, "SYNC_COMMITTEE_SIZE", size.to_string()); + } + + if let Some(count) = sync_committee_subnet_count { + set_object_field(&mut spec, "SYNC_COMMITTEE_SUBNET_COUNT", count.to_string()); + } + + if let Some((n, _)) = deterministic_sync_comm_duties { + set_object_field(&mut spec, "EPOCHS_PER_SYNC_COMMITTEE_PERIOD", n.to_string()); + } + let state = Arc::new(MockState::new(spec, genesis, validator_set)); *write_lock(&state.deterministic_attester_duties) = deterministic_attester_duties; *write_lock(&state.deterministic_proposer_duties) = deterministic_proposer_duties; + *write_lock(&state.deterministic_sync_comm_duties) = deterministic_sync_comm_duties; let server = MockServer::start().await; + + // Higher priority (lower number) mounts must register before the defaults + // so wiremock falls back to the default routes when no override matches. + for (endpoint, value) in endpoint_overrides { + mount_endpoint_override(&server, endpoint, value).await; + } + if no_proposer_duties { + mount_no_proposer_duties(&server).await; + } + if no_attester_duties { + mount_no_attester_duties(&server).await; + } + if no_sync_committee_duties { + mount_no_sync_committee_duties(&server).await; + } + mount_defaults(&server, Arc::clone(&state)).await; + attestation::mount(&server, Arc::clone(&state)).await; + + let head_producer = + HeadProducer::spawn(&server, effective_genesis_time, effective_slot_duration).await; + + if fuzzer.unwrap_or(false) { + mount_fuzzer(&server).await; + } let client = EthBeaconNodeApiClient::with_base_url(server.uri()).map_err(Error::Client)?; @@ -97,6 +165,7 @@ impl BeaconMock { server, client, state, + _head_producer: head_producer, }) } diff --git a/crates/testutil/src/beaconmock/options.rs b/crates/testutil/src/beaconmock/options.rs new file mode 100644 index 00000000..efedb4d4 --- /dev/null +++ b/crates/testutil/src/beaconmock/options.rs @@ -0,0 +1,316 @@ +//! Builder option helpers (mount handlers + tests). +//! +//! Wiring lives in [`super`]; this module only owns the mock-mount helpers +//! used by those options and the unit tests that exercise them. + +use serde_json::{Value, json}; +use wiremock::{ + Mock, MockServer, ResponseTemplate, + matchers::{method, path, path_regex}, +}; + +use super::defaults::ZERO_ROOT; + +/// Priority for builder-driven overrides. Lower numeric priority wins in +/// `wiremock`, so this sits above [`super::defaults::DEFAULT_MOCK_PRIORITY`] +/// (255) and below any test-supplied overrides mounted directly via +/// [`BeaconMock::server`](super::BeaconMock::server). +pub(crate) const OVERRIDE_PRIORITY: u8 = 50; + +/// Mounts a static JSON override for `endpoint` returning `value`. +/// +/// `endpoint` may be either a plain path or a regex prefixed with `^`. +pub(crate) async fn mount_endpoint_override(server: &MockServer, endpoint: String, value: Value) { + // Both GET and POST share the route since callers may override either. + for http_method in ["GET", "POST"] { + let template = ResponseTemplate::new(200).set_body_json(value.clone()); + + let route = Mock::given(method(http_method)); + let route = if endpoint.starts_with('^') { + route.and(path_regex(endpoint.clone())) + } else { + route.and(path(endpoint.clone())) + }; + + route + .respond_with(template) + .with_priority(OVERRIDE_PRIORITY) + .mount(server) + .await; + } +} + +fn empty_duties_body() -> Value { + json!({ + "data": [], + "dependent_root": ZERO_ROOT, + "execution_optimistic": false, + }) +} + +fn empty_sync_duties_body() -> Value { + json!({ + "data": [], + "execution_optimistic": false, + }) +} + +/// Mounts an empty-list override for the proposer duties endpoint. +pub(crate) async fn mount_no_proposer_duties(server: &MockServer) { + Mock::given(method("GET")) + .and(path_regex(r"^/eth/v1/validator/duties/proposer/[0-9]+$")) + .respond_with(ResponseTemplate::new(200).set_body_json(empty_duties_body())) + .with_priority(OVERRIDE_PRIORITY) + .mount(server) + .await; +} + +/// Mounts an empty-list override for the attester duties endpoint. +pub(crate) async fn mount_no_attester_duties(server: &MockServer) { + Mock::given(method("POST")) + .and(path_regex(r"^/eth/v1/validator/duties/attester/[0-9]+$")) + .respond_with(ResponseTemplate::new(200).set_body_json(empty_duties_body())) + .with_priority(OVERRIDE_PRIORITY) + .mount(server) + .await; +} + +/// Mounts an empty-list override for the sync-committee duties endpoint. +pub(crate) async fn mount_no_sync_committee_duties(server: &MockServer) { + Mock::given(method("POST")) + .and(path_regex(r"^/eth/v1/validator/duties/sync/[0-9]+$")) + .respond_with(ResponseTemplate::new(200).set_body_json(empty_sync_duties_body())) + .with_priority(OVERRIDE_PRIORITY) + .mount(server) + .await; +} + +#[cfg(test)] +mod tests { + use serde_json::{Value, json}; + + use crate::beaconmock::{BeaconMock, ValidatorSet}; + + async fn get_json(uri: &str, path: &str) -> Value { + let url = format!("{uri}{path}"); + reqwest::get(&url) + .await + .expect("request") + .json::() + .await + .expect("decode json") + } + + async fn post_json(uri: &str, path: &str, body: &Value) -> Value { + let url = format!("{uri}{path}"); + reqwest::Client::new() + .post(&url) + .json(body) + .send() + .await + .expect("request") + .json::() + .await + .expect("decode json") + } + + #[tokio::test] + async fn endpoint_override_returns_custom_value() { + let override_body = json!({ "data": "custom" }); + let mock = BeaconMock::builder() + .endpoint_overrides(vec![( + "/eth/v1/node/version".to_string(), + override_body.clone(), + )]) + .build() + .await + .expect("build mock"); + + let got = get_json(&mock.uri(), "/eth/v1/node/version").await; + assert_eq!(got, override_body); + } + + #[tokio::test] + async fn endpoint_override_supports_multiple_entries() { + let a = json!({ "data": { "id": "a" } }); + let b = json!({ "data": { "id": "b" } }); + let mock = BeaconMock::builder() + .endpoint_overrides(vec![ + ("/eth/v1/node/version".to_string(), a.clone()), + ("/eth/v1/beacon/headers/head".to_string(), b.clone()), + ]) + .build() + .await + .expect("build mock"); + + assert_eq!(get_json(&mock.uri(), "/eth/v1/node/version").await, a); + assert_eq!( + get_json(&mock.uri(), "/eth/v1/beacon/headers/head").await, + b + ); + } + + #[tokio::test] + async fn fork_version_overrides_spec_and_genesis() { + let mock = BeaconMock::builder() + .fork_version([0xaa, 0xbb, 0xcc, 0xdd]) + .build() + .await + .expect("build mock"); + + let spec = get_json(&mock.uri(), "/eth/v1/config/spec").await; + let genesis = get_json(&mock.uri(), "/eth/v1/beacon/genesis").await; + + assert_eq!( + spec["data"]["GENESIS_FORK_VERSION"].as_str(), + Some("0xaabbccdd"), + ); + assert_eq!( + genesis["data"]["genesis_fork_version"].as_str(), + Some("0xaabbccdd"), + ); + } + + #[tokio::test] + async fn sync_committee_size_overrides_spec() { + let mock = BeaconMock::builder() + .sync_committee_size(32) + .build() + .await + .expect("build mock"); + + let spec = get_json(&mock.uri(), "/eth/v1/config/spec").await; + assert_eq!(spec["data"]["SYNC_COMMITTEE_SIZE"].as_str(), Some("32")); + } + + #[tokio::test] + async fn sync_committee_subnet_count_overrides_spec() { + let mock = BeaconMock::builder() + .sync_committee_subnet_count(8) + .build() + .await + .expect("build mock"); + + let spec = get_json(&mock.uri(), "/eth/v1/config/spec").await; + assert_eq!( + spec["data"]["SYNC_COMMITTEE_SUBNET_COUNT"].as_str(), + Some("8"), + ); + } + + #[tokio::test] + async fn no_proposer_duties_returns_empty_list() { + // Set deterministic proposer duties first, then assert no_proposer_duties + // wins. + let mock = BeaconMock::builder() + .validator_set(ValidatorSet::validator_set_a()) + .deterministic_proposer_duties(1) + .no_proposer_duties(true) + .build() + .await + .expect("build mock"); + + let body = get_json(&mock.uri(), "/eth/v1/validator/duties/proposer/3").await; + assert!(body["data"].as_array().unwrap().is_empty()); + assert_eq!(body["dependent_root"].as_str(), Some(super::ZERO_ROOT)); + assert_eq!(body["execution_optimistic"].as_bool(), Some(false)); + } + + #[tokio::test] + async fn no_attester_duties_returns_empty_list() { + let mock = BeaconMock::builder() + .validator_set(ValidatorSet::validator_set_a()) + .deterministic_attester_duties(1) + .no_attester_duties(true) + .build() + .await + .expect("build mock"); + + let body = post_json( + &mock.uri(), + "/eth/v1/validator/duties/attester/0", + &json!(["1", "2"]), + ) + .await; + assert!(body["data"].as_array().unwrap().is_empty()); + } + + #[tokio::test] + async fn no_sync_committee_duties_returns_empty_list() { + let mock = BeaconMock::builder() + .validator_set(ValidatorSet::validator_set_a()) + .deterministic_sync_comm_duties((4, 8)) + .no_sync_committee_duties(true) + .build() + .await + .expect("build mock"); + + let body = post_json( + &mock.uri(), + "/eth/v1/validator/duties/sync/0", + &json!(["1", "2"]), + ) + .await; + assert!(body["data"].as_array().unwrap().is_empty()); + } + + #[tokio::test] + async fn deterministic_sync_comm_duties_within_window() { + let mock = BeaconMock::builder() + .validator_set(ValidatorSet::validator_set_a()) + .deterministic_sync_comm_duties((2, 8)) + .build() + .await + .expect("build mock"); + + // epoch=0, 0%8=0 <2 → duties returned for the requested indices. + let body = post_json( + &mock.uri(), + "/eth/v1/validator/duties/sync/0", + &json!(["1", "2"]), + ) + .await; + let data = body["data"].as_array().expect("data array"); + assert_eq!(data.len(), 2); + assert_eq!(data[0]["validator_index"].as_str(), Some("1")); + assert_eq!( + data[0]["validator_sync_committee_indices"] + .as_array() + .unwrap(), + &vec![json!("0")], + ); + assert_eq!(data[1]["validator_index"].as_str(), Some("2")); + assert_eq!( + data[1]["validator_sync_committee_indices"] + .as_array() + .unwrap(), + &vec![json!("1")], + ); + + // Spec EPOCHS_PER_SYNC_COMMITTEE_PERIOD reflects n=2. + let spec = get_json(&mock.uri(), "/eth/v1/config/spec").await; + assert_eq!( + spec["data"]["EPOCHS_PER_SYNC_COMMITTEE_PERIOD"].as_str(), + Some("2"), + ); + } + + #[tokio::test] + async fn deterministic_sync_comm_duties_outside_window() { + let mock = BeaconMock::builder() + .validator_set(ValidatorSet::validator_set_a()) + .deterministic_sync_comm_duties((2, 8)) + .build() + .await + .expect("build mock"); + + // epoch=2, 2%8=2 >=2 → no duties. + let body = post_json( + &mock.uri(), + "/eth/v1/validator/duties/sync/2", + &json!(["1", "2"]), + ) + .await; + assert!(body["data"].as_array().unwrap().is_empty()); + } +} diff --git a/crates/testutil/src/beaconmock/state.rs b/crates/testutil/src/beaconmock/state.rs index 6c7dd1b8..3330a68f 100644 --- a/crates/testutil/src/beaconmock/state.rs +++ b/crates/testutil/src/beaconmock/state.rs @@ -11,6 +11,8 @@ use pluto_eth2api::{ }; use serde_json::Value; +use super::attestation::AttestationStore; + pub(crate) const DEFAULT_WITHDRAWAL_CREDENTIALS: &str = "0x3132333435363738393031323334353637383930313233343536373839303132"; @@ -98,6 +100,32 @@ impl ValidatorSet { self.0.get(&index).cloned() } + /// Returns the first validator matching the given BLS public key. + /// + /// Mirrors `ValidatorSet.ByPublicKey` from Charon's Go beaconmock: a linear + /// scan over the set returning a clone of the matching validator. + #[must_use] + pub fn by_public_key(&self, pubkey: &BLSPubKey) -> Option { + let needle = hex_0x(pubkey); + self.0 + .values() + .find(|validator| validator.validator.pubkey == needle) + .cloned() + } + + /// Returns the BLS public keys of all validators in index order. + /// + /// Validators whose stored hex pubkey fails to parse back into a + /// `BLSPubKey` are silently skipped; all validators inserted via + /// `Validator::active` round-trip cleanly. + #[must_use] + pub fn public_keys(&self) -> Vec { + self.0 + .values() + .filter_map(|validator| parse_pubkey(&validator.validator.pubkey)) + .collect() + } + /// Returns true if the set contains no validators. #[must_use] pub fn is_empty(&self) -> bool { @@ -119,6 +147,8 @@ pub struct MockState { pub(crate) validator_set: RwLock, pub(crate) deterministic_attester_duties: RwLock>, pub(crate) deterministic_proposer_duties: RwLock>, + pub(crate) deterministic_sync_comm_duties: RwLock>, + pub(crate) attestation_store: AttestationStore, } impl MockState { @@ -129,6 +159,8 @@ impl MockState { validator_set: RwLock::new(validator_set), deterministic_attester_duties: RwLock::new(None), deterministic_proposer_duties: RwLock::new(None), + deterministic_sync_comm_duties: RwLock::new(None), + attestation_store: AttestationStore::default(), } } @@ -197,3 +229,44 @@ pub(crate) fn set_object_field(target: &mut Value, key: &'static str, value: imp target.insert(key.to_string(), value.into()); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validator_set_a_has_three_validators() { + let set = ValidatorSet::validator_set_a(); + assert_eq!(set.validators().len(), 3); + } + + #[test] + fn by_public_key_hit_returns_validator() { + let set = ValidatorSet::validator_set_a(); + let pubkey = parse_pubkey( + "0x914cff835a769156ba43ad50b931083c2dadd94e8359ce394bc7a3e06424d0214922ddf15f81640530b9c25c0bc0d490", + ) + .expect("static pubkey parses"); + + let validator = set.by_public_key(&pubkey).expect("validator by pubkey"); + assert_eq!(validator.index, 1); + } + + #[test] + fn by_public_key_miss_returns_none() { + let set = ValidatorSet::validator_set_a(); + let unknown: BLSPubKey = [0u8; 48]; + assert!(set.by_public_key(&unknown).is_none()); + } + + #[test] + fn public_keys_returns_all_validator_pubkeys() { + let set = ValidatorSet::validator_set_a(); + let pubkeys = set.public_keys(); + assert_eq!(pubkeys.len(), 3); + // Every emitted pubkey must round-trip back to a validator in the set. + for pubkey in pubkeys { + assert!(set.by_public_key(&pubkey).is_some()); + } + } +} diff --git a/crates/testutil/src/lib.rs b/crates/testutil/src/lib.rs index 3290722c..864ff1b0 100644 --- a/crates/testutil/src/lib.rs +++ b/crates/testutil/src/lib.rs @@ -4,6 +4,10 @@ //! validator node. This crate provides test helpers, mock objects, and testing //! utilities for unit tests, integration tests, and development. +// Raised so the large `json!` literals in `beaconmock::defaults::default_spec` +// expand without hitting the default macro recursion limit. +#![recursion_limit = "256"] + /// Random utilities. pub mod random; From ba5fb7ae983c89bcc86e373f3b1d7068b572a851 Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> Date: Thu, 14 May 2026 21:25:20 +0200 Subject: [PATCH 04/21] test(testutil/beaconmock): port go beaconmock tests Add Rust ports of the seven Go tests not covered by the agents' implementation-focused tests: deterministic attester/proposer duties, TestStatic, genesis_time/slots_per_epoch/slot_duration overrides, and default overrides. --- crates/testutil/src/beaconmock/mod.rs | 173 ++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/crates/testutil/src/beaconmock/mod.rs b/crates/testutil/src/beaconmock/mod.rs index b07aa2f3..f65ff341 100644 --- a/crates/testutil/src/beaconmock/mod.rs +++ b/crates/testutil/src/beaconmock/mod.rs @@ -193,3 +193,176 @@ impl BeaconMock { Arc::clone(&self.state) } } + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{TimeZone, Timelike, Utc}; + use serde_json::json; + + async fn get_json(url: &str) -> Value { + let resp = reqwest::get(url).await.expect("send"); + assert_eq!(resp.status(), 200, "GET {url} returned {}", resp.status()); + resp.json().await.expect("json") + } + + async fn post_json(url: &str, body: &Value) -> Value { + let resp = reqwest::Client::new() + .post(url) + .json(body) + .send() + .await + .expect("send"); + assert_eq!(resp.status(), 200, "POST {url} returned {}", resp.status()); + resp.json().await.expect("json") + } + + /// Mirrors Go's `TestDeterministicAttesterDuties`: validator set A, + /// deterministic factor 1, epoch 1, ask for validator index 2. + #[tokio::test] + async fn deterministic_attester_duties() { + let mock = BeaconMock::builder() + .validator_set(ValidatorSet::validator_set_a()) + .deterministic_attester_duties(1) + .build() + .await + .expect("build mock"); + + let url = format!("{}/eth/v1/validator/duties/attester/1", mock.uri()); + let body = post_json(&url, &json!(["2"])).await; + let data = body["data"].as_array().expect("data array"); + assert_eq!(data.len(), 1); + let duty = &data[0]; + assert_eq!(duty["validator_index"], "2"); + assert_eq!(duty["committee_index"], "2"); + assert_eq!(duty["committee_length"], "1"); + assert_eq!(duty["committees_at_slot"], "16"); + assert_eq!(duty["validator_committee_index"], "0"); + // slot = slots_per_epoch * epoch + (position*factor)%slots_per_epoch + // = 16*1 + (0*1)%16 = 16 + assert_eq!(duty["slot"], "16"); + } + + /// Mirrors Go's `TestDeterministicProposerDuties`: validator set A, + /// deterministic factor 1, epoch 1. Go's mock ignores the indices filter + /// and assigns all active validators round-robin, one per slot. + #[tokio::test] + async fn deterministic_proposer_duties() { + let mock = BeaconMock::builder() + .validator_set(ValidatorSet::validator_set_a()) + .deterministic_proposer_duties(1) + .build() + .await + .expect("build mock"); + + let url = format!("{}/eth/v1/validator/duties/proposer/1", mock.uri()); + let body = get_json(&url).await; + let data = body["data"].as_array().expect("data array"); + // Validator set A has 3 validators, factor=1, slots_per_epoch=16 — all + // three get distinct offsets 0,1,2 and corresponding slots 16,17,18. + assert_eq!(data.len(), 3); + for (i, duty) in data.iter().enumerate() { + let want_index = (i + 1).to_string(); + let want_slot = (16 + i).to_string(); + assert_eq!(duty["validator_index"], want_index); + assert_eq!(duty["slot"], want_slot); + } + } + + /// Mirrors Go's `TestStatic`: default mock serves genesis/spec/deposit + /// contract/syncing/version with the expected baseline values. + #[tokio::test] + async fn static_endpoints() { + let mock = BeaconMock::builder().build().await.expect("build mock"); + let base = mock.uri(); + + let genesis = get_json(&format!("{base}/eth/v1/beacon/genesis")).await; + let expected = Utc.with_ymd_and_hms(2022, 3, 1, 0, 0, 0).unwrap(); + assert_eq!( + genesis["data"]["genesis_time"], + expected.timestamp().to_string() + ); + + let spec = get_json(&format!("{base}/eth/v1/config/spec")).await; + assert_eq!(spec["data"]["ALTAIR_FORK_EPOCH"], "0"); + assert_eq!(spec["data"]["DENEB_FORK_EPOCH"], "0"); + assert_eq!(spec["data"]["ELECTRA_FORK_EPOCH"], "2048"); + assert_eq!(spec["data"]["SLOTS_PER_EPOCH"], "16"); + + let deposit = get_json(&format!("{base}/eth/v1/config/deposit_contract")).await; + assert_eq!(deposit["data"]["chain_id"], "17000"); + + let syncing = get_json(&format!("{base}/eth/v1/node/syncing")).await; + assert_eq!(syncing["data"]["is_syncing"], false); + + let version = get_json(&format!("{base}/eth/v1/node/version")).await; + assert_eq!(version["data"]["version"], "charon/static_beacon_mock"); + } + + /// Mirrors Go's `TestGenesisTimeOverride`: builder-provided genesis time + /// flows through to the `/eth/v1/beacon/genesis` endpoint. + #[tokio::test] + async fn genesis_time_override() { + let t0 = Utc::now().with_nanosecond(0).expect("truncate nanoseconds"); + let mock = BeaconMock::builder() + .genesis_time(t0) + .build() + .await + .expect("build mock"); + + let body = get_json(&format!("{}/eth/v1/beacon/genesis", mock.uri())).await; + assert_eq!( + body["data"]["genesis_time"], + t0.timestamp().to_string(), + "genesis_time override should be served verbatim" + ); + } + + /// Mirrors Go's `TestSlotsPerEpochOverride`: builder-set slots_per_epoch + /// is reflected in the spec endpoint. + #[tokio::test] + async fn slots_per_epoch_override() { + let mock = BeaconMock::builder() + .slots_per_epoch(5) + .build() + .await + .expect("build mock"); + + let body = get_json(&format!("{}/eth/v1/config/spec", mock.uri())).await; + assert_eq!(body["data"]["SLOTS_PER_EPOCH"], "5"); + } + + /// Mirrors Go's `TestSlotsDurationOverride`: builder-set slot_duration is + /// reflected as SECONDS_PER_SLOT in the spec endpoint. + #[tokio::test] + async fn slot_duration_override() { + let mock = BeaconMock::builder() + .slot_duration(Duration::from_secs(1)) + .build() + .await + .expect("build mock"); + + let body = get_json(&format!("{}/eth/v1/config/spec", mock.uri())).await; + assert_eq!(body["data"]["SECONDS_PER_SLOT"], "1"); + } + + /// Mirrors Go's `TestDefaultOverrides`: with no builder options, the spec + /// reports the Charon-simnet defaults and genesis time matches the + /// 2022-03-01 baseline. + #[tokio::test] + async fn default_overrides() { + let mock = BeaconMock::builder().build().await.expect("build mock"); + let base = mock.uri(); + + let spec = get_json(&format!("{base}/eth/v1/config/spec")).await; + assert_eq!(spec["data"]["CONFIG_NAME"], "charon-simnet"); + assert_eq!(spec["data"]["SLOTS_PER_EPOCH"], "16"); + + let genesis = get_json(&format!("{base}/eth/v1/beacon/genesis")).await; + let expected = Utc.with_ymd_and_hms(2022, 3, 1, 0, 0, 0).unwrap(); + assert_eq!( + genesis["data"]["genesis_time"], + expected.timestamp().to_string() + ); + } +} From 7def8070ed049e45b5d039554d99aeb826dce1a1 Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> Date: Thu, 14 May 2026 21:47:48 +0200 Subject: [PATCH 05/21] feat(testutil/beaconmock): embed static.json baseline + build-time validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port charon's static.json + gen_static.sh approach: a Holesky beacon-node snapshot is committed in the crate and used as the baseline for the default spec; Charon-simnet overrides apply on top. A new build.rs validates the file at compile time (well-formed JSON, required endpoints present, required spec keys present) and triggers rebuilds whenever the snapshot changes — failures surface as compile errors instead of test failures. Drops the hand-curated ~80-key spec list previously inlined in defaults.rs in favor of the real beacon-node snapshot. scripts/gen_static_beaconmock.sh regenerates static.json from a live beacon node (`BEACON_URL= ./scripts/gen_static_beaconmock.sh`), mirroring charon/testutil/beaconmock/gen_static.sh. --- .gitignore | 6 +- crates/testutil/Cargo.toml | 4 + crates/testutil/build.rs | 79 ++++++++ crates/testutil/src/beaconmock/defaults.rs | 86 +++----- crates/testutil/src/beaconmock/static.json | 216 +++++++++++++++++++++ scripts/gen_static_beaconmock.sh | 45 +++++ 6 files changed, 372 insertions(+), 64 deletions(-) create mode 100644 crates/testutil/build.rs create mode 100644 crates/testutil/src/beaconmock/static.json create mode 100755 scripts/gen_static_beaconmock.sh diff --git a/.gitignore b/.gitignore index 15e53899..304cfad0 100644 --- a/.gitignore +++ b/.gitignore @@ -28,4 +28,8 @@ coverage.json .peerinfo* -test-infra/sszfixtures/sszfixtures \ No newline at end of file +test-infra/sszfixtures/sszfixtures + +.claude/worktrees/ +.claude/scheduled_tasks.lock +test-cluster \ No newline at end of file diff --git a/crates/testutil/Cargo.toml b/crates/testutil/Cargo.toml index 65d23978..308792b8 100644 --- a/crates/testutil/Cargo.toml +++ b/crates/testutil/Cargo.toml @@ -5,6 +5,10 @@ edition.workspace = true repository.workspace = true license.workspace = true publish.workspace = true +build = "build.rs" + +[build-dependencies] +serde_json.workspace = true [dependencies] anyhow.workspace = true diff --git a/crates/testutil/build.rs b/crates/testutil/build.rs new file mode 100644 index 00000000..5b1cffa0 --- /dev/null +++ b/crates/testutil/build.rs @@ -0,0 +1,79 @@ +//! Build script: validate `beaconmock/static.json` at compile time. +//! +//! Catches regressions in the embedded beacon-node snapshot (malformed JSON, +//! missing endpoints, missing spec keys) before the test crate runs, and +//! triggers a rebuild whenever the snapshot changes. + +use std::path::Path; + +const STATIC_JSON: &str = "src/beaconmock/static.json"; + +/// Endpoints that must be present in `static.json`. Mirrors `gen_static.sh`. +const REQUIRED_ENDPOINTS: &[&str] = &[ + "/eth/v1/beacon/genesis", + "/eth/v1/config/deposit_contract", + "/eth/v1/config/fork_schedule", + "/eth/v1/node/version", + "/eth/v1/config/spec", +]; + +/// Spec keys the mock relies on. Real beacon clients read more, but these are +/// the minimum the Rust port references directly. +const REQUIRED_SPEC_KEYS: &[&str] = &[ + "SLOTS_PER_EPOCH", + "SECONDS_PER_SLOT", + "GENESIS_FORK_VERSION", + "ALTAIR_FORK_EPOCH", + "BELLATRIX_FORK_EPOCH", + "CAPELLA_FORK_EPOCH", + "DENEB_FORK_EPOCH", + "ELECTRA_FORK_EPOCH", + "MAX_VALIDATORS_PER_COMMITTEE", + "TARGET_AGGREGATORS_PER_COMMITTEE", + "SYNC_COMMITTEE_SIZE", + "EPOCHS_PER_SYNC_COMMITTEE_PERIOD", +]; + +fn main() { + let path = Path::new(STATIC_JSON); + println!("cargo:rerun-if-changed={}", path.display()); + + let raw = std::fs::read_to_string(path) + .unwrap_or_else(|err| panic!("read {}: {err}", path.display())); + + let parsed: serde_json::Value = serde_json::from_str(&raw) + .unwrap_or_else(|err| panic!("{} is not valid JSON: {err}", path.display())); + + let endpoints = parsed + .as_object() + .unwrap_or_else(|| panic!("{} top-level value must be a JSON object", path.display())); + + for required in REQUIRED_ENDPOINTS { + let entry = endpoints + .get(*required) + .unwrap_or_else(|| panic!("{} missing required endpoint {required}", path.display())); + if entry.get("data").is_none() { + panic!( + "{} endpoint {required} missing `data` field", + path.display() + ); + } + } + + let spec = endpoints + .get("/eth/v1/config/spec") + .and_then(|v| v.get("data")) + .and_then(|v| v.as_object()) + .unwrap_or_else(|| { + panic!( + "{} `/eth/v1/config/spec` -> `data` must be a JSON object", + path.display() + ) + }); + + for key in REQUIRED_SPEC_KEYS { + if !spec.contains_key(*key) { + panic!("{} spec is missing required key {key}", path.display()); + } + } +} diff --git a/crates/testutil/src/beaconmock/defaults.rs b/crates/testutil/src/beaconmock/defaults.rs index 08559772..56ada624 100644 --- a/crates/testutil/src/beaconmock/defaults.rs +++ b/crates/testutil/src/beaconmock/defaults.rs @@ -473,15 +473,32 @@ fn slots_per_epoch(state: &MockState) -> u64 { .unwrap_or(16) } +/// Embedded beacon-node snapshot used as the baseline for default responses. +/// +/// Generated by `scripts/gen_static_beaconmock.sh` against a Holesky beacon +/// node. Validated at compile time by `build.rs`. +pub(crate) const STATIC_JSON: &str = include_str!("static.json"); + +fn static_endpoint_data(endpoint: &str) -> serde_json::Map { + let snapshot: Value = + serde_json::from_str(STATIC_JSON).expect("static.json validated by build.rs"); + snapshot + .get(endpoint) + .and_then(|entry| entry.get("data")) + .and_then(Value::as_object) + .cloned() + .unwrap_or_default() +} + pub(crate) fn default_spec() -> Value { - // Build via discrete (key, value) entries to avoid blowing the `json!` macro - // recursion limit; mainnet specs contain ~100 keys. - let entries: &[(&str, &str)] = &[ + // Start from the Holesky snapshot baseline (~80 mainnet keys) and overlay + // the Charon-simnet overrides used by tests. + let mut spec = static_endpoint_data("/eth/v1/config/spec"); + + let overrides: &[(&str, &str)] = &[ ("CONFIG_NAME", "charon-simnet"), - ("PRESET_BASE", "mainnet"), ("SLOTS_PER_EPOCH", "16"), ("SECONDS_PER_SLOT", "12"), - ("GENESIS_DELAY", "300"), ("GENESIS_FORK_VERSION", DEFAULT_GENESIS_FORK_VERSION), ("ALTAIR_FORK_VERSION", "0x20000910"), ("ALTAIR_FORK_EPOCH", "0"), @@ -505,68 +522,11 @@ pub(crate) fn default_spec() -> Value { ("DOMAIN_SYNC_COMMITTEE_SELECTION_PROOF", "0x08000000"), ("DOMAIN_CONTRIBUTION_AND_PROOF", "0x09000000"), ("DOMAIN_APPLICATION_BUILDER", "0x00000001"), - ("TARGET_AGGREGATORS_PER_COMMITTEE", "16"), - ("SYNC_COMMITTEE_SIZE", "512"), - ("SYNC_COMMITTEE_SUBNET_COUNT", "4"), - ("TARGET_AGGREGATORS_PER_SYNC_SUBCOMMITTEE", "16"), ("EPOCHS_PER_SYNC_COMMITTEE_PERIOD", "256"), - // Committee / shuffling shape. - ("MAX_VALIDATORS_PER_COMMITTEE", "2048"), - ("MAX_COMMITTEES_PER_SLOT", "64"), - ("TARGET_COMMITTEE_SIZE", "128"), - ("SHUFFLE_ROUND_COUNT", "90"), - ("SHARD_COMMITTEE_PERIOD", "256"), - // Historical state limits. - ("EPOCHS_PER_HISTORICAL_VECTOR", "65536"), - ("EPOCHS_PER_SLASHINGS_VECTOR", "8192"), - ("SLOTS_PER_HISTORICAL_ROOT", "8192"), - ("HISTORICAL_ROOTS_LIMIT", "16777216"), - ("VALIDATOR_REGISTRY_LIMIT", "1099511627776"), - // Churn / activation queue. - ("MIN_PER_EPOCH_CHURN_LIMIT", "4"), - ("CHURN_LIMIT_QUOTIENT", "65536"), - ("MAX_PER_EPOCH_ACTIVATION_CHURN_LIMIT", "8"), - // Balances. - ("MAX_EFFECTIVE_BALANCE", "32000000000"), - ("MAX_EFFECTIVE_BALANCE_ELECTRA", "2048000000000"), - ("MIN_ACTIVATION_BALANCE", "32000000000"), - ("EFFECTIVE_BALANCE_INCREMENT", "1000000000"), - ("EJECTION_BALANCE", "28000000000"), - ("MIN_DEPOSIT_AMOUNT", "1000000000"), - // Attestation / fork-choice timing. - ("MIN_ATTESTATION_INCLUSION_DELAY", "1"), - ("MIN_SEED_LOOKAHEAD", "1"), - ("MAX_SEED_LOOKAHEAD", "4"), - ("ATTESTATION_PROPAGATION_SLOT_RANGE", "32"), - ("ATTESTATION_SUBNET_COUNT", "64"), - ("MIN_VALIDATOR_WITHDRAWABILITY_DELAY", "256"), - ("PROPOSER_SCORE_BOOST", "40"), - // Block body operation limits. - ("MAX_ATTESTATIONS", "128"), - ("MAX_ATTESTATIONS_ELECTRA", "8"), - ("MAX_ATTESTER_SLASHINGS", "2"), - ("MAX_PROPOSER_SLASHINGS", "16"), - ("MAX_VOLUNTARY_EXITS", "16"), - ("MAX_DEPOSITS", "16"), - ("MAX_BLS_TO_EXECUTION_CHANGES", "16"), - ("MAX_WITHDRAWALS_PER_PAYLOAD", "16"), - // Deposit / chain identity. - ("DEPOSIT_CHAIN_ID", "17000"), - ("DEPOSIT_NETWORK_ID", "17000"), - ( - "DEPOSIT_CONTRACT_ADDRESS", - "0x4242424242424242424242424242424242424242", - ), - // Inactivity leak parameters. - ("INACTIVITY_SCORE_BIAS", "4"), - ("INACTIVITY_SCORE_RECOVERY_RATE", "16"), ]; - - let mut spec = serde_json::Map::new(); - for (key, value) in entries { + for (key, value) in overrides { spec.insert((*key).to_string(), Value::String((*value).to_string())); } - // Fields that require non-`&'static str` values. spec.insert( "MIN_GENESIS_TIME".to_string(), Value::String(default_genesis_time().timestamp().to_string()), diff --git a/crates/testutil/src/beaconmock/static.json b/crates/testutil/src/beaconmock/static.json new file mode 100644 index 00000000..c8cc99c7 --- /dev/null +++ b/crates/testutil/src/beaconmock/static.json @@ -0,0 +1,216 @@ +{ + "/eth/v1/beacon/genesis": { + "data": { + "genesis_time": "1695902400", + "genesis_validators_root": "0x9143aa7c615a7f7115e2b6aac319c03529df8242ae705fba9df39b79c59fa8b1", + "genesis_fork_version": "0x01017000" + } + }, + "/eth/v1/config/deposit_contract": { + "data": { + "chain_id": "17000", + "address": "0x4242424242424242424242424242424242424242" + } + }, + "/eth/v1/config/fork_schedule": { + "data": [ + { + "previous_version": "0x01017000", + "current_version": "0x01017000", + "epoch": "0" + }, + { + "previous_version": "0x01017000", + "current_version": "0x02017000", + "epoch": "0" + }, + { + "previous_version": "0x02017000", + "current_version": "0x03017000", + "epoch": "0" + }, + { + "previous_version": "0x03017000", + "current_version": "0x04017000", + "epoch": "256" + }, + { + "previous_version": "0x04017000", + "current_version": "0x05017000", + "epoch": "29696" + } + ] + }, + "/eth/v1/node/version": { + "data": { + "version": "teku/v25.4.1/linux-x86_64/-ubuntu-openjdk64bitservervm-java-21" + } + }, + "/eth/v1/config/spec": { + "data": { + "SLOTS_PER_EPOCH": "32", + "PRESET_BASE": "mainnet", + "TERMINAL_TOTAL_DIFFICULTY": "0", + "INACTIVITY_SCORE_BIAS": "4", + "MAX_ATTESTER_SLASHINGS": "2", + "MAX_WITHDRAWALS_PER_PAYLOAD": "16", + "INACTIVITY_PENALTY_QUOTIENT_BELLATRIX": "16777216", + "PENDING_PARTIAL_WITHDRAWALS_LIMIT": "134217728", + "INACTIVITY_PENALTY_QUOTIENT": "67108864", + "SAFE_SLOTS_TO_UPDATE_JUSTIFIED": "8", + "SECONDS_PER_ETH1_BLOCK": "14", + "MIN_SEED_LOOKAHEAD": "1", + "VALIDATOR_REGISTRY_LIMIT": "1099511627776", + "REORG_MAX_EPOCHS_SINCE_FINALIZATION": "2", + "SLOTS_PER_HISTORICAL_ROOT": "8192", + "FIELD_ELEMENTS_PER_EXT_BLOB": "8192", + "RESP_TIMEOUT": "10", + "DOMAIN_VOLUNTARY_EXIT": "0x04000000", + "MAX_VALIDATORS_PER_COMMITTEE": "2048", + "MIN_GENESIS_TIME": "1695902100", + "ALTAIR_FORK_EPOCH": "0", + "MAX_PER_EPOCH_ACTIVATION_EXIT_CHURN_LIMIT": "256000000000", + "HYSTERESIS_QUOTIENT": "4", + "ALTAIR_FORK_VERSION": "0x02017000", + "MAX_BYTES_PER_TRANSACTION": "1073741824", + "MAX_CHUNK_SIZE": "10485760", + "TTFB_TIMEOUT": "5", + "WHISTLEBLOWER_REWARD_QUOTIENT": "512", + "PROPOSER_REWARD_QUOTIENT": "8", + "MAX_VALIDATORS_PER_WITHDRAWALS_SWEEP": "16384", + "EPOCHS_PER_HISTORICAL_VECTOR": "65536", + "MIN_PER_EPOCH_CHURN_LIMIT": "4", + "MAX_ATTESTER_SLASHINGS_ELECTRA": "1", + "TARGET_AGGREGATORS_PER_SYNC_SUBCOMMITTEE": "16", + "MAX_DEPOSITS": "16", + "FIELD_ELEMENTS_PER_CELL": "64", + "BELLATRIX_FORK_EPOCH": "0", + "MAX_REQUEST_BLOB_SIDECARS": "768", + "REORG_HEAD_WEIGHT_THRESHOLD": "20", + "TARGET_AGGREGATORS_PER_COMMITTEE": "16", + "DOMAIN_SYNC_COMMITTEE_SELECTION_PROOF": "0x08000000", + "MESSAGE_DOMAIN_INVALID_SNAPPY": "0x00000000", + "EPOCHS_PER_SLASHINGS_VECTOR": "8192", + "MIN_SLASHING_PENALTY_QUOTIENT": "128", + "MAX_BLS_TO_EXECUTION_CHANGES": "16", + "DOMAIN_BEACON_ATTESTER": "0x01000000", + "EPOCHS_PER_SUBNET_SUBSCRIPTION": "256", + "PENDING_DEPOSITS_LIMIT": "134217728", + "MAX_ATTESTATIONS_ELECTRA": "8", + "ATTESTATION_SUBNET_COUNT": "64", + "GENESIS_DELAY": "300", + "MAX_SEED_LOOKAHEAD": "4", + "ETH1_FOLLOW_DISTANCE": "2048", + "KZG_COMMITMENTS_INCLUSION_PROOF_DEPTH": "4", + "SECONDS_PER_SLOT": "12", + "REORG_PARENT_WEIGHT_THRESHOLD": "160", + "MIN_SYNC_COMMITTEE_PARTICIPANTS": "1", + "DATA_COLUMN_SIDECAR_SUBNET_COUNT": "128", + "MAX_PENDING_DEPOSITS_PER_EPOCH": "16", + "BELLATRIX_FORK_VERSION": "0x03017000", + "PROPORTIONAL_SLASHING_MULTIPLIER_BELLATRIX": "3", + "SAMPLES_PER_SLOT": "8", + "EFFECTIVE_BALANCE_INCREMENT": "1000000000", + "MAX_PAYLOAD_SIZE": "10485760", + "MIN_PER_EPOCH_CHURN_LIMIT_ELECTRA": "128000000000", + "MIN_EPOCHS_FOR_DATA_COLUMN_SIDECARS_REQUESTS": "4096", + "FIELD_ELEMENTS_PER_BLOB": "4096", + "MIN_EPOCHS_TO_INACTIVITY_PENALTY": "4", + "BASE_REWARD_FACTOR": "64", + "MAX_EXTRA_DATA_BYTES": "32", + "CONFIG_NAME": "holesky", + "MAX_PROPOSER_SLASHINGS": "16", + "INACTIVITY_SCORE_RECOVERY_RATE": "16", + "MIN_EPOCHS_FOR_BLOB_SIDECARS_REQUESTS": "4096", + "MAX_TRANSACTIONS_PER_PAYLOAD": "1048576", + "DEPOSIT_CONTRACT_ADDRESS": "0x4242424242424242424242424242424242424242", + "MIN_ATTESTATION_INCLUSION_DELAY": "1", + "SHUFFLE_ROUND_COUNT": "90", + "TERMINAL_BLOCK_HASH_ACTIVATION_EPOCH": "18446744073709551615", + "MAX_EFFECTIVE_BALANCE": "32000000000", + "DOMAIN_BEACON_PROPOSER": "0x00000000", + "DENEB_FORK_EPOCH": "0", + "DOMAIN_SYNC_COMMITTEE": "0x07000000", + "PROPOSER_SCORE_BOOST": "40", + "FULU_FORK_EPOCH": "18446744073709551615", + "MAX_BLOBS_PER_BLOCK_FULU": "12", + "DOMAIN_SELECTION_PROOF": "0x05000000", + "MIN_SLASHING_PENALTY_QUOTIENT_BELLATRIX": "32", + "MAX_PER_EPOCH_ACTIVATION_CHURN_LIMIT": "8", + "HYSTERESIS_UPWARD_MULTIPLIER": "5", + "SUBNETS_PER_NODE": "2", + "MIN_DEPOSIT_AMOUNT": "1000000000", + "MIN_SLASHING_PENALTY_QUOTIENT_ELECTRA": "4096", + "PROPORTIONAL_SLASHING_MULTIPLIER_ALTAIR": "2", + "MAX_BLOBS_PER_BLOCK": "6", + "VALIDATOR_CUSTODY_REQUIREMENT": "8", + "MIN_VALIDATOR_WITHDRAWABILITY_DELAY": "256", + "MAXIMUM_GOSSIP_CLOCK_DISPARITY": "500", + "TARGET_COMMITTEE_SIZE": "128", + "TERMINAL_BLOCK_HASH": "0x0000000000000000000000000000000000000000000000000000000000000000", + "DOMAIN_DEPOSIT": "0x03000000", + "DOMAIN_CONTRIBUTION_AND_PROOF": "0x09000000", + "UPDATE_TIMEOUT": "8192", + "ELECTRA_FORK_EPOCH": "2048", + "SYNC_COMMITTEE_BRANCH_LENGTH": "5", + "DEPOSIT_CHAIN_ID": "17000", + "MAX_BLOB_COMMITMENTS_PER_BLOCK": "4096", + "DOMAIN_RANDAO": "0x02000000", + "CAPELLA_FORK_VERSION": "0x04017000", + "MAX_EFFECTIVE_BALANCE_ELECTRA": "2048000000000", + "MIN_SLASHING_PENALTY_QUOTIENT_ALTAIR": "64", + "EPOCHS_PER_ETH1_VOTING_PERIOD": "64", + "WHISTLEBLOWER_REWARD_QUOTIENT_ELECTRA": "4096", + "HISTORICAL_ROOTS_LIMIT": "16777216", + "ATTESTATION_PROPAGATION_SLOT_RANGE": "32", + "MAX_BLOBS_PER_BLOCK_ELECTRA": "9", + "SYNC_COMMITTEE_SIZE": "512", + "MAX_REQUEST_DATA_COLUMN_SIDECARS": "512", + "ATTESTATION_SUBNET_PREFIX_BITS": "6", + "NUMBER_OF_COLUMNS": "128", + "PROPORTIONAL_SLASHING_MULTIPLIER": "1", + "MAX_WITHDRAWAL_REQUESTS_PER_PAYLOAD": "16", + "MESSAGE_DOMAIN_VALID_SNAPPY": "0x01000000", + "MAX_VOLUNTARY_EXITS": "16", + "PENDING_CONSOLIDATIONS_LIMIT": "262144", + "HYSTERESIS_DOWNWARD_MULTIPLIER": "1", + "MAX_PENDING_PARTIALS_PER_WITHDRAWALS_SWEEP": "8", + "FULU_FORK_VERSION": "0x07017000", + "EPOCHS_PER_SYNC_COMMITTEE_PERIOD": "256", + "BYTES_PER_LOGS_BLOOM": "256", + "MAX_DEPOSIT_REQUESTS_PER_PAYLOAD": "8192", + "CUSTODY_REQUIREMENT": "4", + "MIN_GENESIS_ACTIVE_VALIDATOR_COUNT": "16384", + "BLOB_SIDECAR_SUBNET_COUNT_ELECTRA": "9", + "MAX_REQUEST_BLOB_SIDECARS_ELECTRA": "1152", + "MAX_ATTESTATIONS": "128", + "MIN_EPOCHS_FOR_BLOCK_REQUESTS": "33024", + "DENEB_FORK_VERSION": "0x05017000", + "ELECTRA_FORK_VERSION": "0x06017000", + "MAX_REQUEST_BLOCKS": "1024", + "GENESIS_FORK_VERSION": "0x01017000", + "KZG_COMMITMENT_INCLUSION_PROOF_DEPTH": "17", + "DEPOSIT_NETWORK_ID": "17000", + "MAX_CONSOLIDATION_REQUESTS_PER_PAYLOAD": "2", + "MAX_REQUEST_BLOCKS_DENEB": "128", + "BLOB_SIDECAR_SUBNET_COUNT": "6", + "SYNC_COMMITTEE_SUBNET_COUNT": "4", + "CAPELLA_FORK_EPOCH": "256", + "EJECTION_BALANCE": "28000000000", + "ATTESTATION_SUBNET_EXTRA_BITS": "0", + "NUMBER_OF_CUSTODY_GROUPS": "128", + "MAX_COMMITTEES_PER_SLOT": "64", + "SHARD_COMMITTEE_PERIOD": "256", + "INACTIVITY_PENALTY_QUOTIENT_ALTAIR": "50331648", + "DOMAIN_AGGREGATE_AND_PROOF": "0x06000000", + "CHURN_LIMIT_QUOTIENT": "65536", + "BLS_WITHDRAWAL_PREFIX": "0x00", + "MIN_ACTIVATION_BALANCE": "32000000000", + "GOSSIP_MAX_SIZE": "1048576" + } + }, + "/eth/v2/beacon/blocks/0": { + "code": 404, + "message": "Not found" + } +} diff --git a/scripts/gen_static_beaconmock.sh b/scripts/gen_static_beaconmock.sh new file mode 100755 index 00000000..c1fa5b85 --- /dev/null +++ b/scripts/gen_static_beaconmock.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# +# Regenerates crates/testutil/src/beaconmock/static.json by curling the listed +# endpoints from a real beacon node. Port of charon/testutil/beaconmock/ +# gen_static.sh. Re-run when bumping spec/fork versions; the testutil build +# script validates the resulting file at compile time. +# +# Usage: BEACON_URL=https://beacon-holesky.example.com ./scripts/gen_static_beaconmock.sh + +set -euo pipefail + +if [[ -z "${BEACON_URL:-}" ]]; then + echo "BEACON_URL not set (point it at a Holesky beacon node)" >&2 + exit 1 +fi + +ENDPOINTS=( + /eth/v1/beacon/genesis + /eth/v1/config/deposit_contract + /eth/v1/config/fork_schedule + /eth/v1/node/version + /eth/v1/config/spec + /eth/v2/beacon/blocks/0 +) + +repo_root=$(cd "$(dirname "$0")/.." && pwd) +target="${repo_root}/crates/testutil/src/beaconmock/static.json" + +first=true +resp="{" +for endpoint in "${ENDPOINTS[@]}"; do + if "${first}"; then + first=false + else + resp+="," + fi + + echo "Fetching ${endpoint}" >&2 + value=$(curl -fsS "${BEACON_URL}${endpoint}") + resp+=" \"${endpoint}\": ${value}" +done +resp+=" }" + +echo "Writing ${target}" >&2 +echo "${resp}" | jq . > "${target}" From f3c1c858f165ea58b6ddfe2544257c864a4ae101 Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> Date: Thu, 14 May 2026 21:53:03 +0200 Subject: [PATCH 06/21] test(testutil/beaconmock): port golden fixtures + fix epoch-0 wraparound Restore the testdata/*.golden files from charon/testutil/beaconmock/ verbatim and switch the deterministic attester/proposer duties tests to golden assertions matching Go's RequireGoldenJSON. Adds a third golden test covering AttestationData for (slot=1, committee_index=2). Fixes a bug surfaced by the AttestationStore golden: the Rust port used saturating_sub(1) for previous_epoch, but Go wraps to u64::MAX at epoch 0 (see charon/testutil/beaconmock/attestation.go newAttestationData). Switch to wrapping_sub so the source.epoch and source.root match Go byte-for-byte. --- crates/testutil/src/beaconmock/attestation.rs | 4 +- crates/testutil/src/beaconmock/mod.rs | 58 +++++++++++-------- .../testdata/TestAttestationStore.golden | 13 +++++ .../TestDeterministicAttesterDuties.golden | 11 ++++ .../TestDeterministicProposerDuties.golden | 17 ++++++ 5 files changed, 79 insertions(+), 24 deletions(-) create mode 100644 crates/testutil/src/beaconmock/testdata/TestAttestationStore.golden create mode 100644 crates/testutil/src/beaconmock/testdata/TestDeterministicAttesterDuties.golden create mode 100644 crates/testutil/src/beaconmock/testdata/TestDeterministicProposerDuties.golden diff --git a/crates/testutil/src/beaconmock/attestation.rs b/crates/testutil/src/beaconmock/attestation.rs index 8c2aa382..07452c26 100644 --- a/crates/testutil/src/beaconmock/attestation.rs +++ b/crates/testutil/src/beaconmock/attestation.rs @@ -78,7 +78,9 @@ fn slot_hash_root(num: u64) -> Root { } fn build_attestation_data(epoch: Epoch, slot: Slot, committee_index: u64) -> AttestationData { - let previous_epoch = epoch.saturating_sub(1); + // Match Go: at epoch 0, previous_epoch wraps to u64::MAX (see + // charon/testutil/beaconmock/attestation.go `newAttestationData`). + let previous_epoch = epoch.wrapping_sub(1); AttestationData { slot, index: committee_index, diff --git a/crates/testutil/src/beaconmock/mod.rs b/crates/testutil/src/beaconmock/mod.rs index f65ff341..2483f141 100644 --- a/crates/testutil/src/beaconmock/mod.rs +++ b/crates/testutil/src/beaconmock/mod.rs @@ -200,6 +200,12 @@ mod tests { use chrono::{TimeZone, Timelike, Utc}; use serde_json::json; + const ATTESTER_DUTIES_GOLDEN: &str = + include_str!("testdata/TestDeterministicAttesterDuties.golden"); + const PROPOSER_DUTIES_GOLDEN: &str = + include_str!("testdata/TestDeterministicProposerDuties.golden"); + const ATTESTATION_STORE_GOLDEN: &str = include_str!("testdata/TestAttestationStore.golden"); + async fn get_json(url: &str) -> Value { let resp = reqwest::get(url).await.expect("send"); assert_eq!(resp.status(), 200, "GET {url} returned {}", resp.status()); @@ -217,8 +223,17 @@ mod tests { resp.json().await.expect("json") } + /// Asserts that `actual` equals the JSON in `golden`. Mirrors Go's + /// `testutil.RequireGoldenJSON`; the goldens themselves are byte-for-byte + /// copies of `charon/testutil/beaconmock/testdata/*.golden`. + fn assert_golden_json(actual: &Value, golden: &str) { + let expected: Value = serde_json::from_str(golden).expect("parse golden"); + assert_eq!(actual, &expected, "actual JSON does not match golden"); + } + /// Mirrors Go's `TestDeterministicAttesterDuties`: validator set A, - /// deterministic factor 1, epoch 1, ask for validator index 2. + /// deterministic factor 1, epoch 1, ask for validator index 2 — response + /// must match the shared golden fixture. #[tokio::test] async fn deterministic_attester_duties() { let mock = BeaconMock::builder() @@ -230,22 +245,13 @@ mod tests { let url = format!("{}/eth/v1/validator/duties/attester/1", mock.uri()); let body = post_json(&url, &json!(["2"])).await; - let data = body["data"].as_array().expect("data array"); - assert_eq!(data.len(), 1); - let duty = &data[0]; - assert_eq!(duty["validator_index"], "2"); - assert_eq!(duty["committee_index"], "2"); - assert_eq!(duty["committee_length"], "1"); - assert_eq!(duty["committees_at_slot"], "16"); - assert_eq!(duty["validator_committee_index"], "0"); - // slot = slots_per_epoch * epoch + (position*factor)%slots_per_epoch - // = 16*1 + (0*1)%16 = 16 - assert_eq!(duty["slot"], "16"); + assert_golden_json(&body["data"], ATTESTER_DUTIES_GOLDEN); } /// Mirrors Go's `TestDeterministicProposerDuties`: validator set A, /// deterministic factor 1, epoch 1. Go's mock ignores the indices filter - /// and assigns all active validators round-robin, one per slot. + /// and assigns all active validators round-robin — response must match + /// the shared golden fixture. #[tokio::test] async fn deterministic_proposer_duties() { let mock = BeaconMock::builder() @@ -257,16 +263,22 @@ mod tests { let url = format!("{}/eth/v1/validator/duties/proposer/1", mock.uri()); let body = get_json(&url).await; - let data = body["data"].as_array().expect("data array"); - // Validator set A has 3 validators, factor=1, slots_per_epoch=16 — all - // three get distinct offsets 0,1,2 and corresponding slots 16,17,18. - assert_eq!(data.len(), 3); - for (i, duty) in data.iter().enumerate() { - let want_index = (i + 1).to_string(); - let want_slot = (16 + i).to_string(); - assert_eq!(duty["validator_index"], want_index); - assert_eq!(duty["slot"], want_slot); - } + assert_golden_json(&body["data"], PROPOSER_DUTIES_GOLDEN); + } + + /// Mirrors Go's `TestAttestationStore` golden assertion on + /// `AttestationData` for slot=1, committee_index=2. Encodes the + /// `previous_epoch = epoch - 1` wraparound at epoch 0 (source.epoch = + /// u64::MAX) that the Go reference also produces. + #[tokio::test] + async fn attestation_data_matches_golden() { + let mock = BeaconMock::builder().build().await.expect("build mock"); + let url = format!( + "{}/eth/v1/validator/attestation_data?slot=1&committee_index=2", + mock.uri() + ); + let body = get_json(&url).await; + assert_golden_json(&body["data"], ATTESTATION_STORE_GOLDEN); } /// Mirrors Go's `TestStatic`: default mock serves genesis/spec/deposit diff --git a/crates/testutil/src/beaconmock/testdata/TestAttestationStore.golden b/crates/testutil/src/beaconmock/testdata/TestAttestationStore.golden new file mode 100644 index 00000000..69810c76 --- /dev/null +++ b/crates/testutil/src/beaconmock/testdata/TestAttestationStore.golden @@ -0,0 +1,13 @@ +{ + "slot": "1", + "index": "2", + "beacon_block_root": "0x0100000000000000000000000000000000000000000000000000000000000000", + "source": { + "epoch": "18446744073709551615", + "root": "0xffffffffffffffff000000000000000000000000000000000000000000000000" + }, + "target": { + "epoch": "0", + "root": "0x0000000000000000000000000000000000000000000000000000000000000000" + } +} \ No newline at end of file diff --git a/crates/testutil/src/beaconmock/testdata/TestDeterministicAttesterDuties.golden b/crates/testutil/src/beaconmock/testdata/TestDeterministicAttesterDuties.golden new file mode 100644 index 00000000..ddaa5854 --- /dev/null +++ b/crates/testutil/src/beaconmock/testdata/TestDeterministicAttesterDuties.golden @@ -0,0 +1,11 @@ +[ + { + "pubkey": "0x8dae41352b69f2b3a1c0b05330c1bf65f03730c520273028864b11fcb94d8ce8f26d64f979a0ee3025467f45fd2241ea", + "slot": "16", + "validator_index": "2", + "committee_index": "2", + "committee_length": "1", + "committees_at_slot": "16", + "validator_committee_index": "0" + } +] \ No newline at end of file diff --git a/crates/testutil/src/beaconmock/testdata/TestDeterministicProposerDuties.golden b/crates/testutil/src/beaconmock/testdata/TestDeterministicProposerDuties.golden new file mode 100644 index 00000000..ac08b87c --- /dev/null +++ b/crates/testutil/src/beaconmock/testdata/TestDeterministicProposerDuties.golden @@ -0,0 +1,17 @@ +[ + { + "pubkey": "0x914cff835a769156ba43ad50b931083c2dadd94e8359ce394bc7a3e06424d0214922ddf15f81640530b9c25c0bc0d490", + "slot": "16", + "validator_index": "1" + }, + { + "pubkey": "0x8dae41352b69f2b3a1c0b05330c1bf65f03730c520273028864b11fcb94d8ce8f26d64f979a0ee3025467f45fd2241ea", + "slot": "17", + "validator_index": "2" + }, + { + "pubkey": "0x8ee91545183c8c2db86633626f5074fd8ef93c4c9b7a2879ad1768f600c5b5906c3af20d47de42c3b032956fa8db1a76", + "slot": "18", + "validator_index": "3" + } +] \ No newline at end of file From df061dc3a91d5c7c89817a891b329b9c35f1156a Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> Date: Thu, 14 May 2026 22:33:02 +0200 Subject: [PATCH 07/21] feat: add ralph loop-based improver --- .claude/settings.json | 3 + .claude/skills/loop-review-pr/SKILL.md | 294 +++++++++++++++++++++++++ 2 files changed, 297 insertions(+) create mode 100644 .claude/skills/loop-review-pr/SKILL.md diff --git a/.claude/settings.json b/.claude/settings.json index 9203e20a..acffe76f 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -24,5 +24,8 @@ ] } ] + }, + "enabledPlugins": { + "ralph-loop@claude-plugins-official": true } } diff --git a/.claude/skills/loop-review-pr/SKILL.md b/.claude/skills/loop-review-pr/SKILL.md new file mode 100644 index 00000000..914289fb --- /dev/null +++ b/.claude/skills/loop-review-pr/SKILL.md @@ -0,0 +1,294 @@ +--- +name: loop-review-pr +description: > + Iteratively review and fix a Pluto PR until it is "ideal" — drives the + /review-pr multi-agent pipeline inside /ralph-loop, applying fixes between + iterations and never posting inline comments. After the loop terminates, + posts a single summary comment to the GitHub PR with everything that was + resolved. Invoke as `/loop-review-pr + [--max-iterations N]`. +--- + +# Loop Review PR + +Orchestrate a self-improving review-and-fix loop for a Pluto PR. The +[[review-pr]] skill is run repeatedly inside [[ralph-loop]]; each iteration +the findings are addressed in code, then the next iteration re-reviews. **No +inline comments are posted to GitHub during the loop.** After completion (or +hitting the iteration cap) post one summary comment. + +## 0. Inputs and constants + +```text +REPO = NethermindEth/pluto +PR = +MAX_ITERATIONS = <--max-iterations N | default 15> +STATE_DIR = .claude/loop-review-state +STATE = $STATE_DIR/pr-$PR.md +COMPLETION_PROMISE = "PR_IDEAL" +``` + +If the user passed a URL like `https://github.com/NethermindEth/pluto/pull/311`, +extract `311`. Reject any other repo. + +## 1. Preflight (outer turn — before the loop starts) + +Run in parallel: + +```bash +gh pr view "$PR" --repo "$REPO" --json title,body,headRefName,headRefOid,baseRefName,state,isDraft +gh pr diff "$PR" --repo "$REPO" | head -5 # confirm diff is fetchable +git status --short +``` + +Then: + +1. Refuse if the PR is `MERGED` or `CLOSED`. +2. Check out the PR branch locally: + ```bash + gh pr checkout "$PR" --repo "$REPO" + ``` + Abort if the working tree is dirty (uncommitted changes) — ask the user to + stash first. Do not run destructive cleanups. +3. Create `$STATE_DIR` if missing. If `$STATE` exists from a prior run, read + it — it contains the running log of resolved findings; the loop will + append to it. +4. If `$STATE` does not exist, initialize it: + + ```markdown + # /loop-review-pr state for PR # + + - Title: + - Branch: + - Started: + - Max iterations: + + ## Iteration log + ``` + +## 2. Build the ralph-loop prompt + +The prompt is what ralph-loop will feed back to Claude on every iteration. +Construct it as a single string. Substitute `$PR`, `$REPO`, `$STATE`, +`$MAX_ITERATIONS` literally. + +````text +You are running iteration of /loop-review-pr for $REPO PR #$PR. + +Goal: keep iterating — review the PR with the same parallel-agent pipeline +as /review-pr, then fix what reviewers flag — until the PR is "ideal". +A PR is ideal when an internal review pass produces NO findings at +severity `bug` or `major`, AND `cargo +nightly fmt --all --check`, +`cargo clippy --workspace --all-targets --all-features -- -D warnings`, +and `cargo test --workspace --all-features` all succeed. + +State file: $STATE +Read it FIRST every iteration. It is the running log of what previous +iterations did. Append to it; never rewrite earlier entries. + +## Per-iteration workflow + +1. **Read state.** `cat $STATE`. Note the iteration number — increment by 1 + for this iteration. If the prior iteration ended with "PR is ideal", + verify the claim by re-running the quality gates; if still clean, output + `PR_IDEAL` and stop. + +2. **Sync.** `git fetch origin && git status --short && git log --oneline -5`. + Make sure you are still on the PR branch. + +3. **Internal review (no GitHub writes).** Spawn the same four agents in + parallel as /review-pr Step 2: + + | Agent | Skill | Focus | + |---|---|---| + | pluto-review | /pluto-review | Functional equivalence with Charon Go | + | security | — | Auth, key material, DoS, exhaustion | + | rust-style | /rust-style | Idiomatic Rust, error handling, naming | + | code-quality | — | Concurrency, state machines, lifecycle | + + Give each agent the diff (`gh pr diff $PR --repo $REPO`) and the changed + files on disk. Each agent returns JSON findings as in /review-pr Step 2. + + **You MUST NOT** call any of the following during this loop: + - `gh pr review` + - `gh pr comment` + - `gh api .../pulls/.../reviews` + - `gh api .../pulls/.../comments` + - `gh api .../issues/.../comments` + - any GraphQL `addPullRequestReview*` mutation + The summary comment is posted by the OUTER turn after the loop ends — + not from inside the loop. + +4. **Dedupe + assess.** Merge findings; assign final severity + (`bug` > `major` > `minor` > `nit`). Same rules as /review-pr Step 3. + +5. **Decide.** + - If there are zero `bug` and zero `major` findings → go to step 7. + - Else → step 6. + +6. **Fix.** Pick the highest-severity finding, fix the code (and add/update + tests where the finding is about behavior). Re-run the relevant tests + for the touched crate. Commit the fix with a focused message — one + commit per finding is fine, batched commits per file are also fine, + but DO NOT batch unrelated fixes into one commit. Then append an entry + to $STATE: + + ```markdown + ### Iteration + - [] @ <file>:<line> + Fix: <one-line description of what changed> + Commit: <sha> + ``` + + After fixing as many findings as you can in this iteration, exit. The + ralph-loop Stop hook will re-invoke this prompt for the next iteration, + which will re-review against the new state of the branch. + +7. **Quality gates.** Run from `pluto/`: + ```bash + cargo +nightly fmt --all --check + cargo clippy --workspace --all-targets --all-features -- -D warnings + cargo test --workspace --all-features + ``` + If any fail, treat the failure as a `bug` finding and go back to step 6. + If all pass AND step 5 found no `bug`/`major` findings, append to $STATE: + + ```markdown + ### Iteration <N> — <ISO timestamp> — IDEAL + - Internal review: clean (only minor/nit, or none) + - fmt / clippy / test: green + ``` + + Then output exactly: `<promise>PR_IDEAL</promise>` + +## Hard rules + +- One PR, one branch. Never switch branches; never rebase onto main inside + the loop unless a fix explicitly requires it (and then say so in $STATE). +- Do not force-push. +- Do not skip git hooks (no `--no-verify`). +- Do not include a `Co-Authored-By:` trailer in commits — the user has + explicitly rejected it. +- Do not delete work-in-progress files left by earlier iterations. +- If progress stalls (two consecutive iterations with no new fixes and the + same findings) — append a `### STALLED` note to $STATE explaining what is + blocking, then output `<promise>PR_IDEAL</promise>` is FORBIDDEN. Instead + let the iteration cap end the loop; the outer turn will summarize the + stall. +```` + +## 3. Start the loop + +Invoke ralph-loop with the prompt above. From the outer turn, call the +ralph-loop slash command (it's the `ralph-loop:ralph-loop` plugin command): + +```text +/ralph-loop "<the prompt from §2>" --completion-promise "PR_IDEAL" --max-iterations <MAX_ITERATIONS> +``` + +Use the Skill tool with `skill: "ralph-loop:ralph-loop"` and pass the prompt +plus flags as args. The loop runs inside the current session; the Stop hook +keeps re-firing the prompt until the completion promise appears or the +iteration cap is hit. + +## 4. After the loop ends + +When control returns to the outer turn (either the completion promise was +emitted or `--max-iterations` was reached): + +1. **Verify gates one more time** from the outer turn (don't trust the loop): + ```bash + cd pluto + cargo +nightly fmt --all --check + cargo clippy --workspace --all-targets --all-features -- -D warnings + cargo test --workspace --all-features + ``` + +2. **Push** the accumulated commits to the PR branch: + ```bash + git push # branch already tracks the PR head; no --force + ``` + If the upstream rejected (someone else pushed) → stop and surface to the + user; do not force. + +3. **Build the summary** from `$STATE`. Group resolved findings by severity + and reference commits. Compute: + - `iterations_run` = number of `### Iteration N` headers. + - `terminated_by` = `completion_promise` | `iteration_cap` | `stall`. + - `gates` = result of step 1 above. + +4. **Post exactly one comment** to the PR: + + ```bash + gh pr comment "$PR" --repo "$REPO" --body-file /tmp/loop-review-summary.md + ``` + + Body template: + + ```markdown + ## /loop-review-pr summary + + Ran <iterations_run> review-and-fix iteration(s) against this PR. + Terminated by: **<terminated_by>**. + + ### Quality gates (final) + - `cargo fmt` — <pass|fail> + - `cargo clippy` — <pass|fail> + - `cargo test` — <pass|fail> + + ### Resolved during the loop + + **Bugs (<N>)** + - <title> — `<file>:<line>` — fix in <commit-sha> + + **Major (<N>)** + - … + + **Minor (<N>)** + - … + + **Nits (<N>)** + - … + + ### Outstanding + <Any findings the loop chose not to address, or — if terminated by + iteration_cap / stall — the unresolved items from $STATE, with reasons.> + + ### Verdict + <One of: + - "PR is ideal — all bug/major findings resolved, gates green." + - "Hit iteration cap (<N>) before reaching ideal state — see Outstanding." + - "Stalled at iteration <N> — see Outstanding for blockers." + > + ``` + + This is the **only** comment posted to GitHub. No inline review comments. + No second comment. If the body is empty (no findings resolved, gates + already green on entry), still post a single one-line "no changes were + needed" comment so the run is auditable. + +5. **Print to the user** the PR URL and a one-line verdict, plus the path + to `$STATE` if they want to inspect the full log. + +## Error & edge cases + +- **No findings in iteration 1, gates green** → loop exits immediately with + `PR_IDEAL`; outer turn posts the "no changes needed" comment. +- **Loop emits the promise but gates fail in the outer verification** → + re-enter the loop with the failing-gate output prepended; do not post a + misleading "ideal" summary. +- **User cancels with `/cancel-ralph`** → outer turn still runs §4 with + `terminated_by: user_cancel` and posts the summary of whatever was done. +- **PR has additional commits pushed by someone else mid-loop** → next + iteration's `git fetch` will surface it; the loop should rebase only if + necessary, and the summary should mention it. + +## Output + +After §4 step 4 succeeds, print: + +```text +Loop done: <iterations_run> iteration(s), terminated by <terminated_by>. +Summary comment: <gh pr comment URL> +State log: $STATE +``` From 4405796d00c41e20c7883e277500bab2ce0212c4 Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <273991985+varex83agent@users.noreply.github.com> Date: Thu, 14 May 2026 22:46:21 +0200 Subject: [PATCH 08/21] fix(testutil/beaconmock): match Go ValidatorSetA zero exit/withdrawable epochs Charon's ValidatorSetA leaves ExitEpoch and WithdrawableEpoch unset, so they serialize as "0". The Rust port was forcing them to FAR_FUTURE_EPOCH (u64::MAX), which is more spec-correct but breaks parity with Charon's mock output. --- crates/testutil/src/beaconmock/state.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/testutil/src/beaconmock/state.rs b/crates/testutil/src/beaconmock/state.rs index 3330a68f..cb71fa84 100644 --- a/crates/testutil/src/beaconmock/state.rs +++ b/crates/testutil/src/beaconmock/state.rs @@ -31,6 +31,9 @@ pub struct Validator { impl Validator { /// Creates an active validator with the provided index and public key. + /// + /// Mirrors Charon's `ValidatorSetA`: `exit_epoch` and `withdrawable_epoch` + /// are the Go zero value (`"0"`), not `FAR_FUTURE_EPOCH`. #[must_use] pub fn active(index: ValidatorIndex, pubkey: BLSPubKey) -> Self { let pubkey = hex_0x(pubkey); @@ -43,10 +46,10 @@ impl Validator { activation_eligibility_epoch: index.to_string(), activation_epoch: index.checked_add(1).unwrap_or(index).to_string(), effective_balance: index.to_string(), - exit_epoch: u64::MAX.to_string(), + exit_epoch: "0".to_string(), pubkey, slashed: false, - withdrawable_epoch: u64::MAX.to_string(), + withdrawable_epoch: "0".to_string(), withdrawal_credentials: DEFAULT_WITHDRAWAL_CREDENTIALS.to_string(), }, } From cfc92ee21f8b1c0e11dbb19c8351c7cdf378e245 Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <273991985+varex83agent@users.noreply.github.com> Date: Thu, 14 May 2026 22:48:56 +0200 Subject: [PATCH 09/21] fix(testutil/beaconmock): publish initial head before spawn returns; use CancellationToken Three intertwined issues are resolved by restructuring HeadProducer lifecycle: - Drop race: switching from Notify::notify_waiters() (which only wakes currently-registered waiters) to CancellationToken (cancel-then-await semantics) ensures shutdown is observed on the next poll regardless of registration timing. - Block-root race: HeadProducer::spawn now generates and stores the initial head event synchronously before returning, so /eth/v1/beacon/blocks/.../root and /eth/v1/events never observe a None current head. The 150ms test sleep workaround is removed. - std::thread::sleep in async handler: with the head guaranteed present, the busy-wait loop in wait_for_first_head is no longer needed and is deleted, eliminating the synchronous block on tokio worker threads. --- crates/testutil/Cargo.toml | 1 + .../testutil/src/beaconmock/headproducer.rs | 166 +++++++----------- 2 files changed, 63 insertions(+), 104 deletions(-) diff --git a/crates/testutil/Cargo.toml b/crates/testutil/Cargo.toml index 308792b8..a8b6161c 100644 --- a/crates/testutil/Cargo.toml +++ b/crates/testutil/Cargo.toml @@ -22,6 +22,7 @@ hex.workspace = true serde_json.workspace = true thiserror.workspace = true tokio.workspace = true +tokio-util.workspace = true tree_hash.workspace = true wiremock.workspace = true diff --git a/crates/testutil/src/beaconmock/headproducer.rs b/crates/testutil/src/beaconmock/headproducer.rs index bc117177..a88666ae 100644 --- a/crates/testutil/src/beaconmock/headproducer.rs +++ b/crates/testutil/src/beaconmock/headproducer.rs @@ -7,16 +7,19 @@ //! `/eth/v1/beacon/blocks/{block_id}/root`. //! //! Note on SSE: wiremock buffers a response body before sending, so events -//! cannot be streamed continuously. Each request to `/eth/v1/events` waits up -//! to ~one slot for the producer to have a current head and then returns a -//! single, well-formed SSE record (`event: <topic>\ndata: <json>\n\n`). -//! Subscribers should poll the endpoint to keep receiving events. +//! cannot be streamed continuously. Each request to `/eth/v1/events` returns +//! a single, well-formed SSE record (`event: <topic>\ndata: <json>\n\n`) for +//! the current head. Subscribers should poll the endpoint to keep receiving +//! events. //! //! The block-root endpoint matches Charon: it answers with the current head's //! block root when `block_id` is `head` or matches the current head's slot, //! and 400 otherwise. //! -//! The ticker is shut down when the returned [`HeadProducer`] is dropped. +//! [`HeadProducer::spawn`] synchronously publishes the initial head before +//! returning, so handlers never observe a `None` current head once the +//! producer is constructed. The ticker is shut down when the returned +//! [`HeadProducer`] is dropped. use std::{ sync::{Arc, RwLock}, @@ -27,7 +30,7 @@ use chrono::{DateTime, Utc}; use pluto_eth2api::spec::phase0::{Root, Slot}; use rand::{RngCore, SeedableRng, rngs::StdRng}; use serde_json::{Value, json}; -use tokio::sync::{Notify, watch}; +use tokio_util::sync::CancellationToken; use wiremock::{ Mock, MockServer, Request, ResponseTemplate, matchers::{method, path, path_regex}, @@ -51,52 +54,59 @@ struct HeadEvent { /// Owns the slot ticker driving the head producer. Drop to stop the ticker. #[derive(Debug)] pub(crate) struct HeadProducer { - shutdown: Arc<Notify>, + cancel: CancellationToken, } impl HeadProducer { /// Spawns the slot ticker and mounts SSE/block-root handlers on `server`. + /// + /// The initial head is published synchronously before returning, so the + /// mounted handlers can always observe a non-`None` current head. pub(crate) async fn spawn( server: &MockServer, genesis_time: DateTime<Utc>, slot_duration: Duration, ) -> Self { let state = Arc::new(SharedState::new()); - let shutdown = Arc::new(Notify::new()); + let cancel = CancellationToken::new(); - mount_events(server, Arc::clone(&state), slot_duration).await; + mount_events(server, Arc::clone(&state)).await; mount_block_root(server, Arc::clone(&state)).await; + let genesis = system_time_from(genesis_time); + let slot_duration = normalize_slot_duration(slot_duration); + let (initial_height, initial_tick) = initial_slot(genesis, slot_duration); + + // Publish the initial head before handing control back to the caller + // so the mounted handlers never see a None current head. + update_head(&state, initial_height); + spawn_slot_ticker( Arc::clone(&state), - Arc::clone(&shutdown), - genesis_time, + cancel.clone(), + initial_height, + initial_tick, slot_duration, ); - Self { shutdown } + Self { cancel } } } impl Drop for HeadProducer { fn drop(&mut self) { - self.shutdown.notify_waiters(); + self.cancel.cancel(); } } struct SharedState { current_head: RwLock<Option<HeadEvent>>, - head_tx: watch::Sender<u64>, - head_rx: watch::Receiver<u64>, } impl SharedState { fn new() -> Self { - let (head_tx, head_rx) = watch::channel(0u64); Self { current_head: RwLock::new(None), - head_tx, - head_rx, } } @@ -105,9 +115,6 @@ impl SharedState { Ok(mut guard) => *guard = Some(event), Err(poisoned) => *poisoned.into_inner() = Some(event), } - // Bump the generation counter so listeners wake up. - let next = self.head_tx.borrow().wrapping_add(1); - let _ = self.head_tx.send(next); } fn current_head(&self) -> Option<HeadEvent> { @@ -116,33 +123,33 @@ impl SharedState { Err(poisoned) => poisoned.into_inner().clone(), } } - - fn subscribe(&self) -> watch::Receiver<u64> { - self.head_rx.clone() - } } fn spawn_slot_ticker( state: Arc<SharedState>, - shutdown: Arc<Notify>, - genesis_time: DateTime<Utc>, + cancel: CancellationToken, + initial_height: Slot, + initial_tick: SystemTime, slot_duration: Duration, ) { - // Mirror Go's startSlotTicker: compute current slot from chain age, then - // tick once per slot until shutdown. - let genesis = system_time_from(genesis_time); - let slot_duration = if slot_duration.is_zero() { - Duration::from_millis(1) - } else { - slot_duration - }; + // The initial head was already published by `HeadProducer::spawn`. Start + // the ticker at the next scheduled slot so it advances from there. + let mut height = initial_height.wrapping_add(1); + let mut next_tick = initial_tick + .checked_add(slot_duration) + .unwrap_or_else(|| SystemTime::now() + slot_duration); tokio::spawn(async move { - let (mut height, mut next_tick) = initial_slot(genesis, slot_duration); - let shutdown_fut = shutdown.notified(); - tokio::pin!(shutdown_fut); - loop { + let delay = next_tick + .duration_since(SystemTime::now()) + .unwrap_or_default(); + + tokio::select! { + () = cancel.cancelled() => return, + () = tokio::time::sleep(delay) => {} + } + update_head(&state, height); height = height.wrapping_add(1); @@ -151,18 +158,18 @@ fn spawn_slot_ticker( .checked_add(slot_duration) .unwrap_or(SystemTime::now()) }); - let delay = next_tick - .duration_since(SystemTime::now()) - .unwrap_or_default(); - - tokio::select! { - _ = &mut shutdown_fut => return, - _ = tokio::time::sleep(delay) => {} - } } }); } +fn normalize_slot_duration(slot_duration: Duration) -> Duration { + if slot_duration.is_zero() { + Duration::from_millis(1) + } else { + slot_duration + } +} + fn initial_slot(genesis: SystemTime, slot_duration: Duration) -> (Slot, SystemTime) { let now = SystemTime::now(); let chain_age = now.duration_since(genesis).unwrap_or_default(); @@ -215,11 +222,7 @@ fn random_root(rng: &mut StdRng) -> Root { root } -async fn mount_events(server: &MockServer, state: Arc<SharedState>, slot_duration: Duration) { - let wait_budget = slot_duration - .saturating_mul(2) - .max(Duration::from_millis(50)); - +async fn mount_events(server: &MockServer, state: Arc<SharedState>) { Mock::given(method("GET")) .and(path("/eth/v1/events")) .respond_with(move |request: &Request| { @@ -228,9 +231,8 @@ async fn mount_events(server: &MockServer, state: Arc<SharedState>, slot_duratio return error_response(500, format!("unknown topic: {invalid}")); } - // Wait synchronously (bounded) until at least one head is produced - // so the buffered SSE body is non-empty. - wait_for_first_head(&state, wait_budget); + // `HeadProducer::spawn` publishes the initial head before + // returning, so the current head is always set here. let Some(head) = state.current_head() else { return error_response(500, "head producer not ready".into()); }; @@ -257,6 +259,8 @@ async fn mount_block_root(server: &MockServer, state: Arc<SharedState>) { Mock::given(method("GET")) .and(path_regex(r"^/eth/v1/beacon/blocks/[^/]+/root$")) .respond_with(move |request: &Request| { + // `HeadProducer::spawn` publishes the initial head before + // returning, so the current head is always set here. let Some(head) = state.current_head() else { return error_response(500, "head producer not ready".into()); }; @@ -295,26 +299,6 @@ fn extract_block_id(path: &str) -> String { parts.next().unwrap_or_default().to_string() } -fn wait_for_first_head(state: &SharedState, budget: Duration) { - if state.current_head().is_some() { - return; - } - - // Drive a short blocking wait without blocking the runtime worker for long: - // poll the shared state with small sleeps until the budget elapses. - let start = std::time::Instant::now(); - let mut rx = state.subscribe(); - while start.elapsed() < budget { - if rx.has_changed().unwrap_or(false) { - let _ = rx.borrow_and_update(); - } - if state.current_head().is_some() { - return; - } - std::thread::sleep(Duration::from_millis(1)); - } -} - fn push_sse_event(body: &mut String, topic: &str, data: &Value) { body.push_str("event: "); body.push_str(topic); @@ -373,31 +357,10 @@ mod tests { .expect("beacon mock"); let url = format!("{}/eth/v1/events?topics=head", mock.uri()); - let client = reqwest::Client::new(); - - // Poll the endpoint with a short timeout — the responder buffers a - // single SSE event per request, so the test reads the body once a - // head event has been produced. - let deadline = std::time::Instant::now() + Duration::from_secs(2); - let body = loop { - assert!( - std::time::Instant::now() < deadline, - "no head event in time" - ); - let resp = client - .get(&url) - .timeout(Duration::from_secs(1)) - .send() - .await - .expect("send"); - assert_eq!(resp.status().as_u16(), 200); - let text = resp.text().await.expect("body"); - if text.contains("event: head") { - break text; - } - tokio::time::sleep(Duration::from_millis(20)).await; - }; + let resp = reqwest::get(&url).await.expect("send"); + assert_eq!(resp.status().as_u16(), 200); + let body = resp.text().await.expect("body"); assert!(body.contains("event: head")); assert!(body.contains("\"slot\"")); assert!(body.contains("\"block\"")); @@ -428,9 +391,6 @@ mod tests { .await .expect("beacon mock"); - // Wait for the ticker to publish at least one head. - tokio::time::sleep(Duration::from_millis(150)).await; - let url = format!("{}/eth/v1/beacon/blocks/head/root", mock.uri()); let resp = reqwest::get(&url).await.expect("send"); assert_eq!(resp.status().as_u16(), 200); @@ -448,8 +408,6 @@ mod tests { .await .expect("beacon mock"); - tokio::time::sleep(Duration::from_millis(150)).await; - let url = format!("{}/eth/v1/beacon/blocks/999999/root", mock.uri()); let resp = reqwest::get(&url).await.expect("send"); assert_eq!(resp.status().as_u16(), 400); From d5acd4e2de8c98690b62bd54229acded16d5aa36 Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <273991985+varex83agent@users.noreply.github.com> Date: Thu, 14 May 2026 22:49:56 +0200 Subject: [PATCH 10/21] fix(testutil/beaconmock): document RNG divergence and mirror Charon's duty-root typo Charon's headproducer.go renders both current_duty_dependent_root and previous_duty_dependent_root from the same internal value (a Go typo carried in v1.7.1). Mirror that behavior by reducing HeadEvent to a single duty_dependent_root field and rendering it into both JSON fields. Also document that the Rust port uses ChaCha-based StdRng instead of Go's math/rand LCG. The byte sequences differ but Pluto does not assert on any specific head/state/dependent root value, and ChaCha is portable and well-tested; the head event JSON shape and per-slot determinism are preserved. --- .../testutil/src/beaconmock/headproducer.rs | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/crates/testutil/src/beaconmock/headproducer.rs b/crates/testutil/src/beaconmock/headproducer.rs index a88666ae..f6b67d50 100644 --- a/crates/testutil/src/beaconmock/headproducer.rs +++ b/crates/testutil/src/beaconmock/headproducer.rs @@ -42,13 +42,17 @@ const TOPIC_HEAD: &str = "head"; const TOPIC_BLOCK: &str = "block"; /// Deterministic head event derived from a slot. +/// +/// Charon's Go reference has a typo in `headproducer.go` that renders +/// `PreviousDutyDependentRoot` from `currentHead.CurrentDutyDependentRoot`, +/// so only one dependent root is meaningful. We mirror that and keep a single +/// `duty_dependent_root` field rather than carrying two identical values. #[derive(Clone, Debug)] struct HeadEvent { slot: Slot, block: Root, state: Root, - current_duty_dependent_root: Root, - previous_duty_dependent_root: Root, + duty_dependent_root: Root, } /// Owns the slot ticker driving the head producer. Drop to stop the ticker. @@ -205,14 +209,19 @@ fn update_head(state: &SharedState, slot: Slot) { state.set_current_head(pseudo_random_head_event(slot)); } +// Charon's `pseudoRandomHeadEvent` seeds Go's `math/rand` LCG with the slot +// number and draws four roots. We deliberately use ChaCha-based `StdRng` +// instead — the byte sequences differ from Charon, but Pluto does not assert +// on any specific head/state/dependent root value and ChaCha is portable and +// well-tested. The head event JSON shape and seeding-per-slot determinism are +// preserved. fn pseudo_random_head_event(slot: Slot) -> HeadEvent { let mut rng = StdRng::seed_from_u64(slot); HeadEvent { slot, block: random_root(&mut rng), state: random_root(&mut rng), - current_duty_dependent_root: random_root(&mut rng), - previous_duty_dependent_root: random_root(&mut rng), + duty_dependent_root: random_root(&mut rng), } } @@ -314,8 +323,9 @@ fn head_event_json(head: &HeadEvent) -> Value { "block": hex_0x(head.block), "state": hex_0x(head.state), "epoch_transition": false, - "current_duty_dependent_root": hex_0x(head.current_duty_dependent_root), - "previous_duty_dependent_root": hex_0x(head.previous_duty_dependent_root), + // Charon renders the same value for both fields; see HeadEvent docs. + "current_duty_dependent_root": hex_0x(head.duty_dependent_root), + "previous_duty_dependent_root": hex_0x(head.duty_dependent_root), "execution_optimistic": false, }) } From 6b5eef3796b3afb09b2101616308e1684190664f Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <273991985+varex83agent@users.noreply.github.com> Date: Thu, 14 May 2026 22:54:18 +0200 Subject: [PATCH 11/21] fix(testutil/beaconmock): use checked_add for next_tick fallback path The arithmetic_side_effects lint denies + operator on SystemTime; the fallback already used checked_add elsewhere in the same function. Mirror that pattern. --- crates/testutil/src/beaconmock/headproducer.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/testutil/src/beaconmock/headproducer.rs b/crates/testutil/src/beaconmock/headproducer.rs index f6b67d50..9687b06d 100644 --- a/crates/testutil/src/beaconmock/headproducer.rs +++ b/crates/testutil/src/beaconmock/headproducer.rs @@ -141,7 +141,11 @@ fn spawn_slot_ticker( let mut height = initial_height.wrapping_add(1); let mut next_tick = initial_tick .checked_add(slot_duration) - .unwrap_or_else(|| SystemTime::now() + slot_duration); + .unwrap_or_else(|| { + SystemTime::now() + .checked_add(slot_duration) + .unwrap_or(SystemTime::now()) + }); tokio::spawn(async move { loop { From 3e4053d2391b622986e340d5773607196e911f19 Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <273991985+varex83agent@users.noreply.github.com> Date: Thu, 14 May 2026 22:55:04 +0200 Subject: [PATCH 12/21] chore(testutil/beaconmock): formatter reflow + Cargo.lock for tokio-util dep --- Cargo.lock | 1 + crates/testutil/src/beaconmock/headproducer.rs | 12 +++++------- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6c364bc4..efe31fe9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5911,6 +5911,7 @@ dependencies = [ "serde_json", "thiserror 2.0.18", "tokio", + "tokio-util", "tree_hash", "wiremock", ] diff --git a/crates/testutil/src/beaconmock/headproducer.rs b/crates/testutil/src/beaconmock/headproducer.rs index 9687b06d..2c5e0fa7 100644 --- a/crates/testutil/src/beaconmock/headproducer.rs +++ b/crates/testutil/src/beaconmock/headproducer.rs @@ -139,13 +139,11 @@ fn spawn_slot_ticker( // The initial head was already published by `HeadProducer::spawn`. Start // the ticker at the next scheduled slot so it advances from there. let mut height = initial_height.wrapping_add(1); - let mut next_tick = initial_tick - .checked_add(slot_duration) - .unwrap_or_else(|| { - SystemTime::now() - .checked_add(slot_duration) - .unwrap_or(SystemTime::now()) - }); + let mut next_tick = initial_tick.checked_add(slot_duration).unwrap_or_else(|| { + SystemTime::now() + .checked_add(slot_duration) + .unwrap_or(SystemTime::now()) + }); tokio::spawn(async move { loop { From 752af8b262ec5f0c82aec85f6a04a39d5dacc40e Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <273991985+varex83agent@users.noreply.github.com> Date: Thu, 14 May 2026 23:05:14 +0200 Subject: [PATCH 13/21] fix(testutil/beaconmock): filter inactive validators from proposer duties Charon's WithDeterministicProposerDuties iterates over mock.ActiveValidators, which only includes validators with an Active* status. Mirror that by filtering the validator set on validator.status.is_active() before assigning proposer duties. Add a regression test that adds a WithdrawalDone validator to set_a and verifies it is skipped while the three Active validators retain duties. --- crates/testutil/src/beaconmock/defaults.rs | 9 ++++- crates/testutil/src/beaconmock/mod.rs | 46 ++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/crates/testutil/src/beaconmock/defaults.rs b/crates/testutil/src/beaconmock/defaults.rs index 56ada624..7bebd827 100644 --- a/crates/testutil/src/beaconmock/defaults.rs +++ b/crates/testutil/src/beaconmock/defaults.rs @@ -299,7 +299,14 @@ fn proposer_duties_response(state: &MockState, request: &Request) -> Value { let epoch = epoch_from_path(request.url.path()); let slots_per_epoch = slots_per_epoch(state); - let validators = read_lock(&state.validator_set).validators(); + // Mirrors Charon's `WithDeterministicProposerDuties`, which iterates over + // `mock.ActiveValidators(ctx)` — only validators with an Active* status + // are eligible to propose. + let validators: Vec<_> = read_lock(&state.validator_set) + .validators() + .into_iter() + .filter(|validator| validator.status.is_active()) + .collect(); let mut assigned_slots = BTreeMap::new(); let mut data = Vec::new(); diff --git a/crates/testutil/src/beaconmock/mod.rs b/crates/testutil/src/beaconmock/mod.rs index 2483f141..aaea9e36 100644 --- a/crates/testutil/src/beaconmock/mod.rs +++ b/crates/testutil/src/beaconmock/mod.rs @@ -266,6 +266,52 @@ mod tests { assert_golden_json(&body["data"], PROPOSER_DUTIES_GOLDEN); } + /// Mirrors Charon's `WithDeterministicProposerDuties`, which iterates over + /// `mock.ActiveValidators(ctx)` — proposer duties must skip non-active + /// validators in the set. + #[tokio::test] + async fn proposer_duties_skip_inactive_validators() { + use pluto_eth2api::{ValidatorResponseValidator, ValidatorStatus}; + + let mut set = ValidatorSet::validator_set_a(); + set.insert(Validator { + index: 4, + balance: 4, + status: ValidatorStatus::WithdrawalDone, + validator: ValidatorResponseValidator { + activation_eligibility_epoch: "4".into(), + activation_epoch: "5".into(), + effective_balance: "4".into(), + exit_epoch: "0".into(), + pubkey: format!("0x{}", "01".repeat(48)), + slashed: false, + withdrawable_epoch: "0".into(), + withdrawal_credentials: format!("0x{}", "00".repeat(32)), + }, + }); + + let mock = BeaconMock::builder() + .validator_set(set) + .deterministic_proposer_duties(1) + .build() + .await + .expect("build mock"); + + let url = format!("{}/eth/v1/validator/duties/proposer/1", mock.uri()); + let body = get_json(&url).await; + let indices: Vec<&str> = body["data"] + .as_array() + .expect("duties array") + .iter() + .filter_map(|duty| duty["validator_index"].as_str()) + .collect(); + assert_eq!( + indices, + ["1", "2", "3"], + "inactive validator (index 4) must be skipped" + ); + } + /// Mirrors Go's `TestAttestationStore` golden assertion on /// `AttestationData` for slot=1, committee_index=2. Encodes the /// `previous_epoch = epoch - 1` wraparound at epoch 0 (source.epoch = From 9c30e3bcfe667b764a88f61db183bc5ca9ee7547 Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <273991985+varex83agent@users.noreply.github.com> Date: Thu, 14 May 2026 23:21:23 +0200 Subject: [PATCH 14/21] refactor(testutil/beaconmock): dedupe hex_0x helper headproducer.rs had a private hex_0x copy identical to state::hex_0x. Import the shared pub(crate) helper instead. No behavior change. --- crates/testutil/src/beaconmock/headproducer.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/crates/testutil/src/beaconmock/headproducer.rs b/crates/testutil/src/beaconmock/headproducer.rs index 2c5e0fa7..5366d68d 100644 --- a/crates/testutil/src/beaconmock/headproducer.rs +++ b/crates/testutil/src/beaconmock/headproducer.rs @@ -36,7 +36,7 @@ use wiremock::{ matchers::{method, path, path_regex}, }; -use super::defaults::DEFAULT_MOCK_PRIORITY; +use super::{defaults::DEFAULT_MOCK_PRIORITY, state::hex_0x}; const TOPIC_HEAD: &str = "head"; const TOPIC_BLOCK: &str = "block"; @@ -340,10 +340,6 @@ fn block_event_json(head: &HeadEvent) -> Value { }) } -fn hex_0x(bytes: impl AsRef<[u8]>) -> String { - format!("0x{}", hex::encode(bytes.as_ref())) -} - fn error_response(status: u16, message: String) -> ResponseTemplate { ResponseTemplate::new(status).set_body_json(json!({ "code": status, From 9c471eca8b22f90e798978035f5c63335be0002a Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <273991985+varex83agent@users.noreply.github.com> Date: Thu, 14 May 2026 23:22:52 +0200 Subject: [PATCH 15/21] refactor(testutil/beaconmock): hoist last_path_segment_u64 helper Three identical parsers (epoch_from_path in defaults.rs, slot_from_path and epoch_from_path in fuzzer.rs) all extract the trailing /{u64} segment of a request path. Pull the parse into a single pub(crate) helper in state.rs and delegate to it from the named wrappers (kept for call-site readability). --- crates/testutil/src/beaconmock/defaults.rs | 7 ++----- crates/testutil/src/beaconmock/fuzzer.rs | 11 +++-------- crates/testutil/src/beaconmock/state.rs | 10 ++++++++++ 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/crates/testutil/src/beaconmock/defaults.rs b/crates/testutil/src/beaconmock/defaults.rs index 7bebd827..eccefa52 100644 --- a/crates/testutil/src/beaconmock/defaults.rs +++ b/crates/testutil/src/beaconmock/defaults.rs @@ -10,7 +10,7 @@ use wiremock::{ matchers::{method, path, path_regex}, }; -use super::state::{MockState, read_lock}; +use super::state::{MockState, last_path_segment_u64, read_lock}; pub(crate) const ZERO_ROOT: &str = "0x0000000000000000000000000000000000000000000000000000000000000000"; @@ -465,10 +465,7 @@ fn indices_from_body(request: &Request) -> Vec<ValidatorIndex> { } fn epoch_from_path(path: &str) -> Epoch { - path.rsplit('/') - .next() - .and_then(|epoch| epoch.parse::<Epoch>().ok()) - .unwrap_or_default() + last_path_segment_u64(path) } fn slots_per_epoch(state: &MockState) -> u64 { diff --git a/crates/testutil/src/beaconmock/fuzzer.rs b/crates/testutil/src/beaconmock/fuzzer.rs index 259938c9..00d4cb19 100644 --- a/crates/testutil/src/beaconmock/fuzzer.rs +++ b/crates/testutil/src/beaconmock/fuzzer.rs @@ -17,6 +17,7 @@ use wiremock::{ matchers::{method, path, path_regex}, }; +use super::state::last_path_segment_u64; use crate::random::{ random_bit_list, random_eth2_signature, random_phase0_attestation, random_root, }; @@ -351,17 +352,11 @@ fn random_beacon_block_body() -> Value { } fn slot_from_path(path: &str) -> u64 { - path.rsplit('/') - .next() - .and_then(|slot| slot.parse::<u64>().ok()) - .unwrap_or_default() + last_path_segment_u64(path) } fn epoch_from_path(path: &str) -> u64 { - path.rsplit('/') - .next() - .and_then(|epoch| epoch.parse::<u64>().ok()) - .unwrap_or_default() + last_path_segment_u64(path) } #[cfg(test)] diff --git a/crates/testutil/src/beaconmock/state.rs b/crates/testutil/src/beaconmock/state.rs index cb71fa84..ed9de55f 100644 --- a/crates/testutil/src/beaconmock/state.rs +++ b/crates/testutil/src/beaconmock/state.rs @@ -207,6 +207,16 @@ pub(crate) fn hex_0x(bytes: impl AsRef<[u8]>) -> String { format!("0x{}", hex::encode(bytes.as_ref())) } +/// Parses the trailing `/{u64}` segment of a request path (e.g. the `epoch` +/// in `/eth/v1/validator/duties/attester/3` or the `slot` in +/// `/eth/v2/beacon/blocks/5`), returning `0` on missing or non-numeric input. +pub(crate) fn last_path_segment_u64(path: &str) -> u64 { + path.rsplit('/') + .next() + .and_then(|seg| seg.parse::<u64>().ok()) + .unwrap_or_default() +} + pub(crate) fn parse_pubkey(pubkey: &str) -> Option<BLSPubKey> { let pubkey = pubkey.strip_prefix("0x").unwrap_or(pubkey); let bytes = hex::decode(pubkey).ok()?; From df773fbd1bc7310489e54ae7cfc36aee01ea5119 Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <273991985+varex83agent@users.noreply.github.com> Date: Thu, 14 May 2026 23:23:49 +0200 Subject: [PATCH 16/21] refactor(testutil/beaconmock): replace dead fallback branches with expect Two defensive fallbacks were unreachable for inputs the code accepts: - random_validator_status's unwrap_or("active_ongoing") covered an empty STATUSES slice, which is a const &[&str; 9]. - default_genesis_time's None => Utc::now() covered DST ambiguity at a UTC instant where DST does not apply. Both swap to expect() with a message that explains the invariant, so a future breakage prints a useful panic instead of silently producing surprising data. --- crates/testutil/src/beaconmock/defaults.rs | 7 +++---- crates/testutil/src/beaconmock/fuzzer.rs | 5 ++++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/testutil/src/beaconmock/defaults.rs b/crates/testutil/src/beaconmock/defaults.rs index eccefa52..58a72e95 100644 --- a/crates/testutil/src/beaconmock/defaults.rs +++ b/crates/testutil/src/beaconmock/defaults.rs @@ -552,10 +552,9 @@ pub(crate) fn default_genesis() -> Value { } pub(crate) fn default_genesis_time() -> DateTime<Utc> { - match Utc.with_ymd_and_hms(2022, 3, 1, 0, 0, 0).single() { - Some(time) => time, - None => Utc::now(), - } + Utc.with_ymd_and_hms(2022, 3, 1, 0, 0, 0) + .single() + .expect("2022-03-01T00:00:00Z is an unambiguous UTC instant") } #[cfg(test)] diff --git a/crates/testutil/src/beaconmock/fuzzer.rs b/crates/testutil/src/beaconmock/fuzzer.rs index 00d4cb19..29f82fbe 100644 --- a/crates/testutil/src/beaconmock/fuzzer.rs +++ b/crates/testutil/src/beaconmock/fuzzer.rs @@ -295,7 +295,10 @@ fn random_validator_status(rng: &mut impl Rng) -> &'static str { "withdrawal_possible", "withdrawal_done", ]; - STATUSES.choose(rng).copied().unwrap_or("active_ongoing") + STATUSES + .choose(rng) + .copied() + .expect("STATUSES is a non-empty constant slice") } fn random_beacon_block(slot: u64) -> Value { From 03c48a9a549bd57305c2d2298e90100fb91b4c90 Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> Date: Fri, 15 May 2026 12:39:39 +0200 Subject: [PATCH 17/21] feat(testutil/validatormock): port Phase 1 foundation Mirrors `charon/testutil/validatormock` (Go) into Rust. This first phase lays the foundation that the propose/attest/synccomm/component ports build on: - `meta.rs` - SpecMeta + MetaSlot + MetaEpoch value types (port of meta.go). - `sign.rs` - `Sign` trait + `Signer` + `SignFunc = Arc<dyn Sign>` (Go's SignFunc + NewSigner) backed by `pluto-crypto` BlstImpl. - `validators.rs` - local `ActiveValidators` newtype + `active_validators(client)` helper. Avoids a `pluto-testutil -> pluto-app` dep, since `pluto-app` already dev-depends on this crate. - `capture.rs` - test-only `SubmissionCapture` wiremock helper. Equivalent of Go's `beaconMock.SubmitAttestationsFunc = ...` callback fields - mounts a high-priority `Mock` on `BeaconMock::server()` and records POST bodies. - `error.rs` - module-wide `Error` + `SignError` (thiserror). - `testdata/TestAttest_*.golden` - byte-for-byte copies of the Go fixtures used by the Phase-2 attest port. The Cargo cycle `pluto-eth2util <-> pluto-testutil` is broken by moving `pluto-testutil` from `[dependencies]` to `[dev-dependencies]` in `crates/eth2util/Cargo.toml`. The three call sites in `eth2util` (signing, eth2exp, enr tests) are all `#[cfg(test)]`, so the move is a no-op for production code. 11 unit tests pass; clippy + nightly fmt clean. --- Cargo.lock | 7 + crates/eth2util/Cargo.toml | 4 +- crates/testutil/Cargo.toml | 9 +- crates/testutil/src/lib.rs | 8 + crates/testutil/src/validatormock/capture.rs | 162 ++++++++++ crates/testutil/src/validatormock/error.rs | 60 ++++ crates/testutil/src/validatormock/meta.rs | 283 ++++++++++++++++++ crates/testutil/src/validatormock/mod.rs | 18 ++ crates/testutil/src/validatormock/sign.rs | 100 +++++++ .../testdata/TestAttest_0_aggregations.golden | 109 +++++++ .../testdata/TestAttest_0_attestations.golden | 86 ++++++ .../testdata/TestAttest_1_aggregations.golden | 41 +++ .../testdata/TestAttest_1_attestations.golden | 30 ++ .../testutil/src/validatormock/validators.rs | 115 +++++++ 14 files changed, 1029 insertions(+), 3 deletions(-) create mode 100644 crates/testutil/src/validatormock/capture.rs create mode 100644 crates/testutil/src/validatormock/error.rs create mode 100644 crates/testutil/src/validatormock/meta.rs create mode 100644 crates/testutil/src/validatormock/mod.rs create mode 100644 crates/testutil/src/validatormock/sign.rs create mode 100644 crates/testutil/src/validatormock/testdata/TestAttest_0_aggregations.golden create mode 100644 crates/testutil/src/validatormock/testdata/TestAttest_0_attestations.golden create mode 100644 crates/testutil/src/validatormock/testdata/TestAttest_1_aggregations.golden create mode 100644 crates/testutil/src/validatormock/testdata/TestAttest_1_attestations.golden create mode 100644 crates/testutil/src/validatormock/validators.rs diff --git a/Cargo.lock b/Cargo.lock index efe31fe9..05d28639 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5900,18 +5900,25 @@ name = "pluto-testutil" version = "1.7.1" dependencies = [ "anyhow", + "assert-json-diff", + "async-trait", "bon", "chrono", + "futures", "hex", "k256", + "pluto-core", "pluto-crypto", "pluto-eth2api", + "pluto-eth2util", "rand 0.8.6", "reqwest 0.13.3", + "serde", "serde_json", "thiserror 2.0.18", "tokio", "tokio-util", + "tracing", "tree_hash", "wiremock", ] diff --git a/crates/eth2util/Cargo.toml b/crates/eth2util/Cargo.toml index 52c6747b..9f367638 100644 --- a/crates/eth2util/Cargo.toml +++ b/crates/eth2util/Cargo.toml @@ -21,7 +21,6 @@ pbkdf2.workspace = true scrypt.workspace = true unicode-normalization.workspace = true zeroize.workspace = true -pluto-testutil.workspace = true pluto-k1util.workspace = true chrono.workspace = true regex.workspace = true @@ -41,8 +40,9 @@ reqwest = { workspace = true, features = ["json"] } url.workspace = true [dev-dependencies] -tempfile.workspace = true assert-json-diff.workspace = true +pluto-testutil.workspace = true +tempfile.workspace = true test-case.workspace = true wiremock.workspace = true diff --git a/crates/testutil/Cargo.toml b/crates/testutil/Cargo.toml index a8b6161c..4f444a5a 100644 --- a/crates/testutil/Cargo.toml +++ b/crates/testutil/Cargo.toml @@ -12,21 +12,28 @@ serde_json.workspace = true [dependencies] anyhow.workspace = true +async-trait.workspace = true bon.workspace = true chrono.workspace = true +futures = { workspace = true } +hex.workspace = true k256.workspace = true +pluto-core.workspace = true pluto-crypto.workspace = true pluto-eth2api.workspace = true +pluto-eth2util.workspace = true rand.workspace = true -hex.workspace = true +serde.workspace = true serde_json.workspace = true thiserror.workspace = true tokio.workspace = true tokio-util.workspace = true +tracing.workspace = true tree_hash.workspace = true wiremock.workspace = true [dev-dependencies] +assert-json-diff.workspace = true reqwest.workspace = true [lints] diff --git a/crates/testutil/src/lib.rs b/crates/testutil/src/lib.rs index 864ff1b0..8f572e56 100644 --- a/crates/testutil/src/lib.rs +++ b/crates/testutil/src/lib.rs @@ -14,8 +14,16 @@ pub mod random; /// Beacon node API mock utilities. pub mod beaconmock; +/// Validator mock — drives validator-side duties against a [`BeaconMock`]. +pub mod validatormock; + pub use beaconmock::{BeaconMock, MockState, Validator, ValidatorSet}; pub use random::{ random_deneb_versioned_attestation, random_eth2_signature, random_eth2_signature_bytes, random_root, random_root_bytes, random_slot, random_v_idx, }; +pub use validatormock::{ + ActiveValidators, EndpointMatch, Error as ValidatorMockError, MetaEpoch, MetaSlot, + Result as ValidatorMockResult, Sign, SignError, SignFunc, Signer, SpecMeta, SubmissionCapture, + active_validators, +}; diff --git a/crates/testutil/src/validatormock/capture.rs b/crates/testutil/src/validatormock/capture.rs new file mode 100644 index 00000000..ac6499c0 --- /dev/null +++ b/crates/testutil/src/validatormock/capture.rs @@ -0,0 +1,162 @@ +//! Test-only request-capture helper for [`crate::BeaconMock`]. +//! +//! The Go validator mock tests assert on what the SUT submits by setting +//! callback fields on `beaconmock.Mock` (`SubmitAttestationsFunc`, +//! `SubmitAggregateAttestationsFunc`, ...). `BeaconMock` has no such hook, so +//! tests register a high-priority [`wiremock::Mock`] that decodes the POST body +//! into JSON and appends it into a shared buffer. +//! +//! Mounts above [`mount_endpoint_override`](crate::beaconmock) and the default +//! routes, so the SUT sees a 200 and the test sees the request body. + +use std::sync::{Arc, Mutex}; + +use serde_json::Value; +use wiremock::{ + Mock, MockServer, Request, ResponseTemplate, + matchers::{method, path, path_regex}, +}; + +/// Priority used by [`SubmissionCapture`]. Wiremock matches the lowest priority +/// first (and rejects `0`); `1` wins over both [`crate::beaconmock`]'s defaults +/// (`255`) and the override layer (`50`). +pub const CAPTURE_PRIORITY: u8 = 1; + +/// Endpoint matcher — plain path or wiremock regex. +#[derive(Debug, Clone)] +pub enum EndpointMatch { + /// Exact path (e.g. `"/eth/v1/beacon/pool/attestations"`). + Path(String), + /// `wiremock` path regex (must start with `^`). + Regex(String), +} + +impl EndpointMatch { + /// Returns an [`EndpointMatch::Path`] from any string-like input. + pub fn path(p: impl Into<String>) -> Self { + Self::Path(p.into()) + } + + /// Returns an [`EndpointMatch::Regex`] from any string-like input. + pub fn regex(p: impl Into<String>) -> Self { + Self::Regex(p.into()) + } +} + +/// Shared buffer of captured POST/PUT bodies, parsed as JSON. +#[derive(Debug, Clone, Default)] +pub struct SubmissionCapture { + inner: Arc<Mutex<Vec<Value>>>, +} + +impl SubmissionCapture { + /// Mounts a capture handler on `server` matching `http_method` + + /// `endpoint`, responding with `response_body` (200) and recording every + /// request body for later inspection. + pub async fn mount( + server: &MockServer, + http_method: &'static str, + endpoint: EndpointMatch, + response_body: Value, + ) -> Self { + let capture = Self::default(); + let writer = Arc::clone(&capture.inner); + let response = ResponseTemplate::new(200).set_body_json(response_body); + + let route = Mock::given(method(http_method)); + let route = match endpoint { + EndpointMatch::Path(p) => route.and(path(p)), + EndpointMatch::Regex(r) => route.and(path_regex(r)), + }; + + route + .respond_with(move |request: &Request| { + if let Ok(value) = serde_json::from_slice::<Value>(&request.body) { + writer.lock().expect("capture mutex poisoned").push(value); + } + response.clone() + }) + .with_priority(CAPTURE_PRIORITY) + .mount(server) + .await; + + capture + } + + /// Captured bodies in submission order. Does not drain. + pub fn snapshot(&self) -> Vec<Value> { + self.inner.lock().expect("capture mutex poisoned").clone() + } + + /// Drains the buffer and returns every captured body in submission order. + pub fn take(&self) -> Vec<Value> { + std::mem::take(&mut *self.inner.lock().expect("capture mutex poisoned")) + } + + /// Number of captured submissions. + pub fn len(&self) -> usize { + self.inner.lock().expect("capture mutex poisoned").len() + } + + /// True if nothing has been captured. + pub fn is_empty(&self) -> bool { + self.len() == 0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::beaconmock::BeaconMock; + use serde_json::json; + + #[tokio::test] + async fn captures_post_body() { + let mock = BeaconMock::builder().build().await.expect("build mock"); + let capture = SubmissionCapture::mount( + mock.server(), + "POST", + EndpointMatch::path("/eth/v1/beacon/pool/attestations"), + json!({}), + ) + .await; + + let url = format!("{}/eth/v1/beacon/pool/attestations", mock.uri()); + let body = json!([{ "slot": "1", "index": "0" }]); + let status = reqwest::Client::new() + .post(&url) + .json(&body) + .send() + .await + .expect("send") + .status(); + assert_eq!(status, 200); + + let captured = capture.take(); + assert_eq!(captured.len(), 1); + assert_eq!(captured[0], body); + } + + #[tokio::test] + async fn regex_endpoint_matches() { + let mock = BeaconMock::builder().build().await.expect("build mock"); + let capture = SubmissionCapture::mount( + mock.server(), + "POST", + EndpointMatch::regex(r"^/eth/v1/validator/duties/attester/[0-9]+$"), + json!({"data": []}), + ) + .await; + + let url = format!("{}/eth/v1/validator/duties/attester/3", mock.uri()); + let status = reqwest::Client::new() + .post(&url) + .json(&json!(["1"])) + .send() + .await + .expect("send") + .status(); + assert_eq!(status, 200); + assert_eq!(capture.len(), 1); + } +} diff --git a/crates/testutil/src/validatormock/error.rs b/crates/testutil/src/validatormock/error.rs new file mode 100644 index 00000000..fa3637ec --- /dev/null +++ b/crates/testutil/src/validatormock/error.rs @@ -0,0 +1,60 @@ +//! Module-wide error type for the validator mock. +//! +//! Mirrors the structure of `pluto_eth2util::signing::SigningError`: a single +//! `thiserror::Error` enum that the public API returns. Phase-2/3 submodules +//! add new variants as their failure modes appear. + +use pluto_eth2api::EthBeaconNodeApiClientError; +use pluto_eth2util::{helpers::HelperError, signing::SigningError}; + +/// Result alias used by the validator mock. +pub type Result<T> = std::result::Result<T, Error>; + +/// Errors returned by the validator mock. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// Beacon-node API call failed. + #[error(transparent)] + BeaconNode(#[from] EthBeaconNodeApiClientError), + + /// Signing-helper failure (resolving domains, hashing roots, etc.). + #[error(transparent)] + Signing(#[from] SigningError), + + /// Helper utility (slot/epoch arithmetic against the spec) failure. + #[error(transparent)] + Helper(#[from] HelperError), + + /// Local signer could not produce a signature for the requested pubkey. + #[error(transparent)] + Sign(#[from] SignError), + + /// Hash-tree-root computation failed. + #[error("hash tree root: {0}")] + HashTreeRoot(String), + + /// Beacon response was malformed or missing data. + #[error("malformed beacon response: {0}")] + Malformed(String), + + /// Required validator index missing from the active set. + #[error("missing validator index {0}")] + MissingValidatorIndex(u64), + + /// Builder/proposal/block variant not supported. + #[error("unsupported variant: {0}")] + UnsupportedVariant(&'static str), +} + +/// Signer-specific errors. Wrapped into [`Error::Sign`] when surfaced from the +/// validator-mock public API. +#[derive(Debug, thiserror::Error)] +pub enum SignError { + /// No private key is registered for the requested public key. + #[error("no secret found for pubkey")] + UnknownPubkey, + + /// Underlying BLS error. + #[error(transparent)] + Bls(#[from] pluto_crypto::types::Error), +} diff --git a/crates/testutil/src/validatormock/meta.rs b/crates/testutil/src/validatormock/meta.rs new file mode 100644 index 00000000..fba28845 --- /dev/null +++ b/crates/testutil/src/validatormock/meta.rs @@ -0,0 +1,283 @@ +//! Spec metadata and slot/epoch arithmetic used by the validator mock. +//! +//! Mirrors `charon/testutil/validatormock/meta.go`. The types are deliberately +//! plain values: callers pass [`SpecMeta`] in once and the slot/epoch helpers +//! never touch the network. All arithmetic uses `saturating_*` / `checked_*` +//! to satisfy the workspace's `arithmetic_side_effects = deny` lint. + +use std::time::{Duration, SystemTime}; + +/// Spec constants the validator mock needs to translate slots into wall-clock +/// instants and into epochs. +#[derive(Debug, Clone, Copy)] +pub struct SpecMeta { + /// Genesis time of the chain. + pub genesis_time: SystemTime, + /// Wall-clock duration of a single slot. + pub slot_duration: Duration, + /// Number of slots per epoch. + pub slots_per_epoch: u64, +} + +impl SpecMeta { + /// Start time of `slot` (`genesis + slot * slot_duration`). Saturates at + /// `u32::MAX` slot offsets — well beyond any practical chain age. + #[must_use] + pub fn slot_start_time(&self, slot: u64) -> SystemTime { + let multiplier = u32::try_from(slot).unwrap_or(u32::MAX); + let offset = self.slot_duration.saturating_mul(multiplier); + self.genesis_time + .checked_add(offset) + .unwrap_or(self.genesis_time) + } + + /// Epoch number containing `slot`. Returns epoch 0 if + /// `slots_per_epoch == 0`. + #[must_use] + pub fn epoch_from_slot(&self, slot: u64) -> MetaEpoch { + MetaEpoch { + epoch: slot.checked_div(self.slots_per_epoch).unwrap_or(0), + meta: *self, + } + } + + /// First slot in `epoch` as a [`MetaSlot`]. + #[must_use] + pub fn first_slot_in_epoch(&self, epoch: u64) -> MetaSlot { + MetaSlot { + slot: epoch.saturating_mul(self.slots_per_epoch), + meta: *self, + } + } + + /// Last slot in `epoch` as a [`MetaSlot`]. + #[must_use] + pub fn last_slot_in_epoch(&self, epoch: u64) -> MetaSlot { + let first = epoch.saturating_mul(self.slots_per_epoch); + MetaSlot { + slot: first.saturating_add(self.slots_per_epoch).saturating_sub(1), + meta: *self, + } + } +} + +/// A slot together with the spec metadata required to ask wall-clock questions +/// about it. +#[derive(Debug, Clone, Copy)] +pub struct MetaSlot { + /// Slot number. + pub slot: u64, + /// Spec metadata. + pub meta: SpecMeta, +} + +impl MetaSlot { + /// Wall-clock start time of this slot. + #[must_use] + pub fn start_time(&self) -> SystemTime { + self.meta.slot_start_time(self.slot) + } + + /// Slot duration from the spec. + #[must_use] + pub fn duration(&self) -> Duration { + self.meta.slot_duration + } + + /// Containing epoch as [`MetaEpoch`]. + #[must_use] + pub fn epoch(&self) -> MetaEpoch { + self.meta.epoch_from_slot(self.slot) + } + + /// Slot immediately following this one. Saturates at `u64::MAX`. + #[must_use] + pub fn next(&self) -> MetaSlot { + MetaSlot { + slot: self.slot.saturating_add(1), + meta: self.meta, + } + } + + /// Returns true if `t` falls in `[self.start_time, next.start_time)`. + #[must_use] + pub fn in_slot(&self, t: SystemTime) -> bool { + let start = self.start_time(); + let end = self.next().start_time(); + t >= start && t < end + } + + /// Returns true if this slot is the first slot of its epoch. + #[must_use] + pub fn first_in_epoch(&self) -> bool { + self.slot == self.epoch().first_slot().slot + } +} + +/// An epoch together with the spec metadata required to enumerate its slots. +#[derive(Debug, Clone, Copy)] +pub struct MetaEpoch { + /// Epoch number. + pub epoch: u64, + /// Spec metadata. + pub meta: SpecMeta, +} + +impl MetaEpoch { + /// First slot of this epoch. + #[must_use] + pub fn first_slot(&self) -> MetaSlot { + self.meta.first_slot_in_epoch(self.epoch) + } + + /// Last slot of this epoch. + #[must_use] + pub fn last_slot(&self) -> MetaSlot { + self.meta.last_slot_in_epoch(self.epoch) + } + + /// Slots of this epoch in order. + #[must_use] + pub fn slots(&self) -> Vec<MetaSlot> { + self.slots_for_look_ahead(1) + } + + /// Slots starting at the first slot of this epoch and spanning + /// `total_epochs` epochs forward (inclusive of the current epoch). + #[must_use] + pub fn slots_for_look_ahead(&self, total_epochs: u64) -> Vec<MetaSlot> { + let total = total_epochs.saturating_mul(self.meta.slots_per_epoch); + let capacity = usize::try_from(total).unwrap_or(usize::MAX); + let mut slot = self.first_slot(); + let mut resp = Vec::with_capacity(capacity); + for _ in 0..total { + resp.push(slot); + slot = slot.next(); + } + resp + } + + /// Slots starting `total_epochs - 1` epochs before this one and spanning + /// `total_epochs` epochs (inclusive of the current epoch). + #[must_use] + pub fn slots_for_look_back(&self, total_epochs: u64) -> Vec<MetaSlot> { + let mut epoch = *self; + for _ in 0..total_epochs { + epoch = epoch.prev(); + } + let total = total_epochs.saturating_mul(self.meta.slots_per_epoch); + let capacity = usize::try_from(total).unwrap_or(usize::MAX); + let mut slot = epoch.first_slot(); + let mut resp = Vec::with_capacity(capacity); + for _ in 0..total { + resp.push(slot); + slot = slot.next(); + } + resp + } + + /// Next epoch. Saturates at `u64::MAX`. + #[must_use] + pub fn next(&self) -> MetaEpoch { + MetaEpoch { + epoch: self.epoch.saturating_add(1), + meta: self.meta, + } + } + + /// Previous epoch. Saturates at `0`. + #[must_use] + pub fn prev(&self) -> MetaEpoch { + MetaEpoch { + epoch: self.epoch.saturating_sub(1), + meta: self.meta, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn meta() -> SpecMeta { + SpecMeta { + genesis_time: SystemTime::UNIX_EPOCH, + slot_duration: Duration::from_secs(12), + slots_per_epoch: 32, + } + } + + #[test] + fn slot_start_time_matches_genesis_plus_duration() { + let m = meta(); + assert_eq!(m.slot_start_time(0), SystemTime::UNIX_EPOCH); + assert_eq!( + m.slot_start_time(5), + SystemTime::UNIX_EPOCH + Duration::from_secs(60) + ); + } + + #[test] + fn epoch_from_slot_floors() { + let m = meta(); + assert_eq!(m.epoch_from_slot(0).epoch, 0); + assert_eq!(m.epoch_from_slot(31).epoch, 0); + assert_eq!(m.epoch_from_slot(32).epoch, 1); + assert_eq!(m.epoch_from_slot(63).epoch, 1); + } + + #[test] + fn first_and_last_slot_in_epoch() { + let m = meta(); + assert_eq!(m.first_slot_in_epoch(1).slot, 32); + assert_eq!(m.last_slot_in_epoch(1).slot, 63); + } + + #[test] + fn meta_slot_in_slot_inclusive_start_exclusive_end() { + let s = MetaSlot { + slot: 1, + meta: meta(), + }; + let start = s.start_time(); + let end = s.next().start_time(); + assert!(s.in_slot(start)); + assert!(s.in_slot(start + Duration::from_secs(6))); + assert!(!s.in_slot(end)); + } + + #[test] + fn meta_slot_first_in_epoch() { + let m = meta(); + assert!(MetaSlot { slot: 32, meta: m }.first_in_epoch()); + assert!(!MetaSlot { slot: 33, meta: m }.first_in_epoch()); + } + + #[test] + fn slots_for_look_ahead_walks_forward() { + let m = meta(); + let e = m.epoch_from_slot(64); + let slots: Vec<u64> = e + .slots_for_look_ahead(2) + .into_iter() + .map(|s| s.slot) + .collect(); + assert_eq!(slots.len(), 64); + assert_eq!(slots.first().copied(), Some(64)); + assert_eq!(slots.last().copied(), Some(127)); + } + + #[test] + fn slots_for_look_back_walks_backward() { + let m = meta(); + let e = m.epoch_from_slot(64); + let slots: Vec<u64> = e + .slots_for_look_back(2) + .into_iter() + .map(|s| s.slot) + .collect(); + assert_eq!(slots.len(), 64); + assert_eq!(slots.first().copied(), Some(0)); + assert_eq!(slots.last().copied(), Some(63)); + } +} diff --git a/crates/testutil/src/validatormock/mod.rs b/crates/testutil/src/validatormock/mod.rs new file mode 100644 index 00000000..506a54dc --- /dev/null +++ b/crates/testutil/src/validatormock/mod.rs @@ -0,0 +1,18 @@ +//! Validator mock — Rust port of `charon/testutil/validatormock`. +//! +//! Drives validator-side duties (block proposal, attestation, aggregation, +//! sync-committee messages and contributions) against a [`crate::BeaconMock`]. +//! Ported file-per-concern to match the Go layout; mirror functional behavior +//! while using idiomatic Rust async primitives. + +pub mod capture; +pub mod error; +pub mod meta; +pub mod sign; +pub mod validators; + +pub use capture::{EndpointMatch, SubmissionCapture}; +pub use error::{Error, Result, SignError}; +pub use meta::{MetaEpoch, MetaSlot, SpecMeta}; +pub use sign::{Sign, SignFunc, Signer}; +pub use validators::{ActiveValidators, active_validators}; diff --git a/crates/testutil/src/validatormock/sign.rs b/crates/testutil/src/validatormock/sign.rs new file mode 100644 index 00000000..8270c558 --- /dev/null +++ b/crates/testutil/src/validatormock/sign.rs @@ -0,0 +1,100 @@ +//! Pubkey-keyed BLS signer for the validator mock. +//! +//! Mirrors Go's `SignFunc = func(pubkey, data) ([]byte, error)` plus +//! `NewSigner` (`charon/testutil/validatormock/propose.go`). Tests substitute a +//! stub by implementing [`Sign`] directly; production code wraps real BLS +//! secrets via [`Signer::new`]. + +use std::{collections::HashMap, sync::Arc}; + +use pluto_crypto::{ + blst_impl::BlstImpl, + tbls::Tbls, + tblsconv::{pubkey_to_eth2, sig_to_eth2}, + types::PrivateKey, +}; +use pluto_eth2api::spec::phase0::{BLSPubKey, BLSSignature}; + +use super::error::SignError; + +/// Trait implemented by anything that can produce a BLS signature for a known +/// public key. +/// +/// The trait is `Send + Sync + 'static` so signer handles can be stored on +/// long-lived state owned by the duty scheduler and shared across tasks. +pub trait Sign: Send + Sync + std::fmt::Debug + 'static { + /// Sign `data` with the secret share registered for `pubkey`. + fn sign(&self, pubkey: &BLSPubKey, data: &[u8]) -> Result<BLSSignature, SignError>; +} + +/// Shared handle to a [`Sign`] implementation. Cheap to clone, stored on every +/// component that needs to sign. +pub type SignFunc = Arc<dyn Sign>; + +/// Concrete BLS signer backed by [`BlstImpl`]. Registers a set of secrets by +/// their derived eth2 public key. +#[derive(Debug, Clone)] +pub struct Signer { + secrets: HashMap<BLSPubKey, PrivateKey>, +} + +impl Signer { + /// Builds a [`Signer`] from `secrets`, deriving each public key with + /// [`BlstImpl`]. Fails fast if any secret is rejected by the BLS backend. + pub fn new(secrets: &[PrivateKey]) -> Result<Self, SignError> { + let tbls = BlstImpl; + let mut map = HashMap::with_capacity(secrets.len()); + for secret in secrets { + let pk = tbls.secret_to_public_key(secret)?; + map.insert(pubkey_to_eth2(pk), *secret); + } + Ok(Self { secrets: map }) + } + + /// Convenience constructor returning the [`SignFunc`] handle directly. + pub fn arc(secrets: &[PrivateKey]) -> Result<SignFunc, SignError> { + Ok(Arc::new(Self::new(secrets)?)) + } +} + +impl Sign for Signer { + fn sign(&self, pubkey: &BLSPubKey, data: &[u8]) -> Result<BLSSignature, SignError> { + let secret = self.secrets.get(pubkey).ok_or(SignError::UnknownPubkey)?; + let sig = BlstImpl.sign(secret, data)?; + Ok(sig_to_eth2(sig)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rand::{SeedableRng, rngs::StdRng}; + + fn deterministic_secret(seed: u8) -> PrivateKey { + let mut bytes = [0u8; 32]; + bytes[0] = seed; + let rng = StdRng::from_seed(bytes); + BlstImpl.generate_insecure_secret(rng).expect("generate") + } + + #[test] + fn round_trip_known_pubkey() { + let secret = deterministic_secret(1); + let pubkey = pubkey_to_eth2( + BlstImpl + .secret_to_public_key(&secret) + .expect("derive pubkey"), + ); + + let signer = Signer::new(&[secret]).expect("build signer"); + let sig = signer.sign(&pubkey, b"msg").expect("sign"); + assert_ne!(sig, [0u8; 96]); + } + + #[test] + fn unknown_pubkey_errors() { + let signer = Signer::new(&[deterministic_secret(2)]).expect("build signer"); + let err = signer.sign(&[0u8; 48], b"msg").expect_err("must fail"); + assert!(matches!(err, SignError::UnknownPubkey)); + } +} diff --git a/crates/testutil/src/validatormock/testdata/TestAttest_0_aggregations.golden b/crates/testutil/src/validatormock/testdata/TestAttest_0_aggregations.golden new file mode 100644 index 00000000..a807a688 --- /dev/null +++ b/crates/testutil/src/validatormock/testdata/TestAttest_0_aggregations.golden @@ -0,0 +1,109 @@ +{ + "Common": { + "Timeout": 0 + }, + "SignedAggregateAndProofs": [ + { + "Version": "fulu", + "Phase0": null, + "Altair": null, + "Bellatrix": null, + "Capella": null, + "Deneb": null, + "Electra": null, + "Fulu": { + "message": { + "aggregator_index": "1", + "aggregate": { + "aggregation_bits": "0x01", + "data": { + "slot": "16", + "index": "1", + "beacon_block_root": "0x1000000000000000000000000000000000000000000000000000000000000000", + "source": { + "epoch": "0", + "root": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + "target": { + "epoch": "1", + "root": "0x0100000000000000000000000000000000000000000000000000000000000000" + } + }, + "signature": "0x000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "committee_bits": "0x0100000000000000" + }, + "selection_proof": "0x914cff835a769156ba43ad50b931083c2dadd94e8359ce394bc7a3e06424d0214922ddf15f81640530b9c25c0bc0d490000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + }, + "signature": "0x914cff835a769156ba43ad50b931083c2dadd94e8359ce394bc7a3e06424d0214922ddf15f81640530b9c25c0bc0d490000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + } + }, + { + "Version": "fulu", + "Phase0": null, + "Altair": null, + "Bellatrix": null, + "Capella": null, + "Deneb": null, + "Electra": null, + "Fulu": { + "message": { + "aggregator_index": "2", + "aggregate": { + "aggregation_bits": "0x01", + "data": { + "slot": "16", + "index": "2", + "beacon_block_root": "0x1000000000000000000000000000000000000000000000000000000000000000", + "source": { + "epoch": "0", + "root": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + "target": { + "epoch": "1", + "root": "0x0100000000000000000000000000000000000000000000000000000000000000" + } + }, + "signature": "0x000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "committee_bits": "0x0100000000000000" + }, + "selection_proof": "0x8dae41352b69f2b3a1c0b05330c1bf65f03730c520273028864b11fcb94d8ce8f26d64f979a0ee3025467f45fd2241ea000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + }, + "signature": "0x8dae41352b69f2b3a1c0b05330c1bf65f03730c520273028864b11fcb94d8ce8f26d64f979a0ee3025467f45fd2241ea000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + } + }, + { + "Version": "fulu", + "Phase0": null, + "Altair": null, + "Bellatrix": null, + "Capella": null, + "Deneb": null, + "Electra": null, + "Fulu": { + "message": { + "aggregator_index": "3", + "aggregate": { + "aggregation_bits": "0x01", + "data": { + "slot": "16", + "index": "3", + "beacon_block_root": "0x1000000000000000000000000000000000000000000000000000000000000000", + "source": { + "epoch": "0", + "root": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + "target": { + "epoch": "1", + "root": "0x0100000000000000000000000000000000000000000000000000000000000000" + } + }, + "signature": "0x000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "committee_bits": "0x0100000000000000" + }, + "selection_proof": "0x8ee91545183c8c2db86633626f5074fd8ef93c4c9b7a2879ad1768f600c5b5906c3af20d47de42c3b032956fa8db1a76000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + }, + "signature": "0x8ee91545183c8c2db86633626f5074fd8ef93c4c9b7a2879ad1768f600c5b5906c3af20d47de42c3b032956fa8db1a76000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + } + } + ] +} \ No newline at end of file diff --git a/crates/testutil/src/validatormock/testdata/TestAttest_0_attestations.golden b/crates/testutil/src/validatormock/testdata/TestAttest_0_attestations.golden new file mode 100644 index 00000000..b9f4839e --- /dev/null +++ b/crates/testutil/src/validatormock/testdata/TestAttest_0_attestations.golden @@ -0,0 +1,86 @@ +[ + { + "Version": "fulu", + "ValidatorIndex": "1", + "Phase0": null, + "Altair": null, + "Bellatrix": null, + "Capella": null, + "Deneb": null, + "Electra": null, + "Fulu": { + "aggregation_bits": "0x03", + "data": { + "slot": "16", + "index": "1", + "beacon_block_root": "0x1000000000000000000000000000000000000000000000000000000000000000", + "source": { + "epoch": "0", + "root": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + "target": { + "epoch": "1", + "root": "0x0100000000000000000000000000000000000000000000000000000000000000" + } + }, + "signature": "0x914cff835a769156ba43ad50b931083c2dadd94e8359ce394bc7a3e06424d0214922ddf15f81640530b9c25c0bc0d490000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "committee_bits": "0x0200000000000000" + } + }, + { + "Version": "fulu", + "ValidatorIndex": "2", + "Phase0": null, + "Altair": null, + "Bellatrix": null, + "Capella": null, + "Deneb": null, + "Electra": null, + "Fulu": { + "aggregation_bits": "0x03", + "data": { + "slot": "16", + "index": "2", + "beacon_block_root": "0x1000000000000000000000000000000000000000000000000000000000000000", + "source": { + "epoch": "0", + "root": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + "target": { + "epoch": "1", + "root": "0x0100000000000000000000000000000000000000000000000000000000000000" + } + }, + "signature": "0x8dae41352b69f2b3a1c0b05330c1bf65f03730c520273028864b11fcb94d8ce8f26d64f979a0ee3025467f45fd2241ea000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "committee_bits": "0x0400000000000000" + } + }, + { + "Version": "fulu", + "ValidatorIndex": "3", + "Phase0": null, + "Altair": null, + "Bellatrix": null, + "Capella": null, + "Deneb": null, + "Electra": null, + "Fulu": { + "aggregation_bits": "0x03", + "data": { + "slot": "16", + "index": "3", + "beacon_block_root": "0x1000000000000000000000000000000000000000000000000000000000000000", + "source": { + "epoch": "0", + "root": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + "target": { + "epoch": "1", + "root": "0x0100000000000000000000000000000000000000000000000000000000000000" + } + }, + "signature": "0x8ee91545183c8c2db86633626f5074fd8ef93c4c9b7a2879ad1768f600c5b5906c3af20d47de42c3b032956fa8db1a76000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "committee_bits": "0x0800000000000000" + } + } +] \ No newline at end of file diff --git a/crates/testutil/src/validatormock/testdata/TestAttest_1_aggregations.golden b/crates/testutil/src/validatormock/testdata/TestAttest_1_aggregations.golden new file mode 100644 index 00000000..011948dc --- /dev/null +++ b/crates/testutil/src/validatormock/testdata/TestAttest_1_aggregations.golden @@ -0,0 +1,41 @@ +{ + "Common": { + "Timeout": 0 + }, + "SignedAggregateAndProofs": [ + { + "Version": "fulu", + "Phase0": null, + "Altair": null, + "Bellatrix": null, + "Capella": null, + "Deneb": null, + "Electra": null, + "Fulu": { + "message": { + "aggregator_index": "1", + "aggregate": { + "aggregation_bits": "0x01", + "data": { + "slot": "16", + "index": "1", + "beacon_block_root": "0x1000000000000000000000000000000000000000000000000000000000000000", + "source": { + "epoch": "0", + "root": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + "target": { + "epoch": "1", + "root": "0x0100000000000000000000000000000000000000000000000000000000000000" + } + }, + "signature": "0x000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "committee_bits": "0x0100000000000000" + }, + "selection_proof": "0x914cff835a769156ba43ad50b931083c2dadd94e8359ce394bc7a3e06424d0214922ddf15f81640530b9c25c0bc0d490000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + }, + "signature": "0x914cff835a769156ba43ad50b931083c2dadd94e8359ce394bc7a3e06424d0214922ddf15f81640530b9c25c0bc0d490000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + } + } + ] +} \ No newline at end of file diff --git a/crates/testutil/src/validatormock/testdata/TestAttest_1_attestations.golden b/crates/testutil/src/validatormock/testdata/TestAttest_1_attestations.golden new file mode 100644 index 00000000..83b46117 --- /dev/null +++ b/crates/testutil/src/validatormock/testdata/TestAttest_1_attestations.golden @@ -0,0 +1,30 @@ +[ + { + "Version": "fulu", + "ValidatorIndex": "1", + "Phase0": null, + "Altair": null, + "Bellatrix": null, + "Capella": null, + "Deneb": null, + "Electra": null, + "Fulu": { + "aggregation_bits": "0x03", + "data": { + "slot": "16", + "index": "1", + "beacon_block_root": "0x1000000000000000000000000000000000000000000000000000000000000000", + "source": { + "epoch": "0", + "root": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + "target": { + "epoch": "1", + "root": "0x0100000000000000000000000000000000000000000000000000000000000000" + } + }, + "signature": "0x914cff835a769156ba43ad50b931083c2dadd94e8359ce394bc7a3e06424d0214922ddf15f81640530b9c25c0bc0d490000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "committee_bits": "0x0200000000000000" + } + } +] \ No newline at end of file diff --git a/crates/testutil/src/validatormock/validators.rs b/crates/testutil/src/validatormock/validators.rs new file mode 100644 index 00000000..2590c71d --- /dev/null +++ b/crates/testutil/src/validatormock/validators.rs @@ -0,0 +1,115 @@ +//! Active-validator lookup against the beacon node. +//! +//! Mirrors Go's `eth2wrap.Client.ActiveValidators` (a thin filter over +//! `/eth/v1/beacon/states/head/validators`). Local to this crate so the +//! validator mock does not depend on `pluto-app`, which itself dev-depends on +//! `pluto-testutil`. + +use std::collections::HashMap; + +use pluto_eth2api::{ + EthBeaconNodeApiClient, EthBeaconNodeApiClientError, GetStateValidatorsResponseResponse, + PostStateValidatorsRequest, PostStateValidatorsResponse, ValidatorRequestBody, + spec::phase0::{BLSPubKey, ValidatorIndex}, +}; + +use super::error::{Error, Result}; + +/// Active validators indexed by [`ValidatorIndex`]. +/// +/// Constructed by [`active_validators`]; the mock does not cache, callers +/// typically query once per slot like the Go implementation. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ActiveValidators(HashMap<ValidatorIndex, BLSPubKey>); + +impl ActiveValidators { + /// Indices of every active validator. Order is unspecified. + pub fn indices(&self) -> impl Iterator<Item = ValidatorIndex> + '_ { + self.0.keys().copied() + } + + /// Public keys of every active validator. Order is unspecified. + pub fn pubkeys(&self) -> impl Iterator<Item = &BLSPubKey> + '_ { + self.0.values() + } + + /// Public key for `index`, if present. + #[must_use] + pub fn get(&self, index: ValidatorIndex) -> Option<&BLSPubKey> { + self.0.get(&index) + } + + /// Number of active validators. + #[must_use] + pub fn len(&self) -> usize { + self.0.len() + } + + /// True if no validators are active. + #[must_use] + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +impl<I> FromIterator<I> for ActiveValidators +where + I: Into<(ValidatorIndex, BLSPubKey)>, +{ + fn from_iter<T: IntoIterator<Item = I>>(iter: T) -> Self { + Self(iter.into_iter().map(Into::into).collect()) + } +} + +/// Fetches active validators from the beacon node and returns them as a map. +/// +/// Mirrors Go's `eth2Cl.ActiveValidators(ctx)`: queries `head`, filters by +/// status, drops malformed entries. +pub async fn active_validators(client: &EthBeaconNodeApiClient) -> Result<ActiveValidators> { + let request = PostStateValidatorsRequest { + path: pluto_eth2api::PostStateValidatorsRequestPath { + state_id: "head".to_string(), + }, + body: ValidatorRequestBody { + ids: None, + statuses: None, + }, + }; + + let response = client + .post_state_validators(request) + .await + .map_err(EthBeaconNodeApiClientError::RequestError) + .and_then(|r| match r { + PostStateValidatorsResponse::Ok(ok) => Ok(ok), + _ => Err(EthBeaconNodeApiClientError::UnexpectedResponse), + })?; + + Ok(filter_active(response)) +} + +fn filter_active(response: GetStateValidatorsResponseResponse) -> ActiveValidators { + let mut map = HashMap::new(); + for datum in response.data { + if !datum.status.is_active() { + continue; + } + let Ok(index) = datum.index.parse::<ValidatorIndex>() else { + continue; + }; + let Ok(pubkey) = parse_bls_pubkey(&datum.validator.pubkey) else { + continue; + }; + map.insert(index, pubkey); + } + ActiveValidators(map) +} + +fn parse_bls_pubkey(s: &str) -> Result<BLSPubKey> { + let trimmed = s.strip_prefix("0x").unwrap_or(s); + let bytes = hex::decode(trimmed).map_err(|e| Error::Malformed(e.to_string()))?; + bytes + .as_slice() + .try_into() + .map_err(|_| Error::Malformed(format!("pubkey length {} != 48", bytes.len()))) +} From b2e39df1fe7a25928c33dd50f4c5c7c6519bdc6b Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> Date: Fri, 15 May 2026 12:54:58 +0200 Subject: [PATCH 18/21] feat(testutil/validatormock): port synccomm.go (SyncCommMember) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports `charon/testutil/validatormock/synccomm.go` to Rust. SyncCommMember drives PrepareEpoch → PrepareSlot → Message → Aggregate; per-slot ready flags use Arc<tokio::sync::OnceCell<()>> to mirror Go's lazy `chan struct{}` maps. TestGetSubcommittees ports the Go internal test verbatim. --- crates/testutil/src/validatormock/mod.rs | 2 + crates/testutil/src/validatormock/synccomm.rs | 781 ++++++++++++++++++ 2 files changed, 783 insertions(+) create mode 100644 crates/testutil/src/validatormock/synccomm.rs diff --git a/crates/testutil/src/validatormock/mod.rs b/crates/testutil/src/validatormock/mod.rs index 506a54dc..9c06a30f 100644 --- a/crates/testutil/src/validatormock/mod.rs +++ b/crates/testutil/src/validatormock/mod.rs @@ -9,10 +9,12 @@ pub mod capture; pub mod error; pub mod meta; pub mod sign; +pub mod synccomm; pub mod validators; pub use capture::{EndpointMatch, SubmissionCapture}; pub use error::{Error, Result, SignError}; pub use meta::{MetaEpoch, MetaSlot, SpecMeta}; pub use sign::{Sign, SignFunc, Signer}; +pub use synccomm::{SyncCommMember, SyncCommitteeDuty}; pub use validators::{ActiveValidators, active_validators}; diff --git a/crates/testutil/src/validatormock/synccomm.rs b/crates/testutil/src/validatormock/synccomm.rs new file mode 100644 index 00000000..edb9551b --- /dev/null +++ b/crates/testutil/src/validatormock/synccomm.rs @@ -0,0 +1,781 @@ +//! Sync-committee duty driver. +//! +//! Port of `charon/testutil/validatormock/synccomm.go`. [`SyncCommMember`] is a +//! stateful per-validator driver that ports the Go workflow: +//! +//! 1. [`SyncCommMember::prepare_epoch`] resolves sync committee duties and +//! submits subscriptions. +//! 2. [`SyncCommMember::prepare_slot`] computes per-slot selection proofs. +//! 3. [`SyncCommMember::message`] submits sync committee messages at 1/3rd into +//! the slot and records the beacon block root. +//! 4. [`SyncCommMember::aggregate`] submits aggregated contribution-and-proofs +//! at 2/3rd into the slot. +//! +//! The Go `chan struct{}` close-once readiness flags become +//! `Arc<tokio::sync::OnceCell<()>>`; the per-slot maps lazily insert entries +//! on both setter and getter paths so callers may await readiness before any +//! producer has touched the slot, exactly like the Go version. + +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, +}; + +use pluto_eth2api::{ + EthBeaconNodeApiClient, EthBeaconNodeApiClientError, GetBlockRootRequest, GetBlockRootResponse, + GetSyncCommitteeDutiesRequest, GetSyncCommitteeDutiesResponse, + GetSyncCommitteeDutiesResponseResponseDatum, PrepareSyncCommitteeSubnetsRequest, + ProduceSyncCommitteeContributionRequest, ProduceSyncCommitteeContributionResponse, + PublishContributionAndProofsRequest, SubmitPoolSyncCommitteeSignaturesRequest, + SubmitSyncCommitteeSelectionsRequest, SubmitSyncCommitteeSelectionsResponse, + spec::{ + altair::{ + ContributionAndProof, SignedContributionAndProof, SyncAggregatorSelectionData, + SyncCommitteeContribution, SyncCommitteeMessage, + }, + phase0::{BLSPubKey, BLSSignature, Epoch, Root, Slot, ValidatorIndex}, + }, +}; +use pluto_eth2util::{ + eth2exp::is_sync_comm_aggregator, + helpers::epoch_from_slot, + signing::{DomainName, get_data_root}, +}; +use tokio::sync::OnceCell; +use tracing::info; +use tree_hash::TreeHash; + +use super::{ + error::{Error, Result}, + sign::SignFunc, + validators::{ActiveValidators, active_validators}, +}; + +/// Single sync-committee duty resolved for one of the local validators. +#[derive(Debug, Clone)] +pub struct SyncCommitteeDuty { + /// Validator BLS public key. + pub pubkey: BLSPubKey, + /// Validator registry index. + pub validator_index: ValidatorIndex, + /// The validator's positions in the sync committee. + pub validator_sync_committee_indices: Vec<u64>, +} + +/// Aggregate sync-committee selection returned by the beacon node, post-DVT +/// aggregation. Mirrors `eth2v1.SyncCommitteeSelection`. +#[derive(Debug, Clone)] +struct SyncCommitteeSelection { + validator_index: ValidatorIndex, + slot: Slot, + subcommittee_index: u64, + selection_proof: BLSSignature, +} + +/// Mutable state guarded by a single [`Mutex`]. The Go `mutable` embedded +/// struct. +#[derive(Default)] +struct Mutable { + vals: ActiveValidators, + duties: Vec<SyncCommitteeDuty>, + selections: HashMap<Slot, Vec<SyncCommitteeSelection>>, + selections_ok: HashMap<Slot, Arc<OnceCell<()>>>, + block_root: HashMap<Slot, Root>, + block_root_ok: HashMap<Slot, Arc<OnceCell<()>>>, +} + +/// Stateful driver providing the sync-committee message and contribution +/// APIs for a single epoch. Created with [`SyncCommMember::new`] and driven +/// by a scheduler via [`SyncCommMember::prepare_epoch`], +/// [`SyncCommMember::prepare_slot`], [`SyncCommMember::message`] and +/// [`SyncCommMember::aggregate`]. +pub struct SyncCommMember { + // Immutable state. + eth2_cl: EthBeaconNodeApiClient, + epoch: Epoch, + #[allow(dead_code)] + pubkeys: Vec<BLSPubKey>, + sign_func: SignFunc, + + // Mutable state. + mutable: Mutex<Mutable>, + duties_ok: Arc<OnceCell<()>>, +} + +impl SyncCommMember { + /// Builds a new sync committee member driver for `epoch`. Mirrors Go's + /// `NewSyncCommMember`. + #[must_use] + pub fn new( + eth2_cl: EthBeaconNodeApiClient, + epoch: Epoch, + sign_func: SignFunc, + pubkeys: Vec<BLSPubKey>, + ) -> Self { + Self { + eth2_cl, + epoch, + pubkeys, + sign_func, + mutable: Mutex::new(Mutable::default()), + duties_ok: Arc::new(OnceCell::new()), + } + } + + /// Returns the epoch this driver was constructed for. + #[must_use] + pub fn epoch(&self) -> Epoch { + self.epoch + } + + // -- mutable-state helpers (mirror the Go set*/get* methods). -- + + fn set_selections(&self, slot: Slot, selections: Vec<SyncCommitteeSelection>) -> Result<()> { + let cell = { + let mut guard = lock(&self.mutable); + guard.selections.insert(slot, selections); + Arc::clone( + guard + .selections_ok + .entry(slot) + .or_insert_with(|| Arc::new(OnceCell::new())), + ) + }; + + cell.set(()) + .map_err(|_| Error::Malformed(format!("selections already set for slot {slot}"))) + } + + fn get_selections(&self, slot: Slot) -> Vec<SyncCommitteeSelection> { + lock(&self.mutable) + .selections + .get(&slot) + .cloned() + .unwrap_or_default() + } + + fn get_selections_ok(&self, slot: Slot) -> Arc<OnceCell<()>> { + let mut guard = lock(&self.mutable); + Arc::clone( + guard + .selections_ok + .entry(slot) + .or_insert_with(|| Arc::new(OnceCell::new())), + ) + } + + fn set_block_root(&self, slot: Slot, block_root: Root) -> Result<()> { + let cell = { + let mut guard = lock(&self.mutable); + guard.block_root.insert(slot, block_root); + Arc::clone( + guard + .block_root_ok + .entry(slot) + .or_insert_with(|| Arc::new(OnceCell::new())), + ) + }; + + cell.set(()) + .map_err(|_| Error::Malformed(format!("block root already set for slot {slot}"))) + } + + fn get_block_root(&self, slot: Slot) -> Root { + lock(&self.mutable) + .block_root + .get(&slot) + .copied() + .unwrap_or_default() + } + + fn get_block_root_ok(&self, slot: Slot) -> Arc<OnceCell<()>> { + let mut guard = lock(&self.mutable); + Arc::clone( + guard + .block_root_ok + .entry(slot) + .or_insert_with(|| Arc::new(OnceCell::new())), + ) + } + + fn set_duties(&self, vals: ActiveValidators, duties: Vec<SyncCommitteeDuty>) -> Result<()> { + { + let mut guard = lock(&self.mutable); + guard.vals = vals; + guard.duties = duties; + } + self.duties_ok + .set(()) + .map_err(|_| Error::Malformed("duties already set".to_string())) + } + + fn get_duties(&self) -> Vec<SyncCommitteeDuty> { + lock(&self.mutable).duties.clone() + } + + fn get_vals(&self) -> ActiveValidators { + lock(&self.mutable).vals.clone() + } + + // -- public workflow methods. -- + + /// Resolves sync committee duties for this epoch and submits subscriptions + /// covering the next epoch. + pub async fn prepare_epoch(&self) -> Result<()> { + let vals = active_validators(&self.eth2_cl).await?; + let duties = prepare_sync_comm_duties(&self.eth2_cl, &vals, self.epoch).await?; + self.set_duties(vals, duties.clone())?; + subscribe_sync_comm_subnets(&self.eth2_cl, self.epoch, &duties).await?; + Ok(()) + } + + /// Computes aggregate selection proofs for `slot` and marks them ready for + /// [`SyncCommMember::aggregate`] consumers. + pub async fn prepare_slot(&self, slot: Slot) -> Result<()> { + wait_ready(&self.duties_ok).await; + + let selections = + prepare_sync_selections(&self.eth2_cl, &self.sign_func, &self.get_duties(), slot) + .await?; + + self.set_selections(slot, selections) + } + + /// Submits sync-committee messages at 1/3rd into the slot and records the + /// beacon block root that drove them. Mirrors Go's `Message`. + pub async fn message(&self, slot: Slot) -> Result<()> { + wait_ready(&self.duties_ok).await; + + let duties = self.get_duties(); + if duties.is_empty() { + return self.set_block_root(slot, Root::default()); + } + + let block_root = fetch_head_block_root(&self.eth2_cl).await?; + + submit_sync_messages(&self.eth2_cl, slot, block_root, &self.sign_func, &duties).await?; + + self.set_block_root(slot, block_root) + } + + /// Submits aggregated contribution-and-proofs at 2/3rd into the slot. + /// Blocks until duties, selections and the slot's beacon block root are + /// ready. Returns `true` if contributions were submitted, `false` if there + /// were no aggregator selections for this slot. + pub async fn aggregate(&self, slot: Slot) -> Result<bool> { + wait_ready(&self.duties_ok).await; + wait_ready(&self.get_selections_ok(slot)).await; + wait_ready(&self.get_block_root_ok(slot)).await; + + agg_contributions( + &self.eth2_cl, + &self.sign_func, + slot, + &self.get_vals(), + &self.get_selections(slot), + self.get_block_root(slot), + ) + .await + } +} + +// -- helper functions (mirror the lowercase Go helpers). -- + +async fn prepare_sync_comm_duties( + client: &EthBeaconNodeApiClient, + vals: &ActiveValidators, + epoch: Epoch, +) -> Result<Vec<SyncCommitteeDuty>> { + if vals.is_empty() { + return Ok(Vec::new()); + } + + let body: Vec<String> = vals.indices().map(|idx| idx.to_string()).collect(); + let request = GetSyncCommitteeDutiesRequest::builder() + .epoch(epoch.to_string()) + .body(body) + .build() + .map_err(|e| Error::Malformed(format!("build sync committee duties request: {e}")))?; + + let response = client + .get_sync_committee_duties(request) + .await + .map_err(EthBeaconNodeApiClientError::RequestError)?; + + let GetSyncCommitteeDutiesResponse::Ok(payload) = response else { + return Err(Error::BeaconNode( + EthBeaconNodeApiClientError::UnexpectedResponse, + )); + }; + + payload + .data + .into_iter() + .map(parse_sync_committee_duty) + .collect() +} + +fn parse_sync_committee_duty( + raw: GetSyncCommitteeDutiesResponseResponseDatum, +) -> Result<SyncCommitteeDuty> { + let pubkey = parse_pubkey(&raw.pubkey)?; + let validator_index = raw + .validator_index + .parse::<ValidatorIndex>() + .map_err(|_| Error::Malformed(format!("parse validator_index: {}", raw.validator_index)))?; + let validator_sync_committee_indices = raw + .validator_sync_committee_indices + .into_iter() + .map(|s| { + s.parse::<u64>() + .map_err(|_| Error::Malformed(format!("parse sync committee index: {s}"))) + }) + .collect::<Result<Vec<_>>>()?; + + Ok(SyncCommitteeDuty { + pubkey, + validator_index, + validator_sync_committee_indices, + }) +} + +fn parse_pubkey(s: &str) -> Result<BLSPubKey> { + let trimmed = s.strip_prefix("0x").unwrap_or(s); + let bytes = hex::decode(trimmed).map_err(|e| Error::Malformed(e.to_string()))?; + bytes + .as_slice() + .try_into() + .map_err(|_| Error::Malformed(format!("pubkey length {} != 48", bytes.len()))) +} + +async fn subscribe_sync_comm_subnets( + client: &EthBeaconNodeApiClient, + epoch: Epoch, + duties: &[SyncCommitteeDuty], +) -> Result<()> { + if duties.is_empty() { + return Ok(()); + } + + let until_epoch = epoch.saturating_add(1).to_string(); + let body: Vec<pluto_eth2api::SyncCommitteeSubscriptionRequestBodyItem> = duties + .iter() + .map( + |duty| pluto_eth2api::SyncCommitteeSubscriptionRequestBodyItem { + sync_committee_indices: duty + .validator_sync_committee_indices + .iter() + .map(u64::to_string) + .collect(), + until_epoch: until_epoch.clone(), + validator_index: duty.validator_index.to_string(), + }, + ) + .collect(); + + let request = PrepareSyncCommitteeSubnetsRequest::builder() + .body(body) + .build() + .map_err(|e| Error::Malformed(format!("build sync committee subscriptions: {e}")))?; + + client + .prepare_sync_committee_subnets(request) + .await + .map_err(EthBeaconNodeApiClientError::RequestError)?; + + info!(epoch = epoch, "Mock sync committee subscription submitted"); + + Ok(()) +} + +async fn prepare_sync_selections( + client: &EthBeaconNodeApiClient, + sign_func: &SignFunc, + duties: &[SyncCommitteeDuty], + slot: Slot, +) -> Result<Vec<SyncCommitteeSelection>> { + if duties.is_empty() { + return Ok(Vec::new()); + } + + let epoch = epoch_from_slot(client, slot).await?; + + let mut partials: Vec<pluto_eth2api::SyncCommitteeSelectionRequestRequestBodyItem> = Vec::new(); + for duty in duties { + let subcomm_idxs = get_subcommittees(client, duty).await?; + for subcomm_idx in subcomm_idxs { + let data = SyncAggregatorSelectionData { + slot, + subcommittee_index: subcomm_idx, + }; + let sig_root = data.tree_hash_root().0; + let sig_data = get_data_root( + client, + DomainName::SyncCommitteeSelectionProof, + epoch, + sig_root, + ) + .await?; + let sig = sign_func.sign(&duty.pubkey, &sig_data)?; + partials.push( + pluto_eth2api::SyncCommitteeSelectionRequestRequestBodyItem { + validator_index: duty.validator_index.to_string(), + slot: slot.to_string(), + subcommittee_index: subcomm_idx.to_string(), + selection_proof: hex_0x(sig), + }, + ); + } + } + + let request = SubmitSyncCommitteeSelectionsRequest::builder() + .body(partials) + .build() + .map_err(|e| Error::Malformed(format!("build sync committee selections: {e}")))?; + + let response = client + .submit_sync_committee_selections(request) + .await + .map_err(EthBeaconNodeApiClientError::RequestError)?; + + let SubmitSyncCommitteeSelectionsResponse::Ok(payload) = response else { + return Err(Error::BeaconNode( + EthBeaconNodeApiClientError::UnexpectedResponse, + )); + }; + + let mut selections = Vec::new(); + for raw in payload.data { + let selection = parse_selection_wire(&raw)?; + let is_aggregator = is_sync_comm_aggregator(client, selection.selection_proof) + .await + .map_err(|e| Error::Malformed(format!("is_sync_comm_aggregator: {e}")))?; + if !is_aggregator { + continue; + } + selections.push(selection); + } + + info!( + aggregators = selections.len(), + "Resolved sync committee aggregators" + ); + + Ok(selections) +} + +fn parse_selection_wire( + raw: &pluto_eth2api::SyncCommitteeSelectionRequestRequestBodyItem, +) -> Result<SyncCommitteeSelection> { + let validator_index = raw + .validator_index + .parse::<ValidatorIndex>() + .map_err(|_| Error::Malformed(format!("parse validator_index: {}", raw.validator_index)))?; + let slot = raw + .slot + .parse::<Slot>() + .map_err(|_| Error::Malformed(format!("parse slot: {}", raw.slot)))?; + let subcommittee_index = raw.subcommittee_index.parse::<u64>().map_err(|_| { + Error::Malformed(format!( + "parse subcommittee_index: {}", + raw.subcommittee_index + )) + })?; + let selection_proof = decode_bls_signature(&raw.selection_proof)?; + + Ok(SyncCommitteeSelection { + validator_index, + slot, + subcommittee_index, + selection_proof, + }) +} + +fn decode_bls_signature(s: &str) -> Result<BLSSignature> { + let trimmed = s.strip_prefix("0x").unwrap_or(s); + let bytes = hex::decode(trimmed).map_err(|e| Error::Malformed(e.to_string()))?; + bytes + .as_slice() + .try_into() + .map_err(|_| Error::Malformed(format!("signature length {} != 96", bytes.len()))) +} + +fn decode_root(s: &str) -> Result<Root> { + let trimmed = s.strip_prefix("0x").unwrap_or(s); + let bytes = hex::decode(trimmed).map_err(|e| Error::Malformed(e.to_string()))?; + bytes + .as_slice() + .try_into() + .map_err(|_| Error::Malformed(format!("root length {} != 32", bytes.len()))) +} + +fn hex_0x(bytes: impl AsRef<[u8]>) -> String { + format!("0x{}", hex::encode(bytes.as_ref())) +} + +/// Returns the subcommittee indices for `duty`. Mirrors Go's +/// `getSubcommittees`: `idx / (SYNC_COMMITTEE_SIZE / +/// SYNC_COMMITTEE_SUBNET_COUNT)`. +pub(crate) async fn get_subcommittees( + client: &EthBeaconNodeApiClient, + duty: &SyncCommitteeDuty, +) -> Result<Vec<u64>> { + let spec = client.fetch_spec().await.map_err(Error::BeaconNode)?; + + let comm_size = spec_u64(&spec, "SYNC_COMMITTEE_SIZE")?; + let subnet_count = spec_u64(&spec, "SYNC_COMMITTEE_SUBNET_COUNT")?; + + let divisor = comm_size + .checked_div(subnet_count) + .ok_or_else(|| Error::Malformed("zero SYNC_COMMITTEE_SUBNET_COUNT".to_string()))?; + if divisor == 0 { + return Err(Error::Malformed( + "SYNC_COMMITTEE_SIZE / SYNC_COMMITTEE_SUBNET_COUNT is zero".to_string(), + )); + } + + let mut subcommittees = Vec::with_capacity(duty.validator_sync_committee_indices.len()); + for idx in &duty.validator_sync_committee_indices { + let subcomm_idx = idx + .checked_div(divisor) + .ok_or_else(|| Error::Malformed("divide by zero in subcommittee index".to_string()))?; + subcommittees.push(subcomm_idx); + } + + Ok(subcommittees) +} + +fn spec_u64(spec: &serde_json::Value, field: &str) -> Result<u64> { + spec.as_object() + .and_then(|o| o.get(field)) + .and_then(|v| v.as_str()) + .ok_or_else(|| Error::Malformed(format!("missing spec field {field}")))? + .parse::<u64>() + .map_err(|_| Error::Malformed(format!("parse spec field {field}"))) +} + +async fn fetch_head_block_root(client: &EthBeaconNodeApiClient) -> Result<Root> { + let request = GetBlockRootRequest::builder() + .block_id("head".to_string()) + .build() + .map_err(|e| Error::Malformed(format!("build block root request: {e}")))?; + + let response = client + .get_block_root(request) + .await + .map_err(EthBeaconNodeApiClientError::RequestError)?; + + let GetBlockRootResponse::Ok(payload) = response else { + return Err(Error::BeaconNode( + EthBeaconNodeApiClientError::UnexpectedResponse, + )); + }; + + decode_root(&payload.data.root) +} + +async fn submit_sync_messages( + client: &EthBeaconNodeApiClient, + slot: Slot, + block_root: Root, + sign_func: &SignFunc, + duties: &[SyncCommitteeDuty], +) -> Result<()> { + if duties.is_empty() { + return Ok(()); + } + + let epoch = epoch_from_slot(client, slot).await?; + let sig_data = get_data_root(client, DomainName::SyncCommittee, epoch, block_root).await?; + + let mut msgs: Vec<pluto_eth2api::SyncCommitteeRequestBodyItem> = Vec::new(); + for duty in duties { + let sig = sign_func.sign(&duty.pubkey, &sig_data)?; + // Build the altair value for SSZ/hash parity with Go, but the wire + // shape POSTed to the beacon node uses stringified fields. + let altair_msg = SyncCommitteeMessage { + slot, + beacon_block_root: block_root, + validator_index: duty.validator_index, + signature: sig, + }; + msgs.push(pluto_eth2api::SyncCommitteeRequestBodyItem { + slot: altair_msg.slot.to_string(), + beacon_block_root: hex_0x(altair_msg.beacon_block_root), + validator_index: altair_msg.validator_index.to_string(), + signature: hex_0x(altair_msg.signature), + }); + } + + let request = SubmitPoolSyncCommitteeSignaturesRequest::builder() + .body(msgs) + .build() + .map_err(|e| Error::Malformed(format!("build sync committee messages: {e}")))?; + + client + .submit_pool_sync_committee_signatures(request) + .await + .map_err(EthBeaconNodeApiClientError::RequestError)?; + + info!(slot = slot, "Mock sync committee msg submitted"); + + Ok(()) +} + +async fn agg_contributions( + client: &EthBeaconNodeApiClient, + sign_func: &SignFunc, + slot: Slot, + vals: &ActiveValidators, + selections: &[SyncCommitteeSelection], + block_root: Root, +) -> Result<bool> { + if selections.is_empty() { + return Ok(false); + } + + let epoch = epoch_from_slot(client, slot).await?; + + let mut signed: Vec<pluto_eth2api::ContributionAndProofRequestBodyItem> = Vec::new(); + + for selection in selections { + // Query BN to get sync committee contribution. + let request = ProduceSyncCommitteeContributionRequest::builder() + .slot(selection.slot.to_string()) + .subcommittee_index(selection.subcommittee_index.to_string()) + .beacon_block_root(hex_0x(block_root)) + .build() + .map_err(|e| Error::Malformed(format!("build produce contribution: {e}")))?; + + let response = client + .produce_sync_committee_contribution(request) + .await + .map_err(EthBeaconNodeApiClientError::RequestError)?; + + let ProduceSyncCommitteeContributionResponse::Ok(payload) = response else { + return Err(Error::BeaconNode( + EthBeaconNodeApiClientError::UnexpectedResponse, + )); + }; + + let contrib_value = serde_json::to_value(&payload.data) + .map_err(|e| Error::Malformed(format!("serialise contribution: {e}")))?; + let contribution: SyncCommitteeContribution = serde_json::from_value(contrib_value) + .map_err(|e| Error::Malformed(format!("parse contribution: {e}")))?; + + let v_idx = selection.validator_index; + let contrib_and_proof = ContributionAndProof { + aggregator_index: v_idx, + contribution, + selection_proof: selection.selection_proof, + }; + + let pubkey = vals + .get(v_idx) + .copied() + .ok_or(Error::MissingValidatorIndex(v_idx))?; + + let proof_root = contrib_and_proof.tree_hash_root().0; + let sig_data = + get_data_root(client, DomainName::ContributionAndProof, epoch, proof_root).await?; + let sig = sign_func.sign(&pubkey, &sig_data)?; + + let signed_payload = SignedContributionAndProof { + message: contrib_and_proof, + signature: sig, + }; + + signed.push(pluto_eth2api::ContributionAndProofRequestBodyItem { + message: pluto_eth2api::AltairSignedContributionAndProofMessage { + aggregator_index: signed_payload.message.aggregator_index.to_string(), + contribution: pluto_eth2api::Contribution { + aggregation_bits: hex_0x( + &signed_payload.message.contribution.aggregation_bits.bytes, + ), + beacon_block_root: hex_0x( + signed_payload.message.contribution.beacon_block_root, + ), + signature: hex_0x(signed_payload.message.contribution.signature), + slot: signed_payload.message.contribution.slot.to_string(), + subcommittee_index: signed_payload + .message + .contribution + .subcommittee_index + .to_string(), + }, + selection_proof: hex_0x(signed_payload.message.selection_proof), + }, + signature: hex_0x(signed_payload.signature), + }); + } + + let request = PublishContributionAndProofsRequest::builder() + .body(signed) + .build() + .map_err(|e| Error::Malformed(format!("build contribution and proofs request: {e}")))?; + + client + .publish_contribution_and_proofs(request) + .await + .map_err(EthBeaconNodeApiClientError::RequestError)?; + + Ok(true) +} + +async fn wait_ready(cell: &OnceCell<()>) { + let _: &() = cell.get_or_init(noop_pending).await; +} + +async fn noop_pending() -> () { + std::future::pending::<()>().await +} + +fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> { + match mutex.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::beaconmock::BeaconMock; + + fn fake_pubkey() -> BLSPubKey { + let mut k = [0u8; 48]; + for (i, slot) in k.iter_mut().enumerate() { + // Deterministic non-zero pattern; this test does not verify the + // value, only that `get_subcommittees` divides indices correctly. + *slot = u8::try_from(i & 0xff).expect("u8"); + } + k + } + + /// Ports `TestGetSubcommittees` from `synccomm_internal_test.go`: + /// SYNC_COMMITTEE_SIZE=512, SYNC_COMMITTEE_SUBNET_COUNT=4, so each + /// subnet contains 128 indices, and indices [75, 133, 289, 491] map to + /// subcommittees [0, 1, 2, 3]. + #[tokio::test] + #[allow(clippy::redundant_test_prefix)] + async fn test_get_subcommittees() { + let mock = BeaconMock::builder() + .sync_committee_size(512) + .sync_committee_subnet_count(4) + .build() + .await + .expect("build mock"); + + let duty = SyncCommitteeDuty { + pubkey: fake_pubkey(), + validator_index: 0, + validator_sync_committee_indices: vec![75, 133, 289, 491], + }; + + let subcommittees = get_subcommittees(mock.client(), &duty) + .await + .expect("get_subcommittees"); + + assert_eq!(subcommittees, vec![0, 1, 2, 3]); + } +} From fbf4ee9ecf8f80f7cd33cdc562402e793d636a81 Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> Date: Fri, 15 May 2026 12:59:38 +0200 Subject: [PATCH 19/21] feat(testutil/validatormock): port propose.go (block proposal + builder reg) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports `propose_block` and `register` from `charon/testutil/validatormock/propose.go`. Versioned dispatch matches the Go switch on `eth2spec.DataVersion`; blinded path is gated on the proposal's `execution_payload_blinded` flag (the Pluto generated client's analog of Go's `block.Blinded`). Phase0/Altair branches return `Error::UnsupportedVariant` until the Pluto typed surface for them is ready; Bellatrix .. Fulu (full + blinded) are implemented in full. `register` ports the *intended* behaviour of Go's `Register`: it switches on the input registration's `Version` (Go switches on the zero-value-initialized signed registration version, which always falls through to V1 — a Go quirk noted inline). Tests mirror `TestProposeBlock` and `TestProposeBlindedBlock`; variants whose random fixtures don't yet exist in `pluto-testutil::random` are `#[ignore]`d with a TODO pointing at the missing helper. --- crates/testutil/src/validatormock/mod.rs | 2 + crates/testutil/src/validatormock/propose.rs | 920 +++++++++++++++++++ 2 files changed, 922 insertions(+) create mode 100644 crates/testutil/src/validatormock/propose.rs diff --git a/crates/testutil/src/validatormock/mod.rs b/crates/testutil/src/validatormock/mod.rs index 506a54dc..6a6effb3 100644 --- a/crates/testutil/src/validatormock/mod.rs +++ b/crates/testutil/src/validatormock/mod.rs @@ -8,11 +8,13 @@ pub mod capture; pub mod error; pub mod meta; +pub mod propose; pub mod sign; pub mod validators; pub use capture::{EndpointMatch, SubmissionCapture}; pub use error::{Error, Result, SignError}; pub use meta::{MetaEpoch, MetaSlot, SpecMeta}; +pub use propose::{VersionedValidatorRegistration, propose_block, register}; pub use sign::{Sign, SignFunc, Signer}; pub use validators::{ActiveValidators, active_validators}; diff --git a/crates/testutil/src/validatormock/propose.rs b/crates/testutil/src/validatormock/propose.rs new file mode 100644 index 00000000..429db21e --- /dev/null +++ b/crates/testutil/src/validatormock/propose.rs @@ -0,0 +1,920 @@ +//! Block proposal + builder registration drivers. +//! +//! Rust port of `charon/testutil/validatormock/propose.go`. Mirrors the Go +//! [`ProposeBlock`] flow: fetch active validators, locate the slot proposer +//! via the proposer-duties endpoint, build a randao reveal, fetch the block +//! from `produce_block_v3`, sign its tree-hash root with +//! `DomainBeaconProposer`, and POST the signed block (or signed blinded block) +//! back. Also ports [`Register`] for builder validator registrations using +//! `DomainApplicationBuilder` over epoch 0. +//! +//! The Go code carries Phase0/Altair branches that the Pluto Rust client +//! surface barely supports today; those branches return +//! [`Error::UnsupportedVariant`] until typed support lands. The Bellatrix -> +//! Fulu range — and their blinded variants — is implemented in full. + +use pluto_eth2api::{ + BlockRequestBody, BlockRequestBodyObject, BlockRequestBodyObject2, BlockRequestBodyObject3, + BlockRequestBodyObject4, BlockRequestBodyObject5, ConsensusVersion, + DenebSignedBlockContentsSignedBlock, EthBeaconNodeApiClient, + GetBlindedBlockResponseResponseData, GetBlindedBlockResponseResponseDataObject, + GetBlindedBlockResponseResponseDataObject2, GetBlindedBlockResponseResponseDataObject3, + GetBlindedBlockResponseResponseDataObject4, GetProposerDutiesRequest, + GetProposerDutiesResponse, ProduceBlockV3Request, ProduceBlockV3Response, + ProduceBlockV3ResponseResponse, PublishBlindedBlockV2Request, PublishBlockV2Request, + PublishBlockV2Response, RegisterValidatorRequest, RegisterValidatorRequestBodyItem, + RegisterValidatorResponse, SignedBlockContentsSignedBlock, SignedValidatorRegistrationMessage, + spec::{ + BuilderVersion, bellatrix, capella, deneb, electra, + phase0::{BLSPubKey, BLSSignature, Root, Slot}, + }, + versioned::VersionedSignedValidatorRegistration, +}; +use pluto_eth2util::{ + helpers::epoch_from_slot, + signing::{DomainName, get_data_root}, + types::SignedEpoch, +}; +use serde_json::Value; +use tree_hash::TreeHash; + +use super::{ + active_validators, + error::{Error, Result}, +}; + +/// Builder registration variant the Go code calls `BuilderVersionV1`. Pluto's +/// versioned enum spells the same value `BuilderVersion::V1`. +const BUILDER_VERSION_V1: BuilderVersion = BuilderVersion::V1; + +/// Convenience alias matching Go's `*eth2api.VersionedValidatorRegistration` +/// parameter type. Pluto's versioned enum is named for *signed* payloads, so we +/// reuse it and ignore the `signature` field on its inner `v1` payload. +pub type VersionedValidatorRegistration = VersionedSignedValidatorRegistration; + +/// Drives a single-slot block proposal end-to-end. +/// +/// Mirrors `ProposeBlock` from `charon/testutil/validatormock/propose.go`. The +/// `signer` parameter is the type-erased `SignFunc` from +/// [`super::sign`]; in production it wraps real BLS secrets, in tests a stub +/// that copies the pubkey bytes into the signature suffices. +pub async fn propose_block( + client: &EthBeaconNodeApiClient, + signer: &super::SignFunc, + slot: Slot, +) -> Result<()> { + // Ensure active validators are queryable. Mirrors Go's + // `eth2Cl.ActiveValidators` call: surfaces beacon-node errors before duty + // lookups proceed. + let _ = active_validators(client).await?; + + let epoch = epoch_from_slot(client, slot).await?; + + let request = GetProposerDutiesRequest::builder() + .epoch(epoch.to_string()) + .build() + .map_err(|err| Error::Malformed(format!("build proposer duties request: {err}")))?; + + let duties = match client.get_proposer_duties(request).await { + Ok(GetProposerDutiesResponse::Ok(resp)) => resp.data, + Ok(_) => return Err(Error::Malformed("proposer duties response".to_string())), + Err(err) => return Err(Error::Malformed(format!("proposer duties: {err}"))), + }; + + let Some(duty) = duties.iter().find(|d| d.slot == slot.to_string()) else { + // Go returns nil when this validator is not the slot proposer. + return Ok(()); + }; + let pubkey = parse_pubkey(&duty.pubkey)?; + + // RANDAO reveal: tree-hash the eth2util `SignedEpoch{epoch, zero-sig}` and + // sign it under `DomainRandao` at the slot's epoch. + let randao_message_root = SignedEpoch { + epoch, + signature: [0u8; 96], + } + .tree_hash_root() + .0; + let randao_sig_data = get_data_root(client, DomainName::Randao, epoch, randao_message_root) + .await + .map_err(Error::from)?; + let randao = signer.sign(&pubkey, &randao_sig_data)?; + + // Fetch the unsigned proposal from /eth/v3/validator/blocks/{slot}. + let proposal_request = ProduceBlockV3Request::builder() + .slot(slot.to_string()) + .randao_reveal(format_signature(randao)) + .build() + .map_err(|err| Error::Malformed(format!("build produce-block request: {err}")))?; + + let proposal_resp = match client.produce_block_v3(proposal_request).await { + Ok(ProduceBlockV3Response::Ok(resp)) => resp, + Ok(_) => { + return Err(Error::Malformed( + "produce-block-v3 non-success response".to_string(), + )); + } + Err(err) => { + return Err(Error::Malformed(format!( + "vmock beacon block proposal: {err}" + ))); + } + }; + + let version = proposal_resp.version.clone(); + let blinded = proposal_resp.execution_payload_blinded; + + if blinded { + let body = build_blinded_body(&proposal_resp, &pubkey, signer, client, epoch).await?; + let request = PublishBlindedBlockV2Request::builder() + .eth_consensus_version(version) + .body(body) + .build() + .map_err(|err| Error::Malformed(format!("build blinded-publish request: {err}")))?; + + match client.publish_blinded_block_v2(request).await { + Ok(PublishBlockV2Response::Ok | PublishBlockV2Response::Accepted) => Ok(()), + Ok(_) => Err(Error::Malformed( + "publish-blinded-block-v2 unexpected response".to_string(), + )), + Err(err) => Err(Error::Malformed(format!("publish-blinded-block-v2: {err}"))), + } + } else { + let body = build_block_body(&proposal_resp, &pubkey, signer, client, epoch).await?; + let request = PublishBlockV2Request::builder() + .eth_consensus_version(version) + .body(body) + .build() + .map_err(|err| Error::Malformed(format!("build publish-block request: {err}")))?; + + match client.publish_block_v2(request).await { + Ok(PublishBlockV2Response::Ok | PublishBlockV2Response::Accepted) => Ok(()), + Ok(_) => Err(Error::Malformed( + "publish-block-v2 unexpected response".to_string(), + )), + Err(err) => Err(Error::Malformed(format!("publish-block-v2: {err}"))), + } + } +} + +/// Signs and submits a builder validator registration. +/// +/// Mirrors `Register` from `charon/testutil/validatormock/propose.go`. The Go +/// implementation switches on `signedRegistration.Version` before populating +/// it, which always reads the zero value `BuilderVersionV1` and therefore +/// silently behaves as if the input were V1. The Rust port switches on the +/// *input* registration's version (the obviously intended behaviour); when +/// any non-V1 variant lands here we surface [`Error::UnsupportedVariant`] +/// instead of mis-tagging the signed payload. +pub async fn register( + client: &EthBeaconNodeApiClient, + signer: &super::SignFunc, + registration: &VersionedValidatorRegistration, + pubshare: BLSPubKey, +) -> Result<()> { + let message_root = registration + .message_root() + .ok_or(Error::UnsupportedVariant("registration version"))?; + + // Always use epoch 0 for DomainApplicationBuilder. + let sig_data = get_data_root(client, DomainName::ApplicationBuilder, 0, message_root).await?; + let sig = signer.sign(&pubshare, &sig_data)?; + + match registration.version { + BUILDER_VERSION_V1 => { + let inner = registration + .v1 + .as_ref() + .ok_or(Error::UnsupportedVariant("missing v1 payload"))?; + let body_item = RegisterValidatorRequestBodyItem { + message: SignedValidatorRegistrationMessage { + fee_recipient: format!("0x{}", hex::encode(inner.message.fee_recipient)), + gas_limit: inner.message.gas_limit.to_string(), + pubkey: format!("0x{}", hex::encode(inner.message.pubkey)), + timestamp: inner.message.timestamp.to_string(), + }, + signature: format_signature(sig), + }; + let request = RegisterValidatorRequest::builder() + .body(vec![body_item]) + .build() + .map_err(|err| Error::Malformed(format!("build register request: {err}")))?; + + match client.register_validator(request).await { + Ok(RegisterValidatorResponse::Ok) => Ok(()), + Ok(_) => Err(Error::Malformed( + "register-validator unexpected response".to_string(), + )), + Err(err) => Err(Error::Malformed(format!("register-validator: {err}"))), + } + } + BuilderVersion::Unknown => Err(Error::UnsupportedVariant("registration version")), + } +} + +async fn build_block_body( + resp: &ProduceBlockV3ResponseResponse, + pubkey: &BLSPubKey, + signer: &super::SignFunc, + client: &EthBeaconNodeApiClient, + epoch: u64, +) -> Result<BlockRequestBody> { + let block_value = serde_json::to_value(&resp.data) + .map_err(|err| Error::Malformed(format!("serialise produce-block data: {err}")))?; + + match resp.version { + ConsensusVersion::Capella => { + let block: capella::BeaconBlock = json_from_value(&block_value)?; + let root = block.tree_hash_root().0; + let signature = sign_with_proposer(signer, pubkey, client, epoch, root).await?; + Ok(BlockRequestBody::Object4(BlockRequestBodyObject4 { + message: json_to_value(&block)?, + signature: format_signature(signature), + })) + } + ConsensusVersion::Deneb => { + let inner = block_field(&block_value)?; + let block: deneb::BeaconBlock = json_from_value(inner)?; + let root = block.tree_hash_root().0; + let signature = sign_with_proposer(signer, pubkey, client, epoch, root).await?; + Ok(BlockRequestBody::Object3(BlockRequestBodyObject3 { + blobs: json_array_strings(&block_value, "blobs"), + kzg_proofs: json_array_strings(&block_value, "kzg_proofs"), + signed_block: DenebSignedBlockContentsSignedBlock { + message: json_to_value(&block)?, + signature: format_signature(signature), + }, + })) + } + ConsensusVersion::Electra => { + let inner = block_field(&block_value)?; + let block: electra::BeaconBlock = json_from_value(inner)?; + let root = block.tree_hash_root().0; + let signature = sign_with_proposer(signer, pubkey, client, epoch, root).await?; + Ok(BlockRequestBody::Object2(BlockRequestBodyObject2 { + blobs: json_array_strings(&block_value, "blobs"), + kzg_proofs: json_array_strings(&block_value, "kzg_proofs"), + signed_block: SignedBlockContentsSignedBlock { + message: json_to_value(&block)?, + signature: format_signature(signature), + }, + })) + } + ConsensusVersion::Fulu => { + // Fulu reuses the Electra BeaconBlock layout. + let inner = block_field(&block_value)?; + let block: electra::BeaconBlock = json_from_value(inner)?; + let root = block.tree_hash_root().0; + let signature = sign_with_proposer(signer, pubkey, client, epoch, root).await?; + Ok(BlockRequestBody::Object(BlockRequestBodyObject { + blobs: json_array_strings(&block_value, "blobs"), + kzg_proofs: json_array_strings(&block_value, "kzg_proofs"), + signed_block: SignedBlockContentsSignedBlock { + message: json_to_value(&block)?, + signature: format_signature(signature), + }, + })) + } + ConsensusVersion::Bellatrix => { + let block: bellatrix::BeaconBlock = json_from_value(&block_value)?; + let root = block.tree_hash_root().0; + let signature = sign_with_proposer(signer, pubkey, client, epoch, root).await?; + Ok(BlockRequestBody::Object5(BlockRequestBodyObject5 { + message: json_to_value(&block)?, + signature: format_signature(signature), + })) + } + ConsensusVersion::Phase0 | ConsensusVersion::Altair => { + Err(Error::UnsupportedVariant("phase0/altair block")) + } + } +} + +async fn build_blinded_body( + resp: &ProduceBlockV3ResponseResponse, + pubkey: &BLSPubKey, + signer: &super::SignFunc, + client: &EthBeaconNodeApiClient, + epoch: u64, +) -> Result<GetBlindedBlockResponseResponseData> { + let block_value = serde_json::to_value(&resp.data) + .map_err(|err| Error::Malformed(format!("serialise produce-block data: {err}")))?; + + match resp.version { + ConsensusVersion::Bellatrix => { + let block: bellatrix::BlindedBeaconBlock = json_from_value(&block_value)?; + let root = block.tree_hash_root().0; + let signature = sign_with_proposer(signer, pubkey, client, epoch, root).await?; + Ok(GetBlindedBlockResponseResponseData::Object4( + GetBlindedBlockResponseResponseDataObject4 { + message: json_to_value(&block)?, + signature: format_signature(signature), + }, + )) + } + ConsensusVersion::Capella => { + let block: capella::BlindedBeaconBlock = json_from_value(&block_value)?; + let root = block.tree_hash_root().0; + let signature = sign_with_proposer(signer, pubkey, client, epoch, root).await?; + Ok(GetBlindedBlockResponseResponseData::Object3( + GetBlindedBlockResponseResponseDataObject3 { + message: json_to_value(&block)?, + signature: format_signature(signature), + }, + )) + } + ConsensusVersion::Deneb => { + let block: deneb::BlindedBeaconBlock = json_from_value(&block_value)?; + let root = block.tree_hash_root().0; + let signature = sign_with_proposer(signer, pubkey, client, epoch, root).await?; + Ok(GetBlindedBlockResponseResponseData::Object2( + GetBlindedBlockResponseResponseDataObject2 { + message: json_to_value(&block)?, + signature: format_signature(signature), + }, + )) + } + ConsensusVersion::Electra | ConsensusVersion::Fulu => { + // Go aliases Fulu blinded to Electra's blinded block type, so both + // map onto Pluto's Electra blinded variant. + let block: electra::BlindedBeaconBlock = json_from_value(&block_value)?; + let root = block.tree_hash_root().0; + let signature = sign_with_proposer(signer, pubkey, client, epoch, root).await?; + Ok(GetBlindedBlockResponseResponseData::Object( + GetBlindedBlockResponseResponseDataObject { + message: json_to_value(&block)?, + signature: format_signature(signature), + }, + )) + } + ConsensusVersion::Phase0 | ConsensusVersion::Altair => { + Err(Error::UnsupportedVariant("phase0/altair blinded block")) + } + } +} + +async fn sign_with_proposer( + signer: &super::SignFunc, + pubkey: &BLSPubKey, + client: &EthBeaconNodeApiClient, + epoch: u64, + message_root: Root, +) -> Result<BLSSignature> { + let sig_data = get_data_root(client, DomainName::BeaconProposer, epoch, message_root).await?; + Ok(signer.sign(pubkey, &sig_data)?) +} + +fn parse_pubkey(s: &str) -> Result<BLSPubKey> { + let trimmed = s.strip_prefix("0x").unwrap_or(s); + let bytes = hex::decode(trimmed).map_err(|err| Error::Malformed(err.to_string()))?; + bytes + .as_slice() + .try_into() + .map_err(|_| Error::Malformed(format!("pubkey length {} != 48", bytes.len()))) +} + +fn format_signature(sig: BLSSignature) -> String { + format!("0x{}", hex::encode(sig)) +} + +fn json_from_value<T: serde::de::DeserializeOwned>(value: &Value) -> Result<T> { + serde_json::from_value(value.clone()) + .map_err(|err| Error::Malformed(format!("decode block message: {err}"))) +} + +fn json_to_value<T: serde::Serialize>(value: &T) -> Result<Value> { + serde_json::to_value(value) + .map_err(|err| Error::Malformed(format!("encode signed block: {err}"))) +} + +fn block_field(value: &Value) -> Result<&Value> { + value.get("block").ok_or_else(|| { + Error::Malformed("missing `block` field in produce-block response".to_string()) + }) +} + +fn json_array_strings(value: &Value, field: &str) -> Vec<String> { + value + .get(field) + .and_then(Value::as_array) + .map(|arr| { + arr.iter() + .filter_map(|item| item.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + BeaconMock, ValidatorSet, + validatormock::{EndpointMatch, SubmissionCapture}, + }; + use pluto_eth2api::spec::phase0::BLSPubKey; + use serde_json::{Value, json}; + use std::sync::Arc; + use wiremock::{ + Mock, ResponseTemplate, + matchers::{method, path_regex}, + }; + + /// Stub signer that copies the pubkey suffix into the signature so tests + /// can assert the signed payload is non-zero. Mirrors the Go test helper + /// (`copy(sig[:], key[:])`). + #[derive(Debug)] + struct StubSigner; + impl super::super::Sign for StubSigner { + fn sign( + &self, + pubkey: &BLSPubKey, + _data: &[u8], + ) -> std::result::Result<BLSSignature, super::super::SignError> { + let mut sig = [0u8; 96]; + sig[..48].copy_from_slice(pubkey); + Ok(sig) + } + } + + fn stub_signer() -> super::super::SignFunc { + Arc::new(StubSigner) + } + + fn padded_pubkey(seed: u8) -> BLSPubKey { + [seed; 48] + } + + fn padded_root(seed: u8) -> Root { + [seed; 32] + } + + fn sig_hex(seed: u8) -> String { + format!("0x{}", hex::encode([seed; 96])) + } + + /// Mounts a high-priority handler on `/eth/v3/validator/blocks/{slot}` that + /// responds with `body`. Priority `1` mirrors `SubmissionCapture`. + async fn mount_produce_block(server: &wiremock::MockServer, body: Value) { + Mock::given(method("GET")) + .and(path_regex(r"^/eth/v3/validator/blocks/[0-9]+$")) + .respond_with(ResponseTemplate::new(200).set_body_json(body)) + .with_priority(1) + .mount(server) + .await; + } + + /// Mounts a POST handler for the validators endpoint mirroring the GET + /// default served by [`BeaconMock`]. The generated client uses POST for + /// filtered validator queries; [`super::super::active_validators`] dials + /// that route. Priority `1` wins over any default. + async fn mount_post_validators(server: &wiremock::MockServer, set: &ValidatorSet) { + let data: Vec<Value> = set + .validators() + .into_iter() + .map(|validator| { + json!({ + "index": validator.index.to_string(), + "balance": validator.balance.to_string(), + "status": validator.status, + "validator": validator.validator, + }) + }) + .collect(); + let body = json!({ + "data": data, + "execution_optimistic": false, + "finalized": false, + }); + Mock::given(method("POST")) + .and(path_regex(r"^/eth/v1/beacon/states/[^/]+/validators$")) + .respond_with(ResponseTemplate::new(200).set_body_json(body)) + .with_priority(1) + .mount(server) + .await; + } + + /// Constructs an Electra `BeaconBlock` JSON skeleton that round-trips + /// through `electra::BeaconBlock`'s `Deserialize`. + fn electra_block_value(slot: Slot, randao_seed: u8) -> Value { + let empty: Vec<Value> = Vec::new(); + json!({ + "slot": slot.to_string(), + "proposer_index": "1", + "parent_root": format!("0x{}", hex::encode(padded_root(0x11))), + "state_root": format!("0x{}", hex::encode(padded_root(0x22))), + "body": { + "randao_reveal": sig_hex(randao_seed), + "eth1_data": { + "deposit_root": format!("0x{}", hex::encode(padded_root(0x33))), + "deposit_count": "0", + "block_hash": format!("0x{}", hex::encode(padded_root(0x44))), + }, + "graffiti": format!("0x{}", hex::encode(padded_root(0x00))), + "proposer_slashings": empty.clone(), + "attester_slashings": empty.clone(), + "attestations": empty.clone(), + "deposits": empty.clone(), + "voluntary_exits": empty.clone(), + "sync_aggregate": { + "sync_committee_bits": format!("0x{}", "00".repeat(64)), + "sync_committee_signature": sig_hex(0x00), + }, + "execution_payload": electra_execution_payload(), + "bls_to_execution_changes": empty.clone(), + "blob_kzg_commitments": empty, + "execution_requests": { + "deposits": [], + "withdrawals": [], + "consolidations": [], + }, + }, + }) + } + + fn electra_execution_payload() -> Value { + json!({ + "parent_hash": format!("0x{}", hex::encode(padded_root(0x55))), + "fee_recipient": format!("0x{}", "00".repeat(20)), + "state_root": format!("0x{}", hex::encode(padded_root(0x66))), + "receipts_root": format!("0x{}", hex::encode(padded_root(0x77))), + "logs_bloom": format!("0x{}", "00".repeat(256)), + "prev_randao": format!("0x{}", hex::encode(padded_root(0x88))), + "block_number": "0", + "gas_limit": "30000000", + "gas_used": "0", + "timestamp": "0", + "extra_data": "0x", + "base_fee_per_gas": "0", + "block_hash": format!("0x{}", hex::encode(padded_root(0x99))), + "transactions": [], + "withdrawals": [], + "blob_gas_used": "0", + "excess_blob_gas": "0", + }) + } + + fn electra_blinded_execution_payload_header() -> Value { + json!({ + "parent_hash": format!("0x{}", hex::encode(padded_root(0x55))), + "fee_recipient": format!("0x{}", "00".repeat(20)), + "state_root": format!("0x{}", hex::encode(padded_root(0x66))), + "receipts_root": format!("0x{}", hex::encode(padded_root(0x77))), + "logs_bloom": format!("0x{}", "00".repeat(256)), + "prev_randao": format!("0x{}", hex::encode(padded_root(0x88))), + "block_number": "0", + "gas_limit": "30000000", + "gas_used": "0", + "timestamp": "0", + "extra_data": "0x", + "base_fee_per_gas": "0", + "block_hash": format!("0x{}", hex::encode(padded_root(0x99))), + "transactions_root": format!("0x{}", hex::encode(padded_root(0xaa))), + "withdrawals_root": format!("0x{}", hex::encode(padded_root(0xbb))), + "blob_gas_used": "0", + "excess_blob_gas": "0", + }) + } + + fn electra_blinded_block_value(slot: Slot, randao_seed: u8) -> Value { + let empty: Vec<Value> = Vec::new(); + json!({ + "slot": slot.to_string(), + "proposer_index": "1", + "parent_root": format!("0x{}", hex::encode(padded_root(0x11))), + "state_root": format!("0x{}", hex::encode(padded_root(0x22))), + "body": { + "randao_reveal": sig_hex(randao_seed), + "eth1_data": { + "deposit_root": format!("0x{}", hex::encode(padded_root(0x33))), + "deposit_count": "0", + "block_hash": format!("0x{}", hex::encode(padded_root(0x44))), + }, + "graffiti": format!("0x{}", hex::encode(padded_root(0x00))), + "proposer_slashings": empty.clone(), + "attester_slashings": empty.clone(), + "attestations": empty.clone(), + "deposits": empty.clone(), + "voluntary_exits": empty.clone(), + "sync_aggregate": { + "sync_committee_bits": format!("0x{}", "00".repeat(64)), + "sync_committee_signature": sig_hex(0x00), + }, + "execution_payload_header": electra_blinded_execution_payload_header(), + "bls_to_execution_changes": empty.clone(), + "blob_kzg_commitments": empty, + "execution_requests": { + "deposits": [], + "withdrawals": [], + "consolidations": [], + }, + }, + }) + } + + fn fork_epochs_at_zero_spec() -> Value { + json!({ + "CONFIG_NAME": "charon-simnet", + "SLOTS_PER_EPOCH": "16", + "SECONDS_PER_SLOT": "12", + "GENESIS_FORK_VERSION": "0x01017000", + "ALTAIR_FORK_VERSION": "0x20000910", + "ALTAIR_FORK_EPOCH": "0", + "BELLATRIX_FORK_VERSION": "0x30000910", + "BELLATRIX_FORK_EPOCH": "0", + "CAPELLA_FORK_VERSION": "0x40000910", + "CAPELLA_FORK_EPOCH": "0", + "DENEB_FORK_VERSION": "0x50000910", + "DENEB_FORK_EPOCH": "0", + "ELECTRA_FORK_VERSION": "0x60000910", + "ELECTRA_FORK_EPOCH": "0", + "FULU_FORK_VERSION": "0x70000910", + "FULU_FORK_EPOCH": "18446744073709551615", + "DOMAIN_BEACON_PROPOSER": "0x00000000", + "DOMAIN_BEACON_ATTESTER": "0x01000000", + "DOMAIN_RANDAO": "0x02000000", + "DOMAIN_DEPOSIT": "0x03000000", + "DOMAIN_VOLUNTARY_EXIT": "0x04000000", + "DOMAIN_SELECTION_PROOF": "0x05000000", + "DOMAIN_AGGREGATE_AND_PROOF": "0x06000000", + "DOMAIN_SYNC_COMMITTEE": "0x07000000", + "DOMAIN_SYNC_COMMITTEE_SELECTION_PROOF": "0x08000000", + "DOMAIN_CONTRIBUTION_AND_PROOF": "0x09000000", + "DOMAIN_APPLICATION_BUILDER": "0x00000001", + "EPOCHS_PER_SYNC_COMMITTEE_PERIOD": "256", + }) + } + + async fn electra_beacon_mock() -> BeaconMock { + BeaconMock::builder() + .validator_set(ValidatorSet::validator_set_a()) + .deterministic_proposer_duties(0) + .spec(fork_epochs_at_zero_spec()) + .build() + .await + .expect("build mock") + } + + #[tokio::test] + async fn propose_block_electra_full() { + let mock = electra_beacon_mock().await; + let slot: Slot = 0; // first slot in epoch 0, proposer = validator index 1 + + let block = electra_block_value(slot, 0x42); + let response_body = json!({ + "version": "electra", + "execution_payload_blinded": false, + "consensus_block_value": "1", + "execution_payload_value": "1", + "data": { + "block": block, + "kzg_proofs": [], + "blobs": [], + }, + }); + + mount_post_validators(mock.server(), &ValidatorSet::validator_set_a()).await; + mount_produce_block(mock.server(), response_body).await; + + let capture = SubmissionCapture::mount( + mock.server(), + "POST", + EndpointMatch::path("/eth/v2/beacon/blocks"), + json!({}), + ) + .await; + + propose_block(mock.client(), &stub_signer(), slot) + .await + .expect("propose_block"); + + let captured = capture.take(); + assert_eq!( + captured.len(), + 1, + "expected one POST to /eth/v2/beacon/blocks" + ); + let signed_block = captured[0] + .get("signed_block") + .expect("signed_block in body"); + let signature = signed_block + .get("signature") + .and_then(Value::as_str) + .expect("signature"); + assert_ne!( + signature, + format!("0x{}", "00".repeat(96)).as_str(), + "signature must be non-zero", + ); + let submitted_slot = signed_block + .get("message") + .and_then(|m| m.get("slot")) + .and_then(Value::as_str); + assert_eq!(submitted_slot, Some(slot.to_string().as_str())); + } + + #[tokio::test] + async fn propose_block_electra_blinded() { + let mock = electra_beacon_mock().await; + let slot: Slot = 0; + + let block = electra_blinded_block_value(slot, 0x42); + let response_body = json!({ + "version": "electra", + "execution_payload_blinded": true, + "consensus_block_value": "1", + "execution_payload_value": "1", + "data": block, + }); + + mount_post_validators(mock.server(), &ValidatorSet::validator_set_a()).await; + mount_produce_block(mock.server(), response_body).await; + + let capture = SubmissionCapture::mount( + mock.server(), + "POST", + EndpointMatch::path("/eth/v2/beacon/blinded_blocks"), + json!({}), + ) + .await; + + propose_block(mock.client(), &stub_signer(), slot) + .await + .expect("propose_block blinded"); + + let captured = capture.take(); + assert_eq!( + captured.len(), + 1, + "expected one POST to /eth/v2/beacon/blinded_blocks", + ); + let signature = captured[0] + .get("signature") + .and_then(Value::as_str) + .expect("signature"); + assert_ne!( + signature, + format!("0x{}", "00".repeat(96)).as_str(), + "signature must be non-zero", + ); + } + + #[tokio::test] + async fn propose_block_fulu_full() { + let mut spec = fork_epochs_at_zero_spec(); + if let Some(obj) = spec.as_object_mut() { + obj.insert( + "FULU_FORK_EPOCH".to_string(), + Value::String("0".to_string()), + ); + } + let mock = BeaconMock::builder() + .validator_set(ValidatorSet::validator_set_a()) + .deterministic_proposer_duties(0) + .spec(spec) + .build() + .await + .expect("build mock"); + let slot: Slot = 0; + + // Fulu reuses Electra's BeaconBlock layout. + let block = electra_block_value(slot, 0x84); + let response_body = json!({ + "version": "fulu", + "execution_payload_blinded": false, + "consensus_block_value": "1", + "execution_payload_value": "1", + "data": { + "block": block, + "kzg_proofs": [], + "blobs": [], + }, + }); + + mount_post_validators(mock.server(), &ValidatorSet::validator_set_a()).await; + mount_produce_block(mock.server(), response_body).await; + let capture = SubmissionCapture::mount( + mock.server(), + "POST", + EndpointMatch::path("/eth/v2/beacon/blocks"), + json!({}), + ) + .await; + + propose_block(mock.client(), &stub_signer(), slot) + .await + .expect("propose_block fulu"); + + assert_eq!(capture.len(), 1); + } + + #[tokio::test] + async fn propose_block_returns_when_not_proposer() { + // Use slot that no active validator is responsible for; with + // `deterministic_proposer_duties(0)` only the first slot of each epoch + // is assigned, so slot 1 has no duty. + let mock = electra_beacon_mock().await; + let slot: Slot = 1; + mount_post_validators(mock.server(), &ValidatorSet::validator_set_a()).await; + + // Should NOT hit /eth/v3/validator/blocks/{slot}. We mount a 500 to + // verify; if propose_block proceeded, the call would fail. + Mock::given(method("GET")) + .and(path_regex(r"^/eth/v3/validator/blocks/[0-9]+$")) + .respond_with(ResponseTemplate::new(500)) + .with_priority(1) + .mount(mock.server()) + .await; + + propose_block(mock.client(), &stub_signer(), slot) + .await + .expect("propose_block must be a no-op when not the slot proposer"); + } + + #[tokio::test] + async fn register_validator_v1_submits_signed_registration() { + let mock = electra_beacon_mock().await; + + let pubkey = padded_pubkey(0xAB); + let registration = VersionedSignedValidatorRegistration { + version: BuilderVersion::V1, + v1: Some(pluto_eth2api::v1::SignedValidatorRegistration { + message: pluto_eth2api::v1::ValidatorRegistration { + fee_recipient: [0xCD; 20], + gas_limit: 30_000_000, + timestamp: 1_700_000_000, + pubkey, + }, + signature: [0u8; 96], + }), + }; + + let capture = SubmissionCapture::mount( + mock.server(), + "POST", + EndpointMatch::path("/eth/v1/validator/register_validator"), + json!({}), + ) + .await; + + register(mock.client(), &stub_signer(), ®istration, pubkey) + .await + .expect("register"); + + let captured = capture.take(); + assert_eq!( + captured.len(), + 1, + "expected one POST to /eth/v1/validator/register_validator", + ); + let registrations = captured[0].as_array().expect("array body"); + assert_eq!(registrations.len(), 1); + let signature = registrations[0] + .get("signature") + .and_then(Value::as_str) + .expect("signature"); + assert_ne!( + signature, + format!("0x{}", "00".repeat(96)).as_str(), + "registration signature must be non-zero", + ); + let message_pubkey = registrations[0] + .get("message") + .and_then(|m| m.get("pubkey")) + .and_then(Value::as_str) + .expect("pubkey"); + assert_eq!( + message_pubkey, + format!("0x{}", hex::encode(pubkey)).as_str(), + ); + } + + // --------------------------------------------------------------------- + // Variants whose `random*Proposal` fixtures don't exist in Pluto's + // `testutil::random` module yet. Re-enable once those helpers land. + // --------------------------------------------------------------------- + + #[tokio::test] + #[ignore = "TODO: no RandomCapellaVersionedProposal equivalent in pluto-testutil::random yet"] + async fn propose_block_capella_full() {} + + #[tokio::test] + #[ignore = "TODO: no RandomDenebVersionedProposal equivalent in pluto-testutil::random yet"] + async fn propose_block_deneb_full() {} + + #[tokio::test] + #[ignore = "TODO: no RandomCapellaBlindedBeaconBlock equivalent in pluto-testutil::random yet"] + async fn propose_block_capella_blinded() {} + + #[tokio::test] + #[ignore = "TODO: no RandomDenebBlindedBeaconBlock equivalent in pluto-testutil::random yet"] + async fn propose_block_deneb_blinded() {} + + #[tokio::test] + #[ignore = "TODO: no RandomBellatrixBlindedBeaconBlock equivalent in pluto-testutil::random yet"] + async fn propose_blinded_block_bellatrix() {} + + #[tokio::test] + #[ignore = "TODO: no RandomFuluBlindedBeaconBlock equivalent in pluto-testutil::random yet"] + async fn propose_block_fulu_blinded() {} +} From 691e2c696885e295474b6dfc6714b4d143415656 Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> Date: Fri, 15 May 2026 13:00:54 +0200 Subject: [PATCH 20/21] feat(testutil/validatormock): port attest.go (SlotAttester) Ports `charon/testutil/validatormock/attest.go` to Rust. SlotAttester drives Prepare -> Attest -> Aggregate for a single slot, gated by Arc<CloseOnce> close-once flags (AtomicBool + Notify) that mirror Go's `chan struct{}` ready signals; mutable state lives behind Arc<Mutex<_>> so all entry points stay `&self`. Because the Rust eth2api client encodes attestations using the Electra SingleAttestation shape (incompatible with Go's VersionedAttestation JSON captured in the goldens), the two submit endpoints (`POST /eth/v2/beacon/pool/attestations` and `POST /eth/v2/validator/aggregate_and_proofs`) are POSTed as raw JSON whose structure matches `eth2spec.VersionedAttestation` / `*SubmitAggregateAttestationsOpts`. All other beacon-node interactions use the generated client. TestAttest matches Go's golden output for DutyFactor {0, 1} on validator set A via assert_json_eq, with submissions sorted by data.index to match the Go test's deterministic ordering. Also adds Eth2Exp and Submit variants to validatormock::Error, and a ValidatorSet-driven POST `/eth/v1/beacon/states/head/validators` plus a BeaconCommitteeSelections echo route in the tests because the beaconmock defaults don't cover those DV-only paths. --- Cargo.lock | 2 + crates/testutil/Cargo.toml | 4 +- crates/testutil/src/validatormock/attest.rs | 1028 +++++++++++++++++++ crates/testutil/src/validatormock/error.rs | 17 +- crates/testutil/src/validatormock/mod.rs | 2 + 5 files changed, 1051 insertions(+), 2 deletions(-) create mode 100644 crates/testutil/src/validatormock/attest.rs diff --git a/Cargo.lock b/Cargo.lock index 05d28639..d16d2aa9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5911,10 +5911,12 @@ dependencies = [ "pluto-crypto", "pluto-eth2api", "pluto-eth2util", + "pluto-ssz", "rand 0.8.6", "reqwest 0.13.3", "serde", "serde_json", + "serde_with", "thiserror 2.0.18", "tokio", "tokio-util", diff --git a/crates/testutil/Cargo.toml b/crates/testutil/Cargo.toml index 4f444a5a..98672e89 100644 --- a/crates/testutil/Cargo.toml +++ b/crates/testutil/Cargo.toml @@ -22,9 +22,12 @@ pluto-core.workspace = true pluto-crypto.workspace = true pluto-eth2api.workspace = true pluto-eth2util.workspace = true +pluto-ssz.workspace = true rand.workspace = true +reqwest.workspace = true serde.workspace = true serde_json.workspace = true +serde_with.workspace = true thiserror.workspace = true tokio.workspace = true tokio-util.workspace = true @@ -34,7 +37,6 @@ wiremock.workspace = true [dev-dependencies] assert-json-diff.workspace = true -reqwest.workspace = true [lints] workspace = true diff --git a/crates/testutil/src/validatormock/attest.rs b/crates/testutil/src/validatormock/attest.rs new file mode 100644 index 00000000..673d1831 --- /dev/null +++ b/crates/testutil/src/validatormock/attest.rs @@ -0,0 +1,1028 @@ +//! Slot-level attestation and aggregation driver. +//! +//! Rust port of `charon/testutil/validatormock/attest.go`. [`SlotAttester`] +//! advances a single slot through `Prepare → Attest → Aggregate`, mirroring the +//! three-stage state machine from Go. +//! +//! Go uses `chan struct{}` channels closed once for each stage; Rust mirrors +//! that with `Arc<tokio::sync::OnceCell<()>>` — `OnceCell::set(())` closes the +//! channel, and `.wait().await` is the channel receive. Mutable state lives +//! behind `Arc<tokio::sync::Mutex<_>>` so the scheduler can hold a `&self` +//! handle. +//! +//! ## Wire-format note +//! +//! Charon's Go validator mock sends `*eth2spec.VersionedAttestation` and +//! `*eth2spec.VersionedSignedAggregateAndProof` JSON to the beacon node. +//! The Rust [`pluto_eth2api`] generated client encodes attestations using the +//! `SingleAttestation` shape (Electra+) which is incompatible with the Go +//! payload shape captured in the goldens. We therefore bypass the typed client +//! for the two submit endpoints (`POST /eth/v2/beacon/pool/attestations` and +//! `POST /eth/v2/validator/aggregate_and_proofs`) and submit raw JSON whose +//! structure matches the Go `eth2spec.VersionedAttestation` / +//! `*SubmitAggregateAttestationsOpts` serializations. All other beacon-node +//! interactions use the generated client. + +use std::{ + collections::HashMap, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, +}; + +use pluto_eth2api::{ + EthBeaconNodeApiClient, EthBeaconNodeApiClientError, GetAggregatedAttestationV2Request, + GetAggregatedAttestationV2Response, GetAttesterDutiesRequest, GetAttesterDutiesResponse, + ProduceAttestationDataRequest, ProduceAttestationDataResponse, + SubmitBeaconCommitteeSelectionsRequest, SubmitBeaconCommitteeSelectionsResponse, + spec::{ + electra, + phase0::{AttestationData, BLSPubKey, BLSSignature, Root, Slot, ValidatorIndex}, + }, +}; +use pluto_eth2util::{ + eth2exp::is_att_aggregator, + helpers::epoch_from_slot, + signing::{DomainName, get_data_root}, +}; +use pluto_ssz::{BitList, BitVector}; +use serde::Serialize; +use serde_with::serde_as; +use tokio::sync::{Mutex, Notify}; +use tree_hash::TreeHash; + +/// Committee index type alias, mirroring Go's `eth2p0.CommitteeIndex` (uint64). +type CommitteeIndex = u64; + +/// One-shot async signal mirroring Go's `chan struct{}` + `close(ch)` idiom. +/// +/// [`Self::close`] is idempotent (matches Go's behaviour, which panics only on +/// the second `close`; we prefer silent re-close). [`Self::wait`] returns +/// immediately once closed, otherwise blocks on a [`Notify`] until the next +/// [`Self::close`] call. Using `notify_waiters` (not `notify_one`) ensures +/// every pending waiter is woken when the signal fires. +#[derive(Debug, Default)] +struct CloseOnce { + closed: AtomicBool, + notify: Notify, +} + +impl CloseOnce { + fn close(&self) { + // Mark first so a fresh waiter that arrives between the store and + // notify sees `closed == true` on its first poll. + if !self.closed.swap(true, Ordering::SeqCst) { + self.notify.notify_waiters(); + } + } + + async fn wait(&self) { + loop { + if self.closed.load(Ordering::SeqCst) { + return; + } + // Register interest before re-checking to avoid a missed wakeup. + let notified = self.notify.notified(); + if self.closed.load(Ordering::SeqCst) { + return; + } + notified.await; + } + } +} + +use super::{ + error::{Error, Result}, + sign::SignFunc, + validators::ActiveValidators, +}; + +/// Single-slot attester duty as returned by +/// `/eth/v1/validator/duties/attester`. +/// +/// Mirrors `*eth2v1.AttesterDuty` after parsing the string-encoded JSON fields +/// into typed integers. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AttesterDuty { + /// Validator public key. + pub pubkey: BLSPubKey, + /// Validator's beacon-chain index. + pub validator_index: ValidatorIndex, + /// Committee index for this slot. + pub committee_index: CommitteeIndex, + /// Number of validators in the committee. + pub committee_length: u64, + /// Number of committees active at this slot. + pub committees_at_slot: u64, + /// Position of this validator inside the committee. + pub validator_committee_index: u64, + /// Slot at which the validator must attest. + pub slot: Slot, +} + +/// Selected aggregator entry returned by +/// `/eth/v1/validator/beacon_committee_selections`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BeaconCommitteeSelection { + /// Validator index. + pub validator_index: ValidatorIndex, + /// Slot the validator is attesting at. + pub slot: Slot, + /// Aggregated selection proof signature. + pub selection_proof: BLSSignature, +} + +/// Drives a single slot through `Prepare → Attest → Aggregate`. +/// +/// All public entry points take `&self`; mutable state is owned by an internal +/// `Mutex`, and inter-stage ordering is enforced with three close-once +/// `OnceCell`s (one per stage) acting as Go's `chan struct{}` ready signals. +#[derive(Debug, Clone)] +pub struct SlotAttester { + eth2_cl: Arc<EthBeaconNodeApiClient>, + slot: Slot, + #[allow(dead_code)] // matched against duties via the active-validator map + pubkeys: Vec<BLSPubKey>, + sign_func: SignFunc, + + state: Arc<Mutex<MutableState>>, + + duties_ok: Arc<CloseOnce>, + selections_ok: Arc<CloseOnce>, + datas_ok: Arc<CloseOnce>, +} + +#[derive(Debug, Default)] +struct MutableState { + vals: ActiveValidators, + duties: Vec<AttesterDuty>, + selections: Vec<BeaconCommitteeSelection>, + datas: Vec<AttestationData>, +} + +impl SlotAttester { + /// Builds a new attester for `slot`. The returned handle is cheap to clone + /// and safe to share between the scheduler tasks. + #[must_use] + pub fn new( + eth2_cl: Arc<EthBeaconNodeApiClient>, + slot: Slot, + sign_func: SignFunc, + pubkeys: Vec<BLSPubKey>, + ) -> Self { + Self { + eth2_cl, + slot, + pubkeys, + sign_func, + state: Arc::new(Mutex::new(MutableState::default())), + duties_ok: Arc::new(CloseOnce::default()), + selections_ok: Arc::new(CloseOnce::default()), + datas_ok: Arc::new(CloseOnce::default()), + } + } + + /// Slot this attester drives. + #[must_use] + pub fn slot(&self) -> Slot { + self.slot + } + + /// Run the start-of-slot prep: fetch active validators, attester duties for + /// the slot, and the beacon-committee selection for aggregators. + /// + /// Mirrors Go's `Prepare`. Calling twice on the same instance panics-like + /// (the `set` calls on the close-once cells will return `Err`), which we + /// silently swallow — matching the Go semantics of `close(ch)` on an + /// already-closed channel only triggering an explicit panic; here we + /// prefer idempotence. + pub async fn prepare(&self) -> Result<()> { + let vals = super::validators::active_validators(&self.eth2_cl).await?; + + let duties = prepare_attesters(&self.eth2_cl, &vals, self.slot).await?; + self.set_prepare_duties(vals, duties.clone()).await; + + let selections = prepare_aggregators( + &self.eth2_cl, + &self.sign_func, + &self.state, + &duties, + self.slot, + ) + .await?; + self.set_prepare_selections(selections).await; + + Ok(()) + } + + /// Build attestation data and submit per-validator attestations. + /// + /// Awaits [`Self::prepare`]'s ready signal first, mirroring Go's + /// `wait(ctx, a.dutiesOK)`. + pub async fn attest(&self) -> Result<()> { + self.duties_ok.wait().await; + + let duties = self.state.lock().await.duties.clone(); + let datas = attest(&self.eth2_cl, &self.sign_func, self.slot, &duties).await?; + + self.set_attest_datas(datas).await; + Ok(()) + } + + /// Build aggregate-and-proof envelopes for selected aggregators and submit + /// them. Returns `true` when at least one aggregate was submitted, matching + /// Go's bool return. + pub async fn aggregate(&self) -> Result<bool> { + self.duties_ok.wait().await; + self.selections_ok.wait().await; + self.datas_ok.wait().await; + + let state = self.state.lock().await; + aggregate( + &self.eth2_cl, + &self.sign_func, + self.slot, + &state.vals, + &state.duties, + &state.selections, + &state.datas, + ) + .await + } + + async fn set_prepare_duties(&self, vals: ActiveValidators, duties: Vec<AttesterDuty>) { + { + let mut state = self.state.lock().await; + state.vals = vals; + state.duties = duties; + } + self.duties_ok.close(); + } + + async fn set_prepare_selections(&self, selections: Vec<BeaconCommitteeSelection>) { + { + let mut state = self.state.lock().await; + state.selections = selections; + } + self.selections_ok.close(); + } + + async fn set_attest_datas(&self, datas: Vec<AttestationData>) { + { + let mut state = self.state.lock().await; + state.datas = datas; + } + self.datas_ok.close(); + } +} + +// --------------------------------------------------------------------------- +// Stage 1: attester duties +// --------------------------------------------------------------------------- + +async fn prepare_attesters( + eth2_cl: &EthBeaconNodeApiClient, + vals: &ActiveValidators, + slot: Slot, +) -> Result<Vec<AttesterDuty>> { + if vals.is_empty() { + return Ok(Vec::new()); + } + + let epoch = epoch_from_slot(eth2_cl, slot).await?; + + let indices: Vec<String> = vals.indices().map(|i| i.to_string()).collect(); + + let request = GetAttesterDutiesRequest::builder() + .epoch(epoch.to_string()) + .body(indices) + .build() + .map_err(EthBeaconNodeApiClientError::RequestError)?; + + let response = eth2_cl + .get_attester_duties(request) + .await + .map_err(EthBeaconNodeApiClientError::RequestError)?; + + let data = match response { + GetAttesterDutiesResponse::Ok(ok) => ok.data, + _ => return Err(EthBeaconNodeApiClientError::UnexpectedResponse.into()), + }; + + let mut duties = Vec::new(); + for datum in &data { + let duty = parse_duty(datum)?; + if duty.slot != slot { + continue; + } + duties.push(duty); + } + + Ok(duties) +} + +fn parse_duty( + datum: &pluto_eth2api::GetAttesterDutiesResponseResponseDatum, +) -> Result<AttesterDuty> { + let pubkey = parse_pubkey(&datum.pubkey)?; + let validator_index = + parse_u64(&datum.validator_index).ok_or_else(|| malformed("validator_index"))?; + let committee_index = + parse_u64(&datum.committee_index).ok_or_else(|| malformed("committee_index"))?; + let committee_length = + parse_u64(&datum.committee_length).ok_or_else(|| malformed("committee_length"))?; + let committees_at_slot = + parse_u64(&datum.committees_at_slot).ok_or_else(|| malformed("committees_at_slot"))?; + let validator_committee_index = parse_u64(&datum.validator_committee_index) + .ok_or_else(|| malformed("validator_committee_index"))?; + let slot = parse_u64(&datum.slot).ok_or_else(|| malformed("slot"))?; + + Ok(AttesterDuty { + pubkey, + validator_index, + committee_index, + committee_length, + committees_at_slot, + validator_committee_index, + slot, + }) +} + +// --------------------------------------------------------------------------- +// Stage 2: aggregator selection +// --------------------------------------------------------------------------- + +async fn prepare_aggregators( + eth2_cl: &EthBeaconNodeApiClient, + sign_func: &SignFunc, + _state: &Arc<Mutex<MutableState>>, + duties: &[AttesterDuty], + slot: Slot, +) -> Result<Vec<BeaconCommitteeSelection>> { + if duties.is_empty() { + return Ok(Vec::new()); + } + + let epoch = epoch_from_slot(eth2_cl, slot).await?; + let slot_root = slot.tree_hash_root().0; + let sig_data = get_data_root(eth2_cl, DomainName::SelectionProof, epoch, slot_root).await?; + + let mut partials = Vec::with_capacity(duties.len()); + let mut comm_lengths: HashMap<ValidatorIndex, u64> = HashMap::with_capacity(duties.len()); + + for duty in duties { + let slot_sig = sign_func.sign(&duty.pubkey, &sig_data)?; + comm_lengths.insert(duty.validator_index, duty.committee_length); + + partials.push( + pluto_eth2api::BeaconCommitteeSelectionRequestRequestBodyItem { + selection_proof: format!("0x{}", hex::encode(slot_sig)), + slot: duty.slot.to_string(), + validator_index: duty.validator_index.to_string(), + }, + ); + } + + let request = SubmitBeaconCommitteeSelectionsRequest::builder() + .body(partials) + .build() + .map_err(EthBeaconNodeApiClientError::RequestError)?; + + let response = eth2_cl + .submit_beacon_committee_selections(request) + .await + .map_err(EthBeaconNodeApiClientError::RequestError)?; + + let aggregate_selections = match response { + SubmitBeaconCommitteeSelectionsResponse::Ok(ok) => ok.data, + _ => return Err(EthBeaconNodeApiClientError::UnexpectedResponse.into()), + }; + + let mut selections = Vec::new(); + for item in aggregate_selections { + let validator_index = + parse_u64(&item.validator_index).ok_or_else(|| malformed("validator_index"))?; + let slot = parse_u64(&item.slot).ok_or_else(|| malformed("slot"))?; + let selection_proof = parse_signature(&item.selection_proof)?; + + let comm_len = *comm_lengths + .get(&validator_index) + .ok_or(Error::MissingValidatorIndex(validator_index))?; + + if !is_att_aggregator(eth2_cl, comm_len, selection_proof).await? { + continue; + } + + selections.push(BeaconCommitteeSelection { + validator_index, + slot, + selection_proof, + }); + } + + Ok(selections) +} + +// --------------------------------------------------------------------------- +// Stage 3: attest +// --------------------------------------------------------------------------- + +async fn attest( + eth2_cl: &EthBeaconNodeApiClient, + sign_func: &SignFunc, + slot: Slot, + duties: &[AttesterDuty], +) -> Result<Vec<AttestationData>> { + if duties.is_empty() { + return Ok(Vec::new()); + } + + // Group duties by committee, preserving each duty list's insertion order. + let mut comm_order: Vec<CommitteeIndex> = Vec::new(); + let mut duty_by_comm: HashMap<CommitteeIndex, Vec<&AttesterDuty>> = HashMap::new(); + for duty in duties { + duty_by_comm + .entry(duty.committee_index) + .or_insert_with(|| { + comm_order.push(duty.committee_index); + Vec::new() + }) + .push(duty); + } + + let mut atts: Vec<VersionedAttestationJson> = Vec::new(); + let mut datas: Vec<AttestationData> = Vec::new(); + + for comm_idx in &comm_order { + let duty_list = duty_by_comm + .get(comm_idx) + .ok_or_else(|| malformed("duty group missing"))?; + + let request = ProduceAttestationDataRequest::builder() + .slot(slot.to_string()) + .committee_index(comm_idx.to_string()) + .build() + .map_err(EthBeaconNodeApiClientError::RequestError)?; + + let response = eth2_cl + .produce_attestation_data(request) + .await + .map_err(EthBeaconNodeApiClientError::RequestError)?; + + let data: AttestationData = match response { + ProduceAttestationDataResponse::Ok(ok) => { + // `Data` uses loose string fields; round-trip through JSON to + // get the strongly typed `AttestationData` (with numeric slot, + // index, hex roots, etc.). + let value = serde_json::to_value(&ok.data).map_err(|e| malformed(e.to_string()))?; + serde_json::from_value(value).map_err(|e| malformed(e.to_string()))? + } + _ => return Err(EthBeaconNodeApiClientError::UnexpectedResponse.into()), + }; + datas.push(data.clone()); + + let root = data.tree_hash_root().0; + let sig_data = + get_data_root(eth2_cl, DomainName::BeaconAttester, data.target.epoch, root).await?; + + for duty in duty_list { + let sig = sign_func.sign(&duty.pubkey, &sig_data)?; + + let agg_bits = BitList::<131_072>::with_bits( + usize_from_u64(duty.committee_length)?, + &[usize_from_u64(duty.validator_committee_index)?], + ); + let comm_bits = BitVector::<64>::with_bits(&[usize_from_u64(duty.committee_index)?]); + + atts.push(VersionedAttestationJson { + version: "fulu", + validator_index: duty.validator_index, + phase0: None, + altair: None, + bellatrix: None, + capella: None, + deneb: None, + electra: None, + fulu: Some(electra::Attestation { + aggregation_bits: agg_bits, + data: data.clone(), + signature: sig, + committee_bits: comm_bits, + }), + }); + } + } + + submit_attestations(eth2_cl, &atts).await?; + + Ok(datas) +} + +// --------------------------------------------------------------------------- +// Stage 4: aggregate +// --------------------------------------------------------------------------- + +async fn aggregate( + eth2_cl: &EthBeaconNodeApiClient, + sign_func: &SignFunc, + slot: Slot, + vals: &ActiveValidators, + duties: &[AttesterDuty], + selections: &[BeaconCommitteeSelection], + datas: &[AttestationData], +) -> Result<bool> { + if selections.is_empty() { + return Ok(false); + } + + let epoch = epoch_from_slot(eth2_cl, slot).await?; + + let committees: HashMap<ValidatorIndex, CommitteeIndex> = duties + .iter() + .map(|duty| (duty.validator_index, duty.committee_index)) + .collect(); + + let mut aggs: Vec<VersionedSignedAggregateAndProofJson> = Vec::new(); + let mut atts_by_comm: HashMap<CommitteeIndex, electra::Attestation> = HashMap::new(); + + for selection in selections { + let comm_idx = *committees + .get(&selection.validator_index) + .ok_or(Error::MissingValidatorIndex(selection.validator_index))?; + + let att = match atts_by_comm.get(&comm_idx) { + Some(att) => att.clone(), + None => { + let att = get_aggregate_attestation(eth2_cl, datas, comm_idx).await?; + atts_by_comm.insert(comm_idx, att.clone()); + att + } + }; + + let proof_message = electra::AggregateAndProof { + aggregator_index: selection.validator_index, + aggregate: att, + selection_proof: selection.selection_proof, + }; + let proof_root = proof_message.tree_hash_root().0; + let sig_data = + get_data_root(eth2_cl, DomainName::AggregateAndProof, epoch, proof_root).await?; + + let pubkey = vals + .get(selection.validator_index) + .ok_or(Error::MissingValidatorIndex(selection.validator_index))?; + + let proof_sig = sign_func.sign(pubkey, &sig_data)?; + + aggs.push(VersionedSignedAggregateAndProofJson { + version: "fulu", + phase0: None, + altair: None, + bellatrix: None, + capella: None, + deneb: None, + electra: None, + fulu: Some(electra::SignedAggregateAndProof { + message: proof_message, + signature: proof_sig, + }), + }); + } + + submit_aggregate_attestations(eth2_cl, &aggs).await?; + + Ok(true) +} + +async fn get_aggregate_attestation( + eth2_cl: &EthBeaconNodeApiClient, + datas: &[AttestationData], + comm_idx: CommitteeIndex, +) -> Result<electra::Attestation> { + for data in datas { + if data.index != comm_idx { + continue; + } + + let root: Root = data.tree_hash_root().0; + let request = GetAggregatedAttestationV2Request::builder() + .attestation_data_root(format!("0x{}", hex::encode(root))) + .slot(data.slot.to_string()) + .committee_index(comm_idx.to_string()) + .build() + .map_err(EthBeaconNodeApiClientError::RequestError)?; + + let response = eth2_cl + .get_aggregated_attestation_v2(request) + .await + .map_err(EthBeaconNodeApiClientError::RequestError)?; + + let data = match response { + GetAggregatedAttestationV2Response::Ok(ok) => ok.data, + _ => return Err(EthBeaconNodeApiClientError::UnexpectedResponse.into()), + }; + // Beaconmock serves the Fulu-shaped Object variant; decode via JSON + // round-trip into the typed `electra::Attestation` since the generated + // Object variant has loosely typed string fields. + let value = serde_json::to_value(&data).map_err(|e| malformed(e.to_string()))?; + let att: electra::Attestation = + serde_json::from_value(value).map_err(|e| malformed(e.to_string()))?; + return Ok(att); + } + + Err(Error::Malformed( + "missing attestation data for committee index".into(), + )) +} + +// --------------------------------------------------------------------------- +// Raw POST helpers +// --------------------------------------------------------------------------- + +async fn submit_attestations( + eth2_cl: &EthBeaconNodeApiClient, + atts: &[VersionedAttestationJson], +) -> Result<()> { + const ENDPOINT: &str = "/eth/v2/beacon/pool/attestations"; + submit_json(eth2_cl, ENDPOINT, atts).await +} + +async fn submit_aggregate_attestations( + eth2_cl: &EthBeaconNodeApiClient, + aggs: &[VersionedSignedAggregateAndProofJson], +) -> Result<()> { + const ENDPOINT: &str = "/eth/v2/validator/aggregate_and_proofs"; + let body = SubmitAggregateAttestationsOptsJson { + common: CommonOpts::default(), + signed_aggregate_and_proofs: aggs, + }; + submit_json(eth2_cl, ENDPOINT, &body).await +} + +async fn submit_json<T: Serialize + ?Sized>( + eth2_cl: &EthBeaconNodeApiClient, + endpoint: &'static str, + body: &T, +) -> Result<()> { + let mut url = eth2_cl.base_url.clone(); + { + let mut segments = url.path_segments_mut().map_err(|()| { + Error::Malformed(format!("base url has no path segments for {endpoint}")) + })?; + // `endpoint` always starts with '/'; skip the empty leading element. + for segment in endpoint.split('/').filter(|s| !s.is_empty()) { + segments.push(segment); + } + } + + eth2_cl + .client + .post(url) + .json(body) + .send() + .await + .and_then(reqwest::Response::error_for_status) + .map_err(|source| Error::Submit { endpoint, source })?; + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Go-shaped JSON payloads +// --------------------------------------------------------------------------- + +/// JSON shape matching Go's `*eth2spec.VersionedAttestation`. +/// +/// Field names use Go's `PascalCase` for the version envelope, with +/// per-fork inner payloads keyed by the fork name. The inner +/// `electra::Attestation` serializes with snake_case fields +/// (`aggregation_bits`, `committee_bits`, `data`, `signature`) which matches +/// the Go output. +#[serde_as] +#[derive(Debug, Serialize)] +struct VersionedAttestationJson { + #[serde(rename = "Version")] + version: &'static str, + #[serde(rename = "ValidatorIndex")] + #[serde_as(as = "serde_with::DisplayFromStr")] + validator_index: ValidatorIndex, + #[serde(rename = "Phase0")] + phase0: Option<()>, + #[serde(rename = "Altair")] + altair: Option<()>, + #[serde(rename = "Bellatrix")] + bellatrix: Option<()>, + #[serde(rename = "Capella")] + capella: Option<()>, + #[serde(rename = "Deneb")] + deneb: Option<()>, + #[serde(rename = "Electra")] + electra: Option<electra::Attestation>, + #[serde(rename = "Fulu")] + fulu: Option<electra::Attestation>, +} + +/// JSON shape matching Go's `*eth2spec.VersionedSignedAggregateAndProof`. +#[derive(Debug, Serialize)] +struct VersionedSignedAggregateAndProofJson { + #[serde(rename = "Version")] + version: &'static str, + #[serde(rename = "Phase0")] + phase0: Option<()>, + #[serde(rename = "Altair")] + altair: Option<()>, + #[serde(rename = "Bellatrix")] + bellatrix: Option<()>, + #[serde(rename = "Capella")] + capella: Option<()>, + #[serde(rename = "Deneb")] + deneb: Option<()>, + #[serde(rename = "Electra")] + electra: Option<electra::SignedAggregateAndProof>, + #[serde(rename = "Fulu")] + fulu: Option<electra::SignedAggregateAndProof>, +} + +/// JSON shape matching Go's `*eth2api.SubmitAggregateAttestationsOpts`. +#[derive(Debug, Serialize)] +struct SubmitAggregateAttestationsOptsJson<'a> { + #[serde(rename = "Common")] + common: CommonOpts, + #[serde(rename = "SignedAggregateAndProofs")] + signed_aggregate_and_proofs: &'a [VersionedSignedAggregateAndProofJson], +} + +/// JSON shape matching Go's `eth2api.CommonOpts` (timeout in nanoseconds). +#[derive(Debug, Serialize, Default)] +struct CommonOpts { + #[serde(rename = "Timeout")] + timeout: u64, +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn parse_pubkey(s: &str) -> Result<BLSPubKey> { + let trimmed = s.strip_prefix("0x").unwrap_or(s); + let bytes = hex::decode(trimmed).map_err(|e| malformed(e.to_string()))?; + bytes + .as_slice() + .try_into() + .map_err(|_| malformed(format!("pubkey length {} != 48", bytes.len()))) +} + +fn parse_signature(s: &str) -> Result<BLSSignature> { + let trimmed = s.strip_prefix("0x").unwrap_or(s); + let bytes = hex::decode(trimmed).map_err(|e| malformed(e.to_string()))?; + bytes + .as_slice() + .try_into() + .map_err(|_| malformed(format!("signature length {} != 96", bytes.len()))) +} + +fn parse_u64(s: &str) -> Option<u64> { + s.parse::<u64>().ok() +} + +fn usize_from_u64(value: u64) -> Result<usize> { + usize::try_from(value).map_err(|_| malformed(format!("usize from u64 overflow: {value}"))) +} + +fn malformed(s: impl Into<String>) -> Error { + Error::Malformed(s.into()) +} + +// --------------------------------------------------------------------------- +// Tests — mirror Go's TestAttest for DutyFactor 0 and 1. +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use assert_json_diff::assert_json_eq; + use pluto_eth2api::spec::phase0::{BLSPubKey, BLSSignature}; + use serde_json::Value; + + use super::*; + use crate::{ + BeaconMock, ValidatorSet, + validatormock::{EndpointMatch, SubmissionCapture, error::SignError, sign::Sign}, + }; + + /// Stub signer mirroring the Go test: copies the pubkey bytes into the + /// signature, zero-padding the remaining 48 bytes. + #[derive(Debug)] + struct PubkeyEchoSigner; + + impl Sign for PubkeyEchoSigner { + fn sign( + &self, + pubkey: &BLSPubKey, + _data: &[u8], + ) -> std::result::Result<BLSSignature, SignError> { + let mut sig = [0u8; 96]; + sig[..48].copy_from_slice(pubkey); + Ok(sig) + } + } + + async fn run_attest_case( + duty_factor: u64, + expect_attestations: usize, + expect_aggregations: usize, + ) { + let valset = ValidatorSet::validator_set_a(); + let pubkeys = valset.public_keys(); + + let mock = BeaconMock::builder() + .validator_set(valset.clone()) + .deterministic_attester_duties(duty_factor) + .build() + .await + .expect("build mock"); + + // Phase 1's `active_validators` uses POST on `states/head/validators`; + // the beaconmock only serves GET by default, so mount a POST passthrough + // that returns the same payload as the GET handler. + mount_post_state_validators(mock.server(), &valset).await; + + // `BeaconCommitteeSelections` is a DV-only endpoint not mounted by the + // default beaconmock — Go's `beaconmock.New` runs the validator mock + // against a DV middleware that echoes selections. We replicate the + // echo: the response body is `{"data": <request body>}`. + mount_echo_selections(mock.server()).await; + + // Capture submission bodies before invoking the SUT. + let atts_capture = SubmissionCapture::mount( + mock.server(), + "POST", + EndpointMatch::path("/eth/v2/beacon/pool/attestations"), + serde_json::json!({}), + ) + .await; + let aggs_capture = SubmissionCapture::mount( + mock.server(), + "POST", + EndpointMatch::path("/eth/v2/validator/aggregate_and_proofs"), + serde_json::json!({}), + ) + .await; + + // First slot in epoch 1. + let (_seconds_per_slot, slots_per_epoch) = mock + .client() + .fetch_slots_config() + .await + .expect("fetch slots config"); + + let sign_func: SignFunc = Arc::new(PubkeyEchoSigner); + let attester = SlotAttester::new( + Arc::new(mock.client().clone()), + slots_per_epoch, + sign_func, + pubkeys, + ); + + attester.prepare().await.expect("prepare"); + attester.attest().await.expect("attest"); + let ok = attester.aggregate().await.expect("aggregate"); + assert_eq!(expect_aggregations > 0, ok); + + // The SUT issues exactly one POST to each endpoint. The body for + // attestations is a JSON array of `VersionedAttestation`s; the body for + // aggregate_and_proofs is a single `SubmitAggregateAttestationsOpts` + // object whose `SignedAggregateAndProofs` array holds the + // `VersionedSignedAggregateAndProof`s. + let atts_bodies = atts_capture.take(); + assert_eq!(atts_bodies.len(), 1, "expected one POST to attestations"); + let mut atts_array = atts_bodies[0] + .as_array() + .cloned() + .expect("attestations body is JSON array"); + + let aggs_bodies = aggs_capture.take(); + assert_eq!( + aggs_bodies.len(), + 1, + "expected one POST to aggregate_and_proofs" + ); + let mut aggs_body = aggs_bodies[0].clone(); + let aggs_array = aggs_body + .get_mut("SignedAggregateAndProofs") + .and_then(Value::as_array_mut) + .expect("SignedAggregateAndProofs must be an array"); + + assert_eq!(atts_array.len(), expect_attestations); + assert_eq!(aggs_array.len(), expect_aggregations); + + // Match Go's TestAttest deterministic ordering: sort by data.index + // (ascending, numeric). + atts_array.sort_by_key(index_of_attestation); + aggs_array.sort_by_key(index_of_aggregate); + + let atts_value = Value::Array(atts_array); + let golden_atts: Value = serde_json::from_str(golden(duty_factor, "attestations")) + .expect("parse attestations golden"); + assert_json_eq!(atts_value, golden_atts); + + let golden_aggs: Value = serde_json::from_str(golden(duty_factor, "aggregations")) + .expect("parse aggregations golden"); + assert_json_eq!(aggs_body, golden_aggs); + } + + fn golden(duty_factor: u64, kind: &str) -> &'static str { + match (duty_factor, kind) { + (0, "attestations") => include_str!("testdata/TestAttest_0_attestations.golden"), + (0, "aggregations") => include_str!("testdata/TestAttest_0_aggregations.golden"), + (1, "attestations") => include_str!("testdata/TestAttest_1_attestations.golden"), + (1, "aggregations") => include_str!("testdata/TestAttest_1_aggregations.golden"), + _ => panic!("unknown golden combination"), + } + } + + fn index_of_attestation(value: &Value) -> u64 { + value + .get("Fulu") + .and_then(|f| f.get("data")) + .and_then(|d| d.get("index")) + .and_then(Value::as_str) + .and_then(|s| s.parse().ok()) + .unwrap_or(u64::MAX) + } + + fn index_of_aggregate(value: &Value) -> u64 { + value + .get("Fulu") + .and_then(|f| f.get("message")) + .and_then(|m| m.get("aggregate")) + .and_then(|a| a.get("data")) + .and_then(|d| d.get("index")) + .and_then(Value::as_str) + .and_then(|s| s.parse().ok()) + .unwrap_or(u64::MAX) + } + + async fn mount_echo_selections(server: &wiremock::MockServer) { + use wiremock::{ + Mock, Request, ResponseTemplate, + matchers::{method, path}, + }; + + Mock::given(method("POST")) + .and(path("/eth/v1/validator/beacon_committee_selections")) + .respond_with(|request: &Request| { + let body: Value = + serde_json::from_slice(&request.body).unwrap_or(Value::Array(Vec::new())); + ResponseTemplate::new(200).set_body_json(serde_json::json!({ "data": body })) + }) + .with_priority(2) + .mount(server) + .await; + } + + async fn mount_post_state_validators(server: &wiremock::MockServer, valset: &ValidatorSet) { + use wiremock::{ + Mock, ResponseTemplate, + matchers::{method, path}, + }; + + let data: Vec<Value> = valset + .validators() + .into_iter() + .map(|v| { + serde_json::json!({ + "index": v.index.to_string(), + "balance": v.balance.to_string(), + "status": v.status, + "validator": v.validator, + }) + }) + .collect(); + let body = serde_json::json!({ + "data": data, + "execution_optimistic": false, + "finalized": false, + }); + + // Priority 2 — above defaults (255) but below capture (1). + Mock::given(method("POST")) + .and(path("/eth/v1/beacon/states/head/validators")) + .respond_with(ResponseTemplate::new(200).set_body_json(body)) + .with_priority(2) + .mount(server) + .await; + } + + #[tokio::test] + async fn attest_duty_factor_0() { + run_attest_case(0, 3, 3).await; + } + + #[tokio::test] + async fn attest_duty_factor_1() { + run_attest_case(1, 1, 1).await; + } +} diff --git a/crates/testutil/src/validatormock/error.rs b/crates/testutil/src/validatormock/error.rs index fa3637ec..809a5cc0 100644 --- a/crates/testutil/src/validatormock/error.rs +++ b/crates/testutil/src/validatormock/error.rs @@ -5,7 +5,7 @@ //! add new variants as their failure modes appear. use pluto_eth2api::EthBeaconNodeApiClientError; -use pluto_eth2util::{helpers::HelperError, signing::SigningError}; +use pluto_eth2util::{eth2exp::Eth2ExpError, helpers::HelperError, signing::SigningError}; /// Result alias used by the validator mock. pub type Result<T> = std::result::Result<T, Error>; @@ -25,6 +25,21 @@ pub enum Error { #[error(transparent)] Helper(#[from] HelperError), + /// Aggregator-selection helper failure. + #[error(transparent)] + Eth2Exp(#[from] Eth2ExpError), + + /// HTTP error from raw POST submissions (attestation / + /// aggregate-and-proof). + #[error("submit {endpoint}: {source}")] + Submit { + /// Path of the failed POST. + endpoint: &'static str, + /// Underlying HTTP error. + #[source] + source: reqwest::Error, + }, + /// Local signer could not produce a signature for the requested pubkey. #[error(transparent)] Sign(#[from] SignError), diff --git a/crates/testutil/src/validatormock/mod.rs b/crates/testutil/src/validatormock/mod.rs index 506a54dc..5369e233 100644 --- a/crates/testutil/src/validatormock/mod.rs +++ b/crates/testutil/src/validatormock/mod.rs @@ -5,12 +5,14 @@ //! Ported file-per-concern to match the Go layout; mirror functional behavior //! while using idiomatic Rust async primitives. +pub mod attest; pub mod capture; pub mod error; pub mod meta; pub mod sign; pub mod validators; +pub use attest::{AttesterDuty, BeaconCommitteeSelection, SlotAttester}; pub use capture::{EndpointMatch, SubmissionCapture}; pub use error::{Error, Result, SignError}; pub use meta::{MetaEpoch, MetaSlot, SpecMeta}; From ef1854e4e9aba52d8ae8723a6b728cceb458e788 Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> Date: Fri, 15 May 2026 13:12:14 +0200 Subject: [PATCH 21/21] feat(testutil/validatormock): port component.go (Component scheduler) Ports `charon/testutil/validatormock/component.go` to Rust, completing the validatormock module. `Component` drives the duty-by-duty workflow across a sliding window of attesters (per slot) and sync-committee members (per epoch) for the configured pubkeys. - `clock.rs`: `Clock` trait + `SystemClock` (production) + `FakeClock` (tests). `FakeClock` is an explicit-advance clock; pending sleepers register a `oneshot::Sender` and fire when `advance` / `advance_to` moves past their deadline. Avoids `tokio::time::pause()`, which interacts poorly with `wiremock::MockServer` (Plan agent flagged this). - `component.rs`: scheduler built around `tokio::sync::mpsc` + `tokio::spawn` + `tokio_util::sync::CancellationToken`. `Component::builder()` returns a handle that owns the consumer task; `shutdown().await` cancels gracefully and `Drop` cancels for the panic path. `run_duty_via_inner` matches the Go `runDuty` switch on `pluto_core::types::DutyType`. - `duties_for_slot` + `duty_start_times` mirror Go's `dutyStartTimeFuncsByDuty` table, including the half-/third-slot offsets for attest/aggregate/sync messages. Tests cover the start-up swallow window (delay_start_slots = 2), the duty-start-time arithmetic, and the duty-collection algorithm. Driving the full Component through 2 epochs with FakeClock would require comprehensive post_state_validators mounting on BeaconMock; the slot_ticked test asserts the epoch-window machinery instead and shuts down cleanly. --- crates/testutil/src/validatormock/clock.rs | 196 +++++++ .../testutil/src/validatormock/component.rs | 538 ++++++++++++++++++ crates/testutil/src/validatormock/mod.rs | 4 + 3 files changed, 738 insertions(+) create mode 100644 crates/testutil/src/validatormock/clock.rs create mode 100644 crates/testutil/src/validatormock/component.rs diff --git a/crates/testutil/src/validatormock/clock.rs b/crates/testutil/src/validatormock/clock.rs new file mode 100644 index 00000000..d2b28676 --- /dev/null +++ b/crates/testutil/src/validatormock/clock.rs @@ -0,0 +1,196 @@ +//! Injectable clock for the validator-mock scheduler. +//! +//! Replaces Go's `clockwork.FakeClock` from `propose_test.go`. The scheduler +//! always calls into [`Clock`], so tests can substitute [`FakeClock`] to drive +//! time-based duties deterministically without `tokio::time::pause()`, which +//! interacts poorly with `wiremock::MockServer`. + +use std::{ + sync::{Arc, Mutex}, + time::{Duration, SystemTime}, +}; + +use async_trait::async_trait; +use tokio::sync::oneshot; + +/// Abstract wall-clock used by [`crate::validatormock::Component`]. +#[async_trait] +pub trait Clock: Send + Sync + std::fmt::Debug + 'static { + /// Returns the current time. + fn now(&self) -> SystemTime; + + /// Sleeps until `wake_at`. Returns immediately if `wake_at` has already + /// passed. + async fn sleep_until(&self, wake_at: SystemTime); +} + +/// Real-time clock backed by `SystemTime::now` and `tokio::time::sleep`. +#[derive(Debug, Default, Clone, Copy)] +pub struct SystemClock; + +#[async_trait] +impl Clock for SystemClock { + fn now(&self) -> SystemTime { + SystemTime::now() + } + + async fn sleep_until(&self, wake_at: SystemTime) { + let now = SystemTime::now(); + let duration = wake_at.duration_since(now).unwrap_or(Duration::ZERO); + if duration.is_zero() { + return; + } + tokio::time::sleep(duration).await; + } +} + +/// Test clock: advances only via [`FakeClock::advance`] / +/// [`FakeClock::advance_to`]. +/// +/// Pending [`Clock::sleep_until`] futures register a oneshot sender; advancing +/// past the wake time fires every sender at or before the new time. +#[derive(Debug, Default, Clone)] +pub struct FakeClock(Arc<Mutex<FakeClockInner>>); + +#[derive(Debug)] +struct FakeClockInner { + now: SystemTime, + pending: Vec<(SystemTime, oneshot::Sender<()>)>, +} + +impl Default for FakeClockInner { + fn default() -> Self { + Self { + now: SystemTime::UNIX_EPOCH, + pending: Vec::new(), + } + } +} + +impl FakeClock { + /// Builds a clock pinned at `now`. + #[must_use] + pub fn new(now: SystemTime) -> Self { + Self(Arc::new(Mutex::new(FakeClockInner { + now, + pending: Vec::new(), + }))) + } + + /// Advances by `delta` and wakes pending sleepers whose deadline has + /// passed. + pub fn advance(&self, delta: Duration) { + let new_now = { + let guard = self.0.lock().expect("FakeClock mutex poisoned"); + guard.now.checked_add(delta).unwrap_or(guard.now) + }; + self.advance_to(new_now); + } + + /// Advances the clock to `target` (no-op if already past) and wakes + /// pending sleepers whose deadline has passed. + pub fn advance_to(&self, target: SystemTime) { + let drained: Vec<oneshot::Sender<()>> = { + let mut guard = self.0.lock().expect("FakeClock mutex poisoned"); + if target > guard.now { + guard.now = target; + } + let now = guard.now; + let mut keep = Vec::with_capacity(guard.pending.len()); + let mut fire = Vec::new(); + for (wake_at, tx) in guard.pending.drain(..) { + if wake_at <= now { + fire.push(tx); + } else { + keep.push((wake_at, tx)); + } + } + guard.pending = keep; + fire + }; + for tx in drained { + let _ = tx.send(()); + } + } +} + +#[async_trait] +impl Clock for FakeClock { + fn now(&self) -> SystemTime { + self.0.lock().expect("FakeClock mutex poisoned").now + } + + async fn sleep_until(&self, wake_at: SystemTime) { + let rx = { + let mut guard = self.0.lock().expect("FakeClock mutex poisoned"); + if wake_at <= guard.now { + return; + } + let (tx, rx) = oneshot::channel(); + guard.pending.push((wake_at, tx)); + rx + }; + let _ = rx.await; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn system_clock_now_advances() { + let c = SystemClock; + let a = c.now(); + tokio::time::sleep(Duration::from_millis(5)).await; + let b = c.now(); + assert!(b > a); + } + + #[tokio::test] + async fn fake_clock_sleep_resolves_after_advance() { + let start = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000); + let clock = FakeClock::new(start); + let clock_for_task = clock.clone(); + let wake = start + Duration::from_secs(10); + + let handle = tokio::spawn(async move { + clock_for_task.sleep_until(wake).await; + }); + + // Give the task a chance to enqueue. + tokio::task::yield_now().await; + clock.advance(Duration::from_secs(10)); + + handle.await.expect("sleeper completes"); + } + + #[tokio::test] + async fn fake_clock_sleep_already_passed_returns_immediately() { + let start = SystemTime::UNIX_EPOCH + Duration::from_secs(100); + let clock = FakeClock::new(start); + clock.sleep_until(start - Duration::from_secs(1)).await; // no panic, returns + } + + #[tokio::test] + async fn fake_clock_multiple_sleepers() { + let start = SystemTime::UNIX_EPOCH; + let clock = FakeClock::new(start); + + let a = tokio::spawn({ + let c = clock.clone(); + async move { c.sleep_until(start + Duration::from_secs(1)).await } + }); + let b = tokio::spawn({ + let c = clock.clone(); + async move { c.sleep_until(start + Duration::from_secs(2)).await } + }); + tokio::task::yield_now().await; + + clock.advance(Duration::from_secs(1)); + a.await.expect("a wakes"); + // b should still be pending; advance more. + clock.advance(Duration::from_secs(1)); + b.await.expect("b wakes"); + } +} diff --git a/crates/testutil/src/validatormock/component.rs b/crates/testutil/src/validatormock/component.rs new file mode 100644 index 00000000..e2d55146 --- /dev/null +++ b/crates/testutil/src/validatormock/component.rs @@ -0,0 +1,538 @@ +//! Validator-mock scheduler. +//! +//! Rust port of `charon/testutil/validatormock/component.go`. Drives a sliding +//! window of attesters and sync-committee members for the configured pubkeys +//! and dispatches duties (propose, attest, aggregate, sync messages, sync +//! contributions, builder registrations) at their slot-relative offsets. +//! +//! Goroutines map to `tokio::spawn`; `chan struct{}` close-once channels live +//! inside the per-slot attester / per-epoch sync-committee handles already +//! ported in [`super::attest`] and [`super::synccomm`]. Time is driven by an +//! injectable [`Clock`] so tests can advance virtual time without +//! `tokio::time::pause()` (which fights `wiremock`). + +use std::{ + collections::HashMap, + sync::Arc, + time::{Duration, SystemTime}, +}; + +use pluto_core::types::DutyType; +use pluto_eth2api::{EthBeaconNodeApiClient, spec::phase0::BLSPubKey}; +use tokio::{ + sync::{Mutex, mpsc}, + task::JoinHandle, +}; +use tokio_util::sync::CancellationToken; +use tracing::warn; + +use super::{ + SignFunc, + attest::SlotAttester, + clock::{Clock, SystemClock}, + error::{Error, Result}, + meta::{MetaEpoch, MetaSlot, SpecMeta}, + propose, + synccomm::SyncCommMember, +}; + +/// Sliding-window depth: keep this many future epochs alive. +const EPOCH_WINDOW: u64 = 2; + +/// Number of leading slots [`Component`] swallows before scheduling duties. +/// Mirrors Go's `delayStartSlots` workaround for simnet peer inconsistencies. +const DELAY_START_SLOTS: u32 = 2; + +/// Duty + the wall-clock instant it should fire at. +#[derive(Debug, Clone)] +struct ScheduleTuple { + duty_type: DutyType, + slot: u64, + start_time: SystemTime, +} + +/// Validator-mock scheduler. Built by [`Component::new`]; drops cleanly when +/// [`Component::shutdown`] is called or the value is dropped. +pub struct Component { + inner: Arc<Inner>, + cancel: CancellationToken, + scheduler: Mutex<Option<JoinHandle<()>>>, + scheduled_tx: mpsc::Sender<ScheduleTuple>, +} + +struct Inner { + eth2_cl: EthBeaconNodeApiClient, + sign_func: SignFunc, + pubkeys: Vec<BLSPubKey>, + meta: SpecMeta, + builder_api: bool, + clock: Arc<dyn Clock>, + state: Mutex<MutableState>, +} + +#[derive(Default)] +struct MutableState { + delay_slots: u32, + started: bool, + attesters_by_slot: HashMap<u64, Arc<SlotAttester>>, + sync_comms_by_epoch: HashMap<u64, Arc<SyncCommMember>>, +} + +impl std::fmt::Debug for Component { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Component") + .field("pubkeys", &self.inner.pubkeys.len()) + .field("meta", &self.inner.meta) + .field("builder_api", &self.inner.builder_api) + .finish() + } +} + +#[bon::bon] +impl Component { + /// Builds a scheduler and spawns the consumer task that fires duties at + /// their target times. Mirrors Go's `New(...)`. + /// + /// `clock` defaults to [`SystemClock`] when omitted. + #[builder] + pub fn new( + eth2_cl: EthBeaconNodeApiClient, + sign_func: SignFunc, + pubkeys: Vec<BLSPubKey>, + meta: SpecMeta, + builder_api: bool, + clock: Option<Arc<dyn Clock>>, + ) -> Self { + let cancel = CancellationToken::new(); + let (scheduled_tx, scheduled_rx) = mpsc::channel::<ScheduleTuple>(64); + let inner = Arc::new(Inner { + eth2_cl, + sign_func, + pubkeys, + meta, + builder_api, + clock: clock.unwrap_or_else(|| Arc::new(SystemClock)), + state: Mutex::new(MutableState::default()), + }); + let scheduler = tokio::spawn(run_scheduler( + Arc::clone(&inner), + cancel.clone(), + scheduled_rx, + )); + Self { + inner, + cancel, + scheduler: Mutex::new(Some(scheduler)), + scheduled_tx, + } + } +} + +impl Component { + /// Cancels the scheduler and awaits its termination. Idempotent. + pub async fn shutdown(&self) { + self.cancel.cancel(); + if let Some(handle) = self.scheduler.lock().await.take() { + let _ = handle.await; + } + } + + /// Called externally each slot. Mirrors Go's `Component.SlotTicked`. + pub async fn slot_ticked(&self, slot: u64) -> Result<()> { + if self.delay_on_startup().await { + return Ok(()); + } + self.schedule_slot(MetaSlot { + slot, + meta: self.inner.meta, + }) + .await + } + + async fn delay_on_startup(&self) -> bool { + let mut state = self.inner.state.lock().await; + if state.delay_slots == DELAY_START_SLOTS { + return false; + } + state.delay_slots = state.delay_slots.saturating_add(1); + true + } + + async fn schedule_slot(&self, slot: MetaSlot) -> Result<()> { + let is_startup = self.is_startup().await; + + if is_startup || slot.first_in_epoch() { + self.manage_epoch_state(slot.epoch()).await?; + } + + let mut duties: Vec<ScheduleTuple> = duties_for_slot(slot, all_duty_types()) + .into_iter() + .collect(); + duties.sort_by_key(|d| d.start_time); + + for duty in duties { + if self.cancel.is_cancelled() { + return Ok(()); + } + if self.scheduled_tx.send(duty).await.is_err() { + // Receiver dropped — scheduler is shutting down. + return Ok(()); + } + } + Ok(()) + } + + async fn is_startup(&self) -> bool { + let mut state = self.inner.state.lock().await; + let was_started = state.started; + state.started = true; + !was_started + } + + /// Refreshes attester + sync-committee state for the lookahead window. + /// Mirrors Go's `manageEpochState`. + async fn manage_epoch_state(&self, epoch: MetaEpoch) -> Result<()> { + // Drop attesters / sync-comm members for the past `EPOCH_WINDOW` epochs. + let mut e = epoch; + for _ in 0..EPOCH_WINDOW { + self.delete_attesters(e).await; + self.delete_sync_comm_members(e).await; + e = e.prev(); + } + + // Bring up future window. + let mut e = epoch; + for _ in 0..EPOCH_WINDOW { + self.start_attesters(e).await; + self.start_sync_comm_members(e).await?; + e = e.next(); + } + Ok(()) + } + + async fn start_attesters(&self, epoch: MetaEpoch) { + for slot in epoch.slots() { + let attester = Arc::new(SlotAttester::new( + Arc::new(self.inner.eth2_cl.clone()), + slot.slot, + Arc::clone(&self.inner.sign_func), + self.inner.pubkeys.clone(), + )); + self.inner + .state + .lock() + .await + .attesters_by_slot + .insert(slot.slot, attester); + } + } + + async fn start_sync_comm_members(&self, epoch: MetaEpoch) -> Result<()> { + let member = Arc::new(SyncCommMember::new( + self.inner.eth2_cl.clone(), + epoch.epoch, + Arc::clone(&self.inner.sign_func), + self.inner.pubkeys.clone(), + )); + member.prepare_epoch().await?; + self.inner + .state + .lock() + .await + .sync_comms_by_epoch + .insert(epoch.epoch, member); + Ok(()) + } + + async fn delete_attesters(&self, epoch: MetaEpoch) { + let mut state = self.inner.state.lock().await; + for slot in epoch.slots() { + state.attesters_by_slot.remove(&slot.slot); + } + } + + async fn delete_sync_comm_members(&self, epoch: MetaEpoch) { + self.inner + .state + .lock() + .await + .sync_comms_by_epoch + .remove(&epoch.epoch); + } +} + +impl Drop for Component { + fn drop(&mut self) { + self.cancel.cancel(); + } +} + +async fn run_scheduler( + inner: Arc<Inner>, + cancel: CancellationToken, + mut scheduled_rx: mpsc::Receiver<ScheduleTuple>, +) { + loop { + tokio::select! { + _ = cancel.cancelled() => return, + maybe = scheduled_rx.recv() => { + let Some(scheduled) = maybe else { return }; + let inner_for_task = Arc::clone(&inner); + let cancel_for_task = cancel.clone(); + tokio::spawn(async move { + let start_time = scheduled.start_time; + let slot = scheduled.slot; + let duty_label = scheduled.duty_type.clone(); + tokio::select! { + _ = cancel_for_task.cancelled() => {}, + () = inner_for_task.clock.sleep_until(start_time) => { + if let Err(err) = run_duty_via_inner(&inner_for_task, scheduled).await { + warn!(?err, slot, ?duty_label, "validatormock: duty failed"); + } + } + } + }); + } + } + } +} + +async fn run_duty_via_inner(inner: &Inner, duty: ScheduleTuple) -> Result<()> { + let state = inner.state.lock().await; + let attester = state.attesters_by_slot.get(&duty.slot).cloned(); + let epoch = inner.meta.epoch_from_slot(duty.slot).epoch; + let sync_comm = state.sync_comms_by_epoch.get(&epoch).cloned(); + drop(state); + + match duty.duty_type { + DutyType::PrepareAggregator => { + attester + .ok_or_else(|| Error::Malformed(format!("attester nil at slot {}", duty.slot)))? + .prepare() + .await + } + DutyType::Attester => { + attester + .ok_or_else(|| Error::Malformed(format!("attester nil at slot {}", duty.slot)))? + .attest() + .await + } + DutyType::Aggregator => attester + .ok_or_else(|| Error::Malformed(format!("attester nil at slot {}", duty.slot)))? + .aggregate() + .await + .map(|_| ()), + DutyType::Proposer => { + propose::propose_block(&inner.eth2_cl, &inner.sign_func, duty.slot).await + } + DutyType::PrepareSyncContribution => { + sync_comm + .ok_or_else(|| Error::Malformed(format!("synccomm nil at slot {}", duty.slot)))? + .prepare_slot(duty.slot) + .await + } + DutyType::SyncMessage => { + sync_comm + .ok_or_else(|| Error::Malformed(format!("synccomm nil at slot {}", duty.slot)))? + .message(duty.slot) + .await + } + DutyType::SyncContribution => sync_comm + .ok_or_else(|| Error::Malformed(format!("synccomm nil at slot {}", duty.slot)))? + .aggregate(duty.slot) + .await + .map(|_| ()), + DutyType::BuilderRegistration => Ok(()), + DutyType::BuilderProposer => Err(Error::UnsupportedVariant("DutyBuilderProposer")), + _ => Err(Error::UnsupportedVariant("unexpected duty type")), + } +} + +fn all_duty_types() -> &'static [DutyType] { + use DutyType::*; + &[ + PrepareAggregator, + Attester, + Aggregator, + Proposer, + BuilderRegistration, + PrepareSyncContribution, + SyncMessage, + SyncContribution, + ] +} + +/// Returns the duty start-time offsets for the given duty type. Mirrors the Go +/// `dutyStartTimeFuncsByDuty` table. +fn duty_start_times(duty: DutyType, slot: MetaSlot) -> Vec<SystemTime> { + use DutyType::*; + match duty { + PrepareAggregator => vec![ + slot.epoch().prev().first_slot().start_time(), + slot.epoch().first_slot().start_time(), + ], + Attester => vec![fraction(slot, 1, 3)], + Aggregator => vec![fraction(slot, 2, 3)], + Proposer => vec![slot.start_time()], + BuilderRegistration => vec![slot.epoch().first_slot().start_time()], + PrepareSyncContribution => vec![slot.start_time()], + SyncMessage => vec![fraction(slot, 1, 3)], + SyncContribution => vec![fraction(slot, 2, 3)], + _ => Vec::new(), + } +} + +/// Returns `slot.start_time + (slot_duration * x / y)`. Saturating arithmetic +/// keeps the workspace's `arithmetic_side_effects` lint happy. +fn fraction(slot: MetaSlot, x: u32, y: u32) -> SystemTime { + let duration = slot.duration(); + let mul = duration.saturating_mul(x); + let offset_nanos = mul.as_nanos().checked_div(u128::from(y)).unwrap_or(0); + let secs = u64::try_from(offset_nanos.checked_div(1_000_000_000).unwrap_or(0)).unwrap_or(0); + let sub_nanos = + u32::try_from(offset_nanos.checked_rem(1_000_000_000).unwrap_or(0)).unwrap_or(0); + let offset = Duration::new(secs, sub_nanos); + slot.start_time() + .checked_add(offset) + .unwrap_or(slot.start_time()) +} + +/// Returns the duties that should fire in `slot`. Mirrors Go's +/// `dutiesForSlot`: scans a small forward window and keeps the duties whose +/// computed start time falls inside `slot`. +fn duties_for_slot(slot: MetaSlot, duty_types: &[DutyType]) -> Vec<ScheduleTuple> { + let mut resp: Vec<ScheduleTuple> = Vec::new(); + let mut seen: std::collections::HashSet<(DutyType, u64, SystemTime)> = + std::collections::HashSet::new(); + + for duty_type in duty_types { + for check_slot in slot.epoch().slots_for_look_ahead(EPOCH_WINDOW) { + for start_time in duty_start_times(duty_type.clone(), check_slot) { + if !slot.in_slot(start_time) { + continue; + } + let key = (duty_type.clone(), check_slot.slot, start_time); + if seen.insert(key) { + resp.push(ScheduleTuple { + duty_type: duty_type.clone(), + slot: check_slot.slot, + start_time, + }); + } + } + } + } + resp +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{BeaconMock, ValidatorSet, validatormock::Signer}; + use std::time::{Duration, SystemTime}; + + fn meta_at(genesis: SystemTime) -> SpecMeta { + SpecMeta { + genesis_time: genesis, + slot_duration: Duration::from_secs(12), + slots_per_epoch: 16, + } + } + + #[tokio::test] + async fn fraction_returns_partial_slot_offsets() { + let slot = MetaSlot { + slot: 0, + meta: meta_at(SystemTime::UNIX_EPOCH), + }; + assert_eq!( + fraction(slot, 1, 3), + slot.start_time() + Duration::from_secs(4) + ); + assert_eq!( + fraction(slot, 2, 3), + slot.start_time() + Duration::from_secs(8) + ); + } + + #[tokio::test] + async fn duties_for_slot_includes_attest_at_third_slot() { + let slot = MetaSlot { + slot: 0, + meta: meta_at(SystemTime::UNIX_EPOCH), + }; + let duties = duties_for_slot(slot, all_duty_types()); + assert!( + duties.iter().any(|d| d.duty_type == DutyType::Attester + && d.start_time == slot.start_time() + Duration::from_secs(4)), + "missing attester duty: {duties:?}" + ); + assert!( + duties + .iter() + .any(|d| d.duty_type == DutyType::Proposer && d.start_time == slot.start_time()), + "missing proposer duty: {duties:?}" + ); + } + + #[tokio::test] + async fn slot_ticked_swallows_first_two_slots() { + use serde_json::json; + use wiremock::{ + Mock, ResponseTemplate, + matchers::{method, path_regex}, + }; + + let genesis = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000); + let mock = BeaconMock::builder() + .validator_set(ValidatorSet::validator_set_a()) + .no_proposer_duties(true) + .no_attester_duties(true) + .no_sync_committee_duties(true) + .build() + .await + .expect("build mock"); + + // BeaconMock does not mount a default for the validators endpoint; + // synccomm's prepare_epoch reaches for it. Return an empty active set + // so duties resolve to no-ops without exercising signing paths. + Mock::given(method("POST")) + .and(path_regex(r"^/eth/v1/beacon/states/[^/]+/validators$")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "execution_optimistic": false, + "finalized": true, + "data": [] + }))) + .with_priority(2) + .mount(mock.server()) + .await; + + let component = Component::builder() + .eth2_cl(mock.client().clone()) + .sign_func(Signer::arc(&[]).expect("empty signer")) + .pubkeys(Vec::new()) + .meta(meta_at(genesis)) + .builder_api(false) + .build(); + + // First two ticks must be no-ops (delay window). + component.slot_ticked(0).await.expect("tick 0"); + component.slot_ticked(1).await.expect("tick 1"); + { + let state = component.inner.state.lock().await; + assert!(state.attesters_by_slot.is_empty()); + assert!(state.sync_comms_by_epoch.is_empty()); + } + + // Third tick starts the window: attesters for current+next epoch get + // installed (`EPOCH_WINDOW = 2`). + component.slot_ticked(2).await.expect("tick 2"); + { + let state = component.inner.state.lock().await; + // 2 epochs * 16 slots/epoch = 32 attesters in the window. + assert_eq!(state.attesters_by_slot.len(), 32); + assert_eq!(state.sync_comms_by_epoch.len(), 2); + } + component.shutdown().await; + } +} diff --git a/crates/testutil/src/validatormock/mod.rs b/crates/testutil/src/validatormock/mod.rs index b468779d..b5acd7a7 100644 --- a/crates/testutil/src/validatormock/mod.rs +++ b/crates/testutil/src/validatormock/mod.rs @@ -7,6 +7,8 @@ pub mod attest; pub mod capture; +pub mod clock; +pub mod component; pub mod error; pub mod meta; pub mod propose; @@ -16,6 +18,8 @@ pub mod validators; pub use attest::{AttesterDuty, BeaconCommitteeSelection, SlotAttester}; pub use capture::{EndpointMatch, SubmissionCapture}; +pub use clock::{Clock, FakeClock, SystemClock}; +pub use component::Component; pub use error::{Error, Result, SignError}; pub use meta::{MetaEpoch, MetaSlot, SpecMeta}; pub use propose::{VersionedValidatorRegistration, propose_block, register};