From fdd3fbad8a3e7b6a7b99ce7c92812816f5a22ad8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:40:39 -0300 Subject: [PATCH 1/6] feat(rpc): serve the single-validator and fork_schedule endpoints Foreign validator clients refuse or stall on a node that 404s these: - GET /eth/v1/beacon/states/{state_id}/validators/{validator_id}: lighthouse resolves every key to an index this way; without it all its validators stay inactive and no proposal is made. Same entry shape, state-id handling and flags as the list form; an unknown validator is a 404. - GET /eth/v1/config/fork_schedule: nimbus reads it every epoch and marks a node it cannot decode as incompatible. Phase0 first and its own predecessor, then each scheduled fork, so the list is the linked list nimbus validates. A test pins every key nimbus's spec comparison reads. --- crates/net/rpc/src/beacon/config.rs | 200 +++++++++++++++++++++++++++- crates/net/rpc/src/beacon/states.rs | 81 +++++++++++ 2 files changed, 280 insertions(+), 1 deletion(-) diff --git a/crates/net/rpc/src/beacon/config.rs b/crates/net/rpc/src/beacon/config.rs index 10e2279f..f3c06db4 100644 --- a/crates/net/rpc/src/beacon/config.rs +++ b/crates/net/rpc/src/beacon/config.rs @@ -33,7 +33,55 @@ use ethlambda_types::beacon::{config::Config, constants, preset, serde_helpers:: use serde_json::{Map, Value}; pub(crate) fn routes() -> Router { - Router::new().route("/eth/v1/config/spec", get(get_spec)) + Router::new() + .route("/eth/v1/config/spec", get(get_spec)) + .route("/eth/v1/config/fork_schedule", get(get_fork_schedule)) +} + +/// `GET /eth/v1/config/fork_schedule`: every fork of the node's `Config` +/// that is scheduled, oldest first. +/// +/// Validator clients compare this against their own schedule at startup (nimbus +/// refuses a node that cannot answer it). +async fn get_fork_schedule(State(store): State) -> Response { + crate::json_response(serde_json::json!({ "data": fork_schedule(store.config().as_ref()) })) +} + +/// The `Fork` objects (`previous_version`, `current_version`, `epoch`) for +/// phase0 and every later fork whose epoch is not `FAR_FUTURE_EPOCH`. +/// +/// Phase0 is its own predecessor, as in the spec's genesis `Fork`. An +/// unscheduled fork is skipped without breaking the chain: the next scheduled +/// fork's `previous_version` is the last *scheduled* one's version. +fn fork_schedule(config: &Config) -> Vec { + let forks = [ + (config.altair_fork_version, config.altair_fork_epoch), + (config.bellatrix_fork_version, config.bellatrix_fork_epoch), + (config.capella_fork_version, config.capella_fork_epoch), + (config.deneb_fork_version, config.deneb_fork_epoch), + (config.electra_fork_version, config.electra_fork_epoch), + (config.fulu_fork_version, config.fulu_fork_epoch), + (config.gloas_fork_version, config.gloas_fork_epoch), + ]; + let fork = |previous: [u8; 4], current: [u8; 4], epoch: u64| { + serde_json::json!({ + "previous_version": hex_string(previous), + "current_version": hex_string(current), + "epoch": epoch.to_string(), + }) + }; + + let genesis = config.genesis_fork_version; + let mut schedule = vec![fork(genesis, genesis, 0)]; + let mut previous = genesis; + for (version, epoch) in forks { + if epoch == constants::FAR_FUTURE_EPOCH { + continue; + } + schedule.push(fork(previous, version, epoch)); + previous = version; + } + schedule } async fn get_spec(State(store): State) -> Response { @@ -287,6 +335,96 @@ mod tests { ); } + /// What nimbus's validator client (`checkConfig` and + /// `getConsensusForkConfig`, v26.10.0) reads off the spec before it will + /// use a node: a missing or differing key marks the node incompatible. + #[tokio::test] + async fn the_spec_carries_every_key_nimbus_checks() { + let json = get_spec_json().await; + let data = &json["data"]; + + let checked = [ + ( + "MAX_VALIDATORS_PER_COMMITTEE", + preset::MAX_VALIDATORS_PER_COMMITTEE.to_string(), + ), + ("SLOTS_PER_EPOCH", preset::SLOTS_PER_EPOCH.to_string()), + ( + "EPOCHS_PER_ETH1_VOTING_PERIOD", + preset::EPOCHS_PER_ETH1_VOTING_PERIOD.to_string(), + ), + ( + "SLOTS_PER_HISTORICAL_ROOT", + preset::SLOTS_PER_HISTORICAL_ROOT.to_string(), + ), + ( + "EPOCHS_PER_HISTORICAL_VECTOR", + preset::EPOCHS_PER_HISTORICAL_VECTOR.to_string(), + ), + ( + "EPOCHS_PER_SLASHINGS_VECTOR", + preset::EPOCHS_PER_SLASHINGS_VECTOR.to_string(), + ), + ( + "HISTORICAL_ROOTS_LIMIT", + preset::HISTORICAL_ROOTS_LIMIT.to_string(), + ), + ( + "VALIDATOR_REGISTRY_LIMIT", + preset::VALIDATOR_REGISTRY_LIMIT.to_string(), + ), + ( + "MAX_PROPOSER_SLASHINGS", + preset::MAX_PROPOSER_SLASHINGS.to_string(), + ), + ( + "MAX_ATTESTER_SLASHINGS", + preset::MAX_ATTESTER_SLASHINGS.to_string(), + ), + ("MAX_ATTESTATIONS", preset::MAX_ATTESTATIONS.to_string()), + ("MAX_DEPOSITS", preset::MAX_DEPOSITS.to_string()), + ( + "MAX_VOLUNTARY_EXITS", + preset::MAX_VOLUNTARY_EXITS.to_string(), + ), + ]; + for (key, expected) in checked { + assert_eq!(data[key], expected, "{key}"); + } + for domain in [ + "DOMAIN_BEACON_PROPOSER", + "DOMAIN_BEACON_ATTESTER", + "DOMAIN_RANDAO", + "DOMAIN_DEPOSIT", + "DOMAIN_VOLUNTARY_EXIT", + "DOMAIN_SELECTION_PROOF", + "DOMAIN_AGGREGATE_AND_PROOF", + ] { + assert!(data[domain].as_str().unwrap().starts_with("0x"), "{domain}"); + } + // One of the two keys that fix the slot time (it compares the one + // present with its own and refuses a node that has neither, unless + // it too runs the default). + assert!(data["SECONDS_PER_SLOT"].is_string()); + // Each fork's version and epoch, altair's scheduled. + for fork in [ + "ALTAIR", + "BELLATRIX", + "CAPELLA", + "DENEB", + "ELECTRA", + "FULU", + "GLOAS", + ] { + assert!(data[format!("{fork}_FORK_VERSION")].is_string(), "{fork}"); + assert!(data[format!("{fork}_FORK_EPOCH")].is_string(), "{fork}"); + } + assert_ne!( + data["ALTAIR_FORK_EPOCH"], + constants::FAR_FUTURE_EPOCH.to_string() + ); + } + #[tokio::test] async fn the_spec_carries_gloas_preset_and_constant_keys() { let json = get_spec_json().await; @@ -320,6 +458,66 @@ mod tests { assert!(data["MIN_BUILDER_WITHDRAWABILITY_DELAY"].is_string()); } + #[tokio::test] + async fn the_fork_schedule_lists_scheduled_forks_with_quoted_epochs() { + let fixture = beacon_fixture(64); + let config = fixture.store.config(); + let app = routes().with_state(fixture.store); + let response = app + .oneshot( + Request::builder() + .uri("/eth/v1/config/fork_schedule") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + let data = json["data"].as_array().unwrap(); + + // Phase0 comes first and is its own predecessor. + assert_eq!(data[0]["epoch"], "0"); + assert_eq!(data[0]["previous_version"], data[0]["current_version"]); + assert_eq!( + data[0]["current_version"], + hex_string(config.genesis_fork_version) + ); + // Each fork's predecessor is the previous entry's version, and epochs + // never go down. + let epoch = |v: &serde_json::Value| v["epoch"].as_str().unwrap().parse::().unwrap(); + for pair in data.windows(2) { + assert_eq!(pair[1]["previous_version"], pair[0]["current_version"]); + assert!(epoch(&pair[1]) >= epoch(&pair[0])); + } + for fork in data { + assert_ne!(fork["epoch"], constants::FAR_FUTURE_EPOCH.to_string()); + } + } + + #[test] + fn an_unscheduled_fork_is_left_out_of_the_schedule() { + let mut config = Config::mainnet(); + config.gloas_fork_epoch = constants::FAR_FUTURE_EPOCH; + let schedule = fork_schedule(&config); + let last = schedule.last().unwrap(); + assert_eq!( + last["current_version"], + hex_string(config.fulu_fork_version) + ); + + config.fulu_fork_epoch = constants::FAR_FUTURE_EPOCH; + config.gloas_fork_epoch = 100; + let schedule = fork_schedule(&config); + let last = schedule.last().unwrap(); + // Gloas follows the last scheduled fork, electra. + assert_eq!( + last["previous_version"], + hex_string(config.electra_fork_version) + ); + } + #[test] fn no_key_is_reported_twice() { let config = Config::mainnet(); diff --git a/crates/net/rpc/src/beacon/states.rs b/crates/net/rpc/src/beacon/states.rs index 2f83b1d1..5d307019 100644 --- a/crates/net/rpc/src/beacon/states.rs +++ b/crates/net/rpc/src/beacon/states.rs @@ -45,6 +45,10 @@ pub(crate) fn routes() -> Router { "/eth/v1/beacon/states/{state_id}/validators", get(get_validators).post(post_validators), ) + .route( + "/eth/v1/beacon/states/{state_id}/validators/{validator_id}", + get(get_validator), + ) } /// Resolve a `state_id` to the block root its state is stored under. @@ -258,6 +262,48 @@ async fn get_validators( validators_response(&store, &state_id, request) } +/// `GET .../validators/{validator_id}`: one registry entry, by index or public +/// key, in the shape of a list entry. Lighthouse's validator client resolves +/// each of its keys to an index this way. An id naming no validator is a 404 +/// here, unlike the list form, which omits it. +async fn get_validator( + Path((state_id, validator_id)): Path<(String, String)>, + State(store): State, +) -> Response { + let id = match ValidatorId::parse(&validator_id) { + Ok(id) => id, + Err(err) => return err.into_response(), + }; + let (root, state) = match load(&store, &state_id) { + Ok(found) => found, + Err(err) => return err.into_response(), + }; + + let found = match id { + ValidatorId::Index(index) => state + .validator(index) + .ok() + .zip(state.balance(index).ok()) + .map(|found| (index, found)), + ValidatorId::Pubkey(pubkey) => state + .iter_validators() + .zip(state.iter_balances()) + .enumerate() + .find(|(_, (validator, _))| validator.pubkey == pubkey) + .map(|(index, found)| (index as ValidatorIndex, found)), + }; + let Some((index, (validator, balance))) = found else { + return ApiError::NotFound("validator not found").into_response(); + }; + + let status = ValidatorStatus::of(validator, balance, compute_epoch_at_slot(state.slot())); + crate::json_response(serde_json::json!({ + "execution_optimistic": crate::shared::optimistic::block_is_optimistic(&store, root), + "finalized": is_finalized(&store, state.slot()), + "data": ValidatorEntry { index, balance, status: status.name(), validator }, + })) +} + /// `POST .../validators`, the form a validator client uses: a long list of /// public keys does not fit in a query string. async fn post_validators( @@ -528,6 +574,41 @@ mod tests { assert_eq!(indices, ["1", "3", "4"]); } + async fn get_one(id: &str) -> axum::response::Response { + let (app, _) = app(); + let request = Request::get(format!("/eth/v1/beacon/states/head/validators/{id}")) + .body(Body::empty()) + .unwrap(); + app.oneshot(request).await.unwrap() + } + + /// What lighthouse's validator client sends for each of its keys. + #[tokio::test] + async fn one_validator_resolves_by_pubkey_or_index() { + let (_, state) = app(); + for id in [pubkey_hex(&state, 3), "3".to_owned()] { + let response = get_one(&id).await; + assert_eq!(response.status(), StatusCode::OK); + let json = body_json(response).await; + assert_eq!(json["execution_optimistic"], false); + assert!(json["finalized"].is_boolean()); + // One object, not a list, in the shape of a list entry. + assert_eq!(json["data"]["index"], "3"); + assert_eq!(json["data"]["status"], "active_ongoing"); + assert!(json["data"]["balance"].is_string()); + assert_eq!(json["data"]["validator"]["pubkey"], pubkey_hex(&state, 3)); + } + } + + #[tokio::test] + async fn an_unknown_single_validator_is_a_404_and_a_malformed_id_a_400() { + assert_eq!(get_one("999").await.status(), StatusCode::NOT_FOUND); + let unknown = format!("0x{}", "ab".repeat(48)); + assert_eq!(get_one(&unknown).await.status(), StatusCode::NOT_FOUND); + assert_eq!(get_one("0x1234").await.status(), StatusCode::BAD_REQUEST); + assert_eq!(get_one("nope").await.status(), StatusCode::BAD_REQUEST); + } + #[test] fn status_follows_the_lifecycle() { let epoch = 10; From b217519b7a0276bc4efffa46ef081b2c7f630a77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:40:43 -0300 Subject: [PATCH 2/6] fix(rpc): accept SSZ bodies on attestation, aggregate and payload-vote submissions Prysm posts aggregate_and_proofs and payload attestations as application/octet-stream and only retries as JSON on a 415; our JSON-only decoder answered 400 "invalid request body" and its aggregates were never published. Nimbus submits attestations as SSZ too. The three batch endpoints now take either encoding, picked by Content-Type (none still means JSON), through one decode_list helper. SSZ is the specification's List[...] of the same items. Any other content type is a 415, the answer prysm's fallback keys on, and a body that does not decode is a 400. --- crates/net/rpc/src/beacon/mod.rs | 33 ++++++ crates/net/rpc/src/beacon/pool.rs | 188 ++++++++++++++++++++++++++---- crates/net/rpc/src/beacon/ptc.rs | 47 +++++++- 3 files changed, 245 insertions(+), 23 deletions(-) diff --git a/crates/net/rpc/src/beacon/mod.rs b/crates/net/rpc/src/beacon/mod.rs index 3b94fbb1..2a44821b 100644 --- a/crates/net/rpc/src/beacon/mod.rs +++ b/crates/net/rpc/src/beacon/mod.rs @@ -72,6 +72,9 @@ pub(crate) enum ApiError { /// A request this node cannot answer right now, typically because its /// execution client did not (the Beacon API's 503). ServiceUnavailable(&'static str), + /// A request body in an encoding this endpoint does not take (415). A + /// client that posted SSZ and gets this one falls back to JSON. + UnsupportedMediaType(&'static str), } impl From for ApiError { @@ -90,6 +93,7 @@ impl IntoResponse for ApiError { ApiError::NotFound(m) => (StatusCode::NOT_FOUND, m), ApiError::Internal(m) => (StatusCode::INTERNAL_SERVER_ERROR, m), ApiError::ServiceUnavailable(m) => (StatusCode::SERVICE_UNAVAILABLE, m), + ApiError::UnsupportedMediaType(m) => (StatusCode::UNSUPPORTED_MEDIA_TYPE, m), }; let body = serde_json::json!({ "code": status.as_u16(), "message": message }); let mut response = crate::json_response(body); @@ -98,6 +102,35 @@ impl IntoResponse for ApiError { } } +/// Decode the array a batch-submission endpoint takes, as JSON or as the SSZ +/// `List[T, ...]` of the same elements, by the request's `Content-Type`. +/// +/// An absent `Content-Type` is read as JSON, which is what every client that +/// predates SSZ submission sends. Anything else is a 415 rather than a guess, +/// so a client that tries SSZ first (prysm) learns this node wants the other. +pub(crate) fn decode_list( + headers: &axum::http::HeaderMap, + body: &[u8], +) -> Result, ApiError> +where + T: serde::de::DeserializeOwned + libssz::SszDecode, +{ + let content_type = headers + .get(axum::http::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(|value| value.split(';').next().unwrap_or("").trim()); + match content_type { + None | Some("application/json") => { + serde_json::from_slice(body).map_err(|_| ApiError::BadRequest("invalid request body")) + } + Some(crate::SSZ_CONTENT_TYPE) => as libssz::SszDecode>::from_ssz_bytes(body) + .map_err(|_| ApiError::BadRequest("invalid request body")), + Some(_) => Err(ApiError::UnsupportedMediaType( + "Content-Type must be application/json or application/octet-stream", + )), + } +} + /// Every route this surface serves. /// /// Deliberately not a superset of [`crate::build_api_router`]: the `/lean/v0` diff --git a/crates/net/rpc/src/beacon/pool.rs b/crates/net/rpc/src/beacon/pool.rs index 88ad1bab..03bd1429 100644 --- a/crates/net/rpc/src/beacon/pool.rs +++ b/crates/net/rpc/src/beacon/pool.rs @@ -51,7 +51,7 @@ use ethlambda_types::{ use serde::{Deserialize, Serialize}; use tracing::{debug, warn}; -use crate::beacon::{ApiError, validator::head}; +use crate::beacon::{ApiError, decode_list, validator::head}; pub(crate) fn routes() -> Router { Router::new() @@ -96,8 +96,9 @@ async fn post_pool_attestations( Ok(fork) => fork, Err(err) => return err.into_response(), }; - let Ok(attestations) = serde_json::from_slice::>(&body) else { - return ApiError::BadRequest("invalid request body").into_response(); + let attestations = match decode_list::(&headers, &body) { + Ok(attestations) => attestations, + Err(err) => return err.into_response(), }; let (_head_root, state) = match head(&store) { Ok(found) => found, @@ -340,23 +341,25 @@ async fn post_aggregate_and_proofs( Err(err) => return err.into_response(), }; // Gloas's aggregate is its own container, so the header picks the decoder. - let aggregates = if header_fork == ForkName::Gloas { - serde_json::from_slice::>(&body).map(|signed| { - signed - .into_iter() - .map(SignedAggregateAndProof::Gloas) - .collect() - }) - } else { - serde_json::from_slice::>(&body).map(|signed| { - signed - .into_iter() - .map(SignedAggregateAndProof::Electra) - .collect() - }) - }; - let Ok(aggregates): Result, _> = aggregates else { - return ApiError::BadRequest("invalid request body").into_response(); + let aggregates: Result, ApiError> = + if header_fork == ForkName::Gloas { + decode_list::(&headers, &body).map(|signed| { + signed + .into_iter() + .map(SignedAggregateAndProof::Gloas) + .collect() + }) + } else { + decode_list::(&headers, &body).map(|signed| { + signed + .into_iter() + .map(SignedAggregateAndProof::Electra) + .collect() + }) + }; + let aggregates = match aggregates { + Ok(aggregates) => aggregates, + Err(err) => return err.into_response(), }; let now_ms = std::time::SystemTime::now() @@ -1090,4 +1093,149 @@ mod tests { assert_eq!(response.status(), StatusCode::BAD_REQUEST); assert!(fixture.network.aggregates.lock().unwrap().is_empty()); } + + /// POST `body` with an explicit content type, as a client that does not + /// send JSON would. + async fn post_raw( + fixture: &Fixture, + uri: &str, + content_type: Option<&str>, + version: &str, + body: Vec, + ) -> (StatusCode, serde_json::Value) { + let network: RpcToP2PRef = fixture.network.clone(); + let app = routes() + .with_state(fixture.store.clone()) + .layer(Extension(network)) + .layer(Extension(fixture.pool.clone())); + let mut request = Request::post(uri).header("eth-consensus-version", version); + if let Some(content_type) = content_type { + request = request.header("content-type", content_type); + } + let response = app + .oneshot(request.body(Body::from(body)).unwrap()) + .await + .unwrap(); + let status = response.status(); + let body = response.into_body().collect().await.unwrap().to_bytes(); + ( + status, + serde_json::from_slice(&body).unwrap_or(serde_json::Value::Null), + ) + } + + /// Nimbus submits its attestations this way. + #[tokio::test] + async fn attestations_are_accepted_as_ssz() { + use libssz::SszEncode as _; + let fixture = fixture(); + let attestation = attestation(&fixture, 0, 0); + let (status, json) = post_raw( + &fixture, + "/eth/v2/beacon/pool/attestations", + Some("application/octet-stream"), + fixture.version, + vec![attestation.clone()].to_ssz(), + ) + .await; + assert_eq!(status, StatusCode::OK, "{json}"); + let published = fixture.network.published.lock().unwrap(); + assert_eq!(published.len(), 1); + assert_eq!(published[0].1, attestation); + } + + #[tokio::test] + async fn an_unsupported_content_type_is_a_415_and_malformed_ssz_a_400() { + let fixture = fixture(); + let uri = "/eth/v2/beacon/pool/attestations"; + let (status, json) = + post_raw(&fixture, uri, Some("text/plain"), fixture.version, vec![]).await; + assert_eq!(status, StatusCode::UNSUPPORTED_MEDIA_TYPE); + assert_eq!(json["code"], 415); + + let (status, _) = post_raw( + &fixture, + uri, + Some("application/octet-stream"), + fixture.version, + vec![1, 2, 3], + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + + // No content type at all is read as JSON, as it always was. + let (status, _) = post_raw(&fixture, uri, None, fixture.version, b"[]".to_vec()).await; + assert_eq!(status, StatusCode::OK); + } + + /// Prysm publishes its aggregates as SSZ and falls back to JSON only on a 415. + #[tokio::test] + async fn aggregates_are_accepted_as_ssz() { + use libssz::SszEncode as _; + let fixture = fixture(); + let slot = fixture.state.slot(); + let committee = get_beacon_committee(&fixture.state, slot, 0).unwrap(); + let votes: Vec = (0..committee.len()) + .map(|position| attestation(&fixture, 0, position)) + .collect(); + submit(&fixture, &votes).await; + let aggregate = fixture + .pool + .lock() + .unwrap() + .aggregate(votes[0].data.hash_tree_root(), slot, 0) + .unwrap(); + // Two entries, so the list's offset table is exercised. + let first = signed_aggregate(&fixture, committee[0], aggregate.clone()); + let second = signed_aggregate(&fixture, committee[1], aggregate); + + let (status, json) = post_raw( + &fixture, + "/eth/v2/validator/aggregate_and_proofs", + Some("application/octet-stream"), + "fulu", + vec![first.clone(), second.clone()].to_ssz(), + ) + .await; + assert_eq!(status, StatusCode::OK, "{json}"); + let published = fixture.network.aggregates.lock().unwrap(); + assert_eq!( + *published, + [ + SignedAggregateAndProof::Electra(first), + SignedAggregateAndProof::Electra(second) + ] + ); + } + + #[tokio::test] + async fn gloas_aggregates_are_accepted_as_ssz() { + use libssz::SszEncode as _; + let fixture = fixture_at(ForkName::Gloas, 0); + let slot = fixture.state.slot(); + let committee = get_beacon_committee(&fixture.state, slot, 0).unwrap(); + let votes: Vec = (0..committee.len()) + .map(|position| attestation(&fixture, 0, position)) + .collect(); + submit(&fixture, &votes).await; + let pooled = fixture + .pool + .lock() + .unwrap() + .aggregate(votes[0].data.hash_tree_root(), slot, 0) + .unwrap(); + let signed = signed_gloas_aggregate(&fixture, committee[0], &pooled); + + let (status, json) = post_raw( + &fixture, + "/eth/v2/validator/aggregate_and_proofs", + Some("application/octet-stream"), + "gloas", + vec![signed.clone()].to_ssz(), + ) + .await; + assert_eq!(status, StatusCode::OK, "{json}"); + let published = fixture.network.aggregates.lock().unwrap(); + assert_eq!(*published, [SignedAggregateAndProof::Gloas(signed)]); + } } diff --git a/crates/net/rpc/src/beacon/ptc.rs b/crates/net/rpc/src/beacon/ptc.rs index e827d3e5..3e57da6b 100644 --- a/crates/net/rpc/src/beacon/ptc.rs +++ b/crates/net/rpc/src/beacon/ptc.rs @@ -54,7 +54,7 @@ use tracing::{debug, warn}; use crate::{ CustodyColumns, - beacon::{ApiError, validator::head}, + beacon::{ApiError, decode_list, validator::head}, shared::content::{Encoding, ssz_response, with_consensus_version}, }; @@ -377,8 +377,9 @@ async fn post_pool_payload_attestations( if let Err(err) = require_gloas_or_absent(&headers) { return err.into_response(); } - let Ok(messages) = serde_json::from_slice::>(&body) else { - return ApiError::BadRequest("invalid request body").into_response(); + let messages = match decode_list::(&headers, &body) { + Ok(messages) => messages, + Err(err) => return err.into_response(), }; let now_ms = std::time::SystemTime::now() @@ -1084,6 +1085,46 @@ mod tests { assert_eq!(reply.status, StatusCode::BAD_REQUEST); } + /// Lighthouse and prysm submit their payload votes as SSZ. + #[tokio::test] + async fn votes_are_accepted_as_ssz_and_other_content_types_are_a_415() { + use libssz::SszEncode as _; + let state = gloas_state(); + let state_epoch = compute_epoch_at_slot(state.slot()); + let member = window_index(state_epoch, state_epoch, 0); + let (store, root) = store_with_head(state.clone(), gloas_config(), 0); + let message = vote(&state, root, member, true); + + let pool = SharedPayloadAttestationPool::default(); + let network = Arc::new(RecordingNetwork::default()); + let ssz_request = Request::post("/eth/v1/beacon/pool/payload_attestations") + .header("content-type", "application/octet-stream") + .header("eth-consensus-version", "gloas") + .body(Body::from(vec![message.clone()].to_ssz())) + .unwrap(); + let reply = send( + store.clone(), + pool.clone(), + network.clone(), + Vec::new(), + Default::default(), + ssz_request, + ) + .await; + assert_eq!(reply.status, StatusCode::OK); + assert_eq!(*network.payload_attestations.lock().unwrap(), vec![message]); + + let reply = request( + store, + Request::post("/eth/v1/beacon/pool/payload_attestations") + .header("content-type", "text/plain") + .body(Body::from("[]")) + .unwrap(), + ) + .await; + assert_eq!(reply.status, StatusCode::UNSUPPORTED_MEDIA_TYPE); + } + #[tokio::test] async fn the_pool_is_listed_as_aggregates_by_slot() { let state = gloas_state(); From e2aab40f08655552967272fc3538d06261876632 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:40:46 -0300 Subject: [PATCH 3/6] docs(rpc): document the endpoints and SSZ request bodies added for foreign validator clients --- docs/rpc.md | 38 +++++++++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/docs/rpc.md b/docs/rpc.md index 3c335723..fd679a61 100644 --- a/docs/rpc.md +++ b/docs/rpc.md @@ -233,22 +233,24 @@ surface rather than sitting beside it; a `/lean/v0` path on a beacon node is a | `GET` | `/eth/v1/beacon/states/{state_id}/finality_checkpoints` | JSON | That state's three checkpoints | | `GET` | `/eth/v1/beacon/genesis` | JSON | Genesis time, validators root, fork version | | `GET` | `/eth/v1/config/spec` | JSON | The store's `Config`, plus `PRESET_BASE`, `CONFIG_NAME`, the preset and the constants (see below) | +| `GET` | `/eth/v1/config/fork_schedule` | JSON | Every scheduled fork as `{previous_version, current_version, epoch}` | | `GET` | `/eth/v1/node/syncing` | JSON | Head slot, sync distance, optimistic flag | | `GET` | `/eth/v1/node/health` | *(status only)* | `200` caught up, `206` syncing | | `GET` | `/eth/v1/node/version` | JSON | Client version string | | `GET` | `/eth/v1/node/identity` | JSON | Peer ID and metadata only (see below) | | `GET`, `POST` | `/eth/v1/beacon/states/{state_id}/validators` | JSON | Registry entries by index or pubkey, with status | +| `GET` | `/eth/v1/beacon/states/{state_id}/validators/{validator_id}` | JSON | One registry entry by index or `0x` pubkey; `404` when unknown | | `GET` | `/eth/v1/validator/duties/proposer/{epoch}` | JSON | Proposers for the head's epoch or the next | | `POST` | `/eth/v1/validator/duties/attester/{epoch}` | JSON | Committee assignments for the given indices | | `POST` | `/eth/v1/validator/duties/ptc/{epoch}` | JSON | Payload timeliness committee seats for the given indices (gloas) | | `GET` | `/eth/v1/validator/attestation_data` | JSON | What to attest to at `slot` | | `GET` | `/eth/v1/validator/payload_attestation_data` | JSON or SSZ | What a committee member signs for `slot` (gloas) | -| `POST` | `/eth/v2/beacon/pool/attestations` | *(status only)* | Validate and gossip `SingleAttestation`s | -| `POST` | `/eth/v1/beacon/pool/payload_attestations` | *(status only)* | Validate, pool and gossip `PayloadAttestationMessage`s (gloas) | +| `POST` | `/eth/v2/beacon/pool/attestations` | *(status only)* | Validate and gossip `SingleAttestation`s (JSON or SSZ body) | +| `POST` | `/eth/v1/beacon/pool/payload_attestations` | *(status only)* | Validate, pool and gossip `PayloadAttestationMessage`s (gloas; JSON or SSZ body) | | `GET` | `/eth/v1/beacon/pool/payload_attestations` | JSON | The pool's votes as aggregated `PayloadAttestation`s (gloas) | | `POST` | `/eth/v1/validator/beacon_committee_subscriptions` | *(status only)* | Aggregators' entries join their committee's subnet | | `GET` | `/eth/v2/validator/aggregate_attestation` | JSON | The pooled votes for a data root and committee, aggregated | -| `POST` | `/eth/v2/validator/aggregate_and_proofs` | *(status only)* | Validate and gossip `SignedAggregateAndProof`s | +| `POST` | `/eth/v2/validator/aggregate_and_proofs` | *(status only)* | Validate and gossip `SignedAggregateAndProof`s (JSON or SSZ body) | | `GET` | `/eth/v3/validator/blocks/{slot}` | SSZ or JSON | An unsigned fulu block built on the head (`produceBlockV3`) | | `POST` | `/eth/v4/validator/blocks/{slot}` | SSZ or JSON | An unsigned self-built gloas block, with its envelope and blobs when asked (`produceBlockV4`) | | `GET` | `/eth/v1/validator/execution_payload_envelopes/{slot}/{beacon_block_root}` | SSZ or JSON | The unsigned envelope `produceBlockV4` built (gloas) | @@ -284,6 +286,12 @@ the chain actor writes, so no request waits on the actor. (`Store::head_payload_status`, or one fresh `get_head_node` walk when none is recorded for this head). A pre-gloas head counts as FULL (see [Spec Deviations](./spec_deviations.md#a-pre-gloas-head-counts-as-full-in-attestation_dataindex)). +- **`states/{state_id}/validators/{validator_id}`** is one entry of the list + endpoint, in the same shape and with the same `state_id` handling, + `execution_optimistic` and `finalized`. The id is an index or a `0x` pubkey; + one naming no validator is a `404` (the list form omits it), a malformed one + a `400`. Lighthouse's validator client resolves each key to its index this + way, and stays inactive without it. - **`pool/attestations`** checks each attestation against the electra `beacon_attestation_{subnet_id}` gossip conditions it can evaluate (clock window, `data.index == 0`, target epoch, the voted block known and the target @@ -319,6 +327,16 @@ the chain actor writes, so no request waits on the actor. nothing about them). What passes is gossiped on the topic and goes into the pool. The `Eth-Consensus-Version` header picks the decoder: `gloas` takes gloas's `SignedAggregateAndProof`, the others electra's. +- **Request body encodings.** `pool/attestations`, `pool/payload_attestations` + and `aggregate_and_proofs` take either JSON (`application/json`, also the + reading of a request with no `Content-Type`) or the SSZ `List[...]` of the + same items (`application/octet-stream`): the offset-table encoding every + variable-size list uses, which is what prysm sends for aggregates and payload + votes, with a JSON retry only on a `415`, and what nimbus can send for + attestations. Any other content type is a `415`, and a body that does not + decode in the type's encoding a `400`. A block, an envelope and the other + validator-client submissions keep their own rules (SSZ only on `blocks` and + the envelope; JSON on the rest). - **The attestation pool** holds, the best-covered per data root and committee: votes from `pool/attestations` and the aggregator subnets, aggregates from `aggregate_and_proofs`, and every electra gossip aggregate @@ -536,6 +554,20 @@ Pre-gloas responses are unchanged. One helper implements both rules (`shared/optimistic.rs`) and every response that carries the flag goes through it. +### `GET /eth/v1/config/fork_schedule` + +The `Fork` objects of the `Config`'s schedule, oldest first, as +`{previous_version, current_version, epoch}` with the epoch quoted. Phase0 is +first, at epoch `0` and its own predecessor; every later fork whose epoch is not +`FAR_FUTURE_EPOCH` follows, and an unscheduled fork is skipped without breaking +the chain, so each entry's `previous_version` is the one before it in the list. +Nimbus's validator client reads this every epoch, requires that linked-list +shape, and marks a node it cannot decode as incompatible. Nimbus also compares +`/eth/v1/config/spec` against its own constants before use: the 13 preset +values, the 7 domain types, `SECONDS_PER_SLOT` (or `SLOT_DURATION_MS`, which +this node does not report) and every fork's `*_FORK_VERSION` and `*_FORK_EPOCH`, +with altair scheduled. All are served (a unit test pins the list). + ### `GET /eth/v1/config/spec` One flat object holding the network's configuration, the compiled preset, and From 9807593b02c102937359dd4730a73dc78c3c48c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:58:04 -0300 Subject: [PATCH 4/6] feat(types): deserialize the block, envelope and request containers from Beacon API JSON The Beacon API's block and envelope request bodies are JSON as well as SSZ, and several validator clients post only JSON (teku's envelopes, nimbus's blocks, lodestar's everything). Every container a fulu or gloas signed block or execution payload envelope holds now derives Deserialize, mirroring each Serialize adapter: seq, quoted_u64_seq, ssz_hex and ssz_hex_seq gain the inverse (collected through the collection's TryFrom>, so a list bound is refused rather than truncated), and U256, KzgCommitment and KzgProof read the way they are written. Round-trip tests cover a populated fulu block, gloas block and gloas envelope. --- .../types/src/beacon/containers/altair.rs | 51 ++- .../types/src/beacon/containers/bellatrix.rs | 44 +- .../types/src/beacon/containers/capella.rs | 86 ++-- .../types/src/beacon/containers/deneb.rs | 70 +-- .../types/src/beacon/containers/electra.rs | 163 +++++-- .../types/src/beacon/containers/fulu.rs | 62 +-- .../types/src/beacon/containers/gloas.rs | 274 +++++++++--- .../types/src/beacon/containers/json_tests.rs | 405 ++++++++++++++++++ .../common/types/src/beacon/containers/mod.rs | 3 + .../types/src/beacon/containers/phase0.rs | 38 +- .../types/src/beacon/containers/shared.rs | 96 ++++- crates/common/types/src/beacon/primitives.rs | 19 +- .../common/types/src/beacon/serde_helpers.rs | 59 +++ 13 files changed, 1102 insertions(+), 268 deletions(-) create mode 100644 crates/common/types/src/beacon/containers/json_tests.rs diff --git a/crates/common/types/src/beacon/containers/altair.rs b/crates/common/types/src/beacon/containers/altair.rs index d968c113..11da84ce 100644 --- a/crates/common/types/src/beacon/containers/altair.rs +++ b/crates/common/types/src/beacon/containers/altair.rs @@ -70,10 +70,19 @@ pub type SyncSubcommitteeBits = /// needs one regardless of how many attestations or other operations it /// includes. #[derive( - Debug, Clone, Default, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot, + Debug, + Clone, + Default, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + SszEncode, + SszDecode, + HashTreeRoot, )] pub struct SyncAggregate { - #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex::serialize")] + #[serde(with = "crate::beacon::serde_helpers::ssz_hex")] pub sync_committee_bits: SyncCommitteeBits, /// The aggregate of every signature from a member set in /// `sync_committee_bits`, over the previous slot's block root. @@ -85,7 +94,7 @@ pub struct SyncAggregate { /// participates. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] pub struct SyncCommittee { - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub pubkeys: SyncCommitteePubkeys, /// The aggregate of every key in `pubkeys`, so `process_sync_aggregate` does /// not have to re-aggregate from scratch when the whole committee signs. @@ -109,15 +118,15 @@ pub struct BeaconBlockBody { pub eth1_data: Eth1Data, /// Arbitrary proposer-chosen bytes, which consensus never reads. pub graffiti: Bytes32, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub proposer_slashings: SszList, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub attester_slashings: SszList, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub attestations: SszList, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub deposits: SszList, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub voluntary_exits: SszList, /// The aggregated sync committee signature over the previous slot's block /// root, plus which members contributed. This is what lets a light client @@ -180,16 +189,16 @@ pub struct BeaconState { /// slot advances, since a block cannot commit to the root of the state /// containing it. pub latest_block_header: BeaconBlockHeader, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub block_roots: BlockRoots, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub state_roots: StateRoots, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub historical_roots: HistoricalRoots, // -- Eth1 -- pub eth1_data: Eth1Data, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub eth1_data_votes: Eth1DataVotes, /// How many deposits from the contract have been processed, which is where /// the next one will be read from. @@ -197,17 +206,17 @@ pub struct BeaconState { pub eth1_deposit_index: u64, // -- Registry -- - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub validators: Validators, - #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::quoted_u64_seq")] pub balances: Balances, // -- Randomness -- - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub randao_mixes: RandaoMixes, // -- Slashings -- - #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::quoted_u64_seq")] pub slashings: Slashings, // -- Participation -- @@ -217,15 +226,15 @@ pub struct BeaconState { /// it is processed instead of deferring to the epoch boundary, so there is /// no longer a backlog of whole attestations to keep around, only a flag /// per validator per epoch. - #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::quoted_u64_seq")] pub previous_epoch_participation: EpochParticipation, /// Flags for the current epoch, which become `previous_epoch_participation` /// at the next epoch boundary. - #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::quoted_u64_seq")] pub current_epoch_participation: EpochParticipation, // -- Finality -- - #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex::serialize")] + #[serde(with = "crate::beacon::serde_helpers::ssz_hex")] pub justification_bits: JustificationBits, pub previous_justified_checkpoint: Checkpoint, pub current_justified_checkpoint: Checkpoint, @@ -237,7 +246,7 @@ pub struct BeaconState { /// non-finalizing epoch and falls otherwise, which is what lets the /// inactivity leak single out validators who are actually offline rather /// than penalizing everyone during a stall. - #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::quoted_u64_seq")] pub inactivity_scores: InactivityScores, // -- Sync committees -- @@ -289,7 +298,7 @@ pub struct SyncCommitteeContribution { pub beacon_block_root: Root, #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] pub subcommittee_index: u64, - #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex::serialize")] + #[serde(with = "crate::beacon::serde_helpers::ssz_hex")] pub aggregation_bits: SyncSubcommitteeBits, /// The aggregate signature of every member set in `aggregation_bits`, over /// `beacon_block_root`. diff --git a/crates/common/types/src/beacon/containers/bellatrix.rs b/crates/common/types/src/beacon/containers/bellatrix.rs index 80993a8b..daafe8ca 100644 --- a/crates/common/types/src/beacon/containers/bellatrix.rs +++ b/crates/common/types/src/beacon/containers/bellatrix.rs @@ -91,7 +91,7 @@ pub struct ExecutionPayload { /// own `state_root`. pub state_root: Bytes32, pub receipts_root: Bytes32, - #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex::serialize")] + #[serde(with = "crate::beacon::serde_helpers::ssz_hex")] pub logs_bloom: LogsBloom, /// The randomness the beacon chain exposes to the EVM for this block; /// `difficulty` in the yellow paper before the merge repurposed the @@ -105,7 +105,7 @@ pub struct ExecutionPayload { pub gas_used: u64, #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] pub timestamp: u64, - #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex::serialize")] + #[serde(with = "crate::beacon::serde_helpers::ssz_hex")] pub extra_data: ExtraData, /// This block's EIP-1559 base fee, a `uint256` because the execution /// layer's fee market is not bounded to fit a `uint64`. @@ -113,7 +113,7 @@ pub struct ExecutionPayload { /// This payload's own hash, which the next payload's `parent_hash` must /// equal. pub block_hash: ExecutionBlockHash, - #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex_seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::ssz_hex_seq")] pub transactions: Transactions, } @@ -127,7 +127,7 @@ pub struct ExecutionPayloadHeader { pub fee_recipient: ExecutionAddress, pub state_root: Bytes32, pub receipts_root: Bytes32, - #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex::serialize")] + #[serde(with = "crate::beacon::serde_helpers::ssz_hex")] pub logs_bloom: LogsBloom, pub prev_randao: Bytes32, #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] @@ -138,7 +138,7 @@ pub struct ExecutionPayloadHeader { pub gas_used: u64, #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] pub timestamp: u64, - #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex::serialize")] + #[serde(with = "crate::beacon::serde_helpers::ssz_hex")] pub extra_data: ExtraData, pub base_fee_per_gas: Uint256, /// The hash of the execution block this header summarizes. @@ -177,15 +177,15 @@ pub struct BeaconBlockBody { pub eth1_data: Eth1Data, /// Arbitrary proposer-chosen bytes, which consensus never reads. pub graffiti: Bytes32, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub proposer_slashings: SszList, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub attester_slashings: SszList, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub attestations: SszList, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub deposits: SszList, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub voluntary_exits: SszList, /// The aggregated sync committee signature over the previous slot's block /// root, plus which members contributed. @@ -292,16 +292,16 @@ pub struct BeaconState { /// slot advances, since a block cannot commit to the root of the state /// containing it. pub latest_block_header: BeaconBlockHeader, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub block_roots: BlockRoots, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub state_roots: StateRoots, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub historical_roots: HistoricalRoots, // -- Eth1 -- pub eth1_data: Eth1Data, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub eth1_data_votes: Eth1DataVotes, /// How many deposits from the contract have been processed, which is where /// the next one will be read from. @@ -309,31 +309,31 @@ pub struct BeaconState { pub eth1_deposit_index: u64, // -- Registry -- - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub validators: Validators, - #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::quoted_u64_seq")] pub balances: Balances, // -- Randomness -- - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub randao_mixes: RandaoMixes, // -- Slashings -- - #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::quoted_u64_seq")] pub slashings: Slashings, // -- Participation -- /// Per-validator participation flags for the previous epoch, positionally /// parallel to `validators`. - #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::quoted_u64_seq")] pub previous_epoch_participation: EpochParticipation, /// Flags for the current epoch, which become `previous_epoch_participation` /// at the next epoch boundary. - #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::quoted_u64_seq")] pub current_epoch_participation: EpochParticipation, // -- Finality -- - #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex::serialize")] + #[serde(with = "crate::beacon::serde_helpers::ssz_hex")] pub justification_bits: JustificationBits, pub previous_justified_checkpoint: Checkpoint, pub current_justified_checkpoint: Checkpoint, @@ -341,7 +341,7 @@ pub struct BeaconState { // -- Inactivity -- /// Per-validator inactivity score, positionally parallel to `validators`. - #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::quoted_u64_seq")] pub inactivity_scores: InactivityScores, // -- Sync committees -- diff --git a/crates/common/types/src/beacon/containers/capella.rs b/crates/common/types/src/beacon/containers/capella.rs index 0ec2e397..c536b1be 100644 --- a/crates/common/types/src/beacon/containers/capella.rs +++ b/crates/common/types/src/beacon/containers/capella.rs @@ -70,7 +70,17 @@ pub type Withdrawals = SszList, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub attester_slashings: SszList, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub attestations: SszList, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub deposits: SszList, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub voluntary_exits: SszList, /// The aggregated sync committee signature over the previous slot's block /// root, plus which members contributed. @@ -234,7 +264,7 @@ pub struct BeaconBlockBody { /// holds here, so it never affects this same slot's withdrawal sweep: that /// sweep already ran, against whatever credentials were in effect before /// this block. - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub bls_to_execution_changes: SszList, } @@ -336,18 +366,18 @@ pub struct BeaconState { /// slot advances, since a block cannot commit to the root of the state /// containing it. pub latest_block_header: BeaconBlockHeader, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub block_roots: BlockRoots, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub state_roots: StateRoots, /// Frozen as of this fork: no longer appended to. See /// [`Self::historical_summaries`]. - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub historical_roots: HistoricalRoots, // -- Eth1 -- pub eth1_data: Eth1Data, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub eth1_data_votes: Eth1DataVotes, /// How many deposits from the contract have been processed, which is where /// the next one will be read from. @@ -355,31 +385,31 @@ pub struct BeaconState { pub eth1_deposit_index: u64, // -- Registry -- - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub validators: Validators, - #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::quoted_u64_seq")] pub balances: Balances, // -- Randomness -- - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub randao_mixes: RandaoMixes, // -- Slashings -- - #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::quoted_u64_seq")] pub slashings: Slashings, // -- Participation -- /// Per-validator participation flags for the previous epoch, positionally /// parallel to `validators`. - #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::quoted_u64_seq")] pub previous_epoch_participation: EpochParticipation, /// Flags for the current epoch, which become `previous_epoch_participation` /// at the next epoch boundary. - #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::quoted_u64_seq")] pub current_epoch_participation: EpochParticipation, // -- Finality -- - #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex::serialize")] + #[serde(with = "crate::beacon::serde_helpers::ssz_hex")] pub justification_bits: JustificationBits, pub previous_justified_checkpoint: Checkpoint, pub current_justified_checkpoint: Checkpoint, @@ -387,7 +417,7 @@ pub struct BeaconState { // -- Inactivity -- /// Per-validator inactivity score, positionally parallel to `validators`. - #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::quoted_u64_seq")] pub inactivity_scores: InactivityScores, // -- Sync committees -- @@ -418,7 +448,7 @@ pub struct BeaconState { /// root period, replacing the growth of `historical_roots` as of this /// fork. See [`super::shared::HistoricalSummary`] for why the two forms /// are `hash_tree_root`-compatible. - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub historical_summaries: HistoricalSummaries, } diff --git a/crates/common/types/src/beacon/containers/deneb.rs b/crates/common/types/src/beacon/containers/deneb.rs index 3092844f..195dbeb2 100644 --- a/crates/common/types/src/beacon/containers/deneb.rs +++ b/crates/common/types/src/beacon/containers/deneb.rs @@ -86,13 +86,23 @@ pub type VersionedHashes = SszList, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub attester_slashings: SszList, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub attestations: SszList, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub deposits: SszList, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub voluntary_exits: SszList, /// The aggregated sync committee signature over the previous slot's block /// root, plus which members contributed. @@ -201,14 +211,14 @@ pub struct BeaconBlockBody { /// each [`SignedBLSToExecutionChange`] wraps a /// [`super::capella::BLSToExecutionChange`] with a signature proving its /// holder controls the credential being changed. - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub bls_to_execution_changes: SszList, /// One KZG commitment per blob this block's proposer chose to include. /// Never the blobs themselves: those are propagated separately as /// [`BlobSidecar`]s, which is what keeps a block's own size independent /// of how much blob data it references. - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub blob_kzg_commitments: KzgCommitments, } @@ -307,16 +317,16 @@ pub struct BeaconState { /// slot advances, since a block cannot commit to the root of the state /// containing it. pub latest_block_header: BeaconBlockHeader, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub block_roots: BlockRoots, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub state_roots: StateRoots, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub historical_roots: HistoricalRoots, // -- Eth1 -- pub eth1_data: Eth1Data, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub eth1_data_votes: Eth1DataVotes, /// How many deposits from the contract have been processed, which is /// where the next one will be read from. @@ -324,31 +334,31 @@ pub struct BeaconState { pub eth1_deposit_index: u64, // -- Registry -- - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub validators: Validators, - #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::quoted_u64_seq")] pub balances: Balances, // -- Randomness -- - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub randao_mixes: RandaoMixes, // -- Slashings -- - #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::quoted_u64_seq")] pub slashings: Slashings, // -- Participation -- /// Per-validator participation flags for the previous epoch, positionally /// parallel to `validators`. - #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::quoted_u64_seq")] pub previous_epoch_participation: EpochParticipation, /// Flags for the current epoch, which become /// `previous_epoch_participation` at the next epoch boundary. - #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::quoted_u64_seq")] pub current_epoch_participation: EpochParticipation, // -- Finality -- - #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex::serialize")] + #[serde(with = "crate::beacon::serde_helpers::ssz_hex")] pub justification_bits: JustificationBits, pub previous_justified_checkpoint: Checkpoint, pub current_justified_checkpoint: Checkpoint, @@ -356,7 +366,7 @@ pub struct BeaconState { // -- Inactivity -- /// Per-validator inactivity score, positionally parallel to `validators`. - #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::quoted_u64_seq")] pub inactivity_scores: InactivityScores, // -- Sync committees -- @@ -384,7 +394,7 @@ pub struct BeaconState { // -- History -- /// Capella's replacement for whole [`super::shared::HistoricalBatch`] /// roots: one summary per historical window, appended the same way. - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub historical_summaries: HistoricalSummaries, } @@ -430,7 +440,7 @@ pub struct BlobSidecar { /// is also its leaf index for `kzg_commitment_inclusion_proof`. #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] pub index: BlobIndex, - #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex::serialize")] + #[serde(with = "crate::beacon::serde_helpers::ssz_hex")] pub blob: Blob, pub kzg_commitment: KzgCommitment, /// A KZG proof that `blob` evaluates to `kzg_commitment`, checkable @@ -439,7 +449,7 @@ pub struct BlobSidecar { /// The header of the block this blob belongs to, signed by its proposer, /// which is what `kzg_commitment_inclusion_proof` terminates at. pub signed_block_header: SignedBeaconBlockHeader, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub kzg_commitment_inclusion_proof: BlobKzgCommitmentInclusionProof, } diff --git a/crates/common/types/src/beacon/containers/electra.rs b/crates/common/types/src/beacon/containers/electra.rs index 4610957c..20ed5e7c 100644 --- a/crates/common/types/src/beacon/containers/electra.rs +++ b/crates/common/types/src/beacon/containers/electra.rs @@ -161,11 +161,21 @@ pub struct Attestation { /// the state transition converts an [`Attestation`] into this form before /// checking it. `attesting_indices` now spans every committee an /// [`Attestation`] covers, following the same widening as [`AggregationBits`]. -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +#[derive( + Debug, + Clone, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + SszEncode, + SszDecode, + HashTreeRoot, +)] pub struct IndexedAttestation { /// The attesters, which the specification requires to be sorted and /// unique. - #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::quoted_u64_seq")] pub attesting_indices: AttestingIndices, pub data: AttestationData, pub signature: BlsSignature, @@ -179,7 +189,17 @@ pub struct IndexedAttestation { /// `MAX_ATTESTER_SLASHINGS_ELECTRA` rather than phase0's larger /// `MAX_ATTESTER_SLASHINGS`: evidence spanning a whole slot is proportionally /// more expensive to include. -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +#[derive( + Debug, + Clone, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + SszEncode, + SszDecode, + HashTreeRoot, +)] pub struct AttesterSlashing { pub attestation_1: IndexedAttestation, pub attestation_2: IndexedAttestation, @@ -232,7 +252,17 @@ pub struct SingleAttestation { /// unlike a contract-log deposit, a request arrives already ordered by the /// execution layer, so there is no merkle proof to check, only a position to /// record. -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +#[derive( + Debug, + Clone, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + SszEncode, + SszDecode, + HashTreeRoot, +)] pub struct DepositRequest { pub pubkey: BlsPubkey, pub withdrawal_credentials: Bytes32, @@ -250,7 +280,17 @@ pub struct DepositRequest { /// credentials before honoring the request. An `amount` of /// `FULL_EXIT_REQUEST_AMOUNT` signals a full exit rather than a partial /// withdrawal of that amount. -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +#[derive( + Debug, + Clone, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + SszEncode, + SszDecode, + HashTreeRoot, +)] pub struct WithdrawalRequest { pub source_address: ExecutionAddress, pub validator_pubkey: BlsPubkey, @@ -262,7 +302,17 @@ pub struct WithdrawalRequest { /// to merge `source_pubkey`'s balance into `target_pubkey`'s and exit the /// source, which is how a validator raises its effective balance past /// `MAX_EFFECTIVE_BALANCE` without a fresh deposit. -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +#[derive( + Debug, + Clone, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + SszEncode, + SszDecode, + HashTreeRoot, +)] pub struct ConsolidationRequest { pub source_address: ExecutionAddress, pub source_pubkey: BlsPubkey, @@ -272,14 +322,23 @@ pub struct ConsolidationRequest { /// The execution payload's envelope for every execution-layer-triggered /// request in this block, grouped by kind. #[derive( - Debug, Clone, Default, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot, + Debug, + Clone, + Default, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + SszEncode, + SszDecode, + HashTreeRoot, )] pub struct ExecutionRequests { - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub deposits: SszList, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub withdrawals: SszList, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub consolidations: SszList, } @@ -294,7 +353,7 @@ pub struct ExecutionRequests { #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] pub struct NewPayloadRequest { pub execution_payload: ExecutionPayload, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub versioned_hashes: VersionedHashes, pub parent_beacon_block_root: Root, pub execution_requests: ExecutionRequests, @@ -367,7 +426,17 @@ pub struct PendingConsolidation { /// Unchanged from deneb except for three things: `attester_slashings` and /// `attestations` are now bounded (and, for attestations, shaped) /// differently, and `execution_requests` is appended at the end. -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +#[derive( + Debug, + Clone, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + SszEncode, + SszDecode, + HashTreeRoot, +)] pub struct BeaconBlockBody { /// The proposer's contribution to the chain's randomness, which is a /// signature over the current epoch and so cannot be chosen freely. @@ -376,24 +445,24 @@ pub struct BeaconBlockBody { pub eth1_data: Eth1Data, /// Arbitrary proposer-chosen bytes, which consensus never reads. pub graffiti: Bytes32, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub proposer_slashings: SszList, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub attester_slashings: AttesterSlashings, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub attestations: Attestations, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub deposits: SszList, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub voluntary_exits: SszList, /// The aggregated sync committee signature over the previous slot's block /// root, plus which members contributed. pub sync_aggregate: SyncAggregate, pub execution_payload: ExecutionPayload, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub bls_to_execution_changes: SszList, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub blob_kzg_commitments: KzgCommitments, /// The execution-layer-triggered deposit, withdrawal, and consolidation /// requests carried by this block's payload. The one field electra adds @@ -449,7 +518,17 @@ impl BeaconBlockBody { } /// A block. -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +#[derive( + Debug, + Clone, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + SszEncode, + SszDecode, + HashTreeRoot, +)] pub struct BeaconBlock { #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] pub slot: Slot, @@ -462,7 +541,17 @@ pub struct BeaconBlock { pub body: BeaconBlockBody, } -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +#[derive( + Debug, + Clone, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + SszEncode, + SszDecode, + HashTreeRoot, +)] pub struct SignedBeaconBlock { pub message: BeaconBlock, pub signature: BlsSignature, @@ -508,16 +597,16 @@ pub struct BeaconState { /// slot advances, since a block cannot commit to the root of the state /// containing it. pub latest_block_header: BeaconBlockHeader, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub block_roots: BlockRoots, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub state_roots: StateRoots, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub historical_roots: HistoricalRoots, // -- Eth1 -- pub eth1_data: Eth1Data, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub eth1_data_votes: Eth1DataVotes, /// How many deposits from the contract have been processed, which is /// where the next one will be read from. Superseded for new deposits once @@ -527,34 +616,34 @@ pub struct BeaconState { pub eth1_deposit_index: u64, // -- Registry -- - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub validators: Validators, - #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::quoted_u64_seq")] pub balances: Balances, // -- Randomness -- - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub randao_mixes: RandaoMixes, // -- Slashings -- - #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::quoted_u64_seq")] pub slashings: Slashings, // -- Participation -- - #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::quoted_u64_seq")] pub previous_epoch_participation: EpochParticipation, - #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::quoted_u64_seq")] pub current_epoch_participation: EpochParticipation, // -- Finality -- - #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex::serialize")] + #[serde(with = "crate::beacon::serde_helpers::ssz_hex")] pub justification_bits: JustificationBits, pub previous_justified_checkpoint: Checkpoint, pub current_justified_checkpoint: Checkpoint, pub finalized_checkpoint: Checkpoint, // -- Inactivity -- - #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::quoted_u64_seq")] pub inactivity_scores: InactivityScores, // -- Sync committees -- @@ -577,7 +666,7 @@ pub struct BeaconState { pub next_withdrawal_validator_index: ValidatorIndex, // -- Deep history -- - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub historical_summaries: HistoricalSummaries, // -- Deposit requests (EIP-6110) -- @@ -614,15 +703,15 @@ pub struct BeaconState { // -- Pending queues (EIP-7251) -- /// Deposits known but not yet credited to the validator registry, /// drained a bounded amount per epoch by `process_pending_deposits`. - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub pending_deposits: PendingDeposits, /// Partial withdrawals known but not yet paid out, drained a bounded /// amount per slot by `get_expected_withdrawals`. - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub pending_partial_withdrawals: PendingPartialWithdrawals, /// Consolidations known but not yet applied, drained a bounded amount per /// epoch by `process_pending_consolidations`. - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub pending_consolidations: PendingConsolidations, } diff --git a/crates/common/types/src/beacon/containers/fulu.rs b/crates/common/types/src/beacon/containers/fulu.rs index 83167aca..a5f9d7b4 100644 --- a/crates/common/types/src/beacon/containers/fulu.rs +++ b/crates/common/types/src/beacon/containers/fulu.rs @@ -124,17 +124,17 @@ pub struct DataColumnSidecar { /// (which requires each element to already implement `Serialize`) or /// [`ssz_hex`](crate::beacon::serde_helpers::ssz_hex) (which would hex-encode /// the whole list as one string instead of one string per cell). - #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex_seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::ssz_hex_seq")] pub column: DataColumn, /// The block's full `blob_kzg_commitments`, repeated in every one of that /// block's sidecars rather than fetched separately, which is what lets a /// sidecar be checked on its own. - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub kzg_commitments: KzgCommitments, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub kzg_proofs: KzgProofs, pub signed_block_header: SignedBeaconBlockHeader, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub kzg_commitments_inclusion_proof: KzgCommitmentsInclusionProof, } @@ -153,7 +153,7 @@ pub struct MatrixEntry { /// what [`ssz_hex`](crate::beacon::serde_helpers::ssz_hex) exists for, /// unlike [`DataColumnSidecar::column`], which holds a whole list of /// cells. - #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex::serialize")] + #[serde(with = "crate::beacon::serde_helpers::ssz_hex")] pub cell: Cell, pub kzg_proof: KzgProof, #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] @@ -169,7 +169,7 @@ pub struct MatrixEntry { )] pub struct DataColumnsByRootIdentifier { pub block_root: Root, - #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::quoted_u64_seq")] pub columns: ColumnIndices, } @@ -208,13 +208,13 @@ pub type OptionalPartialDataColumnHeader = SszList; /// from the gossipsub topic's subnet. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] pub struct PartialDataColumnSidecar { - #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex::serialize")] + #[serde(with = "crate::beacon::serde_helpers::ssz_hex")] pub cells_present_bitmap: CellsBitList, - #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex_seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::ssz_hex_seq")] pub partial_column: DataColumn, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub kzg_proofs: KzgProofs, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub header: OptionalPartialDataColumnHeader, } @@ -226,9 +226,9 @@ pub struct PartialDataColumnSidecar { /// already encodes. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] pub struct PartialDataColumnPartsMetadata { - #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex::serialize")] + #[serde(with = "crate::beacon::serde_helpers::ssz_hex")] pub available: CellsBitList, - #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex::serialize")] + #[serde(with = "crate::beacon::serde_helpers::ssz_hex")] pub requests: CellsBitList, } @@ -243,10 +243,10 @@ pub struct PartialDataColumnPartsMetadata { /// [`DataColumnSidecar::kzg_commitments_inclusion_proof`] uses. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] pub struct PartialDataColumnHeader { - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub kzg_commitments: KzgCommitments, pub signed_block_header: SignedBeaconBlockHeader, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub kzg_commitments_inclusion_proof: KzgCommitmentsInclusionProof, } @@ -290,20 +290,20 @@ pub struct BeaconState { /// slot advances, since a block cannot commit to the root of the state /// containing it. pub latest_block_header: BeaconBlockHeader, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub block_roots: BlockRoots, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub state_roots: StateRoots, /// Frozen since capella: further history is committed to by /// `historical_summaries` instead. Kept rather than removed so a fulu /// state's shape stays hash-tree-root-compatible with every root /// committed while this field was still growing. - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub historical_roots: HistoricalRoots, // -- Eth1 -- pub eth1_data: Eth1Data, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub eth1_data_votes: Eth1DataVotes, /// How many deposits from the contract have been processed, which is /// where the next one will be read from. @@ -311,32 +311,32 @@ pub struct BeaconState { pub eth1_deposit_index: u64, // -- Registry -- - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub validators: Validators, - #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::quoted_u64_seq")] pub balances: Balances, // -- Randomness -- - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub randao_mixes: RandaoMixes, // -- Slashings -- - #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::quoted_u64_seq")] pub slashings: Slashings, // -- Participation -- /// Per-validator participation flags for the previous epoch, positionally /// parallel to `validators`. See altair for why this replaces phase0's /// accumulated attestations. - #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::quoted_u64_seq")] pub previous_epoch_participation: EpochParticipation, /// Flags for the current epoch, which become `previous_epoch_participation` /// at the next epoch boundary. - #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::quoted_u64_seq")] pub current_epoch_participation: EpochParticipation, // -- Finality -- - #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex::serialize")] + #[serde(with = "crate::beacon::serde_helpers::ssz_hex")] pub justification_bits: JustificationBits, pub previous_justified_checkpoint: Checkpoint, pub current_justified_checkpoint: Checkpoint, @@ -344,7 +344,7 @@ pub struct BeaconState { // -- Inactivity -- /// Per-validator inactivity score, positionally parallel to `validators`. - #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::quoted_u64_seq")] pub inactivity_scores: InactivityScores, // -- Sync committees -- @@ -373,7 +373,7 @@ pub struct BeaconState { // -- History (capella) -- /// The commitment to history from capella onward, appended to instead of /// `historical_roots` once that field was frozen. - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub historical_summaries: HistoricalSummaries, // -- Deposits, exits, and consolidations (electra) -- @@ -404,13 +404,13 @@ pub struct BeaconState { pub earliest_consolidation_epoch: Epoch, /// Deposits credited on the execution layer but not yet applied to the /// registry, drained a bounded number at a time each epoch. - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub pending_deposits: PendingDeposits, /// Partial withdrawals requested but not yet paid out. - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub pending_partial_withdrawals: PendingPartialWithdrawals, /// Validator consolidations requested but not yet applied. - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub pending_consolidations: PendingConsolidations, // -- Proposer lookahead (fulu) -- @@ -419,7 +419,7 @@ pub struct BeaconState { /// epoch boundary by `process_proposer_lookahead` so that /// `get_beacon_proposer_index` becomes a lookup into this vector rather /// than a shuffle computed on demand. - #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::quoted_u64_seq")] pub proposer_lookahead: ProposerLookahead, } diff --git a/crates/common/types/src/beacon/containers/gloas.rs b/crates/common/types/src/beacon/containers/gloas.rs index 51d236b7..09838fa0 100644 --- a/crates/common/types/src/beacon/containers/gloas.rs +++ b/crates/common/types/src/beacon/containers/gloas.rs @@ -232,7 +232,16 @@ pub struct BuilderPendingWithdrawal { /// An execution-layer-triggered builder deposit (EIP-8282), the builder-side /// counterpart of [`super::electra::DepositRequest`]. #[derive( - Debug, Clone, Default, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot, + Debug, + Clone, + Default, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + SszEncode, + SszDecode, + HashTreeRoot, )] pub struct BuilderDepositRequest { pub pubkey: BlsPubkey, @@ -245,7 +254,16 @@ pub struct BuilderDepositRequest { /// An execution-layer-triggered builder exit (EIP-8282), the builder-side /// counterpart of [`super::electra::WithdrawalRequest`]'s full-exit case. #[derive( - Debug, Clone, Default, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot, + Debug, + Clone, + Default, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + SszEncode, + SszDecode, + HashTreeRoot, )] pub struct BuilderExitRequest { pub source_address: ExecutionAddress, @@ -280,10 +298,20 @@ pub struct PayloadAttestationData { } /// An aggregate payload timeliness attestation, as included in a block. -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +#[derive( + Debug, + Clone, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + SszEncode, + SszDecode, + HashTreeRoot, +)] #[ssz(progressive_container)] pub struct PayloadAttestation { - #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex::serialize")] + #[serde(with = "crate::beacon::serde_helpers::ssz_hex")] pub aggregation_bits: PayloadTimelinessCommitteeBits, pub data: PayloadAttestationData, pub signature: BlsSignature, @@ -311,10 +339,20 @@ pub struct PayloadAttestationMessage { /// A [`PayloadAttestation`] with its attesters named rather than bit-encoded, /// the payload-attestation counterpart of [`IndexedAttestation`]. -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +#[derive( + Debug, + Clone, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + SszEncode, + SszDecode, + HashTreeRoot, +)] #[ssz(progressive_container)] pub struct IndexedPayloadAttestation { - #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::quoted_u64_seq")] pub attesting_indices: PayloadTimelinessCommitteeIndices, pub data: PayloadAttestationData, pub signature: BlsSignature, @@ -328,7 +366,16 @@ pub struct IndexedPayloadAttestation { /// proposer chooses among, and signs over, in place of embedding a payload in /// the block directly. #[derive( - Debug, Clone, Default, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot, + Debug, + Clone, + Default, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + SszEncode, + SszDecode, + HashTreeRoot, )] #[ssz(progressive_container)] pub struct ExecutionPayloadBid { @@ -349,7 +396,7 @@ pub struct ExecutionPayloadBid { /// the payload is never revealed or attested to as available. #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] pub execution_payment: Gwei, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub blob_kzg_commitments: BlobKzgCommitments, /// The root of the [`ExecutionRequests`] this payload's envelope will /// carry, committed to here so the bid is binding on the requests too, @@ -358,7 +405,16 @@ pub struct ExecutionPayloadBid { } #[derive( - Debug, Clone, Default, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot, + Debug, + Clone, + Default, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + SszEncode, + SszDecode, + HashTreeRoot, )] pub struct SignedExecutionPayloadBid { pub message: ExecutionPayloadBid, @@ -372,19 +428,28 @@ pub struct SignedExecutionPayloadBid { /// and `builder_exits` (EIP-8282), the execution-layer path [`Builder`]s are /// onboarded and retired through from the fork onward. #[derive( - Debug, Clone, Default, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot, + Debug, + Clone, + Default, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + SszEncode, + SszDecode, + HashTreeRoot, )] #[ssz(progressive_container)] pub struct ExecutionRequests { - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub deposits: DepositRequests, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub withdrawals: WithdrawalRequests, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub consolidations: ConsolidationRequests, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub builder_deposits: BuilderDepositRequests, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub builder_exits: BuilderExitRequests, } @@ -397,14 +462,24 @@ pub struct ExecutionRequests { /// (EIP-7928) and `slot_number` (EIP-8061) are appended. Not /// `#[derive(Default)]`: `logs_bloom` is an [`SszVector`], which, like every /// earlier fork's payload, has no meaningful empty value. -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +#[derive( + Debug, + Clone, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + SszEncode, + SszDecode, + HashTreeRoot, +)] #[ssz(progressive_container)] pub struct ExecutionPayload { pub parent_hash: ExecutionBlockHash, pub fee_recipient: ExecutionAddress, pub state_root: Bytes32, pub receipts_root: Bytes32, - #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex::serialize")] + #[serde(with = "crate::beacon::serde_helpers::ssz_hex")] pub logs_bloom: LogsBloom, pub prev_randao: Bytes32, #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] @@ -415,13 +490,13 @@ pub struct ExecutionPayload { pub gas_used: u64, #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] pub timestamp: u64, - #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex::serialize")] + #[serde(with = "crate::beacon::serde_helpers::ssz_hex")] pub extra_data: ExtraData, pub base_fee_per_gas: Uint256, pub block_hash: ExecutionBlockHash, - #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex_seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::ssz_hex_seq")] pub transactions: Transactions, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub withdrawals: Withdrawals, #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] pub blob_gas_used: u64, @@ -429,7 +504,7 @@ pub struct ExecutionPayload { pub excess_blob_gas: u64, /// The serialized block access list this payload's execution produced /// (EIP-7928). - #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex::serialize")] + #[serde(with = "crate::beacon::serde_helpers::ssz_hex")] pub block_access_list: BlockAccessList, /// The slot this payload was built for (EIP-8061's gas limit schedule /// reads it against the schedule rather than the enclosing block's own @@ -441,7 +516,17 @@ pub struct ExecutionPayload { /// A builder-signed reveal of the payload it bid for: the payload itself, /// the execution-layer-triggered requests it carries, and enough to bind it /// to the block whose [`ExecutionPayloadBid`] it fulfills. -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +#[derive( + Debug, + Clone, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + SszEncode, + SszDecode, + HashTreeRoot, +)] #[ssz(progressive_container)] pub struct ExecutionPayloadEnvelope { pub payload: ExecutionPayload, @@ -452,7 +537,17 @@ pub struct ExecutionPayloadEnvelope { pub parent_beacon_block_root: Root, } -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +#[derive( + Debug, + Clone, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + SszEncode, + SszDecode, + HashTreeRoot, +)] pub struct SignedExecutionPayloadEnvelope { pub message: ExecutionPayloadEnvelope, pub signature: BlsSignature, @@ -469,7 +564,7 @@ pub struct SignedExecutionPayloadEnvelope { #[ssz(progressive_container)] pub struct NewPayloadRequest { pub execution_payload: ExecutionPayload, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub versioned_hashes: VersionedHashes, pub parent_beacon_block_root: Root, pub execution_requests: ExecutionRequests, @@ -555,10 +650,20 @@ impl TryFrom<&Attestation> for super::electra::Attestation { /// An attestation with its attesters named rather than bit-encoded, the same /// role electra's plays, with `attesting_indices` now the unbounded /// [`AttestingIndices`]. -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +#[derive( + Debug, + Clone, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + SszEncode, + SszDecode, + HashTreeRoot, +)] #[ssz(progressive_container)] pub struct IndexedAttestation { - #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::quoted_u64_seq")] pub attesting_indices: AttestingIndices, pub data: AttestationData, pub signature: BlsSignature, @@ -567,7 +672,17 @@ pub struct IndexedAttestation { /// Evidence that a set of validators made two conflicting attestations. /// Unchanged in shape from electra; what changed is [`IndexedAttestation`] /// itself. -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +#[derive( + Debug, + Clone, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + SszEncode, + SszDecode, + HashTreeRoot, +)] pub struct AttesterSlashing { pub attestation_1: IndexedAttestation, pub attestation_2: IndexedAttestation, @@ -627,7 +742,16 @@ pub struct SignedAggregateAndProof { /// separate fetch). Every other field is electra's, now progressive /// (EIP-7688). #[derive( - Debug, Clone, Default, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot, + Debug, + Clone, + Default, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + SszEncode, + SszDecode, + HashTreeRoot, )] #[ssz(progressive_container)] pub struct BeaconBlockBody { @@ -638,20 +762,20 @@ pub struct BeaconBlockBody { pub eth1_data: Eth1Data, /// Arbitrary proposer-chosen bytes, which consensus never reads. pub graffiti: Bytes32, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub proposer_slashings: ProposerSlashings, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub attester_slashings: AttesterSlashings, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub attestations: Attestations, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub deposits: Deposits, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub voluntary_exits: VoluntaryExits, /// The aggregated sync committee signature over the previous slot's /// block root, plus which members contributed. pub sync_aggregate: SyncAggregate, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub bls_to_execution_changes: BlsToExecutionChanges, /// The proposer's binding commitment to the builder's bid for this /// slot's payload (EIP-7732); the payload itself is revealed afterward @@ -659,7 +783,7 @@ pub struct BeaconBlockBody { pub signed_execution_payload_bid: SignedExecutionPayloadBid, /// The payload timeliness committee's votes on the *previous* slot's /// payload. - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub payload_attestations: PayloadAttestations, /// The previous slot's execution-layer-triggered requests, carried /// forward so `process_payload_attestation` and friends do not need a @@ -684,7 +808,16 @@ impl BeaconBlockBody { /// A block. #[derive( - Debug, Clone, Default, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot, + Debug, + Clone, + Default, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + SszEncode, + SszDecode, + HashTreeRoot, )] pub struct BeaconBlock { #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] @@ -699,7 +832,16 @@ pub struct BeaconBlock { } #[derive( - Debug, Clone, Default, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot, + Debug, + Clone, + Default, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + SszEncode, + SszDecode, + HashTreeRoot, )] pub struct SignedBeaconBlock { pub message: BeaconBlock, @@ -747,18 +889,18 @@ pub struct BeaconState { /// slot advances, since a block cannot commit to the root of the state /// containing it. pub latest_block_header: BeaconBlockHeader, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub block_roots: BlockRoots, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub state_roots: StateRoots, /// Frozen since capella: further history is committed to by /// `historical_summaries` instead. - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub historical_roots: HistoricalRoots, // -- Eth1 -- pub eth1_data: Eth1Data, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub eth1_data_votes: Eth1DataVotes, /// How many deposits from the contract have been processed, which is /// where the next one will be read from. @@ -768,34 +910,34 @@ pub struct BeaconState { // -- Registry (EIP-7688) -- /// The validator registry, tree-backed and progressively merkleized. See /// [`ProgressiveValidators`]. - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub validators: ProgressiveValidators, - #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::quoted_u64_seq")] pub balances: ProgressiveBalances, // -- Randomness -- - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub randao_mixes: RandaoMixes, // -- Slashings -- - #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::quoted_u64_seq")] pub slashings: Slashings, // -- Participation (EIP-7688) -- - #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::quoted_u64_seq")] pub previous_epoch_participation: EpochParticipation, - #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::quoted_u64_seq")] pub current_epoch_participation: EpochParticipation, // -- Finality -- - #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex::serialize")] + #[serde(with = "crate::beacon::serde_helpers::ssz_hex")] pub justification_bits: JustificationBits, pub previous_justified_checkpoint: Checkpoint, pub current_justified_checkpoint: Checkpoint, pub finalized_checkpoint: Checkpoint, // -- Inactivity (EIP-7688) -- - #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::quoted_u64_seq")] pub inactivity_scores: InactivityScores, // -- Sync committees -- @@ -815,7 +957,7 @@ pub struct BeaconState { pub next_withdrawal_validator_index: ValidatorIndex, // -- History (capella) -- - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub historical_summaries: HistoricalSummaries, // -- Deposits, exits, and consolidations (electra) -- @@ -831,20 +973,20 @@ pub struct BeaconState { pub consolidation_balance_to_consume: Gwei, #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] pub earliest_consolidation_epoch: Epoch, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub pending_deposits: PendingDeposits, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub pending_partial_withdrawals: PendingPartialWithdrawals, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub pending_consolidations: PendingConsolidations, // -- Proposer lookahead (fulu) -- - #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::quoted_u64_seq")] pub proposer_lookahead: ProposerLookahead, // -- Builders (EIP-7732) -- /// The builder registry. - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub builders: Builders, /// Where the builder withdrawal sweep last stopped, the builder-side /// counterpart of `next_withdrawal_validator_index`. @@ -852,13 +994,13 @@ pub struct BeaconState { pub next_withdrawal_builder_index: BuilderIndex, /// Bits tracking, for each of the last `SLOTS_PER_HISTORICAL_ROOT` slots, /// whether that slot's payload was made available. - #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex::serialize")] + #[serde(with = "crate::beacon::serde_helpers::ssz_hex")] pub execution_payload_availability: ExecutionPayloadAvailability, /// Builder payments owed for the previous and current epoch, settled by /// `process_builder_pending_payments`. - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub builder_pending_payments: BuilderPendingPayments, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub builder_pending_withdrawals: BuilderPendingWithdrawals, /// The bid the payload currently expected for this slot, if any, was /// bought under. @@ -866,7 +1008,7 @@ pub struct BeaconState { /// The withdrawals `get_expected_withdrawals` computed for the payload /// this slot expects, cached here so a late-revealed payload's /// `process_execution_payload` does not have to recompute the sweep. - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub payload_expected_withdrawals: Withdrawals, /// The cached window of payload timeliness committees for the previous, /// current, and lookahead epochs, refreshed each epoch by @@ -896,9 +1038,9 @@ pub struct BeaconState { pub struct DataColumnSidecar { #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] pub index: ColumnIndex, - #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex_seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::ssz_hex_seq")] pub column: DataColumn, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub kzg_proofs: KzgProofs, #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] pub slot: Slot, @@ -931,11 +1073,11 @@ pub struct DataColumnSidecar { Debug, Clone, Default, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot, )] pub struct PartialDataColumnSidecar { - #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex::serialize")] + #[serde(with = "crate::beacon::serde_helpers::ssz_hex")] pub cells_present_bitmap: CellsBitList, - #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex_seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::ssz_hex_seq")] pub partial_column: DataColumn, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub kzg_proofs: KzgProofs, } @@ -946,9 +1088,9 @@ pub struct PartialDataColumnSidecar { Debug, Clone, Default, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot, )] pub struct PartialDataColumnPartsMetadata { - #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex::serialize")] + #[serde(with = "crate::beacon::serde_helpers::ssz_hex")] pub available: CellsBitList, - #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex::serialize")] + #[serde(with = "crate::beacon::serde_helpers::ssz_hex")] pub requests: CellsBitList, } diff --git a/crates/common/types/src/beacon/containers/json_tests.rs b/crates/common/types/src/beacon/containers/json_tests.rs new file mode 100644 index 00000000..62c6ca3e --- /dev/null +++ b/crates/common/types/src/beacon/containers/json_tests.rs @@ -0,0 +1,405 @@ +//! JSON round trips of the containers a validator client submits, which the +//! Beacon API's request bodies carry: serialize, deserialize, compare. Each +//! value has every list non-empty and every integer non-zero, so an adapter +//! that dropped or misread a field shows up as a difference. + +use super::{altair, capella, electra, gloas, shared}; +use crate::beacon::primitives::{BlsPubkey, BlsSignature, KzgCommitment, Uint256}; + +fn round_trip(value: &T) +where + T: serde::Serialize + serde::de::DeserializeOwned + PartialEq + std::fmt::Debug, +{ + let json = serde_json::to_string(value).unwrap(); + let back: T = serde_json::from_str(&json).unwrap(); + assert_eq!(&back, value, "{json}"); +} + +fn signature(byte: u8) -> BlsSignature { + let mut signature = BlsSignature::default(); + signature.0[0] = byte; + signature.0[95] = byte; + signature +} + +fn pubkey(byte: u8) -> BlsPubkey { + let mut pubkey = BlsPubkey::default(); + pubkey.0[0] = byte; + pubkey +} + +fn header(slot: u64) -> shared::SignedBeaconBlockHeader { + shared::SignedBeaconBlockHeader { + message: shared::BeaconBlockHeader { + slot, + proposer_index: 3, + parent_root: [1; 32].into(), + state_root: [2; 32].into(), + body_root: [3; 32].into(), + }, + signature: signature(9), + } +} + +fn data() -> shared::AttestationData { + shared::AttestationData { + slot: 12, + index: 0, + beacon_block_root: [4; 32].into(), + source: shared::Checkpoint { + epoch: 1, + root: [5; 32].into(), + }, + target: shared::Checkpoint { + epoch: 2, + root: [6; 32].into(), + }, + } +} + +fn deposit() -> shared::Deposit { + shared::Deposit { + proof: vec![[7u8; 32].into(); crate::beacon::constants::DEPOSIT_CONTRACT_TREE_DEPTH + 1] + .try_into() + .unwrap(), + data: shared::DepositData { + pubkey: pubkey(1), + withdrawal_credentials: [8; 32].into(), + amount: 32_000_000_000, + signature: signature(2), + }, + } +} + +fn exit() -> shared::SignedVoluntaryExit { + shared::SignedVoluntaryExit { + message: shared::VoluntaryExit { + epoch: 5, + validator_index: 6, + }, + signature: signature(3), + } +} + +fn bls_change() -> capella::SignedBLSToExecutionChange { + capella::SignedBLSToExecutionChange { + message: capella::BLSToExecutionChange { + validator_index: 4, + from_bls_pubkey: pubkey(4), + to_execution_address: [0xaa; 20].into(), + }, + signature: signature(4), + } +} + +fn sync_aggregate() -> altair::SyncAggregate { + let mut aggregate = altair::SyncAggregate::default(); + aggregate.sync_committee_bits.set(3, true).unwrap(); + aggregate.sync_committee_signature = signature(5); + aggregate +} + +fn electra_attestation() -> electra::Attestation { + let mut aggregation_bits = electra::AggregationBits::with_length(5).unwrap(); + aggregation_bits.set(1, true).unwrap(); + aggregation_bits.set(4, true).unwrap(); + let mut committee_bits = electra::CommitteeBits::default(); + committee_bits.set(2, true).unwrap(); + electra::Attestation { + aggregation_bits, + data: data(), + signature: signature(6), + committee_bits, + } +} + +fn electra_indexed() -> electra::IndexedAttestation { + electra::IndexedAttestation { + attesting_indices: vec![3, 9, 27].try_into().unwrap(), + data: data(), + signature: signature(7), + } +} + +fn electra_payload() -> super::deneb::ExecutionPayload { + use super::{bellatrix, deneb}; + deneb::ExecutionPayload { + parent_hash: [1; 32].into(), + fee_recipient: [2; 20].into(), + state_root: [3; 32].into(), + receipts_root: [4; 32].into(), + logs_bloom: bellatrix::LogsBloom::try_from(vec![ + 0xab; + crate::beacon::preset::BYTES_PER_LOGS_BLOOM + ]) + .unwrap(), + prev_randao: [5; 32].into(), + block_number: 10, + gas_limit: 30_000_000, + gas_used: 21_000, + timestamp: 1_700_000_000, + extra_data: vec![0xde, 0xad].try_into().unwrap(), + base_fee_per_gas: Uint256::from(7_000_000_000u64), + block_hash: [6; 32].into(), + transactions: vec![ + bellatrix::Transaction::try_from(vec![1, 2, 3]).unwrap(), + bellatrix::Transaction::try_from(vec![4]).unwrap(), + ] + .try_into() + .unwrap(), + withdrawals: vec![capella::Withdrawal { + index: 1, + validator_index: 2, + address: [3; 20].into(), + amount: 4, + }] + .try_into() + .unwrap(), + blob_gas_used: 131_072, + excess_blob_gas: 262_144, + } +} + +fn electra_requests() -> electra::ExecutionRequests { + electra::ExecutionRequests { + deposits: vec![electra::DepositRequest { + pubkey: pubkey(5), + withdrawal_credentials: [1; 32].into(), + amount: 32_000_000_000, + signature: signature(8), + index: 11, + }] + .try_into() + .unwrap(), + withdrawals: vec![electra::WithdrawalRequest { + source_address: [2; 20].into(), + validator_pubkey: pubkey(6), + amount: 5, + }] + .try_into() + .unwrap(), + consolidations: vec![electra::ConsolidationRequest { + source_address: [3; 20].into(), + source_pubkey: pubkey(7), + target_pubkey: pubkey(8), + }] + .try_into() + .unwrap(), + } +} + +fn electra_block() -> electra::SignedBeaconBlock { + let mut body = electra::BeaconBlockBody::empty(); + body.randao_reveal = signature(1); + body.graffiti = [9; 32].into(); + body.proposer_slashings = vec![shared::ProposerSlashing { + signed_header_1: header(7), + signed_header_2: header(7), + }] + .try_into() + .unwrap(); + body.attester_slashings = vec![electra::AttesterSlashing { + attestation_1: electra_indexed(), + attestation_2: electra_indexed(), + }] + .try_into() + .unwrap(); + body.attestations = vec![electra_attestation()].try_into().unwrap(); + body.deposits = vec![deposit()].try_into().unwrap(); + body.voluntary_exits = vec![exit()].try_into().unwrap(); + body.sync_aggregate = sync_aggregate(); + body.execution_payload = electra_payload(); + body.bls_to_execution_changes = vec![bls_change()].try_into().unwrap(); + body.blob_kzg_commitments = vec![KzgCommitment([5; 48])].try_into().unwrap(); + body.execution_requests = electra_requests(); + electra::SignedBeaconBlock { + message: electra::BeaconBlock { + slot: 33, + proposer_index: 4, + parent_root: [1; 32].into(), + state_root: [2; 32].into(), + body, + }, + signature: signature(9), + } +} + +fn gloas_attestation() -> gloas::Attestation { + gloas::Attestation::from(&electra_attestation()) +} + +fn gloas_payload() -> gloas::ExecutionPayload { + let payload = electra_payload(); + gloas::ExecutionPayload { + parent_hash: payload.parent_hash, + fee_recipient: payload.fee_recipient, + state_root: payload.state_root, + receipts_root: payload.receipts_root, + logs_bloom: payload.logs_bloom, + prev_randao: payload.prev_randao, + block_number: payload.block_number, + gas_limit: payload.gas_limit, + gas_used: payload.gas_used, + timestamp: payload.timestamp, + extra_data: payload.extra_data, + base_fee_per_gas: payload.base_fee_per_gas, + block_hash: payload.block_hash, + transactions: payload + .transactions + .iter() + .map(|transaction| gloas::Transaction::from(transaction.to_vec())) + .collect::>() + .into(), + withdrawals: payload + .withdrawals + .iter() + .cloned() + .collect::>() + .into(), + blob_gas_used: payload.blob_gas_used, + excess_blob_gas: payload.excess_blob_gas, + block_access_list: vec![0xca, 0xfe].into(), + slot_number: 33, + } +} + +fn gloas_requests() -> gloas::ExecutionRequests { + let requests = electra_requests(); + gloas::ExecutionRequests { + deposits: requests.deposits.iter().cloned().collect::>().into(), + withdrawals: requests + .withdrawals + .iter() + .cloned() + .collect::>() + .into(), + consolidations: requests + .consolidations + .iter() + .cloned() + .collect::>() + .into(), + builder_deposits: vec![gloas::BuilderDepositRequest { + pubkey: pubkey(9), + withdrawal_credentials: [4; 32].into(), + amount: 1, + signature: signature(1), + }] + .into(), + builder_exits: vec![gloas::BuilderExitRequest { + source_address: [5; 20].into(), + pubkey: pubkey(10), + }] + .into(), + } +} + +fn payload_attestation() -> gloas::PayloadAttestation { + let mut aggregation_bits = gloas::PayloadTimelinessCommitteeBits::default(); + aggregation_bits.set(1, true).unwrap(); + gloas::PayloadAttestation { + aggregation_bits, + data: gloas::PayloadAttestationData { + beacon_block_root: [6; 32].into(), + slot: 32, + payload_present: true, + blob_data_available: true, + }, + signature: signature(2), + } +} + +fn gloas_block() -> gloas::SignedBeaconBlock { + let electra = electra_block().message; + let mut body = gloas::BeaconBlockBody::empty(); + body.randao_reveal = electra.body.randao_reveal; + body.graffiti = electra.body.graffiti; + body.proposer_slashings = electra + .body + .proposer_slashings + .iter() + .cloned() + .collect::>() + .into(); + body.attestations = vec![gloas_attestation()].into(); + body.deposits = electra + .body + .deposits + .iter() + .cloned() + .collect::>() + .into(); + body.voluntary_exits = electra + .body + .voluntary_exits + .iter() + .cloned() + .collect::>() + .into(); + body.sync_aggregate = sync_aggregate(); + body.signed_execution_payload_bid = gloas::SignedExecutionPayloadBid { + message: gloas::ExecutionPayloadBid { + parent_block_hash: [1; 32].into(), + parent_block_root: [2; 32].into(), + block_hash: [3; 32].into(), + prev_randao: [4; 32].into(), + fee_recipient: [5; 20].into(), + gas_limit: 30_000_000, + builder_index: 7, + slot: 33, + value: 8, + execution_payment: 9, + execution_requests_root: [7; 32].into(), + blob_kzg_commitments: vec![KzgCommitment([6; 48])].try_into().unwrap(), + }, + signature: signature(3), + }; + body.payload_attestations = vec![payload_attestation()].try_into().unwrap(); + body.parent_execution_requests = gloas_requests(); + gloas::SignedBeaconBlock { + message: gloas::BeaconBlock { + slot: 33, + proposer_index: 4, + parent_root: [1; 32].into(), + state_root: [2; 32].into(), + body, + }, + signature: signature(9), + } +} + +#[test] +fn a_fulu_block_round_trips_through_json() { + round_trip(&electra_block()); +} + +#[test] +fn a_gloas_block_round_trips_through_json() { + round_trip(&gloas_block()); +} + +#[test] +fn a_gloas_envelope_round_trips_through_json() { + round_trip(&gloas::SignedExecutionPayloadEnvelope { + message: gloas::ExecutionPayloadEnvelope { + payload: gloas_payload(), + execution_requests: gloas_requests(), + builder_index: 7, + beacon_block_root: [1; 32].into(), + parent_beacon_block_root: [2; 32].into(), + }, + signature: signature(4), + }); +} + +#[test] +fn an_oversized_list_is_refused_rather_than_truncated() { + let mut json = serde_json::to_value(electra_block()).unwrap(); + let commitment = json["message"]["body"]["blob_kzg_commitments"][0].clone(); + json["message"]["body"]["blob_kzg_commitments"] = serde_json::Value::Array(vec![ + commitment; + crate::beacon::preset::MAX_BLOB_COMMITMENTS_PER_BLOCK + + 1 + ]); + assert!(serde_json::from_value::(json).is_err()); +} diff --git a/crates/common/types/src/beacon/containers/mod.rs b/crates/common/types/src/beacon/containers/mod.rs index 1e8b09e1..367da3c7 100644 --- a/crates/common/types/src/beacon/containers/mod.rs +++ b/crates/common/types/src/beacon/containers/mod.rs @@ -1630,6 +1630,9 @@ impl DataColumnSidecar { } } +#[cfg(test)] +mod json_tests; + #[cfg(test)] mod tests { use super::*; diff --git a/crates/common/types/src/beacon/containers/phase0.rs b/crates/common/types/src/beacon/containers/phase0.rs index 631dbd8e..d937795a 100644 --- a/crates/common/types/src/beacon/containers/phase0.rs +++ b/crates/common/types/src/beacon/containers/phase0.rs @@ -42,7 +42,7 @@ pub type PendingAttestations = SszList, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub attester_slashings: SszList, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub attestations: SszList, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub deposits: SszList, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub voluntary_exits: SszList, } @@ -167,16 +167,16 @@ pub struct BeaconState { /// advances, since a block cannot commit to the root of the state containing /// it. pub latest_block_header: BeaconBlockHeader, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub block_roots: BlockRoots, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub state_roots: StateRoots, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub historical_roots: HistoricalRoots, // -- Eth1 -- pub eth1_data: Eth1Data, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub eth1_data_votes: Eth1DataVotes, /// How many deposits from the contract have been processed, which is where /// the next one will be read from. @@ -184,31 +184,31 @@ pub struct BeaconState { pub eth1_deposit_index: u64, // -- Registry -- - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub validators: Validators, - #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::quoted_u64_seq")] pub balances: Balances, // -- Randomness -- - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub randao_mixes: RandaoMixes, // -- Slashings -- - #[serde(serialize_with = "crate::beacon::serde_helpers::quoted_u64_seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::quoted_u64_seq")] pub slashings: Slashings, // -- Attestations -- /// Attestations for the previous epoch, replayed at the epoch boundary. Only /// phase0 has these; altair replaces them with participation flags. - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub previous_epoch_attestations: PendingAttestations, /// Attestations for the current epoch, which become /// `previous_epoch_attestations` at the next boundary. - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub current_epoch_attestations: PendingAttestations, // -- Finality -- - #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex::serialize")] + #[serde(with = "crate::beacon::serde_helpers::ssz_hex")] pub justification_bits: JustificationBits, pub previous_justified_checkpoint: Checkpoint, pub current_justified_checkpoint: Checkpoint, diff --git a/crates/common/types/src/beacon/containers/shared.rs b/crates/common/types/src/beacon/containers/shared.rs index c7d857f1..b2df9069 100644 --- a/crates/common/types/src/beacon/containers/shared.rs +++ b/crates/common/types/src/beacon/containers/shared.rs @@ -240,7 +240,16 @@ pub struct AttestationData { /// The execution chain's deposit state, as voted on by proposers. #[derive( - Debug, Clone, Default, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot, + Debug, + Clone, + Default, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + SszEncode, + SszDecode, + HashTreeRoot, )] pub struct Eth1Data { /// The deposit contract's merkle root at this point. @@ -272,9 +281,9 @@ pub struct Eth1Block { /// [`HistoricalRoots`] once the window is full. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] pub struct HistoricalBatch { - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub block_roots: BlockRoots, - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub state_roots: StateRoots, } @@ -324,7 +333,16 @@ pub struct DepositMessage { /// A deposit as recorded by the deposit contract. #[derive( - Debug, Clone, Default, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot, + Debug, + Clone, + Default, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + SszEncode, + SszDecode, + HashTreeRoot, )] pub struct DepositData { pub pubkey: BlsPubkey, @@ -339,9 +357,19 @@ pub struct DepositData { } /// A deposit together with its merkle proof against the deposit contract root. -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +#[derive( + Debug, + Clone, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + SszEncode, + SszDecode, + HashTreeRoot, +)] pub struct Deposit { - #[serde(serialize_with = "crate::beacon::serde_helpers::seq::serialize")] + #[serde(with = "crate::beacon::serde_helpers::seq")] pub proof: DepositProof, pub data: DepositData, } @@ -353,7 +381,16 @@ pub struct Deposit { /// A block without its body, which is what the state retains and what proposer /// slashings compare. #[derive( - Debug, Clone, Default, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot, + Debug, + Clone, + Default, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + SszEncode, + SszDecode, + HashTreeRoot, )] pub struct BeaconBlockHeader { #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] @@ -371,7 +408,16 @@ pub struct BeaconBlockHeader { } #[derive( - Debug, Clone, Default, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot, + Debug, + Clone, + Default, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + SszEncode, + SszDecode, + HashTreeRoot, )] pub struct SignedBeaconBlockHeader { pub message: BeaconBlockHeader, @@ -380,7 +426,16 @@ pub struct SignedBeaconBlockHeader { /// Evidence that a proposer signed two different blocks for the same slot. #[derive( - Debug, Clone, Default, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot, + Debug, + Clone, + Default, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + SszEncode, + SszDecode, + HashTreeRoot, )] pub struct ProposerSlashing { pub signed_header_1: SignedBeaconBlockHeader, @@ -389,7 +444,17 @@ pub struct ProposerSlashing { /// A validator's request to stop validating. #[derive( - Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot, + Debug, + Clone, + Copy, + Default, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + SszEncode, + SszDecode, + HashTreeRoot, )] pub struct VoluntaryExit { /// The earliest epoch the exit may be processed at. @@ -400,7 +465,16 @@ pub struct VoluntaryExit { } #[derive( - Debug, Clone, Default, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot, + Debug, + Clone, + Default, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + SszEncode, + SszDecode, + HashTreeRoot, )] pub struct SignedVoluntaryExit { pub message: VoluntaryExit, diff --git a/crates/common/types/src/beacon/primitives.rs b/crates/common/types/src/beacon/primitives.rs index 43c2589b..b19309a8 100644 --- a/crates/common/types/src/beacon/primitives.rs +++ b/crates/common/types/src/beacon/primitives.rs @@ -337,6 +337,18 @@ impl serde::Serialize for U256 { } } +impl<'de> serde::Deserialize<'de> for U256 { + /// The inverse of [`serde::Serialize`] above: a decimal string, quoted or + /// bare like every integer [`super::serde_helpers::quoted_or_bare`] reads. + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let text = ::deserialize(deserializer)?; + U256::from_dec_str(text.trim()).map_err(serde::de::Error::custom) + } +} + /// Why a decimal string was not a [`U256`]. #[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] pub enum ParseU256Error { @@ -490,8 +502,9 @@ impl_hex_serialize!(H160, BlsPubkey, BlsSignature, KzgCommitment, KzgProof); /// The `Deserialize` counterpart of [`impl_hex_serialize`], for the types the /// Beacon API accepts in a request body: a validator's public key (as a -/// validator id), a signature (inside a submitted attestation) and an execution -/// address (a proposer's fee recipient). Hex with or +/// validator id), a signature (inside a submitted attestation), an execution +/// address (a proposer's fee recipient) and a block's KZG commitments and +/// proofs. Hex with or /// without the `0x` prefix, of exactly the type's width. macro_rules! impl_hex_deserialize { ($($ty:ty),* $(,)?) => { @@ -508,7 +521,7 @@ macro_rules! impl_hex_deserialize { }; } -impl_hex_deserialize!(BlsPubkey, BlsSignature, H160); +impl_hex_deserialize!(BlsPubkey, BlsSignature, H160, KzgCommitment, KzgProof); #[cfg(test)] mod tests { diff --git a/crates/common/types/src/beacon/serde_helpers.rs b/crates/common/types/src/beacon/serde_helpers.rs index 976b2890..a20db64a 100644 --- a/crates/common/types/src/beacon/serde_helpers.rs +++ b/crates/common/types/src/beacon/serde_helpers.rs @@ -179,6 +179,25 @@ pub mod quoted_u64_seq { } seq.end() } + + /// The inverse: an array of quoted (or bare) integers, which is how + /// [`quoted_or_bare`](super::quoted_or_bare) reads one. + pub fn deserialize<'de, D, C, T>(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + C: TryFrom>, + C::Error: std::fmt::Debug, + T: std::str::FromStr, + T::Err: std::fmt::Display, + { + let texts = as serde::Deserialize>::deserialize(deserializer)?; + let values = texts + .iter() + .map(|text| text.trim().parse().map_err(serde::de::Error::custom)) + .collect::, _>>()?; + C::try_from(values) + .map_err(|err| serde::de::Error::custom(format!("invalid sequence: {err:?}"))) + } } /// A sequence of sequences of integers, each innermost value written quoted. @@ -351,6 +370,31 @@ pub mod ssz_hex_seq { } seq.end() } + + /// The inverse: an array of hex strings, each decoded through the + /// element's `SszDecode`, so a byte list's bound is checked as on the wire. + pub fn deserialize<'de, D, C, T>(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + C: TryFrom>, + C::Error: std::fmt::Debug, + T: libssz::SszDecode, + { + let texts = as serde::Deserialize>::deserialize(deserializer)?; + let values = texts + .iter() + .map(|text| { + let digits = text.trim(); + let bytes = hex::decode(digits.strip_prefix("0x").unwrap_or(digits)) + .map_err(serde::de::Error::custom)?; + T::from_ssz_bytes(&bytes).map_err(|err| { + serde::de::Error::custom(format!("invalid SSZ encoding: {err:?}")) + }) + }) + .collect::, _>>()?; + C::try_from(values) + .map_err(|err| serde::de::Error::custom(format!("invalid sequence: {err:?}"))) + } } /// A sequence of values that serialize themselves, from a foreign collection. @@ -375,6 +419,21 @@ pub mod seq { } seq.end() } + + /// The inverse: a JSON array of elements, collected into the foreign + /// collection through its `TryFrom>`, which is where a length bound + /// is checked (a progressive list's conversion cannot fail). + pub fn deserialize<'de, D, C, T>(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + C: TryFrom>, + C::Error: std::fmt::Debug, + T: serde::Deserialize<'de>, + { + let values = as serde::Deserialize>::deserialize(deserializer)?; + C::try_from(values) + .map_err(|err| serde::de::Error::custom(format!("invalid sequence: {err:?}"))) + } } #[cfg(test)] From 0935321777c831f8fddb046998f5871336ba5cc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:58:07 -0300 Subject: [PATCH 5/6] fix(rpc): accept JSON bodies on block and envelope publication Teku's validator client posts the gloas execution payload envelope as JSON and got a 415, so its blocks never had their payload published; nimbus posts blocks as JSON and lodestar posts both as JSON by default. POST /eth/v2/beacon/blocks (fulu SignedBlockContents, gloas SignedBeaconBlock) and POST /eth/v1/beacon/execution_payload_envelopes (SignedExecutionPayloadEnvelope or, with Eth-Blob-Data-Included: true, the Contents form) now take either encoding by Content-Type, through the BodyEncoding that decode_list already used. The decoded value goes through the same validation as the SSZ one. Any other content type is still a 415, and a body that is not the container a 400. The envelope test that asserted JSON was refused now asserts what is still true: an unsupported type is a 415 and SSZ bytes labelled JSON are a 400. --- crates/net/rpc/src/beacon/gloas_proposal.rs | 205 ++++++++++++++++++-- crates/net/rpc/src/beacon/mod.rs | 61 ++++-- crates/net/rpc/src/beacon/proposal.rs | 123 +++++++++--- 3 files changed, 329 insertions(+), 60 deletions(-) diff --git a/crates/net/rpc/src/beacon/gloas_proposal.rs b/crates/net/rpc/src/beacon/gloas_proposal.rs index fb9218a1..12a3a507 100644 --- a/crates/net/rpc/src/beacon/gloas_proposal.rs +++ b/crates/net/rpc/src/beacon/gloas_proposal.rs @@ -84,13 +84,13 @@ use ethlambda_types::{ }, primitives::H256, }; -use libssz::{SszDecode as _, SszEncode as _}; +use libssz::SszEncode as _; use libssz_derive::{SszDecode, SszEncode}; use serde::Deserialize; use tracing::{debug, info, warn}; use crate::beacon::{ - ApiError, + ApiError, BodyEncoding, proposal::{Blobs, CellKzgProofs, decimal, require_gloas_slot}, validator::{FeeRecipients, head}, }; @@ -140,22 +140,24 @@ impl PayloadCache { /// Gloas's `BlockContents`, the Beacon API's envelope for a self-built block /// with its payload: not a consensus container, so it lives with the API. -#[derive(Debug, Clone, PartialEq, SszEncode, SszDecode, serde::Serialize)] +#[derive(Debug, Clone, PartialEq, SszEncode, SszDecode, serde::Serialize, serde::Deserialize)] pub(crate) struct GloasBlockContents { pub(crate) block: BeaconBlock, pub(crate) execution_payload_envelope: ExecutionPayloadEnvelope, - #[serde(serialize_with = "ethlambda_types::beacon::serde_helpers::seq::serialize")] + #[serde(with = "ethlambda_types::beacon::serde_helpers::seq")] pub(crate) kzg_proofs: CellKzgProofs, - #[serde(serialize_with = "ethlambda_types::beacon::serde_helpers::ssz_hex_seq::serialize")] + #[serde(with = "ethlambda_types::beacon::serde_helpers::ssz_hex_seq")] pub(crate) blobs: Blobs, } /// Gloas's `SignedExecutionPayloadEnvelopeContents`, what the stateless form of /// `publishExecutionPayloadEnvelope` receives. -#[derive(Debug, Clone, PartialEq, SszEncode, SszDecode)] +#[derive(Debug, Clone, PartialEq, SszEncode, SszDecode, serde::Serialize, serde::Deserialize)] pub(crate) struct GloasSignedEnvelopeContents { pub(crate) signed_execution_payload_envelope: SignedExecutionPayloadEnvelope, + #[serde(with = "ethlambda_types::beacon::serde_helpers::seq")] pub(crate) kzg_proofs: CellKzgProofs, + #[serde(with = "ethlambda_types::beacon::serde_helpers::ssz_hex_seq")] pub(crate) blobs: Blobs, } @@ -756,13 +758,10 @@ async fn post_envelope_waiting( if consensus_version(headers) != Some(ForkName::Gloas) { return ApiError::BadRequest("Eth-Consensus-Version must be gloas").into_response(); } - if !is_ssz(headers) { - return ( - StatusCode::UNSUPPORTED_MEDIA_TYPE, - "envelopes are accepted as application/octet-stream only", - ) - .into_response(); - } + let encoding = match BodyEncoding::from_headers(headers) { + Ok(encoding) => encoding, + Err(err) => return err.into_response(), + }; let blob_data_included = match headers .get("eth-blob-data-included") .and_then(|value| value.to_str().ok()) @@ -775,8 +774,8 @@ async fn post_envelope_waiting( } }; let (signed, supplied) = if blob_data_included { - match GloasSignedEnvelopeContents::from_ssz_bytes(body) { - Ok(contents) => ( + match encoding.decode::(body) { + Some(contents) => ( contents.signed_execution_payload_envelope, Some(( contents @@ -787,7 +786,7 @@ async fn post_envelope_waiting( contents.kzg_proofs.to_vec(), )), ), - Err(_) => { + None => { return ApiError::BadRequest( "the body is not a gloas SignedExecutionPayloadEnvelopeContents", ) @@ -795,9 +794,9 @@ async fn post_envelope_waiting( } } } else { - match SignedExecutionPayloadEnvelope::from_ssz_bytes(body) { - Ok(signed) => (signed, None), - Err(_) => { + match encoding.decode::(body) { + Some(signed) => (signed, None), + None => { return ApiError::BadRequest( "the body is not a gloas SignedExecutionPayloadEnvelope", ) @@ -1093,6 +1092,82 @@ mod tests { (headers, body) } + /// The same as [`envelope_request`] with a JSON body, which is what teku's + /// validator client posts. + fn json_envelope_request(included: Option<&str>, body: Vec) -> (HeaderMap, Vec) { + let (mut headers, body) = envelope_request(included, body); + headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/json"), + ); + (headers, body) + } + + #[tokio::test] + async fn an_envelope_is_accepted_as_json_exactly_as_it_is_as_ssz() { + let state = state_to_build_on(); + let produced = produced_without_blobs(&state); + let signature = sign_block(&state, &produced.block); + let (store, _root, post) = store_with_block(&state, &produced, signature); + let signed = signed_envelope(&post, &produced); + let wait = Duration::from_millis(100); + + let mut published = Vec::new(); + for json in [false, true] { + let network = Arc::new(RecordingNetwork::default()); + let p2p: RpcToP2PRef = network.clone(); + let (headers, body) = if json { + json_envelope_request(Some("false"), serde_json::to_vec(&signed).unwrap()) + } else { + envelope_request(Some("false"), signed.to_ssz()) + }; + let response = post_envelope_waiting( + &store, + &p2p, + &PayloadCache::default(), + &headers, + &body, + wait, + ) + .await; + assert_eq!(response.status(), StatusCode::OK, "json: {json}"); + published.push(network.envelopes.lock().unwrap().clone()); + } + assert_eq!(published[0].len(), 1); + assert_eq!(published[0], published[1]); + + // The same refusals: a forged signature, and a body that is not an envelope. + let network = Arc::new(RecordingNetwork::default()); + let p2p: RpcToP2PRef = network.clone(); + let mut forged = signed.clone(); + forged.signature = + sign_envelope(&post, produced.block.proposer_index + 1, &produced.envelope); + let (headers, body) = + json_envelope_request(Some("false"), serde_json::to_vec(&forged).unwrap()); + let response = post_envelope_waiting( + &store, + &p2p, + &PayloadCache::default(), + &headers, + &body, + wait, + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let (headers, body) = json_envelope_request(Some("false"), b"{\"message\": 1}".to_vec()); + let response = post_envelope_waiting( + &store, + &p2p, + &PayloadCache::default(), + &headers, + &body, + wait, + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert!(network.envelopes.lock().unwrap().is_empty()); + } + /// A produced block with no blobs, so the envelope needs no columns. fn produced_without_blobs(state: &BeaconState) -> GloasProduced { use ethlambda_state_transition::beacon::gloas_block_production::{ @@ -1242,15 +1317,21 @@ mod tests { let response = post_envelope_waiting(&store, &p2p, &cache, &headers, &body, wait).await; assert_eq!(response.status(), StatusCode::BAD_REQUEST); - // JSON is not accepted. + // Any other content type is a 415 (JSON is accepted, see + // `an_envelope_is_accepted_as_json_exactly_as_it_is_as_ssz`), and SSZ + // bytes labelled JSON are not a JSON envelope. let signed = signed_envelope(&post, &produced); let (mut headers, body) = envelope_request(Some("false"), signed.to_ssz()); + headers.insert(header::CONTENT_TYPE, HeaderValue::from_static("text/plain")); + let response = post_envelope_waiting(&store, &p2p, &cache, &headers, &body, wait).await; + assert_eq!(response.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE); + let (mut headers, body) = envelope_request(Some("false"), signed.to_ssz()); headers.insert( header::CONTENT_TYPE, HeaderValue::from_static("application/json"), ); let response = post_envelope_waiting(&store, &p2p, &cache, &headers, &body, wait).await; - assert_eq!(response.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); // The blob-data header is required. let (headers, body) = envelope_request(None, signed.to_ssz()); @@ -1366,6 +1447,88 @@ mod tests { } } + #[tokio::test] + async fn json_envelope_contents_carry_the_blobs_and_are_checked_like_ssz() { + let WithBlob { + state, + produced, + blob, + proofs, + } = with_blob(); + let signature = sign_block(&state, &produced.block); + let (store, _root, post) = store_with_block(&state, &produced, signature); + let signed = signed_envelope(&post, &produced); + let wait = Duration::from_millis(100); + let contents = |blob: &[u8]| GloasSignedEnvelopeContents { + signed_execution_payload_envelope: signed.clone(), + kzg_proofs: CellKzgProofs::try_from(proofs.clone()).unwrap(), + blobs: blobs_list(vec![blob.to_vec()]).unwrap(), + }; + let send = |network: Arc, contents: GloasSignedEnvelopeContents| { + let store = store.clone(); + async move { + let p2p: RpcToP2PRef = network; + let (headers, body) = + json_envelope_request(Some("true"), serde_json::to_vec(&contents).unwrap()); + post_envelope_waiting( + &store, + &p2p, + &PayloadCache::default(), + &headers, + &body, + wait, + ) + .await + } + }; + + let network = Arc::new(RecordingNetwork::default()); + let response = send(network.clone(), contents(&blob)).await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + network.envelopes.lock().unwrap()[0].1.len(), + ethlambda_types::beacon::preset::NUMBER_OF_COLUMNS + ); + + let mut tampered = blob.clone(); + tampered[63] ^= 1; + let rejected = Arc::new(RecordingNetwork::default()); + let response = send(rejected.clone(), contents(&tampered)).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert!(rejected.envelopes.lock().unwrap().is_empty()); + } + + #[test] + fn the_block_and_envelope_contents_round_trip_through_json() { + let WithBlob { + state, + produced, + blob, + proofs, + } = with_blob(); + let signature = sign_block(&state, &produced.block); + let (_store, _root, post) = store_with_block(&state, &produced, signature); + let signed = signed_envelope(&post, &produced); + let contents = GloasSignedEnvelopeContents { + signed_execution_payload_envelope: signed, + kzg_proofs: CellKzgProofs::try_from(proofs.clone()).unwrap(), + blobs: blobs_list(vec![blob.clone()]).unwrap(), + }; + let back: GloasSignedEnvelopeContents = + serde_json::from_slice(&serde_json::to_vec(&contents).unwrap()).unwrap(); + assert_eq!(back, contents); + + let block_contents = GloasBlockContents { + block: produced.block.clone(), + execution_payload_envelope: produced.envelope.clone(), + kzg_proofs: CellKzgProofs::try_from(proofs).unwrap(), + blobs: blobs_list(vec![blob]).unwrap(), + }; + let back: GloasBlockContents = + serde_json::from_slice(&serde_json::to_vec(&block_contents).unwrap()).unwrap(); + assert_eq!(back, block_contents); + } + #[tokio::test] async fn an_envelope_with_blobs_gossips_every_column_and_a_tampered_blob_is_refused() { let WithBlob { diff --git a/crates/net/rpc/src/beacon/mod.rs b/crates/net/rpc/src/beacon/mod.rs index 2a44821b..728a2087 100644 --- a/crates/net/rpc/src/beacon/mod.rs +++ b/crates/net/rpc/src/beacon/mod.rs @@ -102,12 +102,48 @@ impl IntoResponse for ApiError { } } +/// How a request body is encoded, from its `Content-Type`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum BodyEncoding { + Json, + Ssz, +} + +impl BodyEncoding { + /// An absent `Content-Type` is read as JSON, which is what every client + /// that predates SSZ submission sends. Anything else is a 415 rather than a + /// guess, so a client that tries SSZ first (prysm) learns this node wants + /// the other. + pub(crate) fn from_headers(headers: &axum::http::HeaderMap) -> Result { + let content_type = headers + .get(axum::http::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(|value| value.split(';').next().unwrap_or("").trim()); + match content_type { + None | Some("application/json") => Ok(Self::Json), + Some(crate::SSZ_CONTENT_TYPE) => Ok(Self::Ssz), + Some(_) => Err(ApiError::UnsupportedMediaType( + "Content-Type must be application/json or application/octet-stream", + )), + } + } + + /// Decode one container in this encoding, or `None` when the body is not + /// one. JSON and SSZ go through the same type, so what follows a decode + /// cannot depend on which one the client chose. + pub(crate) fn decode(self, body: &[u8]) -> Option + where + T: serde::de::DeserializeOwned + libssz::SszDecode, + { + match self { + Self::Json => serde_json::from_slice(body).ok(), + Self::Ssz => T::from_ssz_bytes(body).ok(), + } + } +} + /// Decode the array a batch-submission endpoint takes, as JSON or as the SSZ /// `List[T, ...]` of the same elements, by the request's `Content-Type`. -/// -/// An absent `Content-Type` is read as JSON, which is what every client that -/// predates SSZ submission sends. Anything else is a 415 rather than a guess, -/// so a client that tries SSZ first (prysm) learns this node wants the other. pub(crate) fn decode_list( headers: &axum::http::HeaderMap, body: &[u8], @@ -115,19 +151,12 @@ pub(crate) fn decode_list( where T: serde::de::DeserializeOwned + libssz::SszDecode, { - let content_type = headers - .get(axum::http::header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .map(|value| value.split(';').next().unwrap_or("").trim()); - match content_type { - None | Some("application/json") => { - serde_json::from_slice(body).map_err(|_| ApiError::BadRequest("invalid request body")) + let invalid = || ApiError::BadRequest("invalid request body"); + match BodyEncoding::from_headers(headers)? { + BodyEncoding::Json => serde_json::from_slice(body).map_err(|_| invalid()), + BodyEncoding::Ssz => { + as libssz::SszDecode>::from_ssz_bytes(body).map_err(|_| invalid()) } - Some(crate::SSZ_CONTENT_TYPE) => as libssz::SszDecode>::from_ssz_bytes(body) - .map_err(|_| ApiError::BadRequest("invalid request body")), - Some(_) => Err(ApiError::UnsupportedMediaType( - "Content-Type must be application/json or application/octet-stream", - )), } } diff --git a/crates/net/rpc/src/beacon/proposal.rs b/crates/net/rpc/src/beacon/proposal.rs index 52be05c6..475c0005 100644 --- a/crates/net/rpc/src/beacon/proposal.rs +++ b/crates/net/rpc/src/beacon/proposal.rs @@ -50,13 +50,13 @@ use ethlambda_types::{ }, primitives::H256, }; -use libssz::{SszDecode as _, SszEncode as _}; +use libssz::SszEncode as _; use libssz_derive::{SszDecode, SszEncode}; use libssz_types::SszList; use serde::Deserialize; use tracing::{info, warn}; -use crate::beacon::{ApiError, validator::FeeRecipients, validator::head}; +use crate::beacon::{ApiError, BodyEncoding, validator::FeeRecipients, validator::head}; use crate::shared::content::{Encoding, ssz_response, with_consensus_version}; /// One KZG proof per cell of every blob, fulu's `kzg_proofs` bound. @@ -69,20 +69,22 @@ pub(crate) type Blobs = SszList Router { ) } -/// `POST /eth/v2/beacon/blocks`, SSZ-encoded `SignedBlockContents`. +/// `POST /eth/v2/beacon/blocks`, `SignedBlockContents` (fulu) or a bare +/// `SignedBeaconBlock` (gloas), as SSZ or JSON by `Content-Type`. /// /// Checked before it goes anywhere: the fork is fulu, it carries no blobs /// (whose data columns this node cannot publish yet, see the module docs), it @@ -117,21 +120,14 @@ async fn post_block( if !matches!(fork, Some(ForkName::Fulu | ForkName::Gloas)) { return ApiError::BadRequest("Eth-Consensus-Version must be fulu or gloas").into_response(); } - let is_ssz = headers - .get(header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .is_some_and(|value| value.starts_with(crate::SSZ_CONTENT_TYPE)); - if !is_ssz { - return ( - StatusCode::UNSUPPORTED_MEDIA_TYPE, - "blocks are accepted as application/octet-stream only", - ) - .into_response(); - } + let encoding = match BodyEncoding::from_headers(&headers) { + Ok(encoding) => encoding, + Err(err) => return err.into_response(), + }; if fork == Some(ForkName::Gloas) { - return post_gloas_block(&store, &p2p, &body).await; + return post_gloas_block(&store, &p2p, encoding, &body).await; } - let Ok(contents) = FuluSignedBlockContents::from_ssz_bytes(&body) else { + let Some(contents) = encoding.decode::(&body) else { return ApiError::BadRequest("the body is not fulu SignedBlockContents").into_response(); }; if !contents.blobs.is_empty() @@ -176,7 +172,7 @@ async fn post_block( StatusCode::OK.into_response() } -/// The gloas half of `publishBlockV2`: a bare SSZ `SignedBeaconBlock`, since a +/// The gloas half of `publishBlockV2`: a bare `SignedBeaconBlock`, since a /// gloas block carries no payload and no blobs of its own (the envelope and /// the data columns follow through `publishExecutionPayloadEnvelope`). /// @@ -185,8 +181,13 @@ async fn post_block( /// slot's, and the proposer's signature verifies against the parent's state /// advanced to the slot. The advance and the signature are CPU-bound and run /// off the runtime. -async fn post_gloas_block(store: &Store, p2p: &RpcToP2PRef, body: &[u8]) -> Response { - let Ok(signed) = containers::gloas::SignedBeaconBlock::from_ssz_bytes(body) else { +async fn post_gloas_block( + store: &Store, + p2p: &RpcToP2PRef, + encoding: BodyEncoding, + body: &[u8], +) -> Response { + let Some(signed) = encoding.decode::(body) else { return ApiError::BadRequest("the body is not a gloas SignedBeaconBlock").into_response(); }; let block = containers::SignedBeaconBlock::Gloas(signed); @@ -579,6 +580,61 @@ mod tests { assert_eq!(json["message"], "blocks are accepted for fulu slots only"); } + /// A fulu body in either encoding reaches the same check and gets the same + /// answer, here the refusal of a slot the schedule does not place at fulu. + #[tokio::test] + async fn fulu_block_contents_are_accepted_as_json_exactly_as_ssz() { + let (store, _) = gloas_scheduled_store(); + let contents = FuluSignedBlockContents { + signed_block: containers::electra::SignedBeaconBlock { + message: containers::electra::BeaconBlock { + slot: 64, + proposer_index: 0, + parent_root: H256::ZERO, + state_root: H256::ZERO, + body: containers::electra::BeaconBlockBody::empty(), + }, + signature: Default::default(), + }, + kzg_proofs: Default::default(), + blobs: Default::default(), + }; + let back: FuluSignedBlockContents = + serde_json::from_slice(&serde_json::to_vec(&contents).unwrap()).unwrap(); + assert_eq!(back, contents); + + let mut answers = Vec::new(); + for (content_type, body) in [ + (crate::SSZ_CONTENT_TYPE, contents.to_ssz()), + ("application/json", serde_json::to_vec(&contents).unwrap()), + ] { + let network: RpcToP2PRef = Arc::new(RecordingNetwork::default()); + let app = routes().with_state(store.clone()).layer(Extension(network)); + let request = Request::post("/eth/v2/beacon/blocks") + .header("eth-consensus-version", "fulu") + .header(header::CONTENT_TYPE, content_type) + .body(Body::from(body)) + .unwrap(); + answers.push(respond(app, request).await); + } + assert_eq!(answers[0].0, StatusCode::BAD_REQUEST); + assert_eq!(answers[0], answers[1]); + } + + #[tokio::test] + async fn a_block_content_type_other_than_json_or_ssz_is_a_415() { + let (store, _) = gloas_scheduled_store(); + let network: RpcToP2PRef = Arc::new(RecordingNetwork::default()); + let app = routes().with_state(store).layer(Extension(network)); + let request = Request::post("/eth/v2/beacon/blocks") + .header("eth-consensus-version", "fulu") + .header(header::CONTENT_TYPE, "text/plain") + .body(Body::from("{}")) + .unwrap(); + let (status, _) = respond(app, request).await; + assert_eq!(status, StatusCode::UNSUPPORTED_MEDIA_TYPE); + } + #[tokio::test] async fn a_block_over_the_default_body_limit_is_not_a_413() { let (store, _) = gloas_scheduled_store(); @@ -628,6 +684,27 @@ mod tests { assert_eq!(status, StatusCode::OK); assert_eq!(network.blocks.lock().unwrap().len(), 1); + // The same block as JSON is accepted and published identically, and a + // forged one refused. + let post_json = |network: Arc, + signed: &containers::gloas::SignedBeaconBlock| { + let network: RpcToP2PRef = network; + let app = routes().with_state(store.clone()).layer(Extension(network)); + let request = Request::post("/eth/v2/beacon/blocks") + .header("eth-consensus-version", "gloas") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(serde_json::to_vec(signed).unwrap())) + .unwrap(); + respond(app, request) + }; + let via_json = Arc::new(RecordingNetwork::default()); + let (status, _) = post_json(via_json.clone(), &signed).await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + *via_json.blocks.lock().unwrap(), + *network.blocks.lock().unwrap() + ); + // A signature from another validator, and a block whose parent is not held. let rejected = Arc::new(RecordingNetwork::default()); let mut forged = signed.clone(); From 33d0853102667ae505eb14655c439029f58dc72b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:58:12 -0300 Subject: [PATCH 6/6] docs(rpc): block and envelope publication take JSON or SSZ --- docs/rpc.md | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/docs/rpc.md b/docs/rpc.md index fd679a61..17fd4b80 100644 --- a/docs/rpc.md +++ b/docs/rpc.md @@ -254,8 +254,8 @@ surface rather than sitting beside it; a `/lean/v0` path on a beacon node is a | `GET` | `/eth/v3/validator/blocks/{slot}` | SSZ or JSON | An unsigned fulu block built on the head (`produceBlockV3`) | | `POST` | `/eth/v4/validator/blocks/{slot}` | SSZ or JSON | An unsigned self-built gloas block, with its envelope and blobs when asked (`produceBlockV4`) | | `GET` | `/eth/v1/validator/execution_payload_envelopes/{slot}/{beacon_block_root}` | SSZ or JSON | The unsigned envelope `produceBlockV4` built (gloas) | -| `POST` | `/eth/v2/beacon/blocks` | *(status only)* | Gossip and import a signed fulu or gloas block (`publishBlockV2`, SSZ) | -| `POST` | `/eth/v1/beacon/execution_payload_envelopes` | *(status only)* | Gossip a signed envelope and its data columns (gloas, SSZ) | +| `POST` | `/eth/v2/beacon/blocks` | *(status only)* | Gossip and import a signed fulu or gloas block (`publishBlockV2`; JSON or SSZ body) | +| `POST` | `/eth/v1/beacon/execution_payload_envelopes` | *(status only)* | Gossip a signed envelope and its data columns (gloas; JSON or SSZ body) | | `POST` | `/eth/v1/validator/prepare_beacon_proposer` | *(status only)* | Acknowledged, not acted on (see below) | ### Validator endpoints @@ -334,9 +334,9 @@ the chain actor writes, so no request waits on the actor. variable-size list uses, which is what prysm sends for aggregates and payload votes, with a JSON retry only on a `415`, and what nimbus can send for attestations. Any other content type is a `415`, and a body that does not - decode in the type's encoding a `400`. A block, an envelope and the other - validator-client submissions keep their own rules (SSZ only on `blocks` and - the envelope; JSON on the rest). + decode in the type's encoding a `400`. `POST beacon/blocks` and + `POST beacon/execution_payload_envelopes` choose the same way (below); the + other validator-client submissions are JSON. - **The attestation pool** holds, the best-covered per data root and committee: votes from `pool/attestations` and the aggregator subnets, aggregates from `aggregate_and_proofs`, and every electra gossip aggregate @@ -362,8 +362,11 @@ the chain actor writes, so no request waits on the actor. blobs. A slot the schedule does not place at fulu (gloas included) is a `400`, checked before the execution client; `produceBlockV3` is the fulu endpoint, and a gloas slot goes to `produceBlockV4`. -- **`POST beacon/blocks`** takes SSZ and the `Eth-Consensus-Version` header - (`fulu` or `gloas`, else `400`; a non-SSZ content type is a `415`). Fulu: a +- **`POST beacon/blocks`** takes JSON or SSZ, by `Content-Type` as above, and + the `Eth-Consensus-Version` header (`fulu` or `gloas`, else `400`; any other + content type is a `415`). The JSON is the specification's: lodestar's and + nimbus's validator clients post it, teku's falls back to it, and lighthouse + and prysm post SSZ. Both reach the same checks after decoding. Fulu: a `SignedBlockContents`; the node checks the block is after the head and its proposer signature, then gossips it on `beacon_block` and hands it to the chain actor to import. A slot the schedule does not place at fulu is a `400`, @@ -423,8 +426,11 @@ serves the cached unsigned envelope, for a client that asked for the block with root. **`POST /eth/v1/beacon/execution_payload_envelopes`** takes the signed envelope, -SSZ only (`415` otherwise), with `Eth-Consensus-Version: gloas` and -`Eth-Blob-Data-Included` (both required, else `400`): +as JSON or SSZ by `Content-Type` (any other is a `415`; teku's validator client +posts JSON, nimbus posts the bare envelope as JSON and the contents as SSZ, +lighthouse posts SSZ, prysm posts SSZ and retries as JSON on a `415`), with +`Eth-Consensus-Version: gloas` and `Eth-Blob-Data-Included` (both required, else +`400`): - `true`: the body is `SignedExecutionPayloadEnvelopeContents` (the signed envelope, `kzg_proofs`, `blobs`).