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 +``` 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/Cargo.lock b/Cargo.lock index 517f2478..d16d2aa9 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,30 @@ dependencies = [ 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", + "pluto-ssz", "rand 0.8.6", + "reqwest 0.13.3", + "serde", + "serde_json", + "serde_with", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "tree_hash", + "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::<HashMap<ValidatorIndex, PubKey>>(); // 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<StdDu #[cfg(test)] mod tests { use super::*; + use pluto_testutil::BeaconMock; use wiremock::{ Mock, MockServer, ResponseTemplate, matchers::{method, path}, @@ -2161,29 +2162,22 @@ mod tests { } } - async fn start_healthy_mocked_beacon_node() -> 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<Utc>, 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/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/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..98672e89 100644 --- a/crates/testutil/Cargo.toml +++ b/crates/testutil/Cargo.toml @@ -5,13 +5,38 @@ 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 +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 +pluto-ssz.workspace = true rand.workspace = true -hex.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 +tracing.workspace = true +tree_hash.workspace = true +wiremock.workspace = true + +[dev-dependencies] +assert-json-diff.workspace = true [lints] 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/attestation.rs b/crates/testutil/src/beaconmock/attestation.rs new file mode 100644 index 00000000..07452c26 --- /dev/null +++ b/crates/testutil/src/beaconmock/attestation.rs @@ -0,0 +1,353 @@ +//! 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<BTreeMap<Root, AttestationData>>, +} + +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<AttestationData> { + 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 { + // 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, + 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<MockState>) { + 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::<u64>().ok()) + .unwrap_or(0); + let committee_index = query_value(request, "committee_index") + .and_then(|value| value.parse::<u64>().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<Root> { + 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<String> { + 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<F>( + 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 new file mode 100644 index 00000000..58a72e95 --- /dev/null +++ b/crates/testutil/src/beaconmock/defaults.rs @@ -0,0 +1,623 @@ +//! Default spec/genesis and mount logic for the beacon mock HTTP handlers. + +use std::{collections::BTreeMap, sync::Arc}; + +use chrono::{DateTime, TimeZone, Utc}; +use pluto_eth2api::spec::phase0::{Epoch, ValidatorIndex}; +use serde_json::{Value, json}; +use wiremock::{ + Mock, MockServer, Request, ResponseTemplate, + matchers::{method, path, path_regex}, +}; + +use super::state::{MockState, last_path_segment_u64, read_lock}; + +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; + +pub(crate) async fn mount_defaults(server: &MockServer, state: Arc<MockState>) { + 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; + + 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<F>( + 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; +} + +pub(crate) async fn mount_json_with_status<F>( + 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; +} + +pub(crate) 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<Value> = 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); + // 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(); + + 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 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>) -> Value { + json!({ + "data": data, + "dependent_root": ZERO_ROOT, + "execution_optimistic": false + }) +} + +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>) -> Value { + json!({ + "data": data, + "execution_optimistic": false + }) +} + +fn indices_from_body(request: &Request) -> Vec<ValidatorIndex> { + serde_json::from_slice::<Vec<String>>(&request.body) + .map(|indices| { + indices + .into_iter() + .filter_map(|index| index.parse::<ValidatorIndex>().ok()) + .collect() + }) + .unwrap_or_default() +} + +fn epoch_from_path(path: &str) -> Epoch { + last_path_segment_u64(path) +} + +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) +} + +/// 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<String, Value> { + 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 { + // 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"), + ("SLOTS_PER_EPOCH", "16"), + ("SECONDS_PER_SLOT", "12"), + ("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"), + ("EPOCHS_PER_SYNC_COMMITTEE_PERIOD", "256"), + ]; + for (key, value) in overrides { + spec.insert((*key).to_string(), Value::String((*value).to_string())); + } + 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 { + json!({ + "genesis_time": default_genesis_time().timestamp().to_string(), + "genesis_validators_root": DEFAULT_GENESIS_VALIDATORS_ROOT, + "genesis_fork_version": DEFAULT_GENESIS_FORK_VERSION, + }) +} + +pub(crate) fn default_genesis_time() -> DateTime<Utc> { + 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)] +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..29f82fbe --- /dev/null +++ b/crates/testutil/src/beaconmock/fuzzer.rs @@ -0,0 +1,467 @@ +//! 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 super::state::last_path_segment_u64; +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<F>( + 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::<u64>().to_string(), + "index": rng.r#gen::<u64>().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::<u64>(); + 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<Value> = (0..count) + .map(|index| { + json!({ + "index": index.to_string(), + "balance": rng.r#gen::<u64>().to_string(), + "status": random_validator_status(&mut rng), + "validator": { + "pubkey": format!("0x{}", hex::encode([rng.r#gen::<u8>(); 48])), + "withdrawal_credentials": random_root(), + "effective_balance": rng.r#gen::<u64>().to_string(), + "slashed": rng.r#gen::<bool>(), + "activation_eligibility_epoch": rng.r#gen::<u64>().to_string(), + "activation_epoch": rng.r#gen::<u64>().to_string(), + "exit_epoch": rng.r#gen::<u64>().to_string(), + "withdrawable_epoch": rng.r#gen::<u64>().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<Value> = (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::<u8>(); 48])), + "validator_index": i.to_string(), + "committee_index": rng.r#gen::<u64>().to_string(), + "committee_length": rng.r#gen::<u64>().to_string(), + "committees_at_slot": slots_per_epoch.to_string(), + "validator_committee_index": rng.r#gen::<u64>().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<Value> = (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::<u8>(); 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<Value> = (0..count) + .map(|i| { + let subnet_count = rng.gen_range(0..=4u64); + let subnets: Vec<String> = (0..subnet_count).map(|s| s.to_string()).collect(); + json!({ + "pubkey": format!("0x{}", hex::encode([rng.r#gen::<u8>(); 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::<u64>().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() + .expect("STATUSES is a non-empty constant slice") +} + +fn random_beacon_block(slot: u64) -> Value { + let mut rng = rand::thread_rng(); + json!({ + "slot": slot.to_string(), + "proposer_index": rng.r#gen::<u64>().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::<u64>().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 { + last_path_segment_u64(path) +} + +fn epoch_from_path(path: &str) -> u64 { + last_path_segment_u64(path) +} + +#[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..5366d68d --- /dev/null +++ b/crates/testutil/src/beaconmock/headproducer.rs @@ -0,0 +1,423 @@ +//! 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` 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. +//! +//! [`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}, + 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_util::sync::CancellationToken; +use wiremock::{ + Mock, MockServer, Request, ResponseTemplate, + matchers::{method, path, path_regex}, +}; + +use super::{defaults::DEFAULT_MOCK_PRIORITY, state::hex_0x}; + +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, + duty_dependent_root: Root, +} + +/// Owns the slot ticker driving the head producer. Drop to stop the ticker. +#[derive(Debug)] +pub(crate) struct HeadProducer { + 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 cancel = CancellationToken::new(); + + 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), + cancel.clone(), + initial_height, + initial_tick, + slot_duration, + ); + + Self { cancel } + } +} + +impl Drop for HeadProducer { + fn drop(&mut self) { + self.cancel.cancel(); + } +} + +struct SharedState { + current_head: RwLock<Option<HeadEvent>>, +} + +impl SharedState { + fn new() -> Self { + Self { + current_head: RwLock::new(None), + } + } + + 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), + } + } + + fn current_head(&self) -> Option<HeadEvent> { + match self.current_head.read() { + Ok(guard) => guard.clone(), + Err(poisoned) => poisoned.into_inner().clone(), + } + } +} + +fn spawn_slot_ticker( + state: Arc<SharedState>, + cancel: CancellationToken, + initial_height: Slot, + initial_tick: SystemTime, + slot_duration: 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() + .checked_add(slot_duration) + .unwrap_or(SystemTime::now()) + }); + + tokio::spawn(async move { + 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); + next_tick = next_tick.checked_add(slot_duration).unwrap_or_else(|| { + SystemTime::now() + .checked_add(slot_duration) + .unwrap_or(SystemTime::now()) + }); + } + }); +} + +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(); + 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<Utc>) -> 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)); +} + +// 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), + 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<SharedState>) { + 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}")); + } + + // `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()); + }; + + 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<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()); + }; + + 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<String> { + 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 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, + // 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, + }) +} + +fn block_event_json(head: &HeadEvent) -> Value { + json!({ + "slot": head.slot.to_string(), + "block": hex_0x(head.block), + "execution_optimistic": false, + }) +} + +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 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\"")); + } + + #[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"); + + 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"); + + 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 new file mode 100644 index 00000000..aaea9e36 --- /dev/null +++ b/crates/testutil/src/beaconmock/mod.rs @@ -0,0 +1,426 @@ +//! 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 attestation; +mod defaults; +mod fuzzer; +mod headproducer; +mod options; +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_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}; + +/// 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<T> = std::result::Result<T, Error>; + +/// 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<MockState>, + // 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<ValidatorSet>, + slot_duration: Option<Duration>, + slots_per_epoch: Option<u64>, + genesis_time: Option<DateTime<Utc>>, + genesis_validators_root: Option<Root>, + spec: Option<Value>, + deterministic_attester_duties: Option<u64>, + deterministic_proposer_duties: Option<u64>, + fuzzer: Option<bool>, + #[builder(default)] endpoint_overrides: Vec<(String, Value)>, + fork_version: Option<[u8; 4]>, + sync_committee_size: Option<u64>, + sync_committee_subnet_count: Option<u64>, + #[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<Self> { + 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, + "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), + ); + } + + 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)?; + + Ok(Self { + server, + client, + state, + _head_producer: head_producer, + }) + } + + /// 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<MockState> { + Arc::clone(&self.state) + } +} + +#[cfg(test)] +mod tests { + use super::*; + 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()); + 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") + } + + /// 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 — response + /// must match the shared golden fixture. + #[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; + 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 — response must match + /// the shared golden fixture. + #[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; + 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 = + /// 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 + /// 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() + ); + } +} 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::<Value>() + .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::<Value>() + .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 new file mode 100644 index 00000000..ed9de55f --- /dev/null +++ b/crates/testutil/src/beaconmock/state.rs @@ -0,0 +1,285 @@ +//! 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; + +use super::attestation::AttestationStore; + +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. + /// + /// 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); + + 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: "0".to_string(), + pubkey, + slashed: false, + withdrawable_epoch: "0".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<ValidatorIndex, Validator>); + +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<Validator> { + self.0.values().cloned().collect() + } + + /// Returns the validator for an index. + #[must_use] + pub fn by_index(&self, index: ValidatorIndex) -> Option<Validator> { + 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<Validator> { + 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<BLSPubKey> { + 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 { + self.0.is_empty() + } +} + +impl FromIterator<(ValidatorIndex, Validator)> for ValidatorSet { + fn from_iter<T: IntoIterator<Item = (ValidatorIndex, Validator)>>(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<Value>, + pub(crate) genesis: RwLock<Value>, + pub(crate) validator_set: RwLock<ValidatorSet>, + pub(crate) deterministic_attester_duties: RwLock<Option<u64>>, + pub(crate) deterministic_proposer_duties: RwLock<Option<u64>>, + pub(crate) deterministic_sync_comm_duties: RwLock<Option<(u64, u64)>>, + pub(crate) attestation_store: AttestationStore, +} + +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), + deterministic_sync_comm_duties: RwLock::new(None), + attestation_store: AttestationStore::default(), + } + } + + /// 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<String>, value: impl Into<Value>) { + 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<String>, value: impl Into<Value>) { + 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())) +} + +/// 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()?; + bytes.try_into().ok() +} + +pub(crate) fn read_lock<T>(lock: &RwLock<T>) -> RwLockReadGuard<'_, T> { + match lock.read() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + } +} + +pub(crate) fn write_lock<T>(lock: &RwLock<T>) -> 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<Value>) { + if let Some(target) = target.as_object_mut() { + 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/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/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 diff --git a/crates/testutil/src/lib.rs b/crates/testutil/src/lib.rs index 686c8c7a..8f572e56 100644 --- a/crates/testutil/src/lib.rs +++ b/crates/testutil/src/lib.rs @@ -4,10 +4,26 @@ //! 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; +/// 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/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/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/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/error.rs b/crates/testutil/src/validatormock/error.rs new file mode 100644 index 00000000..809a5cc0 --- /dev/null +++ b/crates/testutil/src/validatormock/error.rs @@ -0,0 +1,75 @@ +//! 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::{eth2exp::Eth2ExpError, 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), + + /// 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), + + /// 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..b5acd7a7 --- /dev/null +++ b/crates/testutil/src/validatormock/mod.rs @@ -0,0 +1,28 @@ +//! 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 attest; +pub mod capture; +pub mod clock; +pub mod component; +pub mod error; +pub mod meta; +pub mod propose; +pub mod sign; +pub mod synccomm; +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}; +pub use sign::{Sign, SignFunc, Signer}; +pub use synccomm::{SyncCommMember, SyncCommitteeDuty}; +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() {} +} 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/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]); + } +} 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()))) +} 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}"