From 834f8f832df163c52af74c7d90e136e960d2b5f5 Mon Sep 17 00:00:00 2001 From: Lautaro Emanuel Date: Thu, 28 May 2026 17:01:50 -0300 Subject: [PATCH 01/30] feat(core): port core/fetcher from Charon Ports the Charon core/fetcher module to Pluto, covering all four duty paths (attester, aggregator, proposer, sync contribution). Reworks DutyDefinition into a heterogeneous enum mirroring Charon's interface and promotes UnsignedDataSet/UnsignedDutyData into core::types so it is shared by the fetcher, DutyDB, and consensus. All Go fetcher tests are ported and passing. Resolves NethermindEth/pluto#172. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/core/src/dutydb/memory.rs | 19 +- crates/core/src/dutydb/mod.rs | 6 +- crates/core/src/fetcher/graffiti.rs | 349 ++++++ crates/core/src/fetcher/mod.rs | 1627 +++++++++++++++++++++++++++ crates/core/src/lib.rs | 3 + crates/core/src/types.rs | 232 ++-- 6 files changed, 2106 insertions(+), 130 deletions(-) create mode 100644 crates/core/src/fetcher/graffiti.rs create mode 100644 crates/core/src/fetcher/mod.rs diff --git a/crates/core/src/dutydb/memory.rs b/crates/core/src/dutydb/memory.rs index 01a68b86..a0497221 100644 --- a/crates/core/src/dutydb/memory.rs +++ b/crates/core/src/dutydb/memory.rs @@ -18,7 +18,7 @@ use crate::{ signeddata::{ AttestationData, SyncContribution, VersionedAggregatedAttestation, VersionedProposal, }, - types::{Duty, DutyType, PubKey}, + types::{Duty, DutyType, PubKey, UnsignedDataSet, UnsignedDutyData}, }; /// Error type for DutyDB operations. @@ -129,23 +129,6 @@ pub enum Error { /// Result type for DutyDB operations. pub type Result = std::result::Result; -/// Unsigned duty data variant — matches Go's `core.UnsignedData` interface. -#[derive(Debug, Clone)] -pub enum UnsignedDutyData { - /// Unsigned proposal (DutyProposer). - Proposal(Box), - /// Unsigned attestation data (DutyAttester). - Attestation(AttestationData), - /// Unsigned aggregated attestation (DutyAggregator). - AggAttestation(VersionedAggregatedAttestation), - /// Unsigned sync contribution (DutySyncContribution). - SyncContribution(SyncContribution), -} - -/// Map from public key to unsigned duty data, equivalent to Go's -/// `core.UnsignedDataSet`. -pub type UnsignedDataSet = HashMap; - /// Lookup key for attestation data: (slot, committee index). #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] struct AttKey { diff --git a/crates/core/src/dutydb/mod.rs b/crates/core/src/dutydb/mod.rs index c96e9ddf..0d9d1478 100644 --- a/crates/core/src/dutydb/mod.rs +++ b/crates/core/src/dutydb/mod.rs @@ -2,4 +2,8 @@ pub mod memory; -pub use memory::{Error, MemDB, UnsignedDataSet, UnsignedDutyData}; +pub use memory::{Error, MemDB}; + +// `UnsignedDataSet`/`UnsignedDutyData` now live in `core::types` (shared with +// the fetcher); re-exported here for backwards compatibility. +pub use crate::types::{UnsignedDataSet, UnsignedDutyData}; diff --git a/crates/core/src/fetcher/graffiti.rs b/crates/core/src/fetcher/graffiti.rs new file mode 100644 index 00000000..a5885a8c --- /dev/null +++ b/crates/core/src/fetcher/graffiti.rs @@ -0,0 +1,349 @@ +//! Graffiti construction for block proposals. +//! +//! Ported from `charon/core/fetcher/graffiti.go`. + +use std::collections::HashMap; + +use pluto_eth2api::{EthBeaconNodeApiClient, GetNodeVersionRequest, GetNodeVersionResponse}; + +use crate::{ + types::PubKey, + version::{VERSION, git_commit}, +}; + +/// Obol token appended to graffiti unless client-append is disabled. +const OBOL_TOKEN: &str = "OB"; + +/// Graffiti is a fixed 32-byte field in the beacon block body. +const GRAFFITI_LEN: usize = 32; + +/// Error returned while constructing a [`GraffitiBuilder`]. +#[derive(Debug, thiserror::Error)] +pub enum GraffitiError { + /// More than one graffiti value was provided but the count did not match + /// the number of validators. + #[error("graffiti length must match the number of validators or be a single value")] + LengthMismatch, +} + +/// Maps beacon node product tokens (the first `/`-separated component of the +/// node version string) to their two-letter graffiti code. +pub fn client_graffiti_mappings() -> HashMap<&'static str, &'static str> { + HashMap::from([ + ("teku", "TK"), + ("Lighthouse", "LH"), + ("Lodestar", "LS"), + ("Prysm", "PY"), + ("Nimbus", "NB"), + ("Grandine", "GD"), + ]) +} + +/// Builds per-validator graffiti used when proposing blocks. +#[derive(Debug, Clone, Default)] +pub struct GraffitiBuilder { + default_graffiti: [u8; GRAFFITI_LEN], + graffiti: HashMap, +} + +impl GraffitiBuilder { + /// Creates a new graffiti builder. + /// + /// `graffiti` may be `None` (every validator gets the default graffiti), a + /// single value (applied to every validator) or one value per validator. + pub async fn new( + pubkeys: &[PubKey], + graffiti: Option<&[String]>, + disable_client_append: bool, + eth2_cl: &EthBeaconNodeApiClient, + ) -> Result { + let default = default_graffiti(); + let mut builder = Self { + default_graffiti: default, + graffiti: HashMap::with_capacity(pubkeys.len()), + }; + + // Handle nil graffiti. + let Some(graffiti) = graffiti else { + for pubkey in pubkeys { + builder.graffiti.insert(*pubkey, default); + } + + return Ok(builder); + }; + + if graffiti.len() > 1 && graffiti.len() != pubkeys.len() { + return Err(GraffitiError::LengthMismatch); + } + + let token = fetch_beacon_node_token(eth2_cl).await; + + // Handle single graffiti case. + if graffiti.len() == 1 { + let single_graffiti = &graffiti[0]; + for pubkey in pubkeys { + builder.graffiti.insert( + *pubkey, + build_graffiti(single_graffiti, &token, disable_client_append), + ); + } + + return Ok(builder); + } + + // Handle multiple graffiti case. + for (idx, pubkey) in pubkeys.iter().enumerate() { + builder.graffiti.insert( + *pubkey, + build_graffiti(&graffiti[idx], &token, disable_client_append), + ); + } + + Ok(builder) + } + + /// Returns the graffiti for a given pubkey, or the default graffiti when + /// the pubkey is unknown. + pub fn get_graffiti(&self, pubkey: &PubKey) -> [u8; GRAFFITI_LEN] { + self.graffiti + .get(pubkey) + .copied() + .unwrap_or(self.default_graffiti) + } +} + +/// Copies `s` into a fixed 32-byte array, truncating or zero-padding to match +/// Go's `copy(graffiti[:], s)` semantics. +fn graffiti_bytes(s: &str) -> [u8; GRAFFITI_LEN] { + let mut out = [0u8; GRAFFITI_LEN]; + let bytes = s.as_bytes(); + let n = bytes.len().min(GRAFFITI_LEN); + out[..n].copy_from_slice(&bytes[..n]); + out +} + +/// Builds the graffiti with optional Obol and beacon node token. +fn build_graffiti(graffiti: &str, token: &str, disable_client_append: bool) -> [u8; GRAFFITI_LEN] { + if disable_client_append { + graffiti_bytes(graffiti) + } else { + graffiti_bytes(&format!("{graffiti}{OBOL_TOKEN}{token}")) + } +} + +/// Returns the default graffiti: `pluto/-`. +fn default_graffiti() -> [u8; GRAFFITI_LEN] { + let (commit_sha, _) = git_commit(); + graffiti_bytes(&format!("pluto/{}-{}", *VERSION, commit_sha)) +} + +/// Queries the beacon node for its product token, returning an empty string on +/// any error or unrecognized client. +async fn fetch_beacon_node_token(eth2_cl: &EthBeaconNodeApiClient) -> String { + let Some(version) = node_version(eth2_cl).await else { + return String::new(); + }; + + let product_token = version.split('/').next().unwrap_or_default(); + + client_graffiti_mappings() + .get(product_token) + .map(|token| (*token).to_string()) + .unwrap_or_default() +} + +/// Fetches the beacon node version string (e.g. `Lighthouse/v0.1.5 (Linux +/// x86_64)`), or `None` on any error. +async fn node_version(eth2_cl: &EthBeaconNodeApiClient) -> Option { + match eth2_cl.get_node_version(GetNodeVersionRequest {}).await { + Ok(GetNodeVersionResponse::Ok(resp)) => Some(resp.data.version), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use pluto_testutil::BeaconMock; + use serde_json::json; + + use super::*; + + /// 48-byte BLS public key length used to build distinct test pubkeys. + const PK_LEN: usize = 48; + + /// Builds a beacon mock whose `/eth/v1/node/version` endpoint returns + /// `version`. + async fn mock_with_version(version: &str) -> BeaconMock { + BeaconMock::builder() + .endpoint_overrides(vec![( + "/eth/v1/node/version".to_string(), + json!({ "data": { "version": version } }), + )]) + .build() + .await + .expect("build mock") + } + + #[tokio::test] + async fn fetch_beacon_node_token() { + // fetch token error: unreachable beacon node yields an empty token. + let unreachable = + EthBeaconNodeApiClient::with_base_url("http://127.0.0.1:1").expect("create client"); + assert_eq!(super::fetch_beacon_node_token(&unreachable).await, ""); + + // fetch token unexpected response: no `/`-separated product token. + let mock = mock_with_version("IncorrectUserAgent").await; + assert_eq!(super::fetch_beacon_node_token(mock.client()).await, ""); + + // fetch token not predicted in map. + let mock = mock_with_version("Dune/v1.3 (Windows)").await; + assert_eq!(super::fetch_beacon_node_token(mock.client()).await, ""); + + // fetch token: Lighthouse maps to "LH". + let mock = mock_with_version("Lighthouse/v0.1.5 (Linux x86_64)").await; + assert_eq!(super::fetch_beacon_node_token(mock.client()).await, "LH"); + } + + #[test] + fn build_graffiti() { + let graffiti = "abcdefghij"; // 10 bytes + let token = "BN"; + + // disable client append. + assert_eq!( + super::build_graffiti(graffiti, token, true), + graffiti_bytes(graffiti) + ); + + // enable client append. + assert_eq!( + super::build_graffiti(graffiti, token, false), + graffiti_bytes(&format!("{graffiti}{OBOL_TOKEN}{token}")) + ); + } + + #[test] + fn default_graffiti() { + let (commit_sha, _) = git_commit(); + let expected = graffiti_bytes(&format!("pluto/{}-{}", *VERSION, commit_sha)); + assert_eq!(super::default_graffiti(), expected); + } + + #[test] + fn get_graffiti() { + let pubkeys = [ + PubKey::new([1u8; PK_LEN]), + PubKey::new([2u8; PK_LEN]), + PubKey::new([3u8; PK_LEN]), + ]; + + let mut g0 = [0u8; GRAFFITI_LEN]; + g0[0] = 1; + let mut g1 = [0u8; GRAFFITI_LEN]; + g1[0] = 2; + + let builder = GraffitiBuilder { + default_graffiti: super::default_graffiti(), + graffiti: HashMap::from([(pubkeys[0], g0), (pubkeys[1], g1)]), + }; + + assert_eq!(builder.get_graffiti(&pubkeys[0]), g0); + assert_eq!(builder.get_graffiti(&pubkeys[1]), g1); + assert_eq!(builder.get_graffiti(&pubkeys[2]), super::default_graffiti()); + } + + #[tokio::test] + async fn new_graffiti_builder() { + let pubkeys = [ + PubKey::new([1u8; PK_LEN]), + PubKey::new([2u8; PK_LEN]), + PubKey::new([3u8; PK_LEN]), + ]; + + // graffiti length greater than pubkeys. + let mock = BeaconMock::builder().build().await.expect("build mock"); + let graffiti = vec![ + "a".repeat(10), + "b".repeat(15), + "c".repeat(20), + "d".repeat(25), + ]; + let result = GraffitiBuilder::new(&pubkeys, Some(&graffiti), false, mock.client()).await; + assert!(matches!(result, Err(GraffitiError::LengthMismatch))); + + // graffiti length lesser than pubkeys. + let graffiti = vec!["a".repeat(10), "b".repeat(15)]; + let result = GraffitiBuilder::new(&pubkeys, Some(&graffiti), false, mock.client()).await; + assert!(matches!(result, Err(GraffitiError::LengthMismatch))); + + // nil graffiti. + let builder = GraffitiBuilder::new(&pubkeys, None, false, mock.client()) + .await + .expect("build builder"); + for pubkey in &pubkeys { + assert_eq!(builder.get_graffiti(pubkey), super::default_graffiti()); + } + + // single graffiti with append (Grandine -> GD). + let mock = mock_with_version("Grandine/v2.1.4 (Linux x86_64)").await; + let graffiti = "x".repeat(GRAFFITI_LEN - OBOL_TOKEN.len() - 2); + let builder = GraffitiBuilder::new( + &pubkeys, + Some(std::slice::from_ref(&graffiti)), + false, + mock.client(), + ) + .await + .expect("build builder"); + let expected = graffiti_bytes(&format!("{graffiti}{OBOL_TOKEN}GD")); + for pubkey in &pubkeys { + assert_eq!(builder.get_graffiti(pubkey), expected); + } + + // single graffiti without append. + let mock = mock_with_version("Teku/v4.2.1 (Linux x86_64)").await; + let graffiti = "y".repeat(GRAFFITI_LEN); + let builder = GraffitiBuilder::new( + &pubkeys, + Some(std::slice::from_ref(&graffiti)), + true, + mock.client(), + ) + .await + .expect("build builder"); + let expected = graffiti_bytes(&graffiti); + for pubkey in &pubkeys { + assert_eq!(builder.get_graffiti(pubkey), expected); + } + + // multiple graffiti with append (Prysm -> PY). + let mock = mock_with_version("Prysm/v0.2.7 (Linux x86_64)").await; + let graffiti = vec![ + "a".repeat(10), + "b".repeat(GRAFFITI_LEN - OBOL_TOKEN.len() - 3), + "c".repeat(GRAFFITI_LEN - OBOL_TOKEN.len() - 4), + ]; + let builder = GraffitiBuilder::new(&pubkeys, Some(&graffiti), false, mock.client()) + .await + .expect("build builder"); + for (idx, pubkey) in pubkeys.iter().enumerate() { + let expected = graffiti_bytes(&format!("{}{OBOL_TOKEN}PY", graffiti[idx])); + assert_eq!(builder.get_graffiti(pubkey), expected); + } + + // multiple graffiti without append (empty version -> empty token). + let mock = mock_with_version("").await; + let graffiti = vec![ + "a".repeat(10), + "b".repeat(GRAFFITI_LEN - OBOL_TOKEN.len()), + "c".repeat(GRAFFITI_LEN - OBOL_TOKEN.len() + 1), + ]; + let builder = GraffitiBuilder::new(&pubkeys, Some(&graffiti), true, mock.client()) + .await + .expect("build builder"); + for (idx, pubkey) in pubkeys.iter().enumerate() { + let expected = graffiti_bytes(&graffiti[idx]); + assert_eq!(builder.get_graffiti(pubkey), expected); + } + } +} diff --git a/crates/core/src/fetcher/mod.rs b/crates/core/src/fetcher/mod.rs new file mode 100644 index 00000000..2e462c38 --- /dev/null +++ b/crates/core/src/fetcher/mod.rs @@ -0,0 +1,1627 @@ +//! Fetcher — fetches unsigned duty data from the beacon node. +//! +//! Ported from `charon/core/fetcher/fetcher.go`. + +mod graffiti; + +pub use graffiti::{GraffitiBuilder, GraffitiError, client_graffiti_mappings}; + +use std::{any::Any, collections::HashMap, future::Future, pin::Pin, sync::Arc}; + +use pluto_eth2api::{ + ConsensusVersion, EthBeaconNodeApiClient, EthBeaconNodeApiClientError, + GetAggregatedAttestationV2Request, GetAggregatedAttestationV2Response, + ProduceAttestationDataRequest, ProduceAttestationDataResponse, ProduceBlockV3Request, + ProduceBlockV3Response, ProduceBlockV3ResponseResponse, + ProduceSyncCommitteeContributionRequest, ProduceSyncCommitteeContributionResponse, + spec::{altair, phase0}, + versioned, +}; +use pluto_eth2util::eth2exp::{self, Eth2ExpError}; +use tracing::{debug, info, warn}; +use tree_hash::TreeHash; + +use crate::{ + signeddata::{ + AttestationData, BeaconCommitteeSelection, ProposalBlock, SignedSyncMessage, + SyncCommitteeSelection, SyncContribution, VersionedAggregatedAttestation, + VersionedProposal, + }, + types::{ + Duty, DutyDefinitionSet, DutyType, PubKey, SignedData, UnsignedDataSet, UnsignedDutyData, + }, +}; + +/// Boxed error returned by injected callbacks (subscribers, AggSigDB, DutyDB). +type BoxError = Box; + +/// Future returned by an injected callback. +type CallbackFuture = Pin> + Send>>; + +/// Subscriber callback invoked for each fetched duty data set. +pub type Subscriber = Arc CallbackFuture<()> + Send + Sync>; + +/// AggSigDB callback: resolves aggregated signed data for a duty/pubkey. +pub type AggSigDbFunc = + Arc CallbackFuture> + Send + Sync>; + +/// DutyDB callback: resolves attestation data for a `(slot, committee index)`. +pub type AwaitAttDataFunc = + Arc CallbackFuture + Send + Sync>; + +/// Fee recipient resolver: returns the configured fee recipient for a pubkey. +pub type FeeRecipientFunc = Arc String + Send + Sync>; + +/// Errors returned while fetching duty data. +#[derive(Debug, thiserror::Error)] +pub enum FetcherError { + /// Wraps an inner error with the duty-type context, matching Go's + /// `errors.Wrap(err, "fetch data")`. + #[error("{context}: {source}")] + Fetch { + /// Context prefix (e.g. `fetch attester data`). + context: &'static str, + /// Wrapped inner error. + source: Box, + }, + + /// `DutyBuilderProposer` is deprecated and no longer supported. + #[error("DutyBuilderProposer is deprecated and no longer supported")] + DeprecatedDutyBuilderProposer, + + /// The duty type is not supported by the fetcher. + #[error("unsupported duty type: {0}")] + UnsupportedDutyType(String), + + /// A duty definition was not an attester definition. + #[error("invalid attester definition")] + InvalidAttesterDefinition, + + /// AggSigDB returned a value that was not a beacon committee selection. + #[error("invalid beacon committee selection")] + InvalidBeaconCommitteeSelection, + + /// AggSigDB returned a value that was not a sync committee selection. + #[error("invalid sync committee selection")] + InvalidSyncCommitteeSelection, + + /// AggSigDB returned a value that was not a sync committee message. + #[error("invalid sync committee message")] + InvalidSyncCommitteeMessage, + + /// The beacon node returned a nil attestation data response. + #[error("attestation data cannot be nil")] + NilAttestationData, + + /// The beacon node could not find an aggregate attestation for the root. + #[error("aggregate attestation not found by root (retryable)")] + AggregateAttestationNotFound, + + /// The beacon node could not find a sync committee contribution. + #[error("sync committee contribution not found by root (retryable)")] + SyncContributionNotFound, + + /// The beacon node returned an unexpected (non-success) response. + #[error("unexpected beacon node response")] + UnexpectedResponse, + + /// AggSigDB / DutyDB callback (or a subscriber) returned an error. + #[error("{0}")] + Callback(BoxError), + + /// AggSigDB was queried but no resolver was registered. + #[error("AggSigDB function not registered")] + AggSigDbNotRegistered, + + /// DutyDB was queried but no resolver was registered. + #[error("AwaitAttData function not registered")] + AwaitAttDataNotRegistered, + + /// Error from the beacon node API client. + #[error(transparent)] + BeaconNode(#[from] EthBeaconNodeApiClientError), + + /// Error from aggregator selection. + #[error(transparent)] + Eth2Exp(#[from] Eth2ExpError), + + /// JSON (de)serialization error while decoding a beacon node response. + #[error("decode beacon node response: {0}")] + Json(#[from] serde_json::Error), + + /// A versioned proposal had an unsupported fork version. + #[error("unsupported proposal version: {0:?}")] + UnsupportedProposalVersion(ConsensusVersion), + + /// A versioned proposal response was missing the `block` field. + #[error("proposal response missing block field")] + MissingBlockField, + + /// A signed data value could not produce a signature. + #[error("signature: {0}")] + Signature(String), +} + +/// Result alias for fetcher operations. +type Result = std::result::Result; + +/// Fetches proposed duty data from the beacon node. +pub struct Fetcher { + eth2_cl: EthBeaconNodeApiClient, + fee_recipient_func: Option, + subs: Vec, + agg_sig_db_func: Option, + await_att_data_func: Option, + builder_enabled: bool, + graffiti_builder: GraffitiBuilder, + electra_slot: phase0::Slot, + fetch_only_comm_idx0: bool, +} + +impl Fetcher { + /// Returns a new fetcher instance. + pub fn new( + eth2_cl: EthBeaconNodeApiClient, + fee_recipient_func: Option, + builder_enabled: bool, + graffiti_builder: GraffitiBuilder, + electra_slot: phase0::Slot, + fetch_only_comm_idx0: bool, + ) -> Self { + Self { + eth2_cl, + fee_recipient_func, + subs: Vec::new(), + agg_sig_db_func: None, + await_att_data_func: None, + builder_enabled, + graffiti_builder, + electra_slot, + fetch_only_comm_idx0, + } + } + + /// Registers a callback for fetched duties. + /// + /// Note: this is not thread safe and should be called *before* `fetch`. + pub fn subscribe(&mut self, sub: Subscriber) { + self.subs.push(sub); + } + + /// Registers a function to get resolved aggregated signed data from + /// AggSigDB. + /// + /// Note: this is not thread safe and should be called *before* `fetch`. + pub fn register_agg_sig_db(&mut self, func: AggSigDbFunc) { + self.agg_sig_db_func = Some(func); + } + + /// Registers a function to get attestation data from DutyDB. + /// + /// Note: this is not thread safe and should be called *before* `fetch`. + pub fn register_await_att_data(&mut self, func: AwaitAttDataFunc) { + self.await_att_data_func = Some(func); + } + + /// Triggers fetching of a proposed duty data set. + pub async fn fetch(&self, duty: Duty, def_set: DutyDefinitionSet) -> Result<()> { + let slot = duty.slot.inner(); + + let unsigned_set = match duty.duty_type { + DutyType::Proposer => self + .fetch_proposer_data(slot, &def_set) + .await + .map_err(wrap("fetch proposer data"))?, + DutyType::Attester => self + .fetch_attester_data(slot, &def_set) + .await + .map_err(wrap("fetch attester data"))?, + DutyType::BuilderProposer => return Err(FetcherError::DeprecatedDutyBuilderProposer), + DutyType::Aggregator => { + let set = self + .fetch_aggregator_data(slot, &def_set) + .await + .map_err(wrap("fetch aggregator data"))?; + if set.is_empty() { + // No aggregators found in this slot. + return Ok(()); + } + set + } + DutyType::SyncContribution => { + let set = self + .fetch_contribution_data(slot, &def_set) + .await + .map_err(wrap("fetch contribution data"))?; + if set.is_empty() { + // No sync committee contributors found in this slot. + return Ok(()); + } + set + } + other => return Err(FetcherError::UnsupportedDutyType(other.to_string())), + }; + + for sub in &self.subs { + // Clone before calling each subscriber. + let clone = unsigned_set.clone(); + sub(duty.clone(), clone) + .await + .map_err(FetcherError::Callback)?; + } + + Ok(()) + } + + /// Returns the fetched attestation data set for committees and validators + /// in the arg set. + async fn fetch_attester_data( + &self, + slot: u64, + def_set: &DutyDefinitionSet, + ) -> Result { + // We may have multiple validators in the same committee, use the same + // attestation data in that case. + let mut data_by_comm_idx: HashMap = HashMap::new(); + + let mut resp = UnsignedDataSet::new(); + for (pubkey, def) in def_set { + let att_def = def + .as_attester() + .ok_or(FetcherError::InvalidAttesterDefinition)?; + + let mut comm_idx = att_def.duty().committee_index; + + // Attestation data for Electra is not bound by committee index; + // committee index is still persisted in the request but should be + // set to 0 once all VCs request committee index 0. + if slot >= self.electra_slot && self.fetch_only_comm_idx0 { + comm_idx = 0; + } + + let eth2_att_data = match data_by_comm_idx.get(&comm_idx) { + Some(data) => data.clone(), + None => { + let data = self.attestation_data(slot, comm_idx).await?; + data_by_comm_idx.insert(comm_idx, data.clone()); + data + } + }; + + resp.insert( + *pubkey, + UnsignedDutyData::Attestation(AttestationData { + data: eth2_att_data, + duty: att_def.duty().clone(), + }), + ); + } + + Ok(resp) + } + + /// Fetches the attestation aggregation data. + async fn fetch_aggregator_data( + &self, + slot: u64, + def_set: &DutyDefinitionSet, + ) -> Result { + let mut tracker = PubkeysTracker::new("attester aggregation"); + + // We may have multiple aggregators in the same committee, use the same + // aggregated attestation in that case. + let mut agg_att_by_comm_idx: HashMap = HashMap::new(); + + let mut resp = UnsignedDataSet::new(); + for (pubkey, def) in def_set { + let att_def = def + .as_attester() + .ok_or(FetcherError::InvalidAttesterDefinition)?; + + // Query AggSigDB for DutyPrepareAggregator to get beacon committee + // selections. + let prep_agg_data = self + .agg_sig_db(Duty::new_prepare_aggregator_duty(slot.into()), *pubkey) + .await?; + let selection = downcast::(prep_agg_data.as_ref()) + .ok_or(FetcherError::InvalidBeaconCommitteeSelection)?; + + let is_aggregator = eth2exp::is_att_aggregator( + &self.eth2_cl, + att_def.duty().committee_length, + selection.0.selection_proof, + ) + .await?; + if !is_aggregator { + tracker.add_not_selected(pubkey.to_string()); + continue; + } + + tracker.add_resolved(pubkey.to_string()); + + let comm_idx = att_def.duty().committee_index; + + if let Some(agg_att) = agg_att_by_comm_idx.get(&comm_idx) { + resp.insert( + *pubkey, + UnsignedDutyData::AggAttestation(VersionedAggregatedAttestation( + agg_att.clone(), + )), + ); + // Skip querying aggregate attestation for aggregators of the + // same committee. + continue; + } + + // Query DutyDB for attestation data to get the attestation data root. + let att_data = self.await_att_data(slot, comm_idx).await?; + let data_root = att_data.tree_hash_root().0; + + // Query BN for aggregate attestation. + let agg_att = self + .aggregate_attestation(slot, comm_idx, data_root) + .await?; + + agg_att_by_comm_idx.insert(comm_idx, agg_att.clone()); + resp.insert( + *pubkey, + UnsignedDutyData::AggAttestation(VersionedAggregatedAttestation(agg_att)), + ); + } + + tracker.log(); + + Ok(resp) + } + + /// Fetches the block proposal data set. + async fn fetch_proposer_data( + &self, + slot: u64, + def_set: &DutyDefinitionSet, + ) -> Result { + let mut resp = UnsignedDataSet::new(); + for pubkey in def_set.keys() { + // Fetch previously aggregated randao reveal from AggSigDB. + let randao_data = self + .agg_sig_db(Duty::new_randao_duty(slot.into()), *pubkey) + .await?; + let randao = randao_data + .signature() + .map_err(|e| FetcherError::Signature(e.to_string()))?; + + // Maximum priority to builder blocks when the builder is enabled. + let builder_boost_factor: u64 = if self.builder_enabled { u64::MAX } else { 0 }; + + let graffiti = self.graffiti_builder.get_graffiti(pubkey); + + let request = ProduceBlockV3Request::builder() + .slot(slot.to_string()) + .randao_reveal(hex_0x(&randao)) + .graffiti(hex_0x(&graffiti)) + .builder_boost_factor(builder_boost_factor.to_string()) + .build() + .map_err(EthBeaconNodeApiClientError::RequestError)?; + + let response = match self + .eth2_cl + .produce_block_v3(request) + .await + .map_err(EthBeaconNodeApiClientError::RequestError)? + { + ProduceBlockV3Response::Ok(resp) => resp, + _ => return Err(FetcherError::UnexpectedResponse), + }; + + let proposal = versioned_proposal_from_response(&response)?; + + // Builders set the fee recipient to themselves, so it always differs + // from the validator's; only verify when the builder is disabled. + if !self.builder_enabled { + let fee_recipient = self + .fee_recipient_func + .as_ref() + .map(|f| f(pubkey)) + .unwrap_or_default(); + verify_fee_recipient(&proposal, &fee_recipient); + } + + resp.insert(*pubkey, UnsignedDutyData::Proposal(Box::new(proposal))); + } + + Ok(resp) + } + + /// Fetches the sync committee contribution data. + async fn fetch_contribution_data( + &self, + slot: u64, + def_set: &DutyDefinitionSet, + ) -> Result { + let mut tracker = PubkeysTracker::new("sync committee contribution"); + + let mut resp = UnsignedDataSet::new(); + for pubkey in def_set.keys() { + // Query AggSigDB for DutyPrepareSyncContribution to get the sync + // committee selection. + let selection_data = self + .agg_sig_db( + Duty::new_prepare_sync_contribution_duty(slot.into()), + *pubkey, + ) + .await?; + let selection = downcast::(selection_data.as_ref()) + .ok_or(FetcherError::InvalidSyncCommitteeSelection)?; + + let subcomm_idx = selection.0.subcommittee_index; + + // Check if the validator is an aggregator for the sync committee. + let is_aggregator = + eth2exp::is_sync_comm_aggregator(&self.eth2_cl, selection.0.selection_proof) + .await?; + if !is_aggregator { + tracker.add_not_selected(pubkey.to_string()); + continue; + } + + // Query AggSigDB for DutySyncMessage to get the beacon block root. + let sync_msg_data = self + .agg_sig_db(Duty::new_sync_message_duty(slot.into()), *pubkey) + .await?; + let msg = downcast::(sync_msg_data.as_ref()) + .ok_or(FetcherError::InvalidSyncCommitteeMessage)?; + + let block_root = msg.0.beacon_block_root; + + // Query BN for sync committee contribution. + let contribution = self + .sync_committee_contribution(slot, subcomm_idx, block_root) + .await?; + + tracker.add_resolved(pubkey.to_string()); + + resp.insert( + *pubkey, + UnsignedDutyData::SyncContribution(SyncContribution(contribution)), + ); + } + + tracker.log(); + + Ok(resp) + } + + // --- beacon node helpers ------------------------------------------------- + + /// Queries the beacon node for attestation data. + async fn attestation_data(&self, slot: u64, comm_idx: u64) -> Result { + let request = ProduceAttestationDataRequest::builder() + .slot(slot.to_string()) + .committee_index(comm_idx.to_string()) + .build() + .map_err(EthBeaconNodeApiClientError::RequestError)?; + + match self + .eth2_cl + .produce_attestation_data(request) + .await + .map_err(EthBeaconNodeApiClientError::RequestError)? + { + ProduceAttestationDataResponse::Ok(ok) => round_trip(&ok.data), + _ => Err(FetcherError::NilAttestationData), + } + } + + /// Queries the beacon node for an aggregate attestation by data root. + async fn aggregate_attestation( + &self, + slot: u64, + comm_idx: u64, + data_root: phase0::Root, + ) -> Result { + let request = GetAggregatedAttestationV2Request::builder() + .attestation_data_root(hex_0x(&data_root)) + .slot(slot.to_string()) + .committee_index(comm_idx.to_string()) + .build() + .map_err(EthBeaconNodeApiClientError::RequestError)?; + + let ok = match self + .eth2_cl + .get_aggregated_attestation_v2(request) + .await + .map_err(EthBeaconNodeApiClientError::RequestError)? + { + GetAggregatedAttestationV2Response::Ok(ok) => ok, + // Some beacon nodes return nil if the root is not found; surface a + // retryable error. + _ => return Err(FetcherError::AggregateAttestationNotFound), + }; + + let version = consensus_to_data_version(&ok.version); + Ok(versioned::VersionedAttestation { + version, + validator_index: None, + attestation: Some(attestation_payload(version, &ok.data)?), + }) + } + + /// Queries the beacon node for a sync committee contribution. + async fn sync_committee_contribution( + &self, + slot: u64, + subcomm_idx: u64, + block_root: phase0::Root, + ) -> Result { + let request = ProduceSyncCommitteeContributionRequest::builder() + .slot(slot.to_string()) + .subcommittee_index(subcomm_idx.to_string()) + .beacon_block_root(hex_0x(&block_root)) + .build() + .map_err(EthBeaconNodeApiClientError::RequestError)?; + + match self + .eth2_cl + .produce_sync_committee_contribution(request) + .await + .map_err(EthBeaconNodeApiClientError::RequestError)? + { + ProduceSyncCommitteeContributionResponse::Ok(payload) => round_trip(&payload.data), + _ => Err(FetcherError::SyncContributionNotFound), + } + } + + /// Invokes the registered AggSigDB resolver. + async fn agg_sig_db(&self, duty: Duty, pubkey: PubKey) -> Result> { + let func = self + .agg_sig_db_func + .as_ref() + .ok_or(FetcherError::AggSigDbNotRegistered)?; + func(duty, pubkey).await.map_err(FetcherError::Callback) + } + + /// Invokes the registered DutyDB attestation-data resolver. + async fn await_att_data(&self, slot: u64, comm_idx: u64) -> Result { + let func = self + .await_att_data_func + .as_ref() + .ok_or(FetcherError::AwaitAttDataNotRegistered)?; + func(slot, comm_idx).await.map_err(FetcherError::Callback) + } +} + +/// Builds a closure that wraps a [`FetcherError`] with the duty-type context, +/// matching Go's `errors.Wrap(err, context)`. +fn wrap(context: &'static str) -> impl Fn(FetcherError) -> FetcherError { + move |source| FetcherError::Fetch { + context, + source: Box::new(source), + } +} + +/// Downcasts a `&dyn SignedData` to a concrete signed-data type. +fn downcast(data: &dyn SignedData) -> Option<&T> { + (data as &dyn Any).downcast_ref::() +} + +/// Formats bytes as a `0x`-prefixed lowercase hex string. +fn hex_0x(bytes: &[u8]) -> String { + format!("0x{}", hex::encode(bytes)) +} + +/// Round-trips a loosely-typed beacon node response value into a strongly-typed +/// target via JSON. +fn round_trip(value: &S) -> Result +where + T: serde::de::DeserializeOwned, + S: serde::Serialize, +{ + let value = serde_json::to_value(value)?; + Ok(serde_json::from_value(value)?) +} + +/// Converts a `produce_block_v3` response into an unsigned +/// [`VersionedProposal`]. +fn versioned_proposal_from_response( + resp: &ProduceBlockV3ResponseResponse, +) -> Result { + let data = serde_json::to_value(&resp.data)?; + let blinded = resp.execution_payload_blinded; + + let block = match (&resp.version, blinded) { + (ConsensusVersion::Phase0, _) => ProposalBlock::Phase0(json_from(&data)?), + (ConsensusVersion::Altair, _) => ProposalBlock::Altair(json_from(&data)?), + (ConsensusVersion::Bellatrix, false) => ProposalBlock::Bellatrix(json_from(&data)?), + (ConsensusVersion::Bellatrix, true) => ProposalBlock::BellatrixBlinded(json_from(&data)?), + (ConsensusVersion::Capella, false) => ProposalBlock::Capella(json_from(&data)?), + (ConsensusVersion::Capella, true) => ProposalBlock::CapellaBlinded(json_from(&data)?), + (ConsensusVersion::Deneb, false) => ProposalBlock::Deneb { + block: Box::new(json_from(block_field(&data)?)?), + kzg_proofs: json_from_field(&data, "kzg_proofs")?, + blobs: json_from_field(&data, "blobs")?, + }, + (ConsensusVersion::Deneb, true) => ProposalBlock::DenebBlinded(json_from(&data)?), + (ConsensusVersion::Electra, false) => ProposalBlock::Electra { + block: Box::new(json_from(block_field(&data)?)?), + kzg_proofs: json_from_field(&data, "kzg_proofs")?, + blobs: json_from_field(&data, "blobs")?, + }, + (ConsensusVersion::Electra, true) => ProposalBlock::ElectraBlinded(json_from(&data)?), + (ConsensusVersion::Fulu, false) => ProposalBlock::Fulu { + block: Box::new(json_from(block_field(&data)?)?), + kzg_proofs: json_from_field(&data, "kzg_proofs")?, + blobs: json_from_field(&data, "blobs")?, + }, + (ConsensusVersion::Fulu, true) => ProposalBlock::FuluBlinded(json_from(&data)?), + }; + + Ok(VersionedProposal { block }) +} + +/// Maps a beacon node `ConsensusVersion` onto a `versioned::DataVersion`. +fn consensus_to_data_version(version: &ConsensusVersion) -> versioned::DataVersion { + use versioned::DataVersion as DV; + match version { + ConsensusVersion::Phase0 => DV::Phase0, + ConsensusVersion::Altair => DV::Altair, + ConsensusVersion::Bellatrix => DV::Bellatrix, + ConsensusVersion::Capella => DV::Capella, + ConsensusVersion::Deneb => DV::Deneb, + ConsensusVersion::Electra => DV::Electra, + ConsensusVersion::Fulu => DV::Fulu, + } +} + +/// Builds a versioned attestation payload from a loosely-typed response value. +fn attestation_payload( + version: versioned::DataVersion, + data: &S, +) -> Result { + use versioned::{AttestationPayload as AP, DataVersion as DV}; + Ok(match version { + DV::Phase0 => AP::Phase0(round_trip(data)?), + DV::Altair => AP::Altair(round_trip(data)?), + DV::Bellatrix => AP::Bellatrix(round_trip(data)?), + DV::Capella => AP::Capella(round_trip(data)?), + DV::Deneb => AP::Deneb(round_trip(data)?), + DV::Electra => AP::Electra(round_trip(data)?), + DV::Fulu => AP::Fulu(round_trip(data)?), + DV::Unknown => return Err(FetcherError::AggregateAttestationNotFound), + }) +} + +/// Deserializes a JSON value into `T`. +fn json_from(value: &serde_json::Value) -> Result { + Ok(serde_json::from_value(value.clone())?) +} + +/// Returns the `block` field of a Deneb+ versioned block contents object. +fn block_field(value: &serde_json::Value) -> Result<&serde_json::Value> { + value.get("block").ok_or(FetcherError::MissingBlockField) +} + +/// Deserializes the named field of `value` into `T`, defaulting to `T::default` +/// when absent. +fn json_from_field( + value: &serde_json::Value, + field: &str, +) -> Result { + match value.get(field) { + Some(v) => Ok(serde_json::from_value(v.clone())?), + None => Ok(T::default()), + } +} + +/// Logs a warning when the fee recipient is not correctly populated in the +/// proposal. Fee recipient is unavailable in forks earlier than Bellatrix. +fn verify_fee_recipient(proposal: &VersionedProposal, fee_recipient_address: &str) { + if let Some((expected, actual)) = fee_recipient_mismatch(proposal, fee_recipient_address) { + warn!( + expected = %expected, + actual = %actual, + "Proposal with unexpected fee recipient address" + ); + } +} + +/// Returns `Some((expected, actual))` when the proposal's fee recipient differs +/// (case-insensitively) from `fee_recipient_address`. Returns `None` for forks +/// without a fee recipient (pre-Bellatrix) or when the addresses match. +fn fee_recipient_mismatch( + proposal: &VersionedProposal, + fee_recipient_address: &str, +) -> Option<(String, String)> { + if matches!( + proposal.version(), + versioned::DataVersion::Phase0 | versioned::DataVersion::Altair + ) { + return None; + } + + let value = serde_json::to_value(proposal_body(&proposal.block)).ok()?; + + // Unblinded blocks carry `execution_payload`; blinded blocks carry + // `execution_payload_header`. Both expose `fee_recipient`. + let actual_addr = value + .get("execution_payload") + .or_else(|| value.get("execution_payload_header")) + .and_then(|payload| payload.get("fee_recipient")) + .and_then(|addr| addr.as_str())?; + + if actual_addr.eq_ignore_ascii_case(fee_recipient_address) { + None + } else { + Some((fee_recipient_address.to_string(), actual_addr.to_string())) + } +} + +/// Returns the block body as a JSON value, used by [`verify_fee_recipient`]. +fn proposal_body(block: &ProposalBlock) -> serde_json::Value { + let body = match block { + ProposalBlock::Phase0(b) => serde_json::to_value(&b.body), + ProposalBlock::Altair(b) => serde_json::to_value(&b.body), + ProposalBlock::Bellatrix(b) => serde_json::to_value(&b.body), + ProposalBlock::BellatrixBlinded(b) => serde_json::to_value(&b.body), + ProposalBlock::Capella(b) => serde_json::to_value(&b.body), + ProposalBlock::CapellaBlinded(b) => serde_json::to_value(&b.body), + ProposalBlock::Deneb { block, .. } => serde_json::to_value(&block.body), + ProposalBlock::DenebBlinded(b) => serde_json::to_value(&b.body), + ProposalBlock::Electra { block, .. } => serde_json::to_value(&block.body), + ProposalBlock::ElectraBlinded(b) => serde_json::to_value(&b.body), + ProposalBlock::Fulu { block, .. } => serde_json::to_value(&block.body), + ProposalBlock::FuluBlinded(b) => serde_json::to_value(&b.body), + }; + body.unwrap_or(serde_json::Value::Null) +} + +/// Tracks which pubkeys were selected/resolved for aggregation duties so the +/// outcome can be logged once per fetch. +struct PubkeysTracker { + title: &'static str, + not_selected_pubkeys: Vec, + resolved_pubkeys: Vec, +} + +impl PubkeysTracker { + fn new(title: &'static str) -> Self { + Self { + title, + not_selected_pubkeys: Vec::new(), + resolved_pubkeys: Vec::new(), + } + } + + fn add_not_selected(&mut self, pubkey: String) { + self.not_selected_pubkeys.push(pubkey); + } + + fn add_resolved(&mut self, pubkey: String) { + self.resolved_pubkeys.push(pubkey); + } + + fn log(&self) { + if !self.not_selected_pubkeys.is_empty() { + debug!( + title = self.title, + pubkeys = self.not_selected_pubkeys.join(","), + "not selected pubkeys" + ); + } + + if !self.resolved_pubkeys.is_empty() { + info!( + title = self.title, + pubkeys = self.resolved_pubkeys.join(","), + "resolved pubkeys" + ); + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::Mutex; + + use pluto_testutil::BeaconMock; + + use super::*; + use crate::{ + signeddata::AttesterDuty, + types::{ + AttesterDefinition, DutyDefinition, ProposerDefinition, ProposerDuty, SlotNumber, + SyncCommitteeDefinition, SyncCommitteeDuty, + }, + }; + + /// 48-byte BLS public key length used to build distinct test pubkeys. + const PK_LEN: usize = 48; + + /// Captures the `(duty, set)` passed to the last subscriber invocation. + type Captured = Arc>>; + + /// Builds a subscriber that records its argument into `captured`. + fn capturing_subscriber(captured: Captured) -> Subscriber { + Arc::new(move |duty, set| { + let captured = captured.clone(); + Box::pin(async move { + *captured.lock().unwrap() = Some((duty, set)); + Ok(()) + }) + }) + } + + /// Spec fields required by `is_sync_comm_aggregator` / + /// `is_att_aggregator`, matching the values the prysm selection-proof test + /// vectors were generated against. + fn aggregator_spec() -> serde_json::Value { + serde_json::json!({ + "TARGET_AGGREGATORS_PER_COMMITTEE": "16", + "SYNC_COMMITTEE_SIZE": "512", + "SYNC_COMMITTEE_SUBNET_COUNT": "4", + "TARGET_AGGREGATORS_PER_SYNC_SUBCOMMITTEE": "16", + }) + } + + /// Decodes a 96-byte BLS signature from hex. + fn bls_sig(hex_str: &str) -> phase0::BLSSignature { + hex::decode(hex_str) + .expect("valid hex") + .try_into() + .expect("96-byte signature") + } + + /// Electra block contents (`{block, kzg_proofs, blobs}`) reused as the + /// `produce_block_v3` response payload. + const BLOCK_CONTENTS_GOLDEN: &str = include_str!( + "../../testdata/signeddata/TestJSONSerialisation_VersionedProposal.json.golden" + ); + + /// Mounts a `produce_block_v3` responder that returns the golden Electra + /// block contents with the request's slot, randao reveal and graffiti + /// echoed back and a zero fee recipient. + async fn mount_produce_block(server: &wiremock::MockServer) { + let golden: serde_json::Value = + serde_json::from_str(BLOCK_CONTENTS_GOLDEN).expect("parse golden"); + let base = golden["block"].clone(); + + struct Responder { + base: serde_json::Value, + } + impl wiremock::Respond for Responder { + fn respond(&self, req: &wiremock::Request) -> wiremock::ResponseTemplate { + let query: HashMap = req.url.query_pairs().into_owned().collect(); + let randao = query.get("randao_reveal").cloned().unwrap_or_default(); + let graffiti = query.get("graffiti").cloned().unwrap_or_default(); + // Slot is the final path segment. + let slot = req + .url + .path_segments() + .and_then(|mut s| s.next_back()) + .unwrap_or("0") + .to_string(); + + let mut data = self.base.clone(); + data["block"]["slot"] = serde_json::json!(slot); + data["block"]["body"]["randao_reveal"] = serde_json::json!(randao); + data["block"]["body"]["graffiti"] = serde_json::json!(graffiti); + data["block"]["body"]["execution_payload"]["fee_recipient"] = + serde_json::json!(format!("0x{}", "00".repeat(20))); + + wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "version": "electra", + "execution_payload_blinded": false, + "execution_payload_value": "0", + "consensus_block_value": "0", + "data": data, + })) + } + } + + wiremock::Mock::given(wiremock::matchers::method("GET")) + .and(wiremock::matchers::path_regex( + r"^/eth/v3/validator/blocks/[0-9]+$", + )) + .respond_with(Responder { base }) + .mount(server) + .await; + } + + #[test] + fn verify_fee_recipient() { + use pluto_eth2api::spec::electra; + + // Electra proposal from the golden block contents. + let golden: serde_json::Value = + serde_json::from_str(BLOCK_CONTENTS_GOLDEN).expect("parse golden"); + let block: electra::BeaconBlock = + serde_json::from_value(golden["block"]["block"].clone()).expect("parse block"); + let proposal = VersionedProposal { + block: ProposalBlock::Electra { + block: Box::new(block), + kzg_proofs: vec![], + blobs: vec![], + }, + }; + + // A different address is reported as a mismatch; the actual address + // matches itself (case-insensitively). + let (_, actual) = + fee_recipient_mismatch(&proposal, "0xdead").expect("mismatch against wrong address"); + assert!(fee_recipient_mismatch(&proposal, &actual).is_none()); + assert!(fee_recipient_mismatch(&proposal, &actual.to_uppercase()).is_none()); + } + + #[tokio::test] + async fn fetch_blocks() { + const SLOT: u64 = 1; + let pk_a = PubKey::new([2u8; PK_LEN]); + let pk_b = PubKey::new([3u8; PK_LEN]); + + let randao_a: phase0::BLSSignature = [7u8; 96]; + let randao_b: phase0::BLSSignature = [8u8; 96]; + let randao_by_pubkey: HashMap = + HashMap::from([(pk_a, randao_a), (pk_b, randao_b)]); + + // disable_client_append = true, so graffiti is the raw string padded to + // 32 bytes. + let mut graffiti_a = [0u8; 32]; + graffiti_a[..5].copy_from_slice(b"testA"); + let mut graffiti_b = [0u8; 32]; + graffiti_b[..5].copy_from_slice(b"testB"); + + let mut def_set = DutyDefinitionSet::new(); + def_set.insert( + pk_a, + DutyDefinition::Proposer(ProposerDefinition::new(ProposerDuty { + pubkey: [0u8; 48], + slot: SLOT, + validator_index: 2, + })), + ); + def_set.insert( + pk_b, + DutyDefinition::Proposer(ProposerDefinition::new(ProposerDuty { + pubkey: [0u8; 48], + slot: SLOT, + validator_index: 3, + })), + ); + + let mock = BeaconMock::builder().build().await.expect("build mock"); + mount_produce_block(mock.server()).await; + + let graffiti_builder = GraffitiBuilder::new( + &[pk_a, pk_b], + Some(&["testA".to_string(), "testB".to_string()]), + true, + mock.client(), + ) + .await + .expect("build graffiti"); + + let mut fetch = Fetcher::new( + mock.client().clone(), + None, + true, + graffiti_builder, + 5, + false, + ); + + let randaos = randao_by_pubkey.clone(); + fetch.register_agg_sig_db(Arc::new(move |_duty: Duty, pubkey: PubKey| { + let sig = randaos[&pubkey]; + Box::pin(async move { + let data: Box = Box::new(sig); + Ok(data) + }) + })); + + let captured: Captured = Arc::new(Mutex::new(None)); + fetch.subscribe(capturing_subscriber(captured.clone())); + + let duty = Duty::new_proposer_duty(SlotNumber::new(SLOT)); + fetch.fetch(duty, def_set).await.expect("fetch"); + + let (_, res_set) = captured.lock().unwrap().take().expect("subscriber called"); + assert_eq!(res_set.len(), 2); + + for (pubkey, expected_randao, expected_graffiti) in + [(pk_a, randao_a, graffiti_a), (pk_b, randao_b, graffiti_b)] + { + let UnsignedDutyData::Proposal(proposal) = res_set.get(&pubkey).expect("entry") else { + panic!("expected proposal"); + }; + assert_eq!(proposal.slot(), SLOT); + + let ProposalBlock::Electra { block, .. } = &proposal.block else { + panic!("expected electra block"); + }; + assert_eq!(block.slot, SLOT); + assert_eq!(block.body.randao_reveal, expected_randao); + assert_eq!(block.body.graffiti, expected_graffiti); + assert_eq!(block.body.execution_payload.fee_recipient, [0u8; 20]); + } + } + + #[tokio::test] + async fn fetch_attester() { + const SLOT: u64 = 1; + const V_IDX_A: u64 = 2; + const V_IDX_B: u64 = 3; + const NOT_ZERO: u64 = 99; // Validation requires non-zero values. + + let pk_a = PubKey::new([2u8; PK_LEN]); + let pk_b = PubKey::new([3u8; PK_LEN]); + + let duty_a = AttesterDuty { + slot: SLOT, + validator_index: V_IDX_A, + committee_index: V_IDX_A, + committee_length: NOT_ZERO, + committees_at_slot: NOT_ZERO, + validator_committee_index: 0, + }; + let duty_b = AttesterDuty { + slot: SLOT, + validator_index: V_IDX_B, + committee_index: V_IDX_B, + committee_length: NOT_ZERO, + committees_at_slot: NOT_ZERO, + validator_committee_index: 0, + }; + + let mut def_set = DutyDefinitionSet::new(); + def_set.insert( + pk_a, + DutyDefinition::Attester(AttesterDefinition::new(duty_a.clone())), + ); + def_set.insert( + pk_b, + DutyDefinition::Attester(AttesterDefinition::new(duty_b.clone())), + ); + + let duty = Duty::new_attester_duty(SlotNumber::new(SLOT)); + let mock = BeaconMock::builder().build().await.expect("build mock"); + + let mut fetch = Fetcher::new( + mock.client().clone(), + None, + true, + GraffitiBuilder::default(), + 5, + false, + ); + + let captured: Captured = Arc::new(Mutex::new(None)); + fetch.subscribe(capturing_subscriber(captured.clone())); + + fetch.fetch(duty.clone(), def_set).await.expect("fetch"); + + let (res_duty, res_set) = captured.lock().unwrap().take().expect("subscriber called"); + assert_eq!(res_duty, duty); + assert_eq!(res_set.len(), 2); + + for (pubkey, expected_duty, v_idx) in [(pk_a, &duty_a, V_IDX_A), (pk_b, &duty_b, V_IDX_B)] { + let UnsignedDutyData::Attestation(att) = res_set.get(&pubkey).expect("entry") else { + panic!("expected attestation data"); + }; + assert_eq!(att.data.slot, SLOT); + assert_eq!(att.data.index, v_idx); + assert_eq!(&att.duty, expected_duty); + } + } + + // Aggregator selection proofs from prysm's + // validate_sync_contribution_proof_test.go. + const SYNC_AGG_SIG_A: &str = "a9dbd88a49a7269e91b8ef1296f1e07f87fed919d51a446b67122bfdfd61d23f3f929fc1cd5209bd6862fd60f739b27213fb0a8d339f7f081fc84281f554b190bb49cc97a6b3364e622af9e7ca96a97fe2b766f9e746dead0b33b58473d91562"; + const SYNC_AGG_SIG_B: &str = "99e60f20dde4d4872b048d703f1943071c20213d504012e7e520c229da87661803b9f139b9a0c5be31de3cef6821c080125aed38ebaf51ba9a2e9d21d7fbf2903577983109d097a8599610a92c0305408d97c1fd4b0b2d1743fb4eedf5443f99"; + const SYNC_NON_AGG_SIG: &str = "b9251a82040d4620b8c5665f328ee6c2eaa02d31d71d153f4abba31a7922a981e541e85283f0ced387d26e86aef9386d18c6982b9b5f8759882fe7f25a328180d86e146994ef19d28bc1432baf29751dec12b5f3d65dbbe224d72cf900c6831a"; + + /// Mounts a request-aware sync-committee-contribution responder that echoes + /// the request slot / subcommittee index / beacon block root. + async fn mount_sync_contribution(server: &wiremock::MockServer) { + struct Responder; + impl wiremock::Respond for Responder { + fn respond(&self, req: &wiremock::Request) -> wiremock::ResponseTemplate { + let query: std::collections::HashMap = + req.url.query_pairs().into_owned().collect(); + let slot = query.get("slot").cloned().unwrap_or_default(); + let subcommittee_index = + query.get("subcommittee_index").cloned().unwrap_or_default(); + let beacon_block_root = query.get("beacon_block_root").cloned().unwrap_or_default(); + + wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { + "slot": slot, + "beacon_block_root": beacon_block_root, + "subcommittee_index": subcommittee_index, + "aggregation_bits": format!("0x{}", "00".repeat(16)), + "signature": format!("0x{}", "00".repeat(96)), + } + })) + } + } + + wiremock::Mock::given(wiremock::matchers::method("GET")) + .and(wiremock::matchers::path( + "/eth/v1/validator/sync_committee_contribution", + )) + .respond_with(Responder) + .mount(server) + .await; + } + + /// Builds a phase0 attestation with the given committee index. + fn build_attestation(index: u64) -> phase0::Attestation { + phase0::Attestation { + aggregation_bits: phase0::BitList::default(), + data: phase0::AttestationData { + slot: 1, + index, + beacon_block_root: [u8::try_from(index).unwrap_or(0); 32], + source: phase0::Checkpoint { + epoch: 0, + root: [0u8; 32], + }, + target: phase0::Checkpoint { + epoch: 0, + root: [0u8; 32], + }, + }, + signature: [0u8; 96], + } + } + + /// Mounts an aggregate-attestation responder that returns the Deneb + /// attestation whose data root matches the request, or 404 when unknown. + async fn mount_aggregate( + server: &wiremock::MockServer, + by_root: HashMap, + ) { + struct Responder { + by_root: HashMap, + } + impl wiremock::Respond for Responder { + fn respond(&self, req: &wiremock::Request) -> wiremock::ResponseTemplate { + let query: HashMap = req.url.query_pairs().into_owned().collect(); + let root = query + .get("attestation_data_root") + .cloned() + .unwrap_or_default(); + match self.by_root.get(&root) { + Some(att) => wiremock::ResponseTemplate::new(200) + .set_body_json(serde_json::json!({ "version": "deneb", "data": att })), + None => wiremock::ResponseTemplate::new(404) + .set_body_json(serde_json::json!({ "code": 404, "message": "not found" })), + } + } + } + + wiremock::Mock::given(wiremock::matchers::method("GET")) + .and(wiremock::matchers::path( + "/eth/v2/validator/aggregate_attestation", + )) + .respond_with(Responder { by_root }) + .mount(server) + .await; + } + + /// Builds an attester definition with the given committee index/length. + fn attester_def(comm_idx: u64, comm_len: u64) -> DutyDefinition { + DutyDefinition::Attester(AttesterDefinition::new(AttesterDuty { + slot: 1, + validator_index: 0, + committee_index: comm_idx, + committee_length: comm_len, + committees_at_slot: 1, + validator_committee_index: 0, + })) + } + + /// Wires AggSigDB to return a beacon committee selection and DutyDB to + /// return the attestation data for each committee index. + fn wire_aggregator(fetch: &mut Fetcher, atts: &[phase0::Attestation]) { + use pluto_eth2api::v1; + + fetch.register_agg_sig_db(Arc::new(move |_duty: Duty, _pubkey: PubKey| { + Box::pin(async move { + let selection = BeaconCommitteeSelection::new(v1::BeaconCommitteeSelection { + slot: 1, + validator_index: 0, + selection_proof: [0u8; 96], + }); + let data: Box = Box::new(selection); + Ok(data) + }) + })); + + let by_idx: HashMap = atts + .iter() + .map(|a| (a.data.index, a.data.clone())) + .collect(); + fetch.register_await_att_data(Arc::new(move |_slot: u64, comm_idx: u64| { + let data = by_idx.get(&comm_idx).cloned(); + Box::pin(async move { data.ok_or_else(|| "missing attestation data".into()) }) + })); + } + + #[tokio::test] + async fn fetch_aggregator_different_committee() { + const SLOT: u64 = 1; + let pk_a = PubKey::new([2u8; PK_LEN]); + let pk_b = PubKey::new([3u8; PK_LEN]); + + let att_a = build_attestation(2); + let att_b = build_attestation(3); + + let mut def_set = DutyDefinitionSet::new(); + def_set.insert(pk_a, attester_def(att_a.data.index, 0)); + def_set.insert(pk_b, attester_def(att_b.data.index, 0)); + + let by_root = HashMap::from([ + (hex_0x(&att_a.data.tree_hash_root().0), att_a.clone()), + (hex_0x(&att_b.data.tree_hash_root().0), att_b.clone()), + ]); + + let mock = BeaconMock::builder() + .spec(aggregator_spec()) + .build() + .await + .expect("build mock"); + mount_aggregate(mock.server(), by_root).await; + + let mut fetch = Fetcher::new( + mock.client().clone(), + None, + true, + GraffitiBuilder::default(), + 5, + false, + ); + wire_aggregator(&mut fetch, &[att_a.clone(), att_b.clone()]); + + let captured: Captured = Arc::new(Mutex::new(None)); + fetch.subscribe(capturing_subscriber(captured.clone())); + + let duty = Duty::new_aggregator_duty(SlotNumber::new(SLOT)); + fetch.fetch(duty, def_set).await.expect("fetch"); + + let (_, res_set) = captured.lock().unwrap().take().expect("subscriber called"); + assert_eq!(res_set.len(), 2); + + for (pubkey, expected_idx) in [(pk_a, 2u64), (pk_b, 3u64)] { + let UnsignedDutyData::AggAttestation(agg) = res_set.get(&pubkey).expect("entry") else { + panic!("expected aggregated attestation"); + }; + assert_eq!(agg.data().expect("data").index, expected_idx); + } + } + + #[tokio::test] + async fn fetch_aggregator_same_committee() { + const SLOT: u64 = 1; + let pk_a = PubKey::new([2u8; PK_LEN]); + let pk_b = PubKey::new([3u8; PK_LEN]); + + // Both validators belong to the same committee; the aggregate is fetched + // once and reused for the second validator. + let att = build_attestation(2); + let mut def_set = DutyDefinitionSet::new(); + def_set.insert(pk_a, attester_def(att.data.index, 0)); + def_set.insert(pk_b, attester_def(att.data.index, 0)); + + let by_root = HashMap::from([(hex_0x(&att.data.tree_hash_root().0), att.clone())]); + + let mock = BeaconMock::builder() + .spec(aggregator_spec()) + .build() + .await + .expect("build mock"); + mount_aggregate(mock.server(), by_root).await; + + let mut fetch = Fetcher::new( + mock.client().clone(), + None, + true, + GraffitiBuilder::default(), + 5, + false, + ); + wire_aggregator(&mut fetch, std::slice::from_ref(&att)); + + let captured: Captured = Arc::new(Mutex::new(None)); + fetch.subscribe(capturing_subscriber(captured.clone())); + + let duty = Duty::new_aggregator_duty(SlotNumber::new(SLOT)); + fetch.fetch(duty, def_set).await.expect("fetch"); + + let (_, res_set) = captured.lock().unwrap().take().expect("subscriber called"); + assert_eq!(res_set.len(), 2); + for pubkey in [pk_a, pk_b] { + let UnsignedDutyData::AggAttestation(agg) = res_set.get(&pubkey).expect("entry") else { + panic!("expected aggregated attestation"); + }; + assert_eq!(agg.data().expect("data").index, 2); + } + } + + #[tokio::test] + async fn fetch_aggregator_no_aggregator() { + const SLOT: u64 = 1; + let pk_a = PubKey::new([2u8; PK_LEN]); + + let att_a = build_attestation(2); + let mut def_set = DutyDefinitionSet::new(); + // u64::MAX committee length makes the selection modulo enormous, so the + // validator is never selected as an aggregator. + def_set.insert(pk_a, attester_def(att_a.data.index, u64::MAX)); + + let mock = BeaconMock::builder() + .spec(aggregator_spec()) + .build() + .await + .expect("build mock"); + mount_aggregate(mock.server(), HashMap::new()).await; + + let mut fetch = Fetcher::new( + mock.client().clone(), + None, + true, + GraffitiBuilder::default(), + 5, + false, + ); + wire_aggregator(&mut fetch, std::slice::from_ref(&att_a)); + + let captured: Captured = Arc::new(Mutex::new(None)); + fetch.subscribe(capturing_subscriber(captured.clone())); + + let duty = Duty::new_aggregator_duty(SlotNumber::new(SLOT)); + // No aggregators found -> empty set -> Ok and subscriber not invoked. + fetch.fetch(duty, def_set).await.expect("fetch"); + assert!(captured.lock().unwrap().is_none()); + } + + #[tokio::test] + async fn fetch_aggregator_nil_aggregate() { + const SLOT: u64 = 1; + let pk_a = PubKey::new([2u8; PK_LEN]); + + let att_a = build_attestation(2); + let mut def_set = DutyDefinitionSet::new(); + def_set.insert(pk_a, attester_def(att_a.data.index, 0)); + + let mock = BeaconMock::builder() + .spec(aggregator_spec()) + .build() + .await + .expect("build mock"); + // Empty map -> responder returns 404 for every root. + mount_aggregate(mock.server(), HashMap::new()).await; + + let mut fetch = Fetcher::new( + mock.client().clone(), + None, + true, + GraffitiBuilder::default(), + 5, + false, + ); + wire_aggregator(&mut fetch, std::slice::from_ref(&att_a)); + + let duty = Duty::new_aggregator_duty(SlotNumber::new(SLOT)); + let err = fetch + .fetch(duty, def_set) + .await + .expect_err("expected error"); + assert!( + err.to_string() + .contains("aggregate attestation not found by root (retryable)"), + "got: {err}" + ); + } + + #[tokio::test] + async fn fetch_sync_contribution_aggregator() { + use pluto_eth2api::{spec::altair, v1}; + + const SLOT: u64 = 1; + const V_IDX_A: u64 = 2; + const V_IDX_B: u64 = 3; + const SUBCOMM_A: u64 = 4; + const SUBCOMM_B: u64 = 5; + + let pk_a = PubKey::new([2u8; PK_LEN]); + let pk_b = PubKey::new([3u8; PK_LEN]); + + let root_a = [10u8; 32]; + let root_b = [11u8; 32]; + + let selection = |v_idx, subcomm, sig| { + SyncCommitteeSelection::new(v1::SyncCommitteeSelection { + slot: SLOT, + validator_index: v_idx, + subcommittee_index: subcomm, + selection_proof: bls_sig(sig), + }) + }; + let message = |v_idx, root| { + SignedSyncMessage::new(altair::SyncCommitteeMessage { + slot: SLOT, + beacon_block_root: root, + validator_index: v_idx, + signature: [0u8; 96], + }) + }; + + let sel_a = selection(V_IDX_A, SUBCOMM_A, SYNC_AGG_SIG_A); + let sel_b = selection(V_IDX_B, SUBCOMM_B, SYNC_AGG_SIG_B); + let msg_a = message(V_IDX_A, root_a); + let msg_b = message(V_IDX_B, root_b); + + let selections: HashMap = + HashMap::from([(pk_a, sel_a), (pk_b, sel_b)]); + let messages: HashMap = + HashMap::from([(pk_a, msg_a), (pk_b, msg_b)]); + + let mut def_set = DutyDefinitionSet::new(); + for pk in [pk_a, pk_b] { + def_set.insert( + pk, + DutyDefinition::SyncCommittee(SyncCommitteeDefinition::new(SyncCommitteeDuty { + pubkey: [0u8; 48], + validator_index: 0, + validator_sync_committee_indices: vec![], + })), + ); + } + + let mock = BeaconMock::builder() + .spec(aggregator_spec()) + .build() + .await + .expect("build mock"); + mount_sync_contribution(mock.server()).await; + + let mut fetch = Fetcher::new( + mock.client().clone(), + None, + true, + GraffitiBuilder::default(), + 5, + false, + ); + + let sels = selections.clone(); + let msgs = messages.clone(); + fetch.register_agg_sig_db(Arc::new(move |duty: Duty, pubkey: PubKey| { + let sels = sels.clone(); + let msgs = msgs.clone(); + Box::pin(async move { + let data: Box = match duty.duty_type { + DutyType::PrepareSyncContribution => Box::new(sels[&pubkey].clone()), + DutyType::SyncMessage => Box::new(msgs[&pubkey].clone()), + _ => return Err("unsupported duty".into()), + }; + Ok(data) + }) + })); + + let captured: Captured = Arc::new(Mutex::new(None)); + fetch.subscribe(capturing_subscriber(captured.clone())); + + let duty = Duty::new_sync_contribution_duty(SlotNumber::new(SLOT)); + fetch.fetch(duty, def_set).await.expect("fetch"); + + let (_, res_set) = captured.lock().unwrap().take().expect("subscriber called"); + assert_eq!(res_set.len(), 2); + + for (pubkey, expected_subcomm, expected_root) in + [(pk_a, SUBCOMM_A, root_a), (pk_b, SUBCOMM_B, root_b)] + { + let UnsignedDutyData::SyncContribution(contrib) = res_set.get(&pubkey).expect("entry") + else { + panic!("expected sync contribution"); + }; + assert_eq!(contrib.0.slot, SLOT); + assert_eq!(contrib.0.subcommittee_index, expected_subcomm); + assert_eq!(contrib.0.beacon_block_root, expected_root); + } + } + + #[tokio::test] + async fn fetch_sync_contribution_not_aggregator() { + use pluto_eth2api::v1; + + const SLOT: u64 = 1; + let pk_a = PubKey::new([2u8; PK_LEN]); + let pk_b = PubKey::new([3u8; PK_LEN]); + + let mut def_set = DutyDefinitionSet::new(); + for pk in [pk_a, pk_b] { + def_set.insert( + pk, + DutyDefinition::SyncCommittee(SyncCommitteeDefinition::new(SyncCommitteeDuty { + pubkey: [0u8; 48], + validator_index: 0, + validator_sync_committee_indices: vec![], + })), + ); + } + + let mock = BeaconMock::builder() + .spec(aggregator_spec()) + .build() + .await + .expect("build mock"); + + let mut fetch = Fetcher::new( + mock.client().clone(), + None, + true, + GraffitiBuilder::default(), + 5, + false, + ); + + fetch.register_agg_sig_db(Arc::new(move |duty: Duty, _pubkey: PubKey| { + Box::pin(async move { + if duty.duty_type == DutyType::PrepareSyncContribution { + let selection = SyncCommitteeSelection::new(v1::SyncCommitteeSelection { + slot: 0, + validator_index: 0, + subcommittee_index: 0, + selection_proof: bls_sig(SYNC_NON_AGG_SIG), + }); + let data: Box = Box::new(selection); + return Ok(data); + } + Err("unsupported duty".into()) + }) + })); + + let duty = Duty::new_sync_contribution_duty(SlotNumber::new(SLOT)); + // Non-aggregators are skipped, producing an empty set and no error. + fetch.fetch(duty, def_set).await.expect("fetch"); + } + + #[tokio::test] + async fn fetch_sync_contribution_data_error() { + const SLOT: u64 = 1; + let pk_a = PubKey::new([2u8; PK_LEN]); + + let mut def_set = DutyDefinitionSet::new(); + def_set.insert( + pk_a, + DutyDefinition::SyncCommittee(SyncCommitteeDefinition::new(SyncCommitteeDuty { + pubkey: [0u8; 48], + validator_index: 0, + validator_sync_committee_indices: vec![], + })), + ); + + let mock = BeaconMock::builder().build().await.expect("build mock"); + let mut fetch = Fetcher::new( + mock.client().clone(), + None, + true, + GraffitiBuilder::default(), + 5, + false, + ); + + fetch.register_agg_sig_db(Arc::new(move |_duty: Duty, _pubkey: PubKey| { + Box::pin(async move { Err("error".into()) }) + })); + + let duty = Duty::new_sync_contribution_duty(SlotNumber::new(SLOT)); + let err = fetch + .fetch(duty, def_set) + .await + .expect_err("expected error"); + let msg = err.to_string(); + assert!(msg.contains("fetch contribution data"), "got: {msg}"); + assert!(msg.contains("error"), "got: {msg}"); + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 5e69855f..2c7d89cb 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -32,6 +32,9 @@ pub mod dutydb; /// SigAgg — threshold BLS signature aggregation. pub mod sigagg; +/// Fetcher — fetches unsigned duty data from the beacon node. +pub mod fetcher; + mod parsigex_codec; // SSZ codec operates on compile-time-constant byte sizes and offsets. // Arithmetic is bounded and casts from `usize` to `u32` are safe because all diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index 2707e5ef..48098b9e 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -8,11 +8,16 @@ use dyn_eq::DynEq; use serde::{Deserialize, Serialize}; use std::fmt::Debug as StdDebug; +use pluto_eth2api::spec::phase0; + use crate::{ ParSigExCodecError, corepb::v1::core as pbcore, parsigex_codec::{deserialize_signed_data, serialize_signed_data}, - signeddata::SignedDataError, + signeddata::{ + AttestationData, AttesterDuty, SignedDataError, SyncContribution, + VersionedAggregatedAttestation, VersionedProposal, + }, }; /// The type of duty. @@ -426,134 +431,137 @@ impl AsRef<[u8]> for PubKey { // todo: add toEth2Format for the pub key // https://github.com/ObolNetwork/charon/blob/b3008103c5429b031b63518195f4c49db4e9a68d/core/types.go#L311 -/// Duty definition type. +/// A block proposer duty, mirroring eth2 `v1.ProposerDuty`. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct DutyDefinition(T); - -impl DutyDefinition -where - T: Clone + Serialize + StdDebug, -{ - /// Create a new duty definition. - pub fn new(duty_definition: T) -> Self { - Self(duty_definition) - } - - /// Inner value. - pub fn inner(&self) -> &T { - &self.0 - } +pub struct ProposerDuty { + /// Public key of the validator that should propose. + pub pubkey: phase0::BLSPubKey, + /// Slot in which the validator should propose. + pub slot: phase0::Slot, + /// Index of the validator that should propose. + pub validator_index: phase0::ValidatorIndex, } -/// One duty definition per validator, matching Go's `core.DutyDefinitionSet`. -#[derive(Debug, Default, Clone, PartialEq, Eq)] -pub struct DutyDefinitionSet(HashMap>) -where - T: Clone + Serialize + StdDebug; +/// A sync committee duty, mirroring eth2 `v1.SyncCommitteeDuty`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SyncCommitteeDuty { + /// Public key of the validator that should contribute. + pub pubkey: phase0::BLSPubKey, + /// Index of the validator that should contribute. + pub validator_index: phase0::ValidatorIndex, + /// Indices of the validator in the list of validators in the committee. + pub validator_sync_committee_indices: Vec, +} -impl DutyDefinitionSet -where - T: Clone + Serialize + StdDebug, -{ - /// Create a new duty definition set. - pub fn new() -> Self { - Self(HashMap::default()) - } +/// Attester duty definition. Mirrors Go's `core.AttesterDefinition`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AttesterDefinition(pub AttesterDuty); - /// Get a duty definition by public key. - pub fn get(&self, pubkey: &PubKey) -> Option<&DutyDefinition> { - self.0.get(pubkey) +impl AttesterDefinition { + /// Create a new attester definition. + pub fn new(duty: AttesterDuty) -> Self { + Self(duty) } - /// Insert a duty definition. - pub fn insert(&mut self, pubkey: PubKey, duty_definition: DutyDefinition) { - self.0.insert(pubkey, duty_definition); + /// The wrapped attester duty. + pub fn duty(&self) -> &AttesterDuty { + &self.0 } +} - /// Remove a duty definition by public key. - pub fn remove(&mut self, pubkey: &PubKey) -> Option> { - self.0.remove(pubkey) - } +/// Block proposer duty definition. Mirrors Go's `core.ProposerDefinition`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProposerDefinition(pub ProposerDuty); - /// Iterate over all public keys in the set. - pub fn keys(&self) -> impl Iterator { - self.0.keys() +impl ProposerDefinition { + /// Create a new proposer definition. + pub fn new(duty: ProposerDuty) -> Self { + Self(duty) } - /// Inner map. - pub fn inner(&self) -> &HashMap> { + /// The wrapped proposer duty. + pub fn duty(&self) -> &ProposerDuty { &self.0 } - - /// Inner map (mutable). - pub fn inner_mut(&mut self) -> &mut HashMap> { - &mut self.0 - } } -/// Unsigned data type +/// Sync committee duty definition. Mirrors Go's `core.SyncCommitteeDefinition`. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct UnsignedData(T); +pub struct SyncCommitteeDefinition(pub SyncCommitteeDuty); -impl UnsignedData -where - T: Clone + Serialize + StdDebug, -{ - /// Create a new unsigned data. - pub fn new(unsigned_data: T) -> Self { - Self(unsigned_data) +impl SyncCommitteeDefinition { + /// Create a new sync committee definition. + pub fn new(duty: SyncCommitteeDuty) -> Self { + Self(duty) } -} -/// Unsigned data set -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct UnsignedDataSet(HashMap>) -where - T: Clone + Serialize + StdDebug; -impl Default for UnsignedDataSet -where - T: Clone + Serialize + StdDebug, -{ - fn default() -> Self { - Self(HashMap::default()) + /// The wrapped sync committee duty. + pub fn duty(&self) -> &SyncCommitteeDuty { + &self.0 } } -impl UnsignedDataSet -where - T: Clone + Serialize + StdDebug, -{ - /// Create a new unsigned data set. - pub fn new() -> Self { - Self::default() - } - - /// Get an unsigned data by duty type. - pub fn get(&self, duty_type: &DutyType) -> Option<&UnsignedData> { - self.0.get(duty_type) - } +/// Per-validator duty definition. The Rust equivalent of Go's +/// `core.DutyDefinition` interface: a closed set of concrete duty definitions. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DutyDefinition { + /// Attester duty definition. + Attester(AttesterDefinition), + /// Block proposer duty definition. + Proposer(ProposerDefinition), + /// Sync committee duty definition. + SyncCommittee(SyncCommitteeDefinition), +} - /// Insert an unsigned data. - pub fn insert(&mut self, duty_type: DutyType, unsigned_data: UnsignedData) { - self.0.insert(duty_type, unsigned_data); +impl DutyDefinition { + /// Returns the attester definition, or `None` if this is a different duty. + pub fn as_attester(&self) -> Option<&AttesterDefinition> { + match self { + Self::Attester(d) => Some(d), + _ => None, + } } - /// Remove an unsigned data by duty type. - pub fn remove(&mut self, duty_type: &DutyType) -> Option> { - self.0.remove(duty_type) + /// Returns the proposer definition, or `None` if this is a different duty. + pub fn as_proposer(&self) -> Option<&ProposerDefinition> { + match self { + Self::Proposer(d) => Some(d), + _ => None, + } } - /// Inner unsigned data set. - pub fn inner(&self) -> &HashMap> { - &self.0 + /// Returns the sync committee definition, or `None` if this is a different + /// duty. + pub fn as_sync_committee(&self) -> Option<&SyncCommitteeDefinition> { + match self { + Self::SyncCommittee(d) => Some(d), + _ => None, + } } +} - /// Inner unsigned data set. - pub fn inner_mut(&mut self) -> &mut HashMap> { - &mut self.0 - } +/// One duty definition per validator, matching Go's `core.DutyDefinitionSet` +/// (`map[core.PubKey]core.DutyDefinition`). +pub type DutyDefinitionSet = HashMap; + +/// Unsigned duty data variant — the Rust equivalent of Go's +/// `core.UnsignedData` interface. +#[derive(Debug, Clone, PartialEq)] +pub enum UnsignedDutyData { + /// Unsigned proposal (DutyProposer). + Proposal(Box), + /// Unsigned attestation data (DutyAttester). + Attestation(AttestationData), + /// Unsigned aggregated attestation (DutyAggregator). + AggAttestation(VersionedAggregatedAttestation), + /// Unsigned sync contribution (DutySyncContribution). + SyncContribution(SyncContribution), } +/// Map from public key to unsigned duty data, the Rust equivalent of Go's +/// `core.UnsignedDataSet` (`map[core.PubKey]core.UnsignedData`). +pub type UnsignedDataSet = HashMap; + /// Signed data type pub trait SignedData: Any + DynClone + DynEq + StdDebug + Send + Sync { /// signature returns the signed duty data's signature. @@ -1034,23 +1042,25 @@ mod tests { #[test] fn duty_definition_set() { let pubkey = PubKey::new([1u8; PK_LEN]); + let duty = AttesterDuty { + slot: 1, + validator_index: 2, + committee_index: 3, + committee_length: 4, + committees_at_slot: 5, + validator_committee_index: 6, + }; + let mut set = DutyDefinitionSet::new(); - set.insert(pubkey, DutyDefinition::new(DutyType::Proposer)); - assert_eq!( - set.get(&pubkey), - Some(&DutyDefinition::new(DutyType::Proposer)) + set.insert( + pubkey, + DutyDefinition::Attester(AttesterDefinition::new(duty.clone())), ); - assert_eq!(set.keys().count(), 1); - } - #[test] - fn unsigned_data_set() { - let mut unsigned_data_set = UnsignedDataSet::new(); - unsigned_data_set.insert(DutyType::Proposer, UnsignedData::new(DutyType::Proposer)); - assert_eq!( - unsigned_data_set.get(&DutyType::Proposer), - Some(&UnsignedData::new(DutyType::Proposer)) - ); + let got = set.get(&pubkey).expect("definition present"); + assert_eq!(got.as_attester().map(|d| d.duty()), Some(&duty)); + assert!(got.as_proposer().is_none()); + assert!(got.as_sync_committee().is_none()); } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] From 0fc366f7184286673c27428689a22d38925015e1 Mon Sep 17 00:00:00 2001 From: "emlautarom1-agent[bot]" <292495798+emlautarom1-agent[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 19:24:23 -0300 Subject: [PATCH 02/30] fix(core): reconcile fetcher with main's DutyDefinition after merge The main merge resolved types.rs to main's version, dropping PR #454's DutyDefinition enum and promoted unsigned-data types, while keeping the new core/fetcher. The result did not compile. Adopt main's canonical DutyDefinition (scheduler + validatorapi) and the unsigneddata module as the shared types, and adapt the fetcher to them: - Enrich AttesterDutyDefinition with the committee fields the fetcher needs (committee_index/length, committees_at_slot, validator_committee_index), populated from the beacon duties datum. Mirrors Charon's AttesterDefinition wrapping the full eth2v1.AttesterDuty. - Add as_attester/as_proposer/as_sync_committee accessors to DutyDefinition. - Point the fetcher at crate::unsigneddata for UnsignedDataSet/UnsignedDutyData and populate VersionedProposal's consensus/execution block values. --- crates/core/src/fetcher/mod.rs | 132 ++++++++++++++++++++++----------- crates/core/src/scheduler.rs | 5 +- crates/core/src/types.rs | 63 ++++++++++++++-- 3 files changed, 149 insertions(+), 51 deletions(-) diff --git a/crates/core/src/fetcher/mod.rs b/crates/core/src/fetcher/mod.rs index 2e462c38..aff494a0 100644 --- a/crates/core/src/fetcher/mod.rs +++ b/crates/core/src/fetcher/mod.rs @@ -23,13 +23,12 @@ use tree_hash::TreeHash; use crate::{ signeddata::{ - AttestationData, BeaconCommitteeSelection, ProposalBlock, SignedSyncMessage, + AttestationData, AttesterDuty, BeaconCommitteeSelection, ProposalBlock, SignedSyncMessage, SyncCommitteeSelection, SyncContribution, VersionedAggregatedAttestation, VersionedProposal, }, - types::{ - Duty, DutyDefinitionSet, DutyType, PubKey, SignedData, UnsignedDataSet, UnsignedDutyData, - }, + types::{AttesterDutyDefinition, Duty, DutyDefinitionSet, DutyType, PubKey, SignedData}, + unsigneddata::{UnsignedDataSet, UnsignedDutyData}, }; /// Boxed error returned by injected callbacks (subscribers, AggSigDB, DutyDB). @@ -137,6 +136,10 @@ pub enum FetcherError { #[error("proposal response missing block field")] MissingBlockField, + /// A versioned proposal response carried an unparsable block value. + #[error("invalid proposal block value: {0}")] + InvalidBlockValue(&'static str), + /// A signed data value could not produce a signature. #[error("signature: {0}")] Signature(String), @@ -270,7 +273,7 @@ impl Fetcher { .as_attester() .ok_or(FetcherError::InvalidAttesterDefinition)?; - let mut comm_idx = att_def.duty().committee_index; + let mut comm_idx = att_def.committee_index; // Attestation data for Electra is not bound by committee index; // committee index is still persisted in the request but should be @@ -292,7 +295,7 @@ impl Fetcher { *pubkey, UnsignedDutyData::Attestation(AttestationData { data: eth2_att_data, - duty: att_def.duty().clone(), + duty: attester_duty(att_def), }), ); } @@ -328,7 +331,7 @@ impl Fetcher { let is_aggregator = eth2exp::is_att_aggregator( &self.eth2_cl, - att_def.duty().committee_length, + att_def.committee_length, selection.0.selection_proof, ) .await?; @@ -339,7 +342,7 @@ impl Fetcher { tracker.add_resolved(pubkey.to_string()); - let comm_idx = att_def.duty().committee_index; + let comm_idx = att_def.committee_index; if let Some(agg_att) = agg_att_by_comm_idx.get(&comm_idx) { resp.insert( @@ -604,6 +607,19 @@ fn downcast(data: &dyn SignedData) -> Option<&T> { (data as &dyn Any).downcast_ref::() } +/// Builds the eth2 [`AttesterDuty`] carried by an attestation from a scheduler +/// [`AttesterDutyDefinition`]. +fn attester_duty(def: &AttesterDutyDefinition) -> AttesterDuty { + AttesterDuty { + slot: def.slot.inner(), + validator_index: def.v_idx, + committee_index: def.committee_index, + committee_length: def.committee_length, + committees_at_slot: def.committees_at_slot, + validator_committee_index: def.validator_committee_index, + } +} + /// Formats bytes as a `0x`-prefixed lowercase hex string. fn hex_0x(bytes: &[u8]) -> String { format!("0x{}", hex::encode(bytes)) @@ -655,7 +671,20 @@ fn versioned_proposal_from_response( (ConsensusVersion::Fulu, true) => ProposalBlock::FuluBlinded(json_from(&data)?), }; - Ok(VersionedProposal { block }) + let consensus_block_value = resp + .consensus_block_value + .parse() + .map_err(|_| FetcherError::InvalidBlockValue("consensus_block_value"))?; + let execution_payload_value = resp + .execution_payload_value + .parse() + .map_err(|_| FetcherError::InvalidBlockValue("execution_payload_value"))?; + + Ok(VersionedProposal { + block, + consensus_block_value, + execution_payload_value, + }) } /// Maps a beacon node `ConsensusVersion` onto a `versioned::DataVersion`. @@ -825,12 +854,8 @@ mod tests { use pluto_testutil::BeaconMock; use super::*; - use crate::{ - signeddata::AttesterDuty, - types::{ - AttesterDefinition, DutyDefinition, ProposerDefinition, ProposerDuty, SlotNumber, - SyncCommitteeDefinition, SyncCommitteeDuty, - }, + use crate::types::{ + DutyDefinition, ProposerDutyDefinition, SlotNumber, SyncCommitteeDutyDefinition, }; /// 48-byte BLS public key length used to build distinct test pubkeys. @@ -941,6 +966,8 @@ mod tests { kzg_proofs: vec![], blobs: vec![], }, + consensus_block_value: alloy::primitives::U256::ZERO, + execution_payload_value: alloy::primitives::U256::ZERO, }; // A different address is reported as a mismatch; the actual address @@ -972,19 +999,19 @@ mod tests { let mut def_set = DutyDefinitionSet::new(); def_set.insert( pk_a, - DutyDefinition::Proposer(ProposerDefinition::new(ProposerDuty { - pubkey: [0u8; 48], - slot: SLOT, - validator_index: 2, - })), + DutyDefinition::Proposer(ProposerDutyDefinition { + pubkey: pk_a, + v_idx: 2, + slot: SlotNumber::new(SLOT), + }), ); def_set.insert( pk_b, - DutyDefinition::Proposer(ProposerDefinition::new(ProposerDuty { - pubkey: [0u8; 48], - slot: SLOT, - validator_index: 3, - })), + DutyDefinition::Proposer(ProposerDutyDefinition { + pubkey: pk_b, + v_idx: 3, + slot: SlotNumber::new(SLOT), + }), ); let mock = BeaconMock::builder().build().await.expect("build mock"); @@ -1074,11 +1101,11 @@ mod tests { let mut def_set = DutyDefinitionSet::new(); def_set.insert( pk_a, - DutyDefinition::Attester(AttesterDefinition::new(duty_a.clone())), + DutyDefinition::Attester(attester_duty_def(pk_a, &duty_a)), ); def_set.insert( pk_b, - DutyDefinition::Attester(AttesterDefinition::new(duty_b.clone())), + DutyDefinition::Attester(attester_duty_def(pk_b, &duty_b)), ); let duty = Duty::new_attester_duty(SlotNumber::new(SLOT)); @@ -1207,16 +1234,33 @@ mod tests { .await; } + /// Builds an attester duty definition from an eth2 [`AttesterDuty`], keyed + /// by the given public key. + fn attester_duty_def(pubkey: PubKey, duty: &AttesterDuty) -> AttesterDutyDefinition { + AttesterDutyDefinition { + pubkey, + v_idx: duty.validator_index, + slot: SlotNumber::new(duty.slot), + committee_index: duty.committee_index, + committee_length: duty.committee_length, + committees_at_slot: duty.committees_at_slot, + validator_committee_index: duty.validator_committee_index, + } + } + /// Builds an attester definition with the given committee index/length. fn attester_def(comm_idx: u64, comm_len: u64) -> DutyDefinition { - DutyDefinition::Attester(AttesterDefinition::new(AttesterDuty { - slot: 1, - validator_index: 0, - committee_index: comm_idx, - committee_length: comm_len, - committees_at_slot: 1, - validator_committee_index: 0, - })) + DutyDefinition::Attester(attester_duty_def( + PubKey::new([0u8; PK_LEN]), + &AttesterDuty { + slot: 1, + validator_index: 0, + committee_index: comm_idx, + committee_length: comm_len, + committees_at_slot: 1, + validator_committee_index: 0, + }, + )) } /// Wires AggSigDB to return a beacon committee selection and DutyDB to @@ -1469,11 +1513,11 @@ mod tests { for pk in [pk_a, pk_b] { def_set.insert( pk, - DutyDefinition::SyncCommittee(SyncCommitteeDefinition::new(SyncCommitteeDuty { - pubkey: [0u8; 48], + DutyDefinition::SyncCommittee(SyncCommitteeDutyDefinition { + pubkey: pk, validator_index: 0, validator_sync_committee_indices: vec![], - })), + }), ); } @@ -1542,11 +1586,11 @@ mod tests { for pk in [pk_a, pk_b] { def_set.insert( pk, - DutyDefinition::SyncCommittee(SyncCommitteeDefinition::new(SyncCommitteeDuty { - pubkey: [0u8; 48], + DutyDefinition::SyncCommittee(SyncCommitteeDutyDefinition { + pubkey: pk, validator_index: 0, validator_sync_committee_indices: vec![], - })), + }), ); } @@ -1594,11 +1638,11 @@ mod tests { let mut def_set = DutyDefinitionSet::new(); def_set.insert( pk_a, - DutyDefinition::SyncCommittee(SyncCommitteeDefinition::new(SyncCommitteeDuty { - pubkey: [0u8; 48], + DutyDefinition::SyncCommittee(SyncCommitteeDutyDefinition { + pubkey: pk_a, validator_index: 0, validator_sync_committee_indices: vec![], - })), + }), ); let mock = BeaconMock::builder().build().await.expect("build mock"); diff --git a/crates/core/src/scheduler.rs b/crates/core/src/scheduler.rs index 3893460b..45054132 100644 --- a/crates/core/src/scheduler.rs +++ b/crates/core/src/scheduler.rs @@ -1161,7 +1161,10 @@ mod tests { pubkey: pubkey.to_string(), validator_index: v_idx.to_string(), slot: slot.to_string(), - ..Default::default() + committee_index: "0".to_string(), + committee_length: "0".to_string(), + committees_at_slot: "0".to_string(), + validator_committee_index: "0".to_string(), }; let def: types::AttesterDutyDefinition = datum.try_into().expect("valid attester datum"); types::DutyDefinition::Attester(def) diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index a86976b6..15ff9657 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -8,16 +8,11 @@ use dyn_eq::DynEq; use serde::{Deserialize, Serialize}; use std::fmt::Debug as StdDebug; -use pluto_eth2api::spec::phase0; - use crate::{ ParSigExCodecError, corepb::v1::core as pbcore, parsigex_codec::{deserialize_signed_data, serialize_signed_data}, - signeddata::{ - AttestationData, AttesterDuty, SignedDataError, SyncContribution, - VersionedAggregatedAttestation, VersionedProposal, - }, + signeddata::SignedDataError, }; /// The type of duty. @@ -440,6 +435,14 @@ pub struct AttesterDutyDefinition { pub v_idx: u64, /// The slot at which the validator must attest. pub slot: SlotNumber, + /// Index of the committee the validator belongs to. + pub committee_index: u64, + /// Number of validators in the committee. + pub committee_length: u64, + /// Number of committees at this slot. + pub committees_at_slot: u64, + /// Index of the validator within its committee. + pub validator_committee_index: u64, } impl TryInto @@ -457,11 +460,30 @@ impl TryInto SlotNumber::from(self.slot.parse::().map_err(|_| { pluto_eth2api::EthBeaconNodeApiClientError::ParseError("slot".into()) })?); + let committee_index = self.committee_index.parse::().map_err(|_| { + pluto_eth2api::EthBeaconNodeApiClientError::ParseError("committee_index".into()) + })?; + let committee_length = self.committee_length.parse::().map_err(|_| { + pluto_eth2api::EthBeaconNodeApiClientError::ParseError("committee_length".into()) + })?; + let committees_at_slot = self.committees_at_slot.parse::().map_err(|_| { + pluto_eth2api::EthBeaconNodeApiClientError::ParseError("committees_at_slot".into()) + })?; + let validator_committee_index = + self.validator_committee_index.parse::().map_err(|_| { + pluto_eth2api::EthBeaconNodeApiClientError::ParseError( + "validator_committee_index".into(), + ) + })?; Ok(AttesterDutyDefinition { pubkey, v_idx, slot, + committee_index, + committee_length, + committees_at_slot, + validator_committee_index, }) } } @@ -556,6 +578,33 @@ pub enum DutyDefinition { SyncCommittee(SyncCommitteeDutyDefinition), } +impl DutyDefinition { + /// Returns the attester definition, or `None` if this is a different duty. + pub fn as_attester(&self) -> Option<&AttesterDutyDefinition> { + match self { + Self::Attester(d) => Some(d), + _ => None, + } + } + + /// Returns the proposer definition, or `None` if this is a different duty. + pub fn as_proposer(&self) -> Option<&ProposerDutyDefinition> { + match self { + Self::Proposer(d) => Some(d), + _ => None, + } + } + + /// Returns the sync committee definition, or `None` if this is a different + /// duty. + pub fn as_sync_committee(&self) -> Option<&SyncCommitteeDutyDefinition> { + match self { + Self::SyncCommittee(d) => Some(d), + _ => None, + } + } +} + /// A set of duty definitions for all validators in a given epoch, indexed by /// public key. pub type DutyDefinitionSet = HashMap; @@ -573,6 +622,8 @@ where Self(unsigned_data) } } + +// TODO: Delete `UnsignedDataSet`, use crates/core/src/unsigneddata.rs /// Unsigned data set #[derive(Debug, Clone, PartialEq, Eq)] pub struct UnsignedDataSet(HashMap>) From cefaa1a00777ae8cce99a14d13f8956f47886b8e Mon Sep 17 00:00:00 2001 From: "emlautarom1-agent[bot]" <292495798+emlautarom1-agent[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 19:25:56 -0300 Subject: [PATCH 03/30] refactor(core): remove unused generic UnsignedData/UnsignedDataSet These DutyType-keyed generic types in core::types are superseded by the PubKey-keyed UnsignedDataSet/UnsignedDutyData in core::unsigneddata, which all consumers (fetcher, dutydb, validatorapi) already use. The generics were dead code referenced only by their own test. --- crates/core/src/types.rs | 75 ---------------------------------------- 1 file changed, 75 deletions(-) diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index 15ff9657..9d307c00 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -609,71 +609,6 @@ impl DutyDefinition { /// public key. pub type DutyDefinitionSet = HashMap; -/// Unsigned data type -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct UnsignedData(T); - -impl UnsignedData -where - T: Clone + Serialize + StdDebug, -{ - /// Create a new unsigned data. - pub fn new(unsigned_data: T) -> Self { - Self(unsigned_data) - } -} - -// TODO: Delete `UnsignedDataSet`, use crates/core/src/unsigneddata.rs -/// Unsigned data set -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct UnsignedDataSet(HashMap>) -where - T: Clone + Serialize + StdDebug; - -impl Default for UnsignedDataSet -where - T: Clone + Serialize + StdDebug, -{ - fn default() -> Self { - Self(HashMap::default()) - } -} - -impl UnsignedDataSet -where - T: Clone + Serialize + StdDebug, -{ - /// Create a new unsigned data set. - pub fn new() -> Self { - Self::default() - } - - /// Get an unsigned data by duty type. - pub fn get(&self, duty_type: &DutyType) -> Option<&UnsignedData> { - self.0.get(duty_type) - } - - /// Insert an unsigned data. - pub fn insert(&mut self, duty_type: DutyType, unsigned_data: UnsignedData) { - self.0.insert(duty_type, unsigned_data); - } - - /// Remove an unsigned data by duty type. - pub fn remove(&mut self, duty_type: &DutyType) -> Option> { - self.0.remove(duty_type) - } - - /// Inner unsigned data set. - pub fn inner(&self) -> &HashMap> { - &self.0 - } - - /// Inner unsigned data set. - pub fn inner_mut(&mut self) -> &mut HashMap> { - &mut self.0 - } -} - /// Signed data type pub trait SignedData: Any + DynClone + DynEq + StdDebug + Send + Sync { /// signature returns the signed duty data's signature. @@ -1095,16 +1030,6 @@ mod tests { assert_eq!(pk.abbreviated(), "2a2_a2a"); } - #[test] - fn unsigned_data_set() { - let mut unsigned_data_set = UnsignedDataSet::new(); - unsigned_data_set.insert(DutyType::Proposer, UnsignedData::new(DutyType::Proposer)); - assert_eq!( - unsigned_data_set.get(&DutyType::Proposer), - Some(&UnsignedData::new(DutyType::Proposer)) - ); - } - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] struct MockSignedData; From c5e4cce675aae9c37cc370c089601f3a504b448f Mon Sep 17 00:00:00 2001 From: "emlautarom1-agent[bot]" <292495798+emlautarom1-agent[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 21:30:27 -0300 Subject: [PATCH 04/30] refactor(core): embed AttesterDuty in AttesterDutyDefinition Mirror Charon's core.AttesterDefinition, which embeds the eth2 v1.AttesterDuty, instead of duplicating its fields. AttesterDutyDefinition becomes { pubkey, duty: AttesterDuty }, so the fetcher consumes the duty directly (att_def.duty) with no field-by-field conversion. - Drops the AttesterDutyDefinition <-> signeddata::AttesterDuty bridge (removes the attester_duty() helper). - Resolves the slot type mismatch: the duty's slot is the eth2 u64 slot rather than a separate SlotNumber on the definition. - Scheduler reads the duty via att_duty.duty.{slot,validator_index}. --- crates/core/src/fetcher/mod.rs | 40 ++++++++++-------------------- crates/core/src/scheduler.rs | 16 ++++++------ crates/core/src/types.rs | 45 +++++++++++++++------------------- 3 files changed, 41 insertions(+), 60 deletions(-) diff --git a/crates/core/src/fetcher/mod.rs b/crates/core/src/fetcher/mod.rs index aff494a0..90aee6c6 100644 --- a/crates/core/src/fetcher/mod.rs +++ b/crates/core/src/fetcher/mod.rs @@ -23,11 +23,11 @@ use tree_hash::TreeHash; use crate::{ signeddata::{ - AttestationData, AttesterDuty, BeaconCommitteeSelection, ProposalBlock, SignedSyncMessage, + AttestationData, BeaconCommitteeSelection, ProposalBlock, SignedSyncMessage, SyncCommitteeSelection, SyncContribution, VersionedAggregatedAttestation, VersionedProposal, }, - types::{AttesterDutyDefinition, Duty, DutyDefinitionSet, DutyType, PubKey, SignedData}, + types::{Duty, DutyDefinitionSet, DutyType, PubKey, SignedData}, unsigneddata::{UnsignedDataSet, UnsignedDutyData}, }; @@ -273,7 +273,7 @@ impl Fetcher { .as_attester() .ok_or(FetcherError::InvalidAttesterDefinition)?; - let mut comm_idx = att_def.committee_index; + let mut comm_idx = att_def.duty.committee_index; // Attestation data for Electra is not bound by committee index; // committee index is still persisted in the request but should be @@ -295,7 +295,7 @@ impl Fetcher { *pubkey, UnsignedDutyData::Attestation(AttestationData { data: eth2_att_data, - duty: attester_duty(att_def), + duty: att_def.duty.clone(), }), ); } @@ -331,7 +331,7 @@ impl Fetcher { let is_aggregator = eth2exp::is_att_aggregator( &self.eth2_cl, - att_def.committee_length, + att_def.duty.committee_length, selection.0.selection_proof, ) .await?; @@ -342,7 +342,7 @@ impl Fetcher { tracker.add_resolved(pubkey.to_string()); - let comm_idx = att_def.committee_index; + let comm_idx = att_def.duty.committee_index; if let Some(agg_att) = agg_att_by_comm_idx.get(&comm_idx) { resp.insert( @@ -607,19 +607,6 @@ fn downcast(data: &dyn SignedData) -> Option<&T> { (data as &dyn Any).downcast_ref::() } -/// Builds the eth2 [`AttesterDuty`] carried by an attestation from a scheduler -/// [`AttesterDutyDefinition`]. -fn attester_duty(def: &AttesterDutyDefinition) -> AttesterDuty { - AttesterDuty { - slot: def.slot.inner(), - validator_index: def.v_idx, - committee_index: def.committee_index, - committee_length: def.committee_length, - committees_at_slot: def.committees_at_slot, - validator_committee_index: def.validator_committee_index, - } -} - /// Formats bytes as a `0x`-prefixed lowercase hex string. fn hex_0x(bytes: &[u8]) -> String { format!("0x{}", hex::encode(bytes)) @@ -854,8 +841,12 @@ mod tests { use pluto_testutil::BeaconMock; use super::*; - use crate::types::{ - DutyDefinition, ProposerDutyDefinition, SlotNumber, SyncCommitteeDutyDefinition, + use crate::{ + signeddata::AttesterDuty, + types::{ + AttesterDutyDefinition, DutyDefinition, ProposerDutyDefinition, SlotNumber, + SyncCommitteeDutyDefinition, + }, }; /// 48-byte BLS public key length used to build distinct test pubkeys. @@ -1239,12 +1230,7 @@ mod tests { fn attester_duty_def(pubkey: PubKey, duty: &AttesterDuty) -> AttesterDutyDefinition { AttesterDutyDefinition { pubkey, - v_idx: duty.validator_index, - slot: SlotNumber::new(duty.slot), - committee_index: duty.committee_index, - committee_length: duty.committee_length, - committees_at_slot: duty.committees_at_slot, - validator_committee_index: duty.validator_committee_index, + duty: duty.clone(), } } diff --git a/crates/core/src/scheduler.rs b/crates/core/src/scheduler.rs index 45054132..8cac3df7 100644 --- a/crates/core/src/scheduler.rs +++ b/crates/core/src/scheduler.rs @@ -441,7 +441,7 @@ impl SchedulerActor { let att_duties = fetch_attester_duties(&slot, &vals, &self.client).await?; for att_duty in att_duties.into_iter() { if !self.set_duty_definition( - types::Duty::new_attester_duty(att_duty.slot), + types::Duty::new_attester_duty(att_duty.duty.slot.into()), slot.epoch(), att_duty.pubkey, types::DutyDefinition::Attester(att_duty.clone()), @@ -450,15 +450,15 @@ impl SchedulerActor { } tracing::info!( - slot = %att_duty.slot, - vidx = %att_duty.v_idx, + slot = %att_duty.duty.slot, + vidx = %att_duty.duty.validator_index, pubkey = %att_duty.pubkey, epoch = %slot.epoch(), "Resolved attester duty" ); // Schedule Aggregator duty as well - let agg_duty = types::Duty::new_aggregator_duty(att_duty.slot); + let agg_duty = types::Duty::new_aggregator_duty(att_duty.duty.slot.into()); self.set_duty_definition( agg_duty, slot.epoch(), @@ -870,20 +870,20 @@ async fn fetch_attester_duties( let mut result = vec![]; for att_duty in att_duties.into_iter() { - remaining.remove(&att_duty.v_idx); + remaining.remove(&att_duty.duty.validator_index); - if att_duty.slot < slot.slot { + if att_duty.duty.slot < slot.slot.inner() { // Skip duties for earlier slots in initial epoch. continue; } let Some(pubkey) = validators .iter() - .find(|v| v.v_idx == att_duty.v_idx) + .find(|v| v.v_idx == att_duty.duty.validator_index) .map(|v| v.pubkey) else { tracing::warn!( - vidx = att_duty.v_idx, + vidx = att_duty.duty.validator_index, slot = %slot.slot, "Ignoring unexpected attester duty" ); diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index 9d307c00..442b5ffd 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -12,7 +12,7 @@ use crate::{ ParSigExCodecError, corepb::v1::core as pbcore, parsigex_codec::{deserialize_signed_data, serialize_signed_data}, - signeddata::SignedDataError, + signeddata::{AttesterDuty, SignedDataError}, }; /// The type of duty. @@ -427,22 +427,16 @@ impl AsRef<[u8]> for PubKey { } /// Attestation duties to be performed by validators for a particular epoch. +/// +/// Mirrors Charon's `core.AttesterDefinition`, which embeds the eth2 +/// `v1.AttesterDuty`. Pluto's [`AttesterDuty`] omits the validator public key, +/// so it is carried alongside the embedded duty. #[derive(Debug, Clone, PartialEq)] pub struct AttesterDutyDefinition { - /// The validator's BLS public key + /// The validator's BLS public key. pub pubkey: PubKey, - /// Index of validator in validator registry - pub v_idx: u64, - /// The slot at which the validator must attest. - pub slot: SlotNumber, - /// Index of the committee the validator belongs to. - pub committee_index: u64, - /// Number of validators in the committee. - pub committee_length: u64, - /// Number of committees at this slot. - pub committees_at_slot: u64, - /// Index of the validator within its committee. - pub validator_committee_index: u64, + /// The attester duty to perform. + pub duty: AttesterDuty, } impl TryInto @@ -453,13 +447,12 @@ impl TryInto fn try_into(self) -> Result { let pubkey = PubKey::try_from(self.pubkey.as_str()) .map_err(|_| pluto_eth2api::EthBeaconNodeApiClientError::ParseError("pubkey".into()))?; - let v_idx = self.validator_index.parse::().map_err(|_| { + let validator_index = self.validator_index.parse::().map_err(|_| { pluto_eth2api::EthBeaconNodeApiClientError::ParseError("validator_index".into()) })?; - let slot = - SlotNumber::from(self.slot.parse::().map_err(|_| { - pluto_eth2api::EthBeaconNodeApiClientError::ParseError("slot".into()) - })?); + let slot = self.slot.parse::().map_err(|_| { + pluto_eth2api::EthBeaconNodeApiClientError::ParseError("slot".into()) + })?; let committee_index = self.committee_index.parse::().map_err(|_| { pluto_eth2api::EthBeaconNodeApiClientError::ParseError("committee_index".into()) })?; @@ -478,12 +471,14 @@ impl TryInto Ok(AttesterDutyDefinition { pubkey, - v_idx, - slot, - committee_index, - committee_length, - committees_at_slot, - validator_committee_index, + duty: AttesterDuty { + slot, + validator_index, + committee_index, + committee_length, + committees_at_slot, + validator_committee_index, + }, }) } } From cdcb1b9345db1c21a7c8ff1458c62c706913c761 Mon Sep 17 00:00:00 2001 From: "emlautarom1-agent[bot]" <292495798+emlautarom1-agent[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 21:33:24 -0300 Subject: [PATCH 05/30] refactor(core): drop DutyDefinition accessors for let-else matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove as_attester/as_proposer/as_sync_committee from DutyDefinition. as_proposer/as_sync_committee were unused, and the two as_attester call sites in the fetcher are clearer with a let-else match — the idiomatic Rust equivalent of Charon's def.(core.AttesterDefinition) type assertion. --- crates/core/src/fetcher/mod.rs | 17 ++++++++--------- crates/core/src/types.rs | 27 --------------------------- 2 files changed, 8 insertions(+), 36 deletions(-) diff --git a/crates/core/src/fetcher/mod.rs b/crates/core/src/fetcher/mod.rs index 90aee6c6..161702b9 100644 --- a/crates/core/src/fetcher/mod.rs +++ b/crates/core/src/fetcher/mod.rs @@ -27,7 +27,7 @@ use crate::{ SyncCommitteeSelection, SyncContribution, VersionedAggregatedAttestation, VersionedProposal, }, - types::{Duty, DutyDefinitionSet, DutyType, PubKey, SignedData}, + types::{Duty, DutyDefinition, DutyDefinitionSet, DutyType, PubKey, SignedData}, unsigneddata::{UnsignedDataSet, UnsignedDutyData}, }; @@ -269,9 +269,9 @@ impl Fetcher { let mut resp = UnsignedDataSet::new(); for (pubkey, def) in def_set { - let att_def = def - .as_attester() - .ok_or(FetcherError::InvalidAttesterDefinition)?; + let DutyDefinition::Attester(att_def) = def else { + return Err(FetcherError::InvalidAttesterDefinition); + }; let mut comm_idx = att_def.duty.committee_index; @@ -317,9 +317,9 @@ impl Fetcher { let mut resp = UnsignedDataSet::new(); for (pubkey, def) in def_set { - let att_def = def - .as_attester() - .ok_or(FetcherError::InvalidAttesterDefinition)?; + let DutyDefinition::Attester(att_def) = def else { + return Err(FetcherError::InvalidAttesterDefinition); + }; // Query AggSigDB for DutyPrepareAggregator to get beacon committee // selections. @@ -844,8 +844,7 @@ mod tests { use crate::{ signeddata::AttesterDuty, types::{ - AttesterDutyDefinition, DutyDefinition, ProposerDutyDefinition, SlotNumber, - SyncCommitteeDutyDefinition, + AttesterDutyDefinition, ProposerDutyDefinition, SlotNumber, SyncCommitteeDutyDefinition, }, }; diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index 442b5ffd..f525eeba 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -573,33 +573,6 @@ pub enum DutyDefinition { SyncCommittee(SyncCommitteeDutyDefinition), } -impl DutyDefinition { - /// Returns the attester definition, or `None` if this is a different duty. - pub fn as_attester(&self) -> Option<&AttesterDutyDefinition> { - match self { - Self::Attester(d) => Some(d), - _ => None, - } - } - - /// Returns the proposer definition, or `None` if this is a different duty. - pub fn as_proposer(&self) -> Option<&ProposerDutyDefinition> { - match self { - Self::Proposer(d) => Some(d), - _ => None, - } - } - - /// Returns the sync committee definition, or `None` if this is a different - /// duty. - pub fn as_sync_committee(&self) -> Option<&SyncCommitteeDutyDefinition> { - match self { - Self::SyncCommittee(d) => Some(d), - _ => None, - } - } -} - /// A set of duty definitions for all validators in a given epoch, indexed by /// public key. pub type DutyDefinitionSet = HashMap; From d22fce90a7329fd584890e665e3840593578e31b Mon Sep 17 00:00:00 2001 From: "emlautarom1-agent[bot]" <292495798+emlautarom1-agent[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 21:35:13 -0300 Subject: [PATCH 06/30] refactor(core): mark unreachable attestation payload version arm attestation_payload is only ever called with a version derived from consensus_to_data_version, which maps every ConsensusVersion variant and never yields DataVersion::Unknown. Replace the dead arm's misleading AggregateAttestationNotFound error with unreachable\!. --- crates/core/src/fetcher/mod.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/core/src/fetcher/mod.rs b/crates/core/src/fetcher/mod.rs index 161702b9..1936807e 100644 --- a/crates/core/src/fetcher/mod.rs +++ b/crates/core/src/fetcher/mod.rs @@ -702,7 +702,9 @@ fn attestation_payload( DV::Deneb => AP::Deneb(round_trip(data)?), DV::Electra => AP::Electra(round_trip(data)?), DV::Fulu => AP::Fulu(round_trip(data)?), - DV::Unknown => return Err(FetcherError::AggregateAttestationNotFound), + // `version` is always derived from a `ConsensusVersion` via + // `consensus_to_data_version`, which never yields `Unknown`. + DV::Unknown => unreachable!("attestation payload version cannot be unknown"), }) } From df435c849a92545fa90d9cbf4a217c850aa981ae Mon Sep 17 00:00:00 2001 From: Lautaro Emanuel Date: Wed, 17 Jun 2026 00:24:22 -0300 Subject: [PATCH 07/30] Formatting --- crates/core/src/types.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index f525eeba..0d9f4479 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -450,9 +450,10 @@ impl TryInto let validator_index = self.validator_index.parse::().map_err(|_| { pluto_eth2api::EthBeaconNodeApiClientError::ParseError("validator_index".into()) })?; - let slot = self.slot.parse::().map_err(|_| { - pluto_eth2api::EthBeaconNodeApiClientError::ParseError("slot".into()) - })?; + let slot = self + .slot + .parse::() + .map_err(|_| pluto_eth2api::EthBeaconNodeApiClientError::ParseError("slot".into()))?; let committee_index = self.committee_index.parse::().map_err(|_| { pluto_eth2api::EthBeaconNodeApiClientError::ParseError("committee_index".into()) })?; From 2184f5c3dc21e716e2f90b6b6bfac31000737a72 Mon Sep 17 00:00:00 2001 From: Lautaro Emanuel Date: Wed, 17 Jun 2026 13:20:08 -0300 Subject: [PATCH 08/30] Replace clunky setup with builder --- Cargo.lock | 1 + crates/core/Cargo.toml | 1 + crates/core/src/fetcher/mod.rs | 437 ++++++++++++++++----------------- 3 files changed, 218 insertions(+), 221 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index def66eda..da4a39b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5629,6 +5629,7 @@ dependencies = [ "axum", "backon", "base64", + "bon", "built", "cancellation", "chrono", diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 44470ac0..eef8a2b1 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -10,6 +10,7 @@ publish.workspace = true alloy.workspace = true backon.workspace = true async-trait.workspace = true +bon.workspace = true axum.workspace = true cancellation.workspace = true chrono.workspace = true diff --git a/crates/core/src/fetcher/mod.rs b/crates/core/src/fetcher/mod.rs index 1936807e..e3867f36 100644 --- a/crates/core/src/fetcher/mod.rs +++ b/crates/core/src/fetcher/mod.rs @@ -108,14 +108,6 @@ pub enum FetcherError { #[error("{0}")] Callback(BoxError), - /// AggSigDB was queried but no resolver was registered. - #[error("AggSigDB function not registered")] - AggSigDbNotRegistered, - - /// DutyDB was queried but no resolver was registered. - #[error("AwaitAttData function not registered")] - AwaitAttDataNotRegistered, - /// Error from the beacon node API client. #[error(transparent)] BeaconNode(#[from] EthBeaconNodeApiClientError), @@ -149,63 +141,32 @@ pub enum FetcherError { type Result = std::result::Result; /// Fetches proposed duty data from the beacon node. +#[derive(bon::Builder)] pub struct Fetcher { - eth2_cl: EthBeaconNodeApiClient, - fee_recipient_func: Option, + /// Subscribers invoked for each fetched duty data set. Appended via the + /// builder's `subscribe` method (zero or more times). + #[builder(field)] subs: Vec, - agg_sig_db_func: Option, - await_att_data_func: Option, + eth2_cl: EthBeaconNodeApiClient, + fee_recipient: FeeRecipientFunc, + agg_sig_db: AggSigDbFunc, + await_att_data: AwaitAttDataFunc, builder_enabled: bool, graffiti_builder: GraffitiBuilder, electra_slot: phase0::Slot, fetch_only_comm_idx0: bool, } -impl Fetcher { - /// Returns a new fetcher instance. - pub fn new( - eth2_cl: EthBeaconNodeApiClient, - fee_recipient_func: Option, - builder_enabled: bool, - graffiti_builder: GraffitiBuilder, - electra_slot: phase0::Slot, - fetch_only_comm_idx0: bool, - ) -> Self { - Self { - eth2_cl, - fee_recipient_func, - subs: Vec::new(), - agg_sig_db_func: None, - await_att_data_func: None, - builder_enabled, - graffiti_builder, - electra_slot, - fetch_only_comm_idx0, - } - } - - /// Registers a callback for fetched duties. - /// - /// Note: this is not thread safe and should be called *before* `fetch`. - pub fn subscribe(&mut self, sub: Subscriber) { +impl FetcherBuilder { + /// Registers a callback for fetched duties. May be called multiple times to + /// register several subscribers. + pub fn subscribe(mut self, sub: Subscriber) -> Self { self.subs.push(sub); + self } +} - /// Registers a function to get resolved aggregated signed data from - /// AggSigDB. - /// - /// Note: this is not thread safe and should be called *before* `fetch`. - pub fn register_agg_sig_db(&mut self, func: AggSigDbFunc) { - self.agg_sig_db_func = Some(func); - } - - /// Registers a function to get attestation data from DutyDB. - /// - /// Note: this is not thread safe and should be called *before* `fetch`. - pub fn register_await_att_data(&mut self, func: AwaitAttDataFunc) { - self.await_att_data_func = Some(func); - } - +impl Fetcher { /// Triggers fetching of a proposed duty data set. pub async fn fetch(&self, duty: Duty, def_set: DutyDefinitionSet) -> Result<()> { let slot = duty.slot.inner(); @@ -324,7 +285,7 @@ impl Fetcher { // Query AggSigDB for DutyPrepareAggregator to get beacon committee // selections. let prep_agg_data = self - .agg_sig_db(Duty::new_prepare_aggregator_duty(slot.into()), *pubkey) + .query_agg_sig_db(Duty::new_prepare_aggregator_duty(slot.into()), *pubkey) .await?; let selection = downcast::(prep_agg_data.as_ref()) .ok_or(FetcherError::InvalidBeaconCommitteeSelection)?; @@ -357,7 +318,7 @@ impl Fetcher { } // Query DutyDB for attestation data to get the attestation data root. - let att_data = self.await_att_data(slot, comm_idx).await?; + let att_data = self.query_att_data(slot, comm_idx).await?; let data_root = att_data.tree_hash_root().0; // Query BN for aggregate attestation. @@ -387,7 +348,7 @@ impl Fetcher { for pubkey in def_set.keys() { // Fetch previously aggregated randao reveal from AggSigDB. let randao_data = self - .agg_sig_db(Duty::new_randao_duty(slot.into()), *pubkey) + .query_agg_sig_db(Duty::new_randao_duty(slot.into()), *pubkey) .await?; let randao = randao_data .signature() @@ -421,11 +382,7 @@ impl Fetcher { // Builders set the fee recipient to themselves, so it always differs // from the validator's; only verify when the builder is disabled. if !self.builder_enabled { - let fee_recipient = self - .fee_recipient_func - .as_ref() - .map(|f| f(pubkey)) - .unwrap_or_default(); + let fee_recipient = (self.fee_recipient)(pubkey); verify_fee_recipient(&proposal, &fee_recipient); } @@ -448,7 +405,7 @@ impl Fetcher { // Query AggSigDB for DutyPrepareSyncContribution to get the sync // committee selection. let selection_data = self - .agg_sig_db( + .query_agg_sig_db( Duty::new_prepare_sync_contribution_duty(slot.into()), *pubkey, ) @@ -469,7 +426,7 @@ impl Fetcher { // Query AggSigDB for DutySyncMessage to get the beacon block root. let sync_msg_data = self - .agg_sig_db(Duty::new_sync_message_duty(slot.into()), *pubkey) + .query_agg_sig_db(Duty::new_sync_message_duty(slot.into()), *pubkey) .await?; let msg = downcast::(sync_msg_data.as_ref()) .ok_or(FetcherError::InvalidSyncCommitteeMessage)?; @@ -494,7 +451,7 @@ impl Fetcher { Ok(resp) } - // --- beacon node helpers ------------------------------------------------- + // Beacon node helpers /// Queries the beacon node for attestation data. async fn attestation_data(&self, slot: u64, comm_idx: u64) -> Result { @@ -574,22 +531,18 @@ impl Fetcher { } } - /// Invokes the registered AggSigDB resolver. - async fn agg_sig_db(&self, duty: Duty, pubkey: PubKey) -> Result> { - let func = self - .agg_sig_db_func - .as_ref() - .ok_or(FetcherError::AggSigDbNotRegistered)?; - func(duty, pubkey).await.map_err(FetcherError::Callback) + /// Invokes the AggSigDB resolver. + async fn query_agg_sig_db(&self, duty: Duty, pubkey: PubKey) -> Result> { + (self.agg_sig_db)(duty, pubkey) + .await + .map_err(FetcherError::Callback) } - /// Invokes the registered DutyDB attestation-data resolver. - async fn await_att_data(&self, slot: u64, comm_idx: u64) -> Result { - let func = self - .await_att_data_func - .as_ref() - .ok_or(FetcherError::AwaitAttDataNotRegistered)?; - func(slot, comm_idx).await.map_err(FetcherError::Callback) + /// Invokes the DutyDB attestation-data resolver. + async fn query_att_data(&self, slot: u64, comm_idx: u64) -> Result { + (self.await_att_data)(slot, comm_idx) + .await + .map_err(FetcherError::Callback) } } @@ -867,6 +820,22 @@ mod tests { }) } + /// Fee-recipient stub for tests that don't exercise fee-recipient + /// verification. + fn stub_fee_recipient() -> FeeRecipientFunc { + Arc::new(|_| String::new()) + } + + /// AggSigDB stub for tests whose duty path never queries it. + fn stub_agg_sig_db() -> AggSigDbFunc { + Arc::new(|_, _| Box::pin(async { unreachable!("AggSigDB not expected in this test") })) + } + + /// DutyDB attestation-data stub for tests whose duty path never queries it; + fn stub_await_att_data() -> AwaitAttDataFunc { + Arc::new(|_, _| Box::pin(async { unreachable!("AwaitAttData not expected in this test") })) + } + /// Spec fields required by `is_sync_comm_aggregator` / /// `is_att_aggregator`, matching the values the prysm selection-proof test /// vectors were generated against. @@ -988,23 +957,24 @@ mod tests { let mut graffiti_b = [0u8; 32]; graffiti_b[..5].copy_from_slice(b"testB"); - let mut def_set = DutyDefinitionSet::new(); - def_set.insert( - pk_a, - DutyDefinition::Proposer(ProposerDutyDefinition { - pubkey: pk_a, - v_idx: 2, - slot: SlotNumber::new(SLOT), - }), - ); - def_set.insert( - pk_b, - DutyDefinition::Proposer(ProposerDutyDefinition { - pubkey: pk_b, - v_idx: 3, - slot: SlotNumber::new(SLOT), - }), - ); + let def_set = DutyDefinitionSet::from([ + ( + pk_a, + DutyDefinition::Proposer(ProposerDutyDefinition { + pubkey: pk_a, + v_idx: 2, + slot: SlotNumber::new(SLOT), + }), + ), + ( + pk_b, + DutyDefinition::Proposer(ProposerDutyDefinition { + pubkey: pk_b, + v_idx: 3, + slot: SlotNumber::new(SLOT), + }), + ), + ]); let mock = BeaconMock::builder().build().await.expect("build mock"); mount_produce_block(mock.server()).await; @@ -1018,29 +988,30 @@ mod tests { .await .expect("build graffiti"); - let mut fetch = Fetcher::new( - mock.client().clone(), - None, - true, - graffiti_builder, - 5, - false, - ); - let randaos = randao_by_pubkey.clone(); - fetch.register_agg_sig_db(Arc::new(move |_duty: Duty, pubkey: PubKey| { + let agg_sig_db: AggSigDbFunc = Arc::new(move |_duty: Duty, pubkey: PubKey| { let sig = randaos[&pubkey]; Box::pin(async move { let data: Box = Box::new(sig); Ok(data) }) - })); + }); let captured: Captured = Arc::new(Mutex::new(None)); - fetch.subscribe(capturing_subscriber(captured.clone())); + let fetcher = Fetcher::builder() + .eth2_cl(mock.client().clone()) + .fee_recipient(stub_fee_recipient()) + .agg_sig_db(agg_sig_db) + .await_att_data(stub_await_att_data()) + .builder_enabled(true) + .graffiti_builder(graffiti_builder) + .electra_slot(5) + .fetch_only_comm_idx0(false) + .subscribe(capturing_subscriber(captured.clone())) + .build(); let duty = Duty::new_proposer_duty(SlotNumber::new(SLOT)); - fetch.fetch(duty, def_set).await.expect("fetch"); + fetcher.fetch(duty, def_set).await.expect("fetch"); let (_, res_set) = captured.lock().unwrap().take().expect("subscriber called"); assert_eq!(res_set.len(), 2); @@ -1090,32 +1061,34 @@ mod tests { validator_committee_index: 0, }; - let mut def_set = DutyDefinitionSet::new(); - def_set.insert( - pk_a, - DutyDefinition::Attester(attester_duty_def(pk_a, &duty_a)), - ); - def_set.insert( - pk_b, - DutyDefinition::Attester(attester_duty_def(pk_b, &duty_b)), - ); + let def_set = DutyDefinitionSet::from([ + ( + pk_a, + DutyDefinition::Attester(attester_duty_def(pk_a, &duty_a)), + ), + ( + pk_b, + DutyDefinition::Attester(attester_duty_def(pk_b, &duty_b)), + ), + ]); let duty = Duty::new_attester_duty(SlotNumber::new(SLOT)); let mock = BeaconMock::builder().build().await.expect("build mock"); - let mut fetch = Fetcher::new( - mock.client().clone(), - None, - true, - GraffitiBuilder::default(), - 5, - false, - ); - let captured: Captured = Arc::new(Mutex::new(None)); - fetch.subscribe(capturing_subscriber(captured.clone())); - - fetch.fetch(duty.clone(), def_set).await.expect("fetch"); + let fetcher = Fetcher::builder() + .eth2_cl(mock.client().clone()) + .fee_recipient(stub_fee_recipient()) + .agg_sig_db(stub_agg_sig_db()) + .await_att_data(stub_await_att_data()) + .builder_enabled(true) + .graffiti_builder(GraffitiBuilder::default()) + .electra_slot(5) + .fetch_only_comm_idx0(false) + .subscribe(capturing_subscriber(captured.clone())) + .build(); + + fetcher.fetch(duty.clone(), def_set).await.expect("fetch"); let (res_duty, res_set) = captured.lock().unwrap().take().expect("subscriber called"); assert_eq!(res_duty, duty); @@ -1250,12 +1223,15 @@ mod tests { )) } - /// Wires AggSigDB to return a beacon committee selection and DutyDB to - /// return the attestation data for each committee index. - fn wire_aggregator(fetch: &mut Fetcher, atts: &[phase0::Attestation]) { + /// Builds the AggSigDB (returns a beacon committee selection) and DutyDB + /// (returns the attestation data for each committee index) callbacks used + /// by the aggregator tests. + fn aggregator_funcs( + atts: impl AsRef<[phase0::Attestation]>, + ) -> (AggSigDbFunc, AwaitAttDataFunc) { use pluto_eth2api::v1; - fetch.register_agg_sig_db(Arc::new(move |_duty: Duty, _pubkey: PubKey| { + let agg_sig_db: AggSigDbFunc = Arc::new(move |_duty: Duty, _pubkey: PubKey| { Box::pin(async move { let selection = BeaconCommitteeSelection::new(v1::BeaconCommitteeSelection { slot: 1, @@ -1265,16 +1241,19 @@ mod tests { let data: Box = Box::new(selection); Ok(data) }) - })); + }); let by_idx: HashMap = atts + .as_ref() .iter() .map(|a| (a.data.index, a.data.clone())) .collect(); - fetch.register_await_att_data(Arc::new(move |_slot: u64, comm_idx: u64| { + let await_att_data: AwaitAttDataFunc = Arc::new(move |_slot: u64, comm_idx: u64| { let data = by_idx.get(&comm_idx).cloned(); Box::pin(async move { data.ok_or_else(|| "missing attestation data".into()) }) - })); + }); + + (agg_sig_db, await_att_data) } #[tokio::test] @@ -1286,9 +1265,10 @@ mod tests { let att_a = build_attestation(2); let att_b = build_attestation(3); - let mut def_set = DutyDefinitionSet::new(); - def_set.insert(pk_a, attester_def(att_a.data.index, 0)); - def_set.insert(pk_b, attester_def(att_b.data.index, 0)); + let def_set = DutyDefinitionSet::from([ + (pk_a, attester_def(att_a.data.index, 0)), + (pk_b, attester_def(att_b.data.index, 0)), + ]); let by_root = HashMap::from([ (hex_0x(&att_a.data.tree_hash_root().0), att_a.clone()), @@ -1302,18 +1282,20 @@ mod tests { .expect("build mock"); mount_aggregate(mock.server(), by_root).await; - let mut fetch = Fetcher::new( - mock.client().clone(), - None, - true, - GraffitiBuilder::default(), - 5, - false, - ); - wire_aggregator(&mut fetch, &[att_a.clone(), att_b.clone()]); + let (agg_sig_db, await_att_data) = aggregator_funcs(&[att_a.clone(), att_b.clone()]); let captured: Captured = Arc::new(Mutex::new(None)); - fetch.subscribe(capturing_subscriber(captured.clone())); + let fetch = Fetcher::builder() + .eth2_cl(mock.client().clone()) + .fee_recipient(stub_fee_recipient()) + .agg_sig_db(agg_sig_db) + .await_att_data(await_att_data) + .builder_enabled(true) + .graffiti_builder(GraffitiBuilder::default()) + .electra_slot(5) + .fetch_only_comm_idx0(false) + .subscribe(capturing_subscriber(captured.clone())) + .build(); let duty = Duty::new_aggregator_duty(SlotNumber::new(SLOT)); fetch.fetch(duty, def_set).await.expect("fetch"); @@ -1338,9 +1320,10 @@ mod tests { // Both validators belong to the same committee; the aggregate is fetched // once and reused for the second validator. let att = build_attestation(2); - let mut def_set = DutyDefinitionSet::new(); - def_set.insert(pk_a, attester_def(att.data.index, 0)); - def_set.insert(pk_b, attester_def(att.data.index, 0)); + let def_set = DutyDefinitionSet::from([ + (pk_a, attester_def(att.data.index, 0)), + (pk_b, attester_def(att.data.index, 0)), + ]); let by_root = HashMap::from([(hex_0x(&att.data.tree_hash_root().0), att.clone())]); @@ -1351,18 +1334,20 @@ mod tests { .expect("build mock"); mount_aggregate(mock.server(), by_root).await; - let mut fetch = Fetcher::new( - mock.client().clone(), - None, - true, - GraffitiBuilder::default(), - 5, - false, - ); - wire_aggregator(&mut fetch, std::slice::from_ref(&att)); + let (agg_sig_db, await_att_data) = aggregator_funcs(std::slice::from_ref(&att)); let captured: Captured = Arc::new(Mutex::new(None)); - fetch.subscribe(capturing_subscriber(captured.clone())); + let fetch = Fetcher::builder() + .eth2_cl(mock.client().clone()) + .fee_recipient(stub_fee_recipient()) + .agg_sig_db(agg_sig_db) + .await_att_data(await_att_data) + .builder_enabled(true) + .graffiti_builder(GraffitiBuilder::default()) + .electra_slot(5) + .fetch_only_comm_idx0(false) + .subscribe(capturing_subscriber(captured.clone())) + .build(); let duty = Duty::new_aggregator_duty(SlotNumber::new(SLOT)); fetch.fetch(duty, def_set).await.expect("fetch"); @@ -1395,22 +1380,24 @@ mod tests { .expect("build mock"); mount_aggregate(mock.server(), HashMap::new()).await; - let mut fetch = Fetcher::new( - mock.client().clone(), - None, - true, - GraffitiBuilder::default(), - 5, - false, - ); - wire_aggregator(&mut fetch, std::slice::from_ref(&att_a)); + let (agg_sig_db, await_att_data) = aggregator_funcs([att_a]); let captured: Captured = Arc::new(Mutex::new(None)); - fetch.subscribe(capturing_subscriber(captured.clone())); + let fetcher = Fetcher::builder() + .eth2_cl(mock.client().clone()) + .fee_recipient(stub_fee_recipient()) + .agg_sig_db(agg_sig_db) + .await_att_data(await_att_data) + .builder_enabled(true) + .graffiti_builder(GraffitiBuilder::default()) + .electra_slot(5) + .fetch_only_comm_idx0(false) + .subscribe(capturing_subscriber(captured.clone())) + .build(); let duty = Duty::new_aggregator_duty(SlotNumber::new(SLOT)); // No aggregators found -> empty set -> Ok and subscriber not invoked. - fetch.fetch(duty, def_set).await.expect("fetch"); + fetcher.fetch(duty, def_set).await.expect("fetch"); assert!(captured.lock().unwrap().is_none()); } @@ -1431,18 +1418,21 @@ mod tests { // Empty map -> responder returns 404 for every root. mount_aggregate(mock.server(), HashMap::new()).await; - let mut fetch = Fetcher::new( - mock.client().clone(), - None, - true, - GraffitiBuilder::default(), - 5, - false, - ); - wire_aggregator(&mut fetch, std::slice::from_ref(&att_a)); + let (agg_sig_db, await_att_data) = aggregator_funcs(std::slice::from_ref(&att_a)); + + let fetcher = Fetcher::builder() + .eth2_cl(mock.client().clone()) + .fee_recipient(stub_fee_recipient()) + .agg_sig_db(agg_sig_db) + .await_att_data(await_att_data) + .builder_enabled(true) + .graffiti_builder(GraffitiBuilder::default()) + .electra_slot(5) + .fetch_only_comm_idx0(false) + .build(); let duty = Duty::new_aggregator_duty(SlotNumber::new(SLOT)); - let err = fetch + let err = fetcher .fetch(duty, def_set) .await .expect_err("expected error"); @@ -1515,18 +1505,9 @@ mod tests { .expect("build mock"); mount_sync_contribution(mock.server()).await; - let mut fetch = Fetcher::new( - mock.client().clone(), - None, - true, - GraffitiBuilder::default(), - 5, - false, - ); - let sels = selections.clone(); let msgs = messages.clone(); - fetch.register_agg_sig_db(Arc::new(move |duty: Duty, pubkey: PubKey| { + let agg_sig_db: AggSigDbFunc = Arc::new(move |duty: Duty, pubkey: PubKey| { let sels = sels.clone(); let msgs = msgs.clone(); Box::pin(async move { @@ -1537,13 +1518,23 @@ mod tests { }; Ok(data) }) - })); + }); let captured: Captured = Arc::new(Mutex::new(None)); - fetch.subscribe(capturing_subscriber(captured.clone())); + let fetcher = Fetcher::builder() + .eth2_cl(mock.client().clone()) + .fee_recipient(stub_fee_recipient()) + .agg_sig_db(agg_sig_db) + .await_att_data(stub_await_att_data()) + .builder_enabled(true) + .graffiti_builder(GraffitiBuilder::default()) + .electra_slot(5) + .fetch_only_comm_idx0(false) + .subscribe(capturing_subscriber(captured.clone())) + .build(); let duty = Duty::new_sync_contribution_duty(SlotNumber::new(SLOT)); - fetch.fetch(duty, def_set).await.expect("fetch"); + fetcher.fetch(duty, def_set).await.expect("fetch"); let (_, res_set) = captured.lock().unwrap().take().expect("subscriber called"); assert_eq!(res_set.len(), 2); @@ -1587,16 +1578,7 @@ mod tests { .await .expect("build mock"); - let mut fetch = Fetcher::new( - mock.client().clone(), - None, - true, - GraffitiBuilder::default(), - 5, - false, - ); - - fetch.register_agg_sig_db(Arc::new(move |duty: Duty, _pubkey: PubKey| { + let agg_sig_db: AggSigDbFunc = Arc::new(move |duty: Duty, _pubkey: PubKey| { Box::pin(async move { if duty.duty_type == DutyType::PrepareSyncContribution { let selection = SyncCommitteeSelection::new(v1::SyncCommitteeSelection { @@ -1610,11 +1592,22 @@ mod tests { } Err("unsupported duty".into()) }) - })); + }); + + let fetcher = Fetcher::builder() + .eth2_cl(mock.client().clone()) + .fee_recipient(stub_fee_recipient()) + .agg_sig_db(agg_sig_db) + .await_att_data(stub_await_att_data()) + .builder_enabled(true) + .graffiti_builder(GraffitiBuilder::default()) + .electra_slot(5) + .fetch_only_comm_idx0(false) + .build(); let duty = Duty::new_sync_contribution_duty(SlotNumber::new(SLOT)); // Non-aggregators are skipped, producing an empty set and no error. - fetch.fetch(duty, def_set).await.expect("fetch"); + fetcher.fetch(duty, def_set).await.expect("fetch"); } #[tokio::test] @@ -1633,21 +1626,23 @@ mod tests { ); let mock = BeaconMock::builder().build().await.expect("build mock"); - let mut fetch = Fetcher::new( - mock.client().clone(), - None, - true, - GraffitiBuilder::default(), - 5, - false, - ); - - fetch.register_agg_sig_db(Arc::new(move |_duty: Duty, _pubkey: PubKey| { + let agg_sig_db: AggSigDbFunc = Arc::new(move |_duty: Duty, _pubkey: PubKey| { Box::pin(async move { Err("error".into()) }) - })); + }); + + let fetcher = Fetcher::builder() + .eth2_cl(mock.client().clone()) + .fee_recipient(stub_fee_recipient()) + .agg_sig_db(agg_sig_db) + .await_att_data(stub_await_att_data()) + .builder_enabled(true) + .graffiti_builder(GraffitiBuilder::default()) + .electra_slot(5) + .fetch_only_comm_idx0(false) + .build(); let duty = Duty::new_sync_contribution_duty(SlotNumber::new(SLOT)); - let err = fetch + let err = fetcher .fetch(duty, def_set) .await .expect_err("expected error"); From 7f5e179b2f5c608918e2eafaf71959bcfc19b931 Mon Sep 17 00:00:00 2001 From: "emlautarom1-agent[bot]" <292495798+emlautarom1-agent[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 13:48:47 -0300 Subject: [PATCH 09/30] refactor(eth2api): add ConversionError and beacon-API parse helpers Add a spec-level ConversionError plus pub(crate) helpers (parse_u64, decode_hex_var, decode_hex_fixed) for converting the loosely-typed, all-string beacon-API response types into strongly-typed spec values. Re-export ConversionError from the spec module. These back the direct TryFrom conversions that replace the JSON round-trips in the fetcher. --- crates/eth2api/src/spec/mod.rs | 1 + crates/eth2api/src/spec/serde_utils.rs | 44 ++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/crates/eth2api/src/spec/mod.rs b/crates/eth2api/src/spec/mod.rs index a7924b78..c8ee25fc 100644 --- a/crates/eth2api/src/spec/mod.rs +++ b/crates/eth2api/src/spec/mod.rs @@ -3,6 +3,7 @@ /// Shared serde helpers for spec-compatible JSON. pub mod serde_utils; +pub use serde_utils::ConversionError; /// Spec-level version enums. pub mod version; diff --git a/crates/eth2api/src/spec/serde_utils.rs b/crates/eth2api/src/spec/serde_utils.rs index 0cf98850..781f4d14 100644 --- a/crates/eth2api/src/spec/serde_utils.rs +++ b/crates/eth2api/src/spec/serde_utils.rs @@ -1,5 +1,49 @@ //! Shared serde helpers for consensus-spec JSON encoding. +use pluto_ssz::serde_utils::trim_0x_prefix; + +/// Error raised while converting a loosely-typed beacon-API value (whose +/// numeric and byte fields are carried as decimal / `0x`-hex strings) into a +/// strongly-typed spec value. +#[derive(Debug, thiserror::Error)] +pub enum ConversionError { + /// A decimal-encoded integer field could not be parsed. + #[error("parse integer field `{field}`")] + ParseInt { + /// Name of the offending field. + field: &'static str, + }, + /// A `0x`-hex field could not be decoded or had an unexpected length. + #[error("decode hex field `{field}`")] + DecodeHex { + /// Name of the offending field. + field: &'static str, + }, +} + +/// Parses a decimal-encoded unsigned integer field. +pub(crate) fn parse_u64(value: &str, field: &'static str) -> Result { + value + .parse() + .map_err(|_| ConversionError::ParseInt { field }) +} + +/// Decodes a `0x`-prefixed (or bare) hex string into a byte vector. +pub(crate) fn decode_hex_var(value: &str, field: &'static str) -> Result, ConversionError> { + hex::decode(trim_0x_prefix(value)).map_err(|_| ConversionError::DecodeHex { field }) +} + +/// Decodes a `0x`-prefixed (or bare) hex string into a fixed-size byte array, +/// erroring when the decoded length does not match `N`. +pub(crate) fn decode_hex_fixed( + value: &str, + field: &'static str, +) -> Result<[u8; N], ConversionError> { + decode_hex_var(value, field)? + .try_into() + .map_err(|_| ConversionError::DecodeHex { field }) +} + /// JSON helpers for decimal-encoded `U256` values with optional `0x` input /// support. pub(crate) mod u256_dec_serde { From 51f91c3c79e4a15eda14031c04c53f3ee10cf91d Mon Sep 17 00:00:00 2001 From: "emlautarom1-agent[bot]" <292495798+emlautarom1-agent[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 13:48:53 -0300 Subject: [PATCH 10/30] refactor(eth2api): add TryFrom conversions for phase0 attestation types Implement TryFrom from the generated beacon-API types into Checkpoint, AttestationData, and the phase0-style Attestation, co-located next to each target type. Tests assert each conversion equals the JSON round-trip it replaces, plus parse/decode error cases. --- crates/eth2api/src/spec/phase0.rs | 131 ++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) diff --git a/crates/eth2api/src/spec/phase0.rs b/crates/eth2api/src/spec/phase0.rs index 4b67a76f..0580b0a7 100644 --- a/crates/eth2api/src/spec/phase0.rs +++ b/crates/eth2api/src/spec/phase0.rs @@ -9,6 +9,8 @@ use tree_hash_derive::TreeHash; pub use pluto_ssz::{BitList, SszList, SszVector}; +use crate::spec::serde_utils::{ConversionError, decode_hex_fixed, decode_hex_var, parse_u64}; + /// Fork version length in bytes. pub const VERSION_LEN: usize = 4; /// Signature domain length in bytes. @@ -336,6 +338,19 @@ pub struct Checkpoint { pub root: Root, } +impl TryFrom<&crate::AltairBeaconStateCurrentJustifiedCheckpoint> for Checkpoint { + type Error = ConversionError; + + fn try_from( + value: &crate::AltairBeaconStateCurrentJustifiedCheckpoint, + ) -> Result { + Ok(Self { + epoch: parse_u64(&value.epoch, "checkpoint.epoch")?, + root: decode_hex_fixed(&value.root, "checkpoint.root")?, + }) + } +} + /// Attestation data. /// /// Spec: @@ -357,6 +372,23 @@ pub struct AttestationData { pub target: Checkpoint, } +impl TryFrom<&crate::Data> for AttestationData { + type Error = ConversionError; + + fn try_from(value: &crate::Data) -> Result { + Ok(Self { + slot: parse_u64(&value.slot, "attestation_data.slot")?, + index: parse_u64(&value.index, "attestation_data.index")?, + beacon_block_root: decode_hex_fixed( + &value.beacon_block_root, + "attestation_data.beacon_block_root", + )?, + source: Checkpoint::try_from(&value.source)?, + target: Checkpoint::try_from(&value.target)?, + }) + } +} + /// Attestation object. /// /// Spec: @@ -372,6 +404,23 @@ pub struct Attestation { pub signature: BLSSignature, } +impl TryFrom<&crate::GetBlockAttestationsV2ResponseResponseDataArray2> for Attestation { + type Error = ConversionError; + + fn try_from( + value: &crate::GetBlockAttestationsV2ResponseResponseDataArray2, + ) -> Result { + Ok(Self { + aggregation_bits: BitList::from_ssz_bytes(decode_hex_var( + &value.aggregation_bits, + "attestation.aggregation_bits", + )?), + data: AttestationData::try_from(&value.data)?, + signature: decode_hex_fixed(&value.signature, "attestation.signature")?, + }) + } +} + /// Aggregate-and-proof payload. /// /// Spec: @@ -685,4 +734,86 @@ mod tests { serde_json::from_value(json).expect("deserialize indexed attestation"); assert_eq!(roundtrip.attesting_indices.0, vec![11, 12]); } + + /// Wire-format JSON for an attestation-data object, as returned by the + /// beacon node. + fn attestation_data_wire() -> serde_json::Value { + serde_json::json!({ + "slot": "42", + "index": "3", + "beacon_block_root": format!("0x{}", "11".repeat(32)), + "source": { "epoch": "5", "root": format!("0x{}", "22".repeat(32)) }, + "target": { "epoch": "6", "root": format!("0x{}", "33".repeat(32)) }, + }) + } + + #[test] + fn attestation_data_try_from_matches_json_roundtrip() { + let wire = attestation_data_wire(); + let generated: crate::Data = + serde_json::from_value(wire.clone()).expect("deserialize generated Data"); + + // Direct conversion must equal the loosely-typed JSON round-trip it + // replaces. + let direct = AttestationData::try_from(&generated).expect("convert"); + let via_json: AttestationData = serde_json::from_value(wire).expect("json round-trip"); + assert_eq!(direct, via_json); + + assert_eq!(direct.slot, 42); + assert_eq!(direct.index, 3); + assert_eq!(direct.beacon_block_root, [0x11; 32]); + assert_eq!( + direct.source, + Checkpoint { + epoch: 5, + root: [0x22; 32] + } + ); + assert_eq!( + direct.target, + Checkpoint { + epoch: 6, + root: [0x33; 32] + } + ); + } + + #[test] + fn attestation_data_try_from_rejects_bad_fields() { + let mut wire = attestation_data_wire(); + wire["slot"] = serde_json::json!("not-a-number"); + let generated: crate::Data = serde_json::from_value(wire).expect("deserialize"); + assert!(matches!( + AttestationData::try_from(&generated), + Err(ConversionError::ParseInt { + field: "attestation_data.slot" + }) + )); + + let mut wire = attestation_data_wire(); + wire["beacon_block_root"] = serde_json::json!("0xZZ"); + let generated: crate::Data = serde_json::from_value(wire).expect("deserialize"); + assert!(matches!( + AttestationData::try_from(&generated), + Err(ConversionError::DecodeHex { + field: "attestation_data.beacon_block_root" + }) + )); + } + + #[test] + fn attestation_try_from_matches_json_roundtrip() { + let wire = serde_json::json!({ + "aggregation_bits": "0x0102", + "data": attestation_data_wire(), + "signature": format!("0x{}", "44".repeat(96)), + }); + let generated: crate::GetBlockAttestationsV2ResponseResponseDataArray2 = + serde_json::from_value(wire.clone()).expect("deserialize generated attestation"); + + let direct = Attestation::try_from(&generated).expect("convert"); + let via_json: Attestation = serde_json::from_value(wire).expect("json round-trip"); + assert_eq!(direct, via_json); + assert_eq!(direct.signature, [0x44; 96]); + } } From c35ce886a933d0c471a66b7082b78359a9a4d373 Mon Sep 17 00:00:00 2001 From: "emlautarom1-agent[bot]" <292495798+emlautarom1-agent[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 13:49:00 -0300 Subject: [PATCH 11/30] refactor(eth2api): add TryFrom conversion for SyncCommitteeContribution Convert the generated Contribution into altair::SyncCommitteeContribution directly, decoding the aggregation bits via ssz::Decode. Tests cover equivalence with the JSON round-trip and the bad-bit-length error. --- crates/eth2api/src/spec/altair.rs | 76 ++++++++++++++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/crates/eth2api/src/spec/altair.rs b/crates/eth2api/src/spec/altair.rs index e30321dc..755d7b4f 100644 --- a/crates/eth2api/src/spec/altair.rs +++ b/crates/eth2api/src/spec/altair.rs @@ -8,7 +8,10 @@ use tree_hash_derive::TreeHash; use pluto_ssz::BitVector; -use crate::spec::phase0; +use crate::spec::{ + phase0, + serde_utils::{ConversionError, decode_hex_fixed, decode_hex_var, parse_u64}, +}; /// Sync aggregate included in Altair+ block bodies. /// @@ -131,6 +134,33 @@ pub struct SyncCommitteeContribution { pub signature: phase0::BLSSignature, } +impl TryFrom<&crate::Contribution> for SyncCommitteeContribution { + type Error = ConversionError; + + fn try_from(value: &crate::Contribution) -> Result { + const BITS_FIELD: &str = "sync_committee_contribution.aggregation_bits"; + let aggregation_bits = as ssz::Decode>::from_ssz_bytes(&decode_hex_var( + &value.aggregation_bits, + BITS_FIELD, + )?) + .map_err(|_| ConversionError::DecodeHex { field: BITS_FIELD })?; + + Ok(Self { + slot: parse_u64(&value.slot, "sync_committee_contribution.slot")?, + beacon_block_root: decode_hex_fixed( + &value.beacon_block_root, + "sync_committee_contribution.beacon_block_root", + )?, + subcommittee_index: parse_u64( + &value.subcommittee_index, + "sync_committee_contribution.subcommittee_index", + )?, + aggregation_bits, + signature: decode_hex_fixed(&value.signature, "sync_committee_contribution.signature")?, + }) + } +} + /// Contribution-and-proof payload. /// /// Spec: @@ -277,4 +307,48 @@ mod tests { fn tree_hash_matches_vector(actual: String, expected: &'static str) { assert_eq!(actual, expected); } + + #[test] + fn sync_committee_contribution_try_from_matches_json_roundtrip() { + let wire = serde_json::json!({ + "slot": "9", + "beacon_block_root": format!("0x{}", "66".repeat(32)), + "subcommittee_index": "3", + "aggregation_bits": format!("0x{}", "00".repeat(16)), + "signature": format!("0x{}", "77".repeat(96)), + }); + let generated: crate::Contribution = + serde_json::from_value(wire.clone()).expect("deserialize generated Contribution"); + + // Direct conversion must equal the loosely-typed JSON round-trip it + // replaces. + let direct = SyncCommitteeContribution::try_from(&generated).expect("convert"); + let via_json: SyncCommitteeContribution = + serde_json::from_value(wire).expect("json round-trip"); + assert_eq!(direct, via_json); + + assert_eq!(direct.slot, 9); + assert_eq!(direct.subcommittee_index, 3); + assert_eq!(direct.beacon_block_root, [0x66; 32]); + assert_eq!(direct.signature, [0x77; 96]); + } + + #[test] + fn sync_committee_contribution_try_from_rejects_bad_bits_length() { + let wire = serde_json::json!({ + "slot": "9", + "beacon_block_root": format!("0x{}", "66".repeat(32)), + "subcommittee_index": "3", + // BitVector<128> requires exactly 16 bytes. + "aggregation_bits": "0x0102", + "signature": format!("0x{}", "77".repeat(96)), + }); + let generated: crate::Contribution = serde_json::from_value(wire).expect("deserialize"); + assert!(matches!( + SyncCommitteeContribution::try_from(&generated), + Err(ConversionError::DecodeHex { + field: "sync_committee_contribution.aggregation_bits" + }) + )); + } } From 9dd6fc85a86df972d7620104e8e8c655e264f1cc Mon Sep 17 00:00:00 2001 From: "emlautarom1-agent[bot]" <292495798+emlautarom1-agent[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 13:49:06 -0300 Subject: [PATCH 12/30] refactor(eth2api): add TryFrom conversion for electra Attestation Convert the committee-aware generated attestation into electra::Attestation directly, including committee_bits. Test asserts equivalence with the JSON round-trip. --- crates/eth2api/src/spec/electra.rs | 58 +++++++++++++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/crates/eth2api/src/spec/electra.rs b/crates/eth2api/src/spec/electra.rs index ff98d337..7ceeab82 100644 --- a/crates/eth2api/src/spec/electra.rs +++ b/crates/eth2api/src/spec/electra.rs @@ -7,7 +7,10 @@ use tree_hash_derive::TreeHash; use pluto_ssz::{BitList, BitVector}; -use crate::spec::{altair, bellatrix, capella, deneb, phase0}; +use crate::spec::{ + altair, bellatrix, capella, deneb, phase0, + serde_utils::{ConversionError, decode_hex_fixed, decode_hex_var}, +}; /// Maximum number of attester slashings per block (Electra). pub const MAX_ATTESTER_SLASHINGS_ELECTRA: usize = 1; @@ -64,6 +67,33 @@ pub struct Attestation { pub committee_bits: BitVector<64>, } +impl TryFrom<&crate::GetBlockAttestationsV2ResponseResponseDataArray> for Attestation { + type Error = ConversionError; + + fn try_from( + value: &crate::GetBlockAttestationsV2ResponseResponseDataArray, + ) -> Result { + const COMMITTEE_BITS_FIELD: &str = "attestation.committee_bits"; + let committee_bits = as ssz::Decode>::from_ssz_bytes(&decode_hex_var( + &value.committee_bits, + COMMITTEE_BITS_FIELD, + )?) + .map_err(|_| ConversionError::DecodeHex { + field: COMMITTEE_BITS_FIELD, + })?; + + Ok(Self { + aggregation_bits: BitList::from_ssz_bytes(decode_hex_var( + &value.aggregation_bits, + "attestation.aggregation_bits", + )?), + data: phase0::AttestationData::try_from(&value.data)?, + signature: decode_hex_fixed(&value.signature, "attestation.signature")?, + committee_bits, + }) + } +} + /// Execution-layer deposit request. /// /// Spec: @@ -370,4 +400,30 @@ mod tests { serde_json::from_value(json).expect("deserialize indexed attestation"); assert_eq!(roundtrip.attesting_indices.0, vec![21, 22]); } + + #[test] + fn attestation_try_from_matches_json_roundtrip() { + let wire = serde_json::json!({ + "aggregation_bits": "0x0102", + "committee_bits": format!("0x{}", "00".repeat(8)), + "data": { + "slot": "42", + "index": "3", + "beacon_block_root": format!("0x{}", "11".repeat(32)), + "source": { "epoch": "5", "root": format!("0x{}", "22".repeat(32)) }, + "target": { "epoch": "6", "root": format!("0x{}", "33".repeat(32)) }, + }, + "signature": format!("0x{}", "44".repeat(96)), + }); + let generated: crate::GetBlockAttestationsV2ResponseResponseDataArray = + serde_json::from_value(wire.clone()).expect("deserialize generated attestation"); + + // Direct conversion must equal the loosely-typed JSON round-trip it + // replaces. + let direct = super::Attestation::try_from(&generated).expect("convert"); + let via_json: super::Attestation = serde_json::from_value(wire).expect("json round-trip"); + assert_eq!(direct, via_json); + assert_eq!(direct.data.slot, 42); + assert_eq!(direct.signature, [0x44; 96]); + } } From 48fc7b976f270e51369e1d36677c9f61b3f1d30c Mon Sep 17 00:00:00 2001 From: "emlautarom1-agent[bot]" <292495798+emlautarom1-agent[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 13:49:14 -0300 Subject: [PATCH 13/30] refactor(core): replace fetcher JSON round-trips with direct TryFrom Drop the round_trip helper and convert beacon-node responses into spec types via the new TryFrom impls: attestation_data and sync_committee_contribution call them directly, and attestation_payload matches on (version, untagged attestation variant) before dispatching. Add FetcherError::Conversion to surface conversion failures. This avoids the double serde pass through serde_json::Value, makes the mapping compile-checked, and matches Charon, whose go-eth2-client returns typed structs directly. --- crates/core/src/fetcher/mod.rs | 69 +++++++++++++++++++--------------- 1 file changed, 38 insertions(+), 31 deletions(-) diff --git a/crates/core/src/fetcher/mod.rs b/crates/core/src/fetcher/mod.rs index e3867f36..33377bb7 100644 --- a/crates/core/src/fetcher/mod.rs +++ b/crates/core/src/fetcher/mod.rs @@ -11,10 +11,11 @@ use std::{any::Any, collections::HashMap, future::Future, pin::Pin, sync::Arc}; use pluto_eth2api::{ ConsensusVersion, EthBeaconNodeApiClient, EthBeaconNodeApiClientError, GetAggregatedAttestationV2Request, GetAggregatedAttestationV2Response, - ProduceAttestationDataRequest, ProduceAttestationDataResponse, ProduceBlockV3Request, - ProduceBlockV3Response, ProduceBlockV3ResponseResponse, - ProduceSyncCommitteeContributionRequest, ProduceSyncCommitteeContributionResponse, - spec::{altair, phase0}, + GetAggregatedAttestationV2ResponseResponseData, ProduceAttestationDataRequest, + ProduceAttestationDataResponse, ProduceBlockV3Request, ProduceBlockV3Response, + ProduceBlockV3ResponseResponse, ProduceSyncCommitteeContributionRequest, + ProduceSyncCommitteeContributionResponse, + spec::{ConversionError, altair, phase0}, versioned, }; use pluto_eth2util::eth2exp::{self, Eth2ExpError}; @@ -120,6 +121,10 @@ pub enum FetcherError { #[error("decode beacon node response: {0}")] Json(#[from] serde_json::Error), + /// Failed to convert a loosely-typed beacon node value into a spec type. + #[error("convert beacon node response: {0}")] + Conversion(#[from] ConversionError), + /// A versioned proposal had an unsupported fork version. #[error("unsupported proposal version: {0:?}")] UnsupportedProposalVersion(ConsensusVersion), @@ -467,7 +472,9 @@ impl Fetcher { .await .map_err(EthBeaconNodeApiClientError::RequestError)? { - ProduceAttestationDataResponse::Ok(ok) => round_trip(&ok.data), + ProduceAttestationDataResponse::Ok(ok) => { + Ok(phase0::AttestationData::try_from(&ok.data)?) + } _ => Err(FetcherError::NilAttestationData), } } @@ -526,7 +533,9 @@ impl Fetcher { .await .map_err(EthBeaconNodeApiClientError::RequestError)? { - ProduceSyncCommitteeContributionResponse::Ok(payload) => round_trip(&payload.data), + ProduceSyncCommitteeContributionResponse::Ok(payload) => { + Ok(altair::SyncCommitteeContribution::try_from(&payload.data)?) + } _ => Err(FetcherError::SyncContributionNotFound), } } @@ -565,17 +574,6 @@ fn hex_0x(bytes: &[u8]) -> String { format!("0x{}", hex::encode(bytes)) } -/// Round-trips a loosely-typed beacon node response value into a strongly-typed -/// target via JSON. -fn round_trip(value: &S) -> Result -where - T: serde::de::DeserializeOwned, - S: serde::Serialize, -{ - let value = serde_json::to_value(value)?; - Ok(serde_json::from_value(value)?) -} - /// Converts a `produce_block_v3` response into an unsigned /// [`VersionedProposal`]. fn versioned_proposal_from_response( @@ -641,23 +639,32 @@ fn consensus_to_data_version(version: &ConsensusVersion) -> versioned::DataVersi } } -/// Builds a versioned attestation payload from a loosely-typed response value. -fn attestation_payload( +/// Builds a versioned attestation payload from the beacon node's aggregate +/// attestation response. +/// +/// The response carries the attestation as an untagged union: `Object2` is the +/// phase0-style attestation returned up to Deneb, `Object` is the +/// committee-aware Electra shape returned from Electra onwards. +fn attestation_payload( version: versioned::DataVersion, - data: &S, + data: &GetAggregatedAttestationV2ResponseResponseData, ) -> Result { + use GetAggregatedAttestationV2ResponseResponseData as GenData; use versioned::{AttestationPayload as AP, DataVersion as DV}; - Ok(match version { - DV::Phase0 => AP::Phase0(round_trip(data)?), - DV::Altair => AP::Altair(round_trip(data)?), - DV::Bellatrix => AP::Bellatrix(round_trip(data)?), - DV::Capella => AP::Capella(round_trip(data)?), - DV::Deneb => AP::Deneb(round_trip(data)?), - DV::Electra => AP::Electra(round_trip(data)?), - DV::Fulu => AP::Fulu(round_trip(data)?), - // `version` is always derived from a `ConsensusVersion` via - // `consensus_to_data_version`, which never yields `Unknown`. - DV::Unknown => unreachable!("attestation payload version cannot be unknown"), + + Ok(match (version, data) { + (DV::Phase0, GenData::Object2(att)) => AP::Phase0(att.try_into()?), + (DV::Altair, GenData::Object2(att)) => AP::Altair(att.try_into()?), + (DV::Bellatrix, GenData::Object2(att)) => AP::Bellatrix(att.try_into()?), + (DV::Capella, GenData::Object2(att)) => AP::Capella(att.try_into()?), + (DV::Deneb, GenData::Object2(att)) => AP::Deneb(att.try_into()?), + (DV::Electra, GenData::Object(att)) => AP::Electra(att.try_into()?), + (DV::Fulu, GenData::Object(att)) => AP::Fulu(att.try_into()?), + // A spec-compliant beacon node never pairs a fork version with the + // other fork's attestation shape (e.g. an Electra version reporting a + // phase0-style body), and `version` is derived from a + // `ConsensusVersion`, so it is never `Unknown`. + _ => return Err(FetcherError::UnexpectedResponse), }) } From ff1a9e12f7df250abaafc071a8088652cdc8b73d Mon Sep 17 00:00:00 2001 From: Lautaro Emanuel Date: Wed, 17 Jun 2026 16:36:21 -0300 Subject: [PATCH 14/30] Make `PubkeysTracker` log on drop --- crates/core/src/fetcher/mod.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/crates/core/src/fetcher/mod.rs b/crates/core/src/fetcher/mod.rs index 33377bb7..3f6c209e 100644 --- a/crates/core/src/fetcher/mod.rs +++ b/crates/core/src/fetcher/mod.rs @@ -338,8 +338,6 @@ impl Fetcher { ); } - tracker.log(); - Ok(resp) } @@ -451,8 +449,6 @@ impl Fetcher { ); } - tracker.log(); - Ok(resp) } @@ -796,6 +792,13 @@ impl PubkeysTracker { } } +impl Drop for PubkeysTracker { + fn drop(&mut self) { + // Log at the end of scope + self.log(); + } +} + #[cfg(test)] mod tests { use std::sync::Mutex; From 39e6136dcb7ff0b42f9f477e4cef75adb6eb50f7 Mon Sep 17 00:00:00 2001 From: Lautaro Emanuel Date: Wed, 17 Jun 2026 16:38:09 -0300 Subject: [PATCH 15/30] Use full path for `std::any::Any` --- crates/core/src/fetcher/mod.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/core/src/fetcher/mod.rs b/crates/core/src/fetcher/mod.rs index 3f6c209e..bbe796c6 100644 --- a/crates/core/src/fetcher/mod.rs +++ b/crates/core/src/fetcher/mod.rs @@ -6,7 +6,7 @@ mod graffiti; pub use graffiti::{GraffitiBuilder, GraffitiError, client_graffiti_mappings}; -use std::{any::Any, collections::HashMap, future::Future, pin::Pin, sync::Arc}; +use std::{collections::HashMap, future::Future, pin::Pin, sync::Arc}; use pluto_eth2api::{ ConsensusVersion, EthBeaconNodeApiClient, EthBeaconNodeApiClientError, @@ -562,7 +562,7 @@ fn wrap(context: &'static str) -> impl Fn(FetcherError) -> FetcherError { /// Downcasts a `&dyn SignedData` to a concrete signed-data type. fn downcast(data: &dyn SignedData) -> Option<&T> { - (data as &dyn Any).downcast_ref::() + (data as &dyn std::any::Any).downcast_ref::() } /// Formats bytes as a `0x`-prefixed lowercase hex string. From 521798259c8ee5a404bf60a60562a50f0c862e64 Mon Sep 17 00:00:00 2001 From: "emlautarom1-agent[bot]" <292495798+emlautarom1-agent[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 16:46:42 -0300 Subject: [PATCH 16/30] refactor(core): move proposal decoding into VersionedProposal::try_from Relocate versioned_proposal_from_response from the fetcher to signeddata, co-located with VersionedProposal as TryFrom<&ProduceBlockV3ResponseResponse>. The block-contents JSON layout is a property of the proposal type, not the fetcher, which now just calls VersionedProposal::try_from. The conversion returns SignedDataError (gaining InvalidBlockValue and MissingBlockField variants); FetcherError maps it via a new SignedData(#[from]) variant and drops the two now-orphaned proposal variants. The json_from/block_field/json_from_field helpers move along. --- crates/core/src/fetcher/mod.rs | 96 +++-------------------------- crates/core/src/signeddata.rs | 107 +++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 87 deletions(-) diff --git a/crates/core/src/fetcher/mod.rs b/crates/core/src/fetcher/mod.rs index bbe796c6..cf0f908b 100644 --- a/crates/core/src/fetcher/mod.rs +++ b/crates/core/src/fetcher/mod.rs @@ -13,8 +13,7 @@ use pluto_eth2api::{ GetAggregatedAttestationV2Request, GetAggregatedAttestationV2Response, GetAggregatedAttestationV2ResponseResponseData, ProduceAttestationDataRequest, ProduceAttestationDataResponse, ProduceBlockV3Request, ProduceBlockV3Response, - ProduceBlockV3ResponseResponse, ProduceSyncCommitteeContributionRequest, - ProduceSyncCommitteeContributionResponse, + ProduceSyncCommitteeContributionRequest, ProduceSyncCommitteeContributionResponse, spec::{ConversionError, altair, phase0}, versioned, }; @@ -24,9 +23,9 @@ use tree_hash::TreeHash; use crate::{ signeddata::{ - AttestationData, BeaconCommitteeSelection, ProposalBlock, SignedSyncMessage, - SyncCommitteeSelection, SyncContribution, VersionedAggregatedAttestation, - VersionedProposal, + AttestationData, BeaconCommitteeSelection, ProposalBlock, SignedDataError, + SignedSyncMessage, SyncCommitteeSelection, SyncContribution, + VersionedAggregatedAttestation, VersionedProposal, }, types::{Duty, DutyDefinition, DutyDefinitionSet, DutyType, PubKey, SignedData}, unsigneddata::{UnsignedDataSet, UnsignedDutyData}, @@ -125,18 +124,14 @@ pub enum FetcherError { #[error("convert beacon node response: {0}")] Conversion(#[from] ConversionError), + /// Failed to decode a beacon node response into a signed-data type. + #[error("decode proposal: {0}")] + SignedData(#[from] SignedDataError), + /// A versioned proposal had an unsupported fork version. #[error("unsupported proposal version: {0:?}")] UnsupportedProposalVersion(ConsensusVersion), - /// A versioned proposal response was missing the `block` field. - #[error("proposal response missing block field")] - MissingBlockField, - - /// A versioned proposal response carried an unparsable block value. - #[error("invalid proposal block value: {0}")] - InvalidBlockValue(&'static str), - /// A signed data value could not produce a signature. #[error("signature: {0}")] Signature(String), @@ -380,7 +375,7 @@ impl Fetcher { _ => return Err(FetcherError::UnexpectedResponse), }; - let proposal = versioned_proposal_from_response(&response)?; + let proposal = VersionedProposal::try_from(&response)?; // Builders set the fee recipient to themselves, so it always differs // from the validator's; only verify when the builder is disabled. @@ -570,57 +565,6 @@ fn hex_0x(bytes: &[u8]) -> String { format!("0x{}", hex::encode(bytes)) } -/// Converts a `produce_block_v3` response into an unsigned -/// [`VersionedProposal`]. -fn versioned_proposal_from_response( - resp: &ProduceBlockV3ResponseResponse, -) -> Result { - let data = serde_json::to_value(&resp.data)?; - let blinded = resp.execution_payload_blinded; - - let block = match (&resp.version, blinded) { - (ConsensusVersion::Phase0, _) => ProposalBlock::Phase0(json_from(&data)?), - (ConsensusVersion::Altair, _) => ProposalBlock::Altair(json_from(&data)?), - (ConsensusVersion::Bellatrix, false) => ProposalBlock::Bellatrix(json_from(&data)?), - (ConsensusVersion::Bellatrix, true) => ProposalBlock::BellatrixBlinded(json_from(&data)?), - (ConsensusVersion::Capella, false) => ProposalBlock::Capella(json_from(&data)?), - (ConsensusVersion::Capella, true) => ProposalBlock::CapellaBlinded(json_from(&data)?), - (ConsensusVersion::Deneb, false) => ProposalBlock::Deneb { - block: Box::new(json_from(block_field(&data)?)?), - kzg_proofs: json_from_field(&data, "kzg_proofs")?, - blobs: json_from_field(&data, "blobs")?, - }, - (ConsensusVersion::Deneb, true) => ProposalBlock::DenebBlinded(json_from(&data)?), - (ConsensusVersion::Electra, false) => ProposalBlock::Electra { - block: Box::new(json_from(block_field(&data)?)?), - kzg_proofs: json_from_field(&data, "kzg_proofs")?, - blobs: json_from_field(&data, "blobs")?, - }, - (ConsensusVersion::Electra, true) => ProposalBlock::ElectraBlinded(json_from(&data)?), - (ConsensusVersion::Fulu, false) => ProposalBlock::Fulu { - block: Box::new(json_from(block_field(&data)?)?), - kzg_proofs: json_from_field(&data, "kzg_proofs")?, - blobs: json_from_field(&data, "blobs")?, - }, - (ConsensusVersion::Fulu, true) => ProposalBlock::FuluBlinded(json_from(&data)?), - }; - - let consensus_block_value = resp - .consensus_block_value - .parse() - .map_err(|_| FetcherError::InvalidBlockValue("consensus_block_value"))?; - let execution_payload_value = resp - .execution_payload_value - .parse() - .map_err(|_| FetcherError::InvalidBlockValue("execution_payload_value"))?; - - Ok(VersionedProposal { - block, - consensus_block_value, - execution_payload_value, - }) -} - /// Maps a beacon node `ConsensusVersion` onto a `versioned::DataVersion`. fn consensus_to_data_version(version: &ConsensusVersion) -> versioned::DataVersion { use versioned::DataVersion as DV; @@ -664,28 +608,6 @@ fn attestation_payload( }) } -/// Deserializes a JSON value into `T`. -fn json_from(value: &serde_json::Value) -> Result { - Ok(serde_json::from_value(value.clone())?) -} - -/// Returns the `block` field of a Deneb+ versioned block contents object. -fn block_field(value: &serde_json::Value) -> Result<&serde_json::Value> { - value.get("block").ok_or(FetcherError::MissingBlockField) -} - -/// Deserializes the named field of `value` into `T`, defaulting to `T::default` -/// when absent. -fn json_from_field( - value: &serde_json::Value, - field: &str, -) -> Result { - match value.get(field) { - Some(v) => Ok(serde_json::from_value(v.clone())?), - None => Ok(T::default()), - } -} - /// Logs a warning when the fee recipient is not correctly populated in the /// proposal. Fee recipient is unavailable in forks earlier than Bellatrix. fn verify_fee_recipient(proposal: &VersionedProposal, fee_recipient_address: &str) { diff --git a/crates/core/src/signeddata.rs b/crates/core/src/signeddata.rs index 2fc4ed95..6eb84785 100644 --- a/crates/core/src/signeddata.rs +++ b/crates/core/src/signeddata.rs @@ -5,6 +5,7 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer}; use tree_hash::TreeHash; use pluto_eth2api::{ + ConsensusVersion, ProduceBlockV3ResponseResponse, spec::{ altair, bellatrix, capella, deneb, electra, phase0, serde_legacy_builder_version, serde_legacy_data_version, @@ -51,6 +52,12 @@ pub enum SignedDataError { /// Invalid attestation wrapper JSON. #[error("unmarshal attestation")] AttestationJson, + /// A proposal response carried an unparsable block reward value. + #[error("invalid proposal block value: {0}")] + InvalidBlockValue(&'static str), + /// A versioned proposal response was missing the `block` field. + #[error("proposal response missing block field")] + MissingBlockField, /// Custom error. #[error("{0}")] Custom(Box), @@ -1367,6 +1374,85 @@ impl VersionedProposal { } } +impl TryFrom<&ProduceBlockV3ResponseResponse> for VersionedProposal { + type Error = SignedDataError; + + /// Builds an unsigned proposal from a `produce_block_v3` response, + /// selecting the block variant by `(version, blinded)`. + fn try_from(resp: &ProduceBlockV3ResponseResponse) -> Result { + let data = serde_json::to_value(&resp.data)?; + let blinded = resp.execution_payload_blinded; + + let block = match (&resp.version, blinded) { + (ConsensusVersion::Phase0, _) => ProposalBlock::Phase0(json_from(&data)?), + (ConsensusVersion::Altair, _) => ProposalBlock::Altair(json_from(&data)?), + (ConsensusVersion::Bellatrix, false) => ProposalBlock::Bellatrix(json_from(&data)?), + (ConsensusVersion::Bellatrix, true) => { + ProposalBlock::BellatrixBlinded(json_from(&data)?) + } + (ConsensusVersion::Capella, false) => ProposalBlock::Capella(json_from(&data)?), + (ConsensusVersion::Capella, true) => ProposalBlock::CapellaBlinded(json_from(&data)?), + (ConsensusVersion::Deneb, false) => ProposalBlock::Deneb { + block: Box::new(json_from(block_field(&data)?)?), + kzg_proofs: json_from_field(&data, "kzg_proofs")?, + blobs: json_from_field(&data, "blobs")?, + }, + (ConsensusVersion::Deneb, true) => ProposalBlock::DenebBlinded(json_from(&data)?), + (ConsensusVersion::Electra, false) => ProposalBlock::Electra { + block: Box::new(json_from(block_field(&data)?)?), + kzg_proofs: json_from_field(&data, "kzg_proofs")?, + blobs: json_from_field(&data, "blobs")?, + }, + (ConsensusVersion::Electra, true) => ProposalBlock::ElectraBlinded(json_from(&data)?), + (ConsensusVersion::Fulu, false) => ProposalBlock::Fulu { + block: Box::new(json_from(block_field(&data)?)?), + kzg_proofs: json_from_field(&data, "kzg_proofs")?, + blobs: json_from_field(&data, "blobs")?, + }, + (ConsensusVersion::Fulu, true) => ProposalBlock::FuluBlinded(json_from(&data)?), + }; + + let consensus_block_value = resp + .consensus_block_value + .parse() + .map_err(|_| SignedDataError::InvalidBlockValue("consensus_block_value"))?; + let execution_payload_value = resp + .execution_payload_value + .parse() + .map_err(|_| SignedDataError::InvalidBlockValue("execution_payload_value"))?; + + Ok(VersionedProposal { + block, + consensus_block_value, + execution_payload_value, + }) + } +} + +/// Deserializes a JSON value into `T`. +fn json_from( + value: &serde_json::Value, +) -> Result { + Ok(serde_json::from_value(value.clone())?) +} + +/// Returns the `block` field of a Deneb+ versioned block contents object. +fn block_field(value: &serde_json::Value) -> Result<&serde_json::Value, SignedDataError> { + value.get("block").ok_or(SignedDataError::MissingBlockField) +} + +/// Deserializes the named field of `value` into `T`, defaulting to `T::default` +/// when absent. +fn json_from_field( + value: &serde_json::Value, + field: &str, +) -> Result { + match value.get(field) { + Some(v) => Ok(serde_json::from_value(v.clone())?), + None => Ok(T::default()), + } +} + #[cfg(test)] mod tests { use super::*; @@ -2825,4 +2911,25 @@ mod tests { assert_eq!(Some(aggregation_bits.clone()), wrapped.aggregation_bits()); } } + + #[test] + fn versioned_proposal_from_produce_block_response() { + // Electra block contents `{block, kzg_proofs, blobs}` from the golden + // fixture, wrapped as a `produce_block_v3` response. + let golden = load_signeddata_fixture("TestJSONSerialisation_VersionedProposal.json.golden"); + let resp: ProduceBlockV3ResponseResponse = serde_json::from_value(serde_json::json!({ + "version": "electra", + "execution_payload_blinded": false, + "execution_payload_value": "11", + "consensus_block_value": "22", + "data": golden["block"], + })) + .expect("deserialize produce_block_v3 response"); + + let proposal = VersionedProposal::try_from(&resp).expect("convert"); + assert!(matches!(proposal.block, ProposalBlock::Electra { .. })); + assert_eq!(proposal.version(), versioned::DataVersion::Electra); + assert_eq!(proposal.execution_payload_value, U256::from(11)); + assert_eq!(proposal.consensus_block_value, U256::from(22)); + } } From 05985705de736b188914af9a99677f73b4c429a6 Mon Sep 17 00:00:00 2001 From: "emlautarom1-agent[bot]" <292495798+emlautarom1-agent[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 17:00:05 -0300 Subject: [PATCH 17/30] refactor(eth2api): move ConsensusVersion->DataVersion mapping into version.rs Relocate the fetcher's consensus_to_data_version into spec/version.rs as From<&ConsensusVersion> for DataVersion, co-located with DataVersion. The mapping is total (ConsensusVersion has no Unknown variant), so a plain From fits. The fetcher now calls versioned::DataVersion::from. --- crates/core/src/fetcher/mod.rs | 16 +--------------- crates/eth2api/src/spec/version.rs | 29 +++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 15 deletions(-) diff --git a/crates/core/src/fetcher/mod.rs b/crates/core/src/fetcher/mod.rs index cf0f908b..47a14ee8 100644 --- a/crates/core/src/fetcher/mod.rs +++ b/crates/core/src/fetcher/mod.rs @@ -496,7 +496,7 @@ impl Fetcher { _ => return Err(FetcherError::AggregateAttestationNotFound), }; - let version = consensus_to_data_version(&ok.version); + let version = versioned::DataVersion::from(&ok.version); Ok(versioned::VersionedAttestation { version, validator_index: None, @@ -565,20 +565,6 @@ fn hex_0x(bytes: &[u8]) -> String { format!("0x{}", hex::encode(bytes)) } -/// Maps a beacon node `ConsensusVersion` onto a `versioned::DataVersion`. -fn consensus_to_data_version(version: &ConsensusVersion) -> versioned::DataVersion { - use versioned::DataVersion as DV; - match version { - ConsensusVersion::Phase0 => DV::Phase0, - ConsensusVersion::Altair => DV::Altair, - ConsensusVersion::Bellatrix => DV::Bellatrix, - ConsensusVersion::Capella => DV::Capella, - ConsensusVersion::Deneb => DV::Deneb, - ConsensusVersion::Electra => DV::Electra, - ConsensusVersion::Fulu => DV::Fulu, - } -} - /// Builds a versioned attestation payload from the beacon node's aggregate /// attestation response. /// diff --git a/crates/eth2api/src/spec/version.rs b/crates/eth2api/src/spec/version.rs index 25b47e6e..bc4892fc 100644 --- a/crates/eth2api/src/spec/version.rs +++ b/crates/eth2api/src/spec/version.rs @@ -2,6 +2,8 @@ use core::fmt; use serde::{Deserialize, Serialize}; +use crate::ConsensusVersion; + /// Error returned when converting unknown data or builder versions. #[derive(Debug, thiserror::Error, Clone, Copy, PartialEq, Eq)] pub enum VersionError { @@ -86,6 +88,22 @@ impl fmt::Display for DataVersion { } } +impl From<&ConsensusVersion> for DataVersion { + /// Maps a beacon-node `ConsensusVersion` onto the corresponding data + /// version. Total: `ConsensusVersion` has no `Unknown` variant. + fn from(version: &ConsensusVersion) -> Self { + match version { + ConsensusVersion::Phase0 => DataVersion::Phase0, + ConsensusVersion::Altair => DataVersion::Altair, + ConsensusVersion::Bellatrix => DataVersion::Bellatrix, + ConsensusVersion::Capella => DataVersion::Capella, + ConsensusVersion::Deneb => DataVersion::Deneb, + ConsensusVersion::Electra => DataVersion::Electra, + ConsensusVersion::Fulu => DataVersion::Fulu, + } + } +} + /// Builder API version. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] @@ -210,6 +228,17 @@ mod tests { use super::*; use test_case::test_case; + #[test_case(ConsensusVersion::Phase0, DataVersion::Phase0 ; "phase0")] + #[test_case(ConsensusVersion::Altair, DataVersion::Altair ; "altair")] + #[test_case(ConsensusVersion::Bellatrix, DataVersion::Bellatrix ; "bellatrix")] + #[test_case(ConsensusVersion::Capella, DataVersion::Capella ; "capella")] + #[test_case(ConsensusVersion::Deneb, DataVersion::Deneb ; "deneb")] + #[test_case(ConsensusVersion::Electra, DataVersion::Electra ; "electra")] + #[test_case(ConsensusVersion::Fulu, DataVersion::Fulu ; "fulu")] + fn data_version_from_consensus_version(consensus: ConsensusVersion, expected: DataVersion) { + assert_eq!(DataVersion::from(&consensus), expected); + } + #[test_case(DataVersion::Phase0, "\"phase0\"" ; "phase0")] #[test_case(DataVersion::Deneb, "\"deneb\"" ; "deneb")] #[test_case(DataVersion::Fulu, "\"fulu\"" ; "fulu")] From f83d8b390176d67938eaa7201a8620a8226cd637 Mon Sep 17 00:00:00 2001 From: Lautaro Emanuel Date: Wed, 17 Jun 2026 17:26:17 -0300 Subject: [PATCH 18/30] Cleanup imports --- Cargo.lock | 717 +++++++++++----------------- crates/core/src/fetcher/graffiti.rs | 2 - crates/core/src/fetcher/mod.rs | 9 +- 3 files changed, 281 insertions(+), 447 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index da4a39b1..abc0a032 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -419,7 +419,7 @@ dependencies = [ "lru", "parking_lot", "pin-project", - "reqwest 0.13.3", + "reqwest 0.13.4", "serde", "serde_json", "thiserror 2.0.18", @@ -448,7 +448,7 @@ checksum = "f36834a5c0a2fa56e171bf256c34d70fca07d0c0031583edea1c4946b7889c9e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -463,7 +463,7 @@ dependencies = [ "alloy-transport-http", "futures", "pin-project", - "reqwest 0.13.3", + "reqwest 0.13.4", "serde", "serde_json", "tokio", @@ -571,7 +571,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -589,7 +589,7 @@ dependencies = [ "proc-macro2", "quote", "sha3 0.11.0", - "syn 2.0.117", + "syn 2.0.118", "syn-solidity", ] @@ -607,7 +607,7 @@ dependencies = [ "proc-macro2", "quote", "serde_json", - "syn 2.0.117", + "syn 2.0.118", "syn-solidity", ] @@ -665,7 +665,7 @@ dependencies = [ "alloy-json-rpc", "alloy-transport", "itertools 0.14.0", - "reqwest 0.13.3", + "reqwest 0.13.4", "serde_json", "tower", "tracing", @@ -697,7 +697,7 @@ dependencies = [ "darling 0.23.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -856,7 +856,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62945a2f7e6de02a31fe400aa489f0e0f5b2502e69f95f853adb82a96c7a6b60" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -894,7 +894,7 @@ dependencies = [ "num-traits", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -996,7 +996,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "synstructure", ] @@ -1008,7 +1008,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1092,7 +1092,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1103,7 +1103,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1145,14 +1145,14 @@ checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" @@ -1281,7 +1281,7 @@ checksum = "7b9a5040dce49a7642c97ccb1ae59567098967b5d52c29773f1299a42d23bb39" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1307,15 +1307,15 @@ checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" [[package]] name = "bitcoin-io" -version = "0.1.4" +version = "0.1.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dee39a0ee5b4095224a0cfc6bf4cc1baf0f9624b96b367e53b66d974e51d953" +checksum = "11301df0b06f22dea7bb1916403fdd88a371031e495c49b8f96931b28189e175" [[package]] name = "bitcoin_hashes" -version = "0.14.1" +version = "0.14.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b" +checksum = "0c9901a56e133a1fc86eeb1113e2591f45f4682451ca893bff494d2f88918e3f" dependencies = [ "bitcoin-io", "hex-conservative", @@ -1323,9 +1323,9 @@ dependencies = [ [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "bitvec" @@ -1359,9 +1359,9 @@ dependencies = [ [[package]] name = "block-buffer" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ "hybrid-array", ] @@ -1429,8 +1429,8 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85a885520bf6249ab931a764ffdb87b0ceef48e6e7d807cfdb21b751e086e1ad" dependencies = [ - "prost 0.14.3", - "prost-types 0.14.3", + "prost 0.14.4", + "prost-types 0.14.4", "tonic", "tonic-prost", "ureq", @@ -1445,7 +1445,7 @@ dependencies = [ "base64", "bollard-buildkit-proto", "bytes", - "prost 0.14.3", + "prost 0.14.4", "serde", "serde_json", "serde_repr", @@ -1454,9 +1454,9 @@ dependencies = [ [[package]] name = "bon" -version = "3.9.1" +version = "3.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f47dbe92550676ee653353c310dfb9cf6ba17ee70396e1f7cf0a2020ad49b2fe" +checksum = "a602c73c7b0148ec6d12af6fd5cc7a46e2eacc8878271a999abac56eed12f561" dependencies = [ "bon-macros", "rustversion", @@ -1464,9 +1464,9 @@ dependencies = [ [[package]] name = "bon-macros" -version = "3.9.1" +version = "3.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "519bd3116aeeb42d5372c29d982d16d0170d3d4a5ed85fc7dd91642ffff3c67c" +checksum = "6dee98b0db6a962de883bf5d20362dee4d7ca0d12fe39a7c6c73c844e1cd7c1f" dependencies = [ "darling 0.23.0", "ident_case", @@ -1474,7 +1474,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1498,7 +1498,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1512,9 +1512,9 @@ dependencies = [ [[package]] name = "built" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64" +checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" dependencies = [ "cargo-lock", "chrono", @@ -1523,9 +1523,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "byte-slice-cast" @@ -1571,9 +1571,9 @@ checksum = "f7a879c84c21f354f13535f87ad119ac3be22ebb9097b552a0af6a78f86628c4" [[package]] name = "cargo-lock" -version = "10.1.0" +version = "11.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c06acb4f71407ba205a07cb453211e0e6a67b21904e47f6ba1f9589e38f2e454" +checksum = "63585cdf8572aa7adf0e30a253f988f2b77233bfac1973d52efb6dd53a75920e" dependencies = [ "semver 1.0.28", "serde", @@ -1598,9 +1598,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.62" +version = "1.2.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" dependencies = [ "find-msvc-tools", "jobserver", @@ -1657,9 +1657,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -1738,7 +1738,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1789,9 +1789,9 @@ dependencies = [ [[package]] name = "const-hex" -version = "1.19.0" +version = "1.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20d9a563d167a9cce0f94153382b33cb6eded6dfabff03c69ad65a28ea1514e0" +checksum = "33e2a781ebdf4467d1428dc4593067825fb646f6871475098d8577421af73558" dependencies = [ "cfg-if", "cpufeatures 0.2.17", @@ -2051,7 +2051,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2098,7 +2098,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2132,7 +2132,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2146,7 +2146,7 @@ dependencies = [ "quote", "serde", "strsim", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2157,7 +2157,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2168,7 +2168,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core 0.23.0", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2208,7 +2208,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2260,7 +2260,6 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ - "powerfmt", "serde_core", ] @@ -2294,7 +2293,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version 0.4.1", - "syn 2.0.117", + "syn 2.0.118", "unicode-xid", ] @@ -2325,19 +2324,19 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer 0.12.0", + "block-buffer 0.12.1", "crypto-common 0.2.2", ] [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2423,7 +2422,7 @@ dependencies = [ "enum-ordinalize", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2483,7 +2482,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2503,7 +2502,7 @@ checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2580,7 +2579,7 @@ dependencies = [ "darling 0.23.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2844,7 +2843,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2954,16 +2953,14 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", "rand_core 0.10.1", - "wasip2", - "wasip3", ] [[package]] @@ -2978,15 +2975,14 @@ dependencies = [ [[package]] name = "git2" -version = "0.20.4" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" +checksum = "ddddbf932745a6be37109b6112d3ee09696106f848449069d3a57bba937ab82e" dependencies = [ "bitflags", "libc", "libgit2-sys", "log", - "url", ] [[package]] @@ -3032,9 +3028,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.14" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" dependencies = [ "atomic-waker", "bytes", @@ -3126,9 +3122,9 @@ dependencies = [ [[package]] name = "hashlink" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" dependencies = [ "hashbrown 0.16.1", ] @@ -3248,9 +3244,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.0" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" dependencies = [ "bytes", "itoa", @@ -3308,9 +3304,9 @@ dependencies = [ [[package]] name = "hyper" -version = "1.9.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", @@ -3404,7 +3400,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.3", + "socket2 0.6.4", "system-configuration", "tokio", "tower-service", @@ -3533,12 +3529,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "ident_case" version = "1.0.1" @@ -3637,7 +3627,7 @@ checksum = "a0eb5a3343abf848c0984fe4604b2b105da9539376e24fc0a3b0007411ae4fd9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3678,7 +3668,7 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" dependencies = [ - "socket2 0.6.3", + "socket2 0.6.4", "widestring", "windows-registry", "windows-result 0.4.1", @@ -3757,7 +3747,7 @@ dependencies = [ "quote", "rustc_version 0.4.1", "simd_cesu8", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3776,7 +3766,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3791,13 +3781,12 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.98" +version = "0.3.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] @@ -3837,9 +3826,9 @@ dependencies = [ [[package]] name = "keccak-asm" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1766b89733097006f3a1388a02849865d6bc98c89273cb622e29fdd209922183" +checksum = "dd5dc2c0d691cbf7595cde551ced329cca99c2387c2cbc97754c5d0cd045d3ee" dependencies = [ "digest 0.10.7", "sha3-asm", @@ -3866,12 +3855,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "libc" version = "0.2.186" @@ -3880,9 +3863,9 @@ checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libgit2-sys" -version = "0.18.4+1.9.3" +version = "0.18.5+1.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b26f66f35e1871b22efcf7191564123d2a446ca0538cde63c23adfefa9b15b7" +checksum = "005d6ae6eac1912906073e069f7db60b1fa98e052a68227824afe3e3a1c59ca2" dependencies = [ "cc", "libc", @@ -4131,9 +4114,9 @@ dependencies = [ [[package]] name = "libp2p-identity" -version = "0.2.13" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0c7892c221730ba55f7196e98b0b8ba5e04b4155651736036628e9f73ed6fc3" +checksum = "9525f3831544f7ae497bde79adf114ef127b0fbbb97edbbf692a80408636421c" dependencies = [ "asn1_der", "bs58", @@ -4142,7 +4125,7 @@ dependencies = [ "k256", "multihash", "p256", - "quick-protobuf", + "prost 0.14.4", "rand 0.8.6", "ring", "sec1", @@ -4361,7 +4344,7 @@ dependencies = [ "bimap", "futures", "futures-timer", - "hashlink 0.11.0", + "hashlink 0.11.1", "libp2p-core", "libp2p-identity", "libp2p-request-response", @@ -4426,7 +4409,7 @@ checksum = "dd297cf53f0cb3dee4d2620bb319ae47ef27c702684309f682bdb7e55a18ae9c" dependencies = [ "heck", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4440,7 +4423,7 @@ dependencies = [ "if-watch", "libc", "libp2p-core", - "socket2 0.6.3", + "socket2 0.6.4", "tokio", "tracing", ] @@ -4610,9 +4593,9 @@ dependencies = [ [[package]] name = "libz-sys" -version = "1.1.28" +version = "1.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc3a226e576f50782b3305c5ccf458698f92798987f551c6a02efe8276721e22" +checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9" dependencies = [ "cc", "libc", @@ -4643,9 +4626,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" [[package]] name = "loki-api" @@ -4680,7 +4663,7 @@ checksum = "59a9dbbfc75d2688ed057456ce8a3ee3f48d12eec09229f560f3643b9f275653" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4691,7 +4674,7 @@ checksum = "757aee279b8bdbb9f9e676796fd459e4207a1f986e87886700abf589f5abf771" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4711,9 +4694,9 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" [[package]] name = "memory-stats" @@ -4759,9 +4742,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "wasi 0.11.1+wasi-snapshot-preview1", @@ -5065,7 +5048,7 @@ checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5120,12 +5103,12 @@ dependencies = [ "quick-xml 0.40.1", "quote", "regex", - "reqwest 0.13.3", + "reqwest 0.13.4", "serde", "serde_json", "serde_path_to_error", "serde_with", - "syn 2.0.117", + "syn 2.0.118", "thiserror 2.0.18", "uuid", "validator", @@ -5170,9 +5153,9 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "openssl" -version = "0.10.80" +version = "0.10.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" dependencies = [ "bitflags", "cfg-if", @@ -5190,7 +5173,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5201,9 +5184,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.116" +version = "0.9.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" dependencies = [ "cc", "libc", @@ -5258,7 +5241,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5312,7 +5295,7 @@ dependencies = [ "regex", "regex-syntax", "structmeta", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5405,7 +5388,7 @@ checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5482,10 +5465,10 @@ dependencies = [ "pluto-k1util", "pluto-ssz", "pluto-testutil", - "prost 0.14.3", - "prost-types 0.14.3", + "prost 0.14.4", + "prost-types 0.14.4", "regex", - "reqwest 0.13.3", + "reqwest 0.13.4", "serde", "serde_json", "tar", @@ -5535,7 +5518,7 @@ dependencies = [ "pluto-tracing", "quick-xml 0.39.4", "rand 0.8.6", - "reqwest 0.13.3", + "reqwest 0.13.4", "serde", "serde_json", "serde_with", @@ -5568,11 +5551,11 @@ dependencies = [ "pluto-p2p", "pluto-ssz", "pluto-testutil", - "prost 0.14.3", + "prost 0.14.4", "prost-build", - "prost-types 0.14.3", + "prost-types 0.14.4", "rand 0.8.6", - "reqwest 0.13.3", + "reqwest 0.13.4", "serde", "serde_json", "serde_with", @@ -5608,8 +5591,8 @@ dependencies = [ "pluto-p2p", "pluto-ssz", "pluto-tracing", - "prost 0.14.3", - "prost-types 0.14.3", + "prost 0.14.4", + "prost-types 0.14.4", "serde_json", "test-case", "thiserror 2.0.18", @@ -5648,8 +5631,8 @@ dependencies = [ "pluto-ssz", "pluto-testutil", "pluto-tracing", - "prost 0.14.3", - "prost-types 0.14.3", + "prost 0.14.4", + "prost-types 0.14.4", "rand 0.8.6", "regex", "serde", @@ -5707,8 +5690,8 @@ dependencies = [ "pluto-peerinfo", "pluto-testutil", "pluto-tracing", - "prost 0.14.3", - "prost-types 0.14.3", + "prost 0.14.4", + "prost-types 0.14.4", "rand 0.8.6", "serde", "serde_json", @@ -5750,7 +5733,7 @@ dependencies = [ "oas3-gen-support", "pluto-ssz", "regex", - "reqwest 0.13.3", + "reqwest 0.13.4", "serde", "serde_json", "serde_with", @@ -5785,7 +5768,7 @@ dependencies = [ "pluto-testutil", "rand 0.8.6", "regex", - "reqwest 0.13.3", + "reqwest 0.13.4", "scrypt", "serde", "serde_json", @@ -5862,9 +5845,9 @@ dependencies = [ "pluto-k1util", "pluto-testutil", "pluto-tracing", - "prost 0.14.3", + "prost 0.14.4", "rand 0.8.6", - "reqwest 0.13.3", + "reqwest 0.13.4", "serde_json", "tempfile", "thiserror 2.0.18", @@ -5892,7 +5875,7 @@ dependencies = [ "pluto-core", "pluto-p2p", "pluto-tracing", - "prost 0.14.3", + "prost 0.14.4", "serde_json", "thiserror 2.0.18", "tokio", @@ -5919,8 +5902,8 @@ dependencies = [ "pluto-eth2util", "pluto-p2p", "pluto-tracing", - "prost 0.14.3", - "prost-types 0.14.3", + "prost 0.14.4", + "prost-types 0.14.4", "regex", "serde_json", "thiserror 2.0.18", @@ -5946,7 +5929,7 @@ dependencies = [ "pluto-p2p", "pluto-tracing", "rand 0.8.6", - "reqwest 0.13.3", + "reqwest 0.13.4", "serde_json", "thiserror 2.0.18", "tokio", @@ -5987,7 +5970,7 @@ dependencies = [ "pluto-eth2util", "pluto-ssz", "rand 0.8.6", - "reqwest 0.13.3", + "reqwest 0.13.4", "serde", "serde_json", "serde_with", @@ -6088,7 +6071,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -6117,7 +6100,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.11+spec-1.1.0", + "toml_edit", ] [[package]] @@ -6139,7 +6122,7 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -6171,7 +6154,7 @@ checksum = "440f724eba9f6996b75d63681b0a92b06947f1457076d503a4d2e2c8f56442b8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -6205,19 +6188,19 @@ dependencies = [ [[package]] name = "prost" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" dependencies = [ "bytes", - "prost-derive 0.14.3", + "prost-derive 0.14.4", ] [[package]] name = "prost-build" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" +checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" dependencies = [ "heck", "itertools 0.14.0", @@ -6225,10 +6208,10 @@ dependencies = [ "multimap", "petgraph", "prettyplease", - "prost 0.14.3", - "prost-types 0.14.3", + "prost 0.14.4", + "prost-types 0.14.4", "regex", - "syn 2.0.117", + "syn 2.0.118", "tempfile", ] @@ -6242,20 +6225,20 @@ dependencies = [ "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "prost-derive" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -6269,11 +6252,11 @@ dependencies = [ [[package]] name = "prost-types" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" dependencies = [ - "prost 0.14.3", + "prost 0.14.4", ] [[package]] @@ -6338,7 +6321,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.3", + "socket2 0.6.4", "thiserror 2.0.18", "tokio", "tracing", @@ -6376,7 +6359,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.3", + "socket2 0.6.4", "tracing", "windows-sys 0.60.2", ] @@ -6451,7 +6434,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ "chacha20 0.10.0", - "getrandom 0.4.2", + "getrandom 0.4.3", "rand_core 0.10.1", ] @@ -6605,14 +6588,14 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "regex" -version = "1.12.3" +version = "1.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" dependencies = [ "aho-corasick", "memchr", @@ -6633,9 +6616,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "reqwest" @@ -6675,9 +6658,9 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.13.3" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ "base64", "bytes", @@ -6880,9 +6863,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -7186,14 +7169,14 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "indexmap 2.14.0", "itoa", @@ -7222,16 +7205,16 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "serde_spanned" -version = "0.6.9" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" dependencies = [ - "serde", + "serde_core", ] [[package]] @@ -7248,9 +7231,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.20.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" dependencies = [ "base64", "bs58", @@ -7268,14 +7251,14 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.20.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" dependencies = [ "darling 0.23.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -7345,9 +7328,9 @@ dependencies = [ [[package]] name = "sha3-asm" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f3f15d4e239ebe08413eed880e0f9b5af4b40ee0472543320efa91d488e96a7" +checksum = "a6287fd675f713484342a89cbf0a386abef5f15919cfad607e5e1f19e1e15331" dependencies = [ "cc", "cfg-if", @@ -7364,9 +7347,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook-registry" @@ -7418,9 +7401,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" dependencies = [ "serde", ] @@ -7460,9 +7443,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", "windows-sys 0.61.2", @@ -7520,7 +7503,7 @@ dependencies = [ "proc-macro2", "quote", "structmeta-derive", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -7531,7 +7514,7 @@ checksum = "152a0b65a590ff6c3da95cabe2353ee04e6167c896b28e3b14478c2636c922fc" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -7552,7 +7535,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -7574,9 +7557,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.117" +version = "2.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" dependencies = [ "proc-macro2", "quote", @@ -7592,7 +7575,7 @@ dependencies = [ "paste", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -7612,7 +7595,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -7680,7 +7663,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", @@ -7704,7 +7687,7 @@ dependencies = [ "cfg-if", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -7715,7 +7698,7 @@ checksum = "5c89e72a01ed4c579669add59014b9a524d609c0c88c6a585ce37485879f6ffb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "test-case-core", ] @@ -7776,7 +7759,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -7787,7 +7770,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -7810,12 +7793,11 @@ dependencies = [ [[package]] name = "time" -version = "0.3.47" +version = "0.3.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "711a53c2d47bbd818258c498c8dbfe186a2526c631495cfe7e078567f86b8469" dependencies = [ "deranged", - "itoa", "num-conv", "powerfmt", "serde_core", @@ -7825,15 +7807,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "71c652a3727a9cbb9a02f707f530b618ce00d0ccd762009c8c23bd191df3c17d" dependencies = [ "num-conv", "time-core", @@ -7886,7 +7868,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.3", + "socket2 0.6.4", "tokio-macros", "windows-sys 0.61.2", ] @@ -7899,7 +7881,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -7949,23 +7931,26 @@ dependencies = [ [[package]] name = "toml" -version = "0.8.23" +version = "0.9.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ - "serde", + "indexmap 2.14.0", + "serde_core", "serde_spanned", - "toml_datetime 0.6.11", - "toml_edit 0.22.27", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", ] [[package]] name = "toml_datetime" -version = "0.6.11" +version = "0.7.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" dependencies = [ - "serde", + "serde_core", ] [[package]] @@ -7979,23 +7964,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.22.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" -dependencies = [ - "indexmap 2.14.0", - "serde", - "serde_spanned", - "toml_datetime 0.6.11", - "toml_write", - "winnow 0.7.15", -] - -[[package]] -name = "toml_edit" -version = "0.25.11+spec-1.1.0" +version = "0.25.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ "indexmap 2.14.0", "toml_datetime 1.1.1+spec-1.1.0", @@ -8013,10 +7984,10 @@ dependencies = [ ] [[package]] -name = "toml_write" -version = "0.1.2" +name = "toml_writer" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" [[package]] name = "tonic" @@ -8037,7 +8008,7 @@ dependencies = [ "hyper-util", "percent-encoding", "pin-project", - "socket2 0.6.3", + "socket2 0.6.4", "sync_wrapper", "tokio", "tokio-stream", @@ -8054,7 +8025,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" dependencies = [ "bytes", - "prost 0.14.3", + "prost 0.14.4", "tonic", ] @@ -8127,7 +8098,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -8221,7 +8192,7 @@ dependencies = [ "darling 0.23.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -8232,9 +8203,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "ucd-trie" @@ -8295,9 +8266,9 @@ dependencies = [ [[package]] name = "unicode-segmentation" -version = "1.13.2" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-xid" @@ -8403,11 +8374,11 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.1" +version = "1.23.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" dependencies = [ - "getrandom 0.4.2", + "getrandom 0.4.3", "js-sys", "serde_core", "wasm-bindgen", @@ -8440,7 +8411,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -8495,7 +8466,7 @@ source = "git+https://github.com/matter-labs/vise?rev=73c654303d8190023cf30034d6 dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -8540,27 +8511,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" -dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "wit-bindgen 0.51.0", + "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.121" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" dependencies = [ "cfg-if", "once_cell", @@ -8571,9 +8533,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.71" +version = "0.4.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" dependencies = [ "js-sys", "wasm-bindgen", @@ -8581,9 +8543,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.121" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -8591,48 +8553,26 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.121" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.121" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap 2.14.0", - "wasm-encoder", - "wasmparser", -] - [[package]] name = "wasm-streams" version = "0.5.0" @@ -8646,18 +8586,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap 2.14.0", - "semver 1.0.28", -] - [[package]] name = "wasmtimer" version = "0.4.3" @@ -8674,9 +8602,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.98" +version = "0.3.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" dependencies = [ "js-sys", "wasm-bindgen", @@ -8694,9 +8622,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.7" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" dependencies = [ "rustls-pki-types", ] @@ -8707,14 +8635,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.7", + "webpki-roots 1.0.8", ] [[package]] name = "webpki-roots" -version = "1.0.7" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" dependencies = [ "rustls-pki-types", ] @@ -8831,7 +8759,7 @@ checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -8842,7 +8770,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -8853,7 +8781,7 @@ checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -8864,7 +8792,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -9091,9 +9019,6 @@ name = "winnow" version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] [[package]] name = "winnow" @@ -9127,100 +9052,12 @@ dependencies = [ "url", ] -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap 2.14.0", - "prettyplease", - "syn 2.0.117", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.117", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags", - "indexmap 2.14.0", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap 2.14.0", - "log", - "semver 1.0.28", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - [[package]] name = "writeable" version = "0.6.3" @@ -9332,9 +9169,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -9349,28 +9186,28 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -9390,28 +9227,28 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "synstructure", ] [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" dependencies = [ "zeroize_derive", ] [[package]] name = "zeroize_derive" -version = "1.4.3" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -9444,7 +9281,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] diff --git a/crates/core/src/fetcher/graffiti.rs b/crates/core/src/fetcher/graffiti.rs index a5885a8c..744e15c2 100644 --- a/crates/core/src/fetcher/graffiti.rs +++ b/crates/core/src/fetcher/graffiti.rs @@ -1,6 +1,4 @@ //! Graffiti construction for block proposals. -//! -//! Ported from `charon/core/fetcher/graffiti.go`. use std::collections::HashMap; diff --git a/crates/core/src/fetcher/mod.rs b/crates/core/src/fetcher/mod.rs index 47a14ee8..766539fe 100644 --- a/crates/core/src/fetcher/mod.rs +++ b/crates/core/src/fetcher/mod.rs @@ -4,7 +4,7 @@ mod graffiti; -pub use graffiti::{GraffitiBuilder, GraffitiError, client_graffiti_mappings}; +use graffiti::GraffitiBuilder; use std::{collections::HashMap, future::Future, pin::Pin, sync::Arc}; @@ -18,7 +18,6 @@ use pluto_eth2api::{ versioned, }; use pluto_eth2util::eth2exp::{self, Eth2ExpError}; -use tracing::{debug, info, warn}; use tree_hash::TreeHash; use crate::{ @@ -598,7 +597,7 @@ fn attestation_payload( /// proposal. Fee recipient is unavailable in forks earlier than Bellatrix. fn verify_fee_recipient(proposal: &VersionedProposal, fee_recipient_address: &str) { if let Some((expected, actual)) = fee_recipient_mismatch(proposal, fee_recipient_address) { - warn!( + tracing::warn!( expected = %expected, actual = %actual, "Proposal with unexpected fee recipient address" @@ -683,7 +682,7 @@ impl PubkeysTracker { fn log(&self) { if !self.not_selected_pubkeys.is_empty() { - debug!( + tracing::debug!( title = self.title, pubkeys = self.not_selected_pubkeys.join(","), "not selected pubkeys" @@ -691,7 +690,7 @@ impl PubkeysTracker { } if !self.resolved_pubkeys.is_empty() { - info!( + tracing::info!( title = self.title, pubkeys = self.resolved_pubkeys.join(","), "resolved pubkeys" From a668f7a561fb8a368ad0bb8396c10a1d9328947e Mon Sep 17 00:00:00 2001 From: "emlautarom1-agent[bot]" <292495798+emlautarom1-agent[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 17:55:24 -0300 Subject: [PATCH 19/30] test(core): cover all fork/blinded combos in verify_fee_recipient Expand verify_fee_recipient into a table-driven test over every fork/blinded combination from Bellatrix onwards, mirroring Go's TestVerifyFeeRecipient. The unblinded golden (Electra) is a field superset of earlier forks and the blinded golden carries the Deneb-era blob fields, so both deserialize into each fork's block type once the variable-shape lists are emptied; Electra+ blinded additionally gets an empty execution_requests. Guard fee_recipient_mismatch with a debug_assert\!/warn\! when a Bellatrix+ proposal yields no extractable fee recipient, so a future payload key rename surfaces in dev/test instead of silently reporting "no mismatch". --- crates/core/src/fetcher/mod.rs | 190 +++++++++++++++++++++++++++++---- 1 file changed, 168 insertions(+), 22 deletions(-) diff --git a/crates/core/src/fetcher/mod.rs b/crates/core/src/fetcher/mod.rs index 766539fe..21e2f175 100644 --- a/crates/core/src/fetcher/mod.rs +++ b/crates/core/src/fetcher/mod.rs @@ -623,11 +623,27 @@ fn fee_recipient_mismatch( // Unblinded blocks carry `execution_payload`; blinded blocks carry // `execution_payload_header`. Both expose `fee_recipient`. - let actual_addr = value + let Some(actual_addr) = value .get("execution_payload") .or_else(|| value.get("execution_payload_header")) .and_then(|payload| payload.get("fee_recipient")) - .and_then(|addr| addr.as_str())?; + .and_then(|addr| addr.as_str()) + else { + // Every Bellatrix+ proposal carries a fee recipient, so reaching here + // means the payload shape changed (e.g. a renamed key) and the + // traversal above silently stopped matching. Fail loudly in dev/test + // and warn in production rather than reporting a false "no mismatch". + debug_assert!( + false, + "Bellatrix+ proposal yielded no fee recipient address; \ + execution payload shape may have changed" + ); + tracing::warn!( + version = ?proposal.version(), + "Bellatrix+ proposal yielded no extractable fee recipient address" + ); + return None; + }; if actual_addr.eq_ignore_ascii_case(fee_recipient_address) { None @@ -779,6 +795,13 @@ mod tests { "../../testdata/signeddata/TestJSONSerialisation_VersionedProposal.json.golden" ); + /// Blinded proposal (`{slot, .., body}`) whose body carries an + /// `execution_payload_header` with the Deneb-era blob fields. Reused to + /// build blinded proposals across forks in [`verify_fee_recipient`]. + const BLINDED_BLOCK_GOLDEN: &str = include_str!( + "../../testdata/signeddata/TestJSONSerialisation_VersionedBlindedProposal.json.golden" + ); + /// Mounts a `produce_block_v3` responder that returns the golden Electra /// block contents with the request's slot, randao reveal and graffiti /// echoed back and a zero fee recipient. @@ -829,31 +852,154 @@ mod tests { .await; } + /// Empties the variable-shape list fields of a block body JSON so it + /// deserializes into any fork's block type, regardless of per-fork element + /// shapes (e.g. Electra's committee-aware attestations vs. phase0's). + fn empty_block_body_lists(body: &mut serde_json::Value) { + for field in [ + "proposer_slashings", + "attester_slashings", + "attestations", + "deposits", + "voluntary_exits", + ] { + body[field] = serde_json::json!([]); + } + } + + /// Mirrors Go's `TestVerifyFeeRecipient`: every fork/blinded combination + /// from Bellatrix onwards must extract a fee recipient. A matching address + /// (case-insensitively) yields no mismatch; a different one is flagged. #[test] fn verify_fee_recipient() { - use pluto_eth2api::spec::electra; + // The unblinded golden is the richest fork shape (Electra), whose body + // is a field-superset of every earlier fork. Since the spec types + // ignore unknown fields it deserializes into each fork's `BeaconBlock`. + let unblinded_block = { + let golden: serde_json::Value = + serde_json::from_str(BLOCK_CONTENTS_GOLDEN).expect("parse unblinded golden"); + let mut block = golden["block"]["block"].clone(); + empty_block_body_lists(&mut block["body"]); + block + }; - // Electra proposal from the golden block contents. - let golden: serde_json::Value = - serde_json::from_str(BLOCK_CONTENTS_GOLDEN).expect("parse golden"); - let block: electra::BeaconBlock = - serde_json::from_value(golden["block"]["block"].clone()).expect("parse block"); - let proposal = VersionedProposal { - block: ProposalBlock::Electra { - block: Box::new(block), - kzg_proofs: vec![], - blobs: vec![], - }, - consensus_block_value: alloy::primitives::U256::ZERO, - execution_payload_value: alloy::primitives::U256::ZERO, + // The blinded golden's `execution_payload_header` already carries the + // Deneb-era blob fields, so it deserializes into Bellatrix..Deneb + // blinded blocks directly. + let blinded_block = { + let golden: serde_json::Value = + serde_json::from_str(BLINDED_BLOCK_GOLDEN).expect("parse blinded golden"); + let mut block = golden["block"].clone(); + empty_block_body_lists(&mut block["body"]); + block + }; + + // Electra+ blinded blocks additionally require `execution_requests`. + let blinded_block_electra = { + let mut block = blinded_block.clone(); + block["body"]["execution_requests"] = serde_json::json!({ + "deposits": [], + "withdrawals": [], + "consolidations": [], + }); + block }; - // A different address is reported as a mismatch; the actual address - // matches itself (case-insensitively). - let (_, actual) = - fee_recipient_mismatch(&proposal, "0xdead").expect("mismatch against wrong address"); - assert!(fee_recipient_mismatch(&proposal, &actual).is_none()); - assert!(fee_recipient_mismatch(&proposal, &actual.to_uppercase()).is_none()); + let kzg_proofs = Vec::new(); + let blobs = Vec::new(); + let cases: Vec<(&str, ProposalBlock)> = vec![ + ( + "bellatrix", + ProposalBlock::Bellatrix( + serde_json::from_value(unblinded_block.clone()).expect("bellatrix"), + ), + ), + ( + "bellatrix blinded", + ProposalBlock::BellatrixBlinded( + serde_json::from_value(blinded_block.clone()).expect("bellatrix b"), + ), + ), + ( + "capella", + ProposalBlock::Capella( + serde_json::from_value(unblinded_block.clone()).expect("capella"), + ), + ), + ( + "capella blinded", + ProposalBlock::CapellaBlinded( + serde_json::from_value(blinded_block.clone()).expect("capella b"), + ), + ), + ( + "deneb", + ProposalBlock::Deneb { + block: Box::new( + serde_json::from_value(unblinded_block.clone()).expect("deneb"), + ), + kzg_proofs: kzg_proofs.clone(), + blobs: blobs.clone(), + }, + ), + ( + "deneb blinded", + ProposalBlock::DenebBlinded( + serde_json::from_value(blinded_block.clone()).expect("deneb b"), + ), + ), + ( + "electra", + ProposalBlock::Electra { + block: Box::new( + serde_json::from_value(unblinded_block.clone()).expect("electra"), + ), + kzg_proofs: kzg_proofs.clone(), + blobs: blobs.clone(), + }, + ), + ( + "electra blinded", + ProposalBlock::ElectraBlinded( + serde_json::from_value(blinded_block_electra.clone()).expect("electra b"), + ), + ), + ( + "fulu", + ProposalBlock::Fulu { + block: Box::new(serde_json::from_value(unblinded_block.clone()).expect("fulu")), + kzg_proofs: kzg_proofs.clone(), + blobs: blobs.clone(), + }, + ), + ( + "fulu blinded", + ProposalBlock::FuluBlinded( + serde_json::from_value(blinded_block_electra.clone()).expect("fulu b"), + ), + ), + ]; + + for (name, block) in cases { + let proposal = VersionedProposal { + block, + consensus_block_value: alloy::primitives::U256::ZERO, + execution_payload_value: alloy::primitives::U256::ZERO, + }; + + // A different address is reported as a mismatch; the proposal's own + // fee recipient matches itself (case-insensitively). + let (_, actual) = fee_recipient_mismatch(&proposal, "0xdead") + .unwrap_or_else(|| panic!("{name}: expected a mismatch against 0xdead")); + assert!( + fee_recipient_mismatch(&proposal, &actual).is_none(), + "{name}: should match its own fee recipient", + ); + assert!( + fee_recipient_mismatch(&proposal, &actual.to_uppercase()).is_none(), + "{name}: should match case-insensitively", + ); + } } #[tokio::test] From 0f5cd265c31544bddf9084670f1c3d26e5ffa6f8 Mon Sep 17 00:00:00 2001 From: Lautaro Emanuel Date: Thu, 18 Jun 2026 12:02:13 -0300 Subject: [PATCH 20/30] Update lockfile --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index abc0a032..fda4aded 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1541,9 +1541,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" dependencies = [ "serde", ] From b994425dac1d533297b3b832ce021f3296b9b1fa Mon Sep 17 00:00:00 2001 From: Lautaro Emanuel Date: Thu, 18 Jun 2026 12:03:11 -0300 Subject: [PATCH 21/30] Merge remote-tracking branch 'origin/main' into worktree-graceful-spinning-wombat --- Cargo.lock | 2 + crates/consensus/Cargo.toml | 2 + crates/consensus/examples/qbft.rs | 3 +- crates/consensus/src/controller.rs | 196 +++++++++++ crates/consensus/src/debugger.rs | 379 +++++++++++++++++++++ crates/consensus/src/lib.rs | 9 + crates/consensus/src/qbft/component.rs | 98 +++++- crates/consensus/src/qbft/mod.rs | 2 +- crates/consensus/src/qbft/p2p.rs | 46 ++- crates/consensus/src/qbft/qbft_run_test.rs | 30 +- crates/consensus/src/wrapper.rs | 228 +++++++++++++ crates/core/src/gater.rs | 311 +++++++++++++++++ crates/core/src/lib.rs | 4 + crates/dkg/src/exchanger.rs | 11 +- crates/parsigex/examples/parsigex.rs | 5 +- crates/parsigex/src/behaviour.rs | 12 +- crates/parsigex/src/handler.rs | 13 +- crates/parsigex/src/lib.rs | 3 +- 18 files changed, 1289 insertions(+), 65 deletions(-) create mode 100644 crates/consensus/src/controller.rs create mode 100644 crates/consensus/src/debugger.rs create mode 100644 crates/consensus/src/wrapper.rs create mode 100644 crates/core/src/gater.rs diff --git a/Cargo.lock b/Cargo.lock index fda4aded..1d2359d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5572,12 +5572,14 @@ name = "pluto-consensus" version = "1.7.1" dependencies = [ "anyhow", + "axum", "cancellation", "chrono", "clap", "crossbeam", "either", "ethereum_ssz", + "flate2", "futures", "hex", "k256", diff --git a/crates/consensus/Cargo.toml b/crates/consensus/Cargo.toml index b58bda89..63913a87 100644 --- a/crates/consensus/Cargo.toml +++ b/crates/consensus/Cargo.toml @@ -7,9 +7,11 @@ license.workspace = true publish.workspace = true [dependencies] +axum.workspace = true cancellation.workspace = true chrono.workspace = true crossbeam.workspace = true +flate2.workspace = true futures.workspace = true hex.workspace = true either.workspace = true diff --git a/crates/consensus/examples/qbft.rs b/crates/consensus/examples/qbft.rs index 2ae29fc7..17b3eda1 100644 --- a/crates/consensus/examples/qbft.rs +++ b/crates/consensus/examples/qbft.rs @@ -602,6 +602,7 @@ fn build_consensus( local_peer_idx: i64::try_from(fixture.local_index)?, privkey: fixture.key.clone(), deadliner, + expired_rx, duty_gater: Arc::new(|duty| duty.duty_type == DutyType::Attester), broadcaster, sniffer: Arc::new(move |instance| { @@ -623,7 +624,7 @@ fn build_consensus( }); Ok(()) }); - let lifecycle_task = Arc::clone(&component).start(expired_rx, cancel.child_token()); + let lifecycle_task = component.start(cancel.child_token()); Ok((component, lifecycle_task)) } diff --git a/crates/consensus/src/controller.rs b/crates/consensus/src/controller.rs new file mode 100644 index 00000000..88cd0849 --- /dev/null +++ b/crates/consensus/src/controller.rs @@ -0,0 +1,196 @@ +//! Consensus protocol controller. + +use std::sync::Arc; + +use k256::SecretKey; +use pluto_core::{deadline::DeadlinerHandle, gater::DutyGaterFn, types::Duty}; +use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; + +use crate::{ + debugger::Debugger, + qbft, + timer::RoundTimerFunc, + wrapper::{Consensus, ConsensusWrapper}, +}; + +/// Consensus controller result. +pub type Result = std::result::Result; + +/// Consensus controller error. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// Failed to construct the default QBFT consensus implementation. + #[error("{0}")] + Qbft(#[from] qbft::Error), + /// Protocol ID is not supported by this controller. + #[error("unsupported protocol id")] + UnsupportedProtocolId, +} + +/// Consensus controller constructor config. +pub struct Config { + /// Consensus peers in process-index order. + pub peers: Vec, + /// Local zero-based process index. + pub local_peer_idx: i64, + /// Local secp256k1 private key. + pub privkey: SecretKey, + /// Duty deadline scheduler. Name it `"consensus.qbft"` to match Go's + /// internally-built deadliner for log parity. + pub deadliner: DeadlinerHandle, + /// Expired-duty receiver paired with `deadliner`. + pub expired_rx: mpsc::Receiver, + /// Duty admission gate. + pub duty_gater: DutyGaterFn, + /// External message broadcaster. + pub broadcaster: qbft::Broadcaster, + /// Consensus debugger. + pub debugger: Debugger, + /// Enables attestation value comparison. + pub compare_attestations: bool, + /// Round timer factory. + pub timer_func: RoundTimerFunc, +} + +/// Controls the active consensus protocol implementation. +pub struct ConsensusController { + default_consensus: Arc, + wrapped_consensus: ConsensusWrapper, +} + +impl ConsensusController { + /// Creates a new consensus controller with QBFT as the default protocol. + pub fn new(config: Config) -> Result { + let qbft = Arc::new(qbft::Consensus::new(qbft::Config { + peers: config.peers, + local_peer_idx: config.local_peer_idx, + privkey: config.privkey, + deadliner: config.deadliner, + expired_rx: config.expired_rx, + duty_gater: config.duty_gater, + broadcaster: config.broadcaster, + sniffer: config.debugger.sniffer(), + compare_attestations: config.compare_attestations, + timer_func: config.timer_func, + })?); + let default_consensus: Arc = qbft; + + Ok(Self { + wrapped_consensus: ConsensusWrapper::new(default_consensus.clone()), + default_consensus, + }) + } + + /// Starts the default consensus implementation. + pub fn start(&self, ct: CancellationToken) { + self.default_consensus.start(ct); + } + + /// Returns the default consensus implementation. + pub fn default_consensus(&self) -> Arc { + Arc::clone(&self.default_consensus) + } + + /// Returns the current consensus wrapper. + pub fn current_consensus(&self) -> &ConsensusWrapper { + &self.wrapped_consensus + } + + /// Sets the current consensus implementation for `protocol`. + pub fn set_current_consensus_for_protocol(&self, protocol: &str) -> Result<()> { + if self.wrapped_consensus.protocol_id() == protocol { + return Ok(()); + } + + if self.default_consensus.protocol_id() == protocol { + self.wrapped_consensus + .set_impl(Arc::clone(&self.default_consensus)); + return Ok(()); + } + + // TODO: When introducing non-default consensus protocols, mirror Go's + // deferred wrapped-context cancellation here: cancel the previous + // non-default impl, build a `"consensus."` deadliner, set the + // new impl, and start it under a fresh cancellation token. + Err(Error::UnsupportedProtocolId) + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use k256::SecretKey; + use pluto_core::{ + deadline::{DeadlinerTask, NeverExpiringCalculator}, + types::DutyType, + }; + + use crate::{debugger::Debugger, protocols::QBFT_V2_PROTOCOL_ID, timer::get_round_timer_func}; + + use super::*; + + #[tokio::test] + async fn consensus_controller_uses_qbft_as_default_and_current() { + let controller = ConsensusController::new(config()).expect("controller should construct"); + let ct = CancellationToken::new(); + + controller.start(ct.clone()); + + let default_consensus = controller.default_consensus(); + assert_eq!(default_consensus.protocol_id(), QBFT_V2_PROTOCOL_ID); + assert_eq!( + controller.current_consensus().protocol_id(), + QBFT_V2_PROTOCOL_ID + ); + + controller + .set_current_consensus_for_protocol(QBFT_V2_PROTOCOL_ID) + .expect("default protocol is supported"); + let err = controller + .set_current_consensus_for_protocol("boo") + .expect_err("unknown protocol should fail"); + assert!(matches!(err, Error::UnsupportedProtocolId)); + + ct.cancel(); + } + + fn config() -> Config { + let ct = CancellationToken::new(); + let (deadliner, expired_rx) = + DeadlinerTask::start(ct, "controller-test", NeverExpiringCalculator); + + Config { + peers: peers(), + local_peer_idx: 0, + privkey: secret_key(1), + deadliner, + expired_rx, + duty_gater: Arc::new(|duty| duty.duty_type == DutyType::Attester), + broadcaster: Arc::new(|_, _| Box::pin(async { Ok(()) })), + debugger: Debugger::new(), + compare_attestations: false, + timer_func: get_round_timer_func(), + } + } + + fn peers() -> Vec { + vec![ + qbft::Peer { + index: 0, + name: "node-0".to_string(), + public_key: secret_key(1).public_key(), + }, + qbft::Peer { + index: 1, + name: "node-1".to_string(), + public_key: secret_key(2).public_key(), + }, + ] + } + + fn secret_key(seed: u8) -> SecretKey { + SecretKey::from_slice(&[seed; 32]).expect("test secret key is valid") + } +} diff --git a/crates/consensus/src/debugger.rs b/crates/consensus/src/debugger.rs new file mode 100644 index 00000000..63e04ecb --- /dev/null +++ b/crates/consensus/src/debugger.rs @@ -0,0 +1,379 @@ +//! Consensus debug message buffer. +//! +//! [`Debugger`] stores completed QBFT sniffer instances in a bounded FIFO +//! buffer and serves them as a gzipped [`SniffedConsensusInstances`] protobuf. +//! +//! # Usage +//! +//! Create one debugger during node startup, wire its sniffer callback into the +//! QBFT consensus config, and mount its router on the debug HTTP server: +//! +//! ```no_run +//! use axum::Router; +//! use pluto_consensus::debugger::Debugger; +//! +//! let debugger = Debugger::new(); +//! +//! let sniffer = debugger.sniffer(); +//! let app = Router::new().merge(debugger.router("/debug/consensus")); +//! +//! // Pass `sniffer` into `qbft::Config { sniffer, .. }`. +//! // Serve `app` with the node's debug HTTP server. +//! ``` +//! +//! Lower-level callers can use [`Debugger::add_instance`] to store an instance, +//! [`Debugger::get_zipped_proto`] to get the raw gzipped protobuf bytes, or +//! [`Debugger::serve_http`] to build a single HTTP response without using +//! [`Debugger::router`]. + +use std::{ + collections::VecDeque, + io::Write, + sync::{Arc, Mutex, PoisonError}, +}; + +use axum::{ + Router, + body::Body, + http::{ + HeaderValue, Response, StatusCode, + header::{CONTENT_DISPOSITION, CONTENT_TYPE}, + }, + routing::get, +}; +use flate2::{Compression, write::GzEncoder}; +use pluto_core::{ + corepb::v1::consensus::{SniffedConsensusInstance, SniffedConsensusInstances}, + version, +}; +use prost::Message; + +use crate::qbft::SnifferSink; + +const DEFAULT_MAX_BUFFER_SIZE: usize = 52_428_800; +const DEBUGGER_CONTENT_TYPE: &str = "application/octet-stream"; +const DEBUGGER_FILENAME: &str = r#"attachment; filename="consensus_messages.pb.gz""#; +const DEBUGGER_ERROR_BODY: &str = "something went wrong, see logs\n"; + +/// Consensus debug message buffer. +#[derive(Clone, Debug)] +pub struct Debugger { + inner: Arc, +} + +#[derive(Debug)] +struct Inner { + git_hash: String, + max_buffer_size: usize, + state: Mutex, +} + +#[derive(Debug, Default)] +struct State { + total_size: usize, + instances: VecDeque, +} + +/// Debugger serialization error. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// Protobuf encoding failed. + #[error("marshal proto: {0}")] + MarshalProto(#[from] prost::EncodeError), + /// Gzip writer failed. + #[error("gzip: {0}")] + Gzip(std::io::Error), +} + +impl Debugger { + /// Returns a new consensus debugger. + pub fn new() -> Self { + let (git_hash, _) = version::git_commit(); + Self::with_git_hash_and_max_buffer(git_hash, DEFAULT_MAX_BUFFER_SIZE) + } + + /// Adds a sniffed consensus instance to the FIFO buffer. + pub fn add_instance(&self, instance: SniffedConsensusInstance) { + let size = instance.encoded_len(); + let mut state = self + .inner + .state + .lock() + .unwrap_or_else(PoisonError::into_inner); + + state.total_size = state.total_size.saturating_add(size); + state.instances.push_back(instance); + + while state.total_size > self.inner.max_buffer_size { + let Some(dropped) = state.instances.pop_front() else { + state.total_size = 0; + break; + }; + state.total_size = state.total_size.saturating_sub(dropped.encoded_len()); + } + } + + /// Returns the buffered consensus instances as a gzipped protobuf payload. + pub fn get_zipped_proto(&self) -> Result, Error> { + let instances = { + let state = self + .inner + .state + .lock() + .unwrap_or_else(PoisonError::into_inner); + state.instances.iter().cloned().collect() + }; + + let mut encoded = Vec::new(); + SniffedConsensusInstances { + instances, + git_hash: self.inner.git_hash.clone(), + } + .encode(&mut encoded)?; + + let mut encoder = GzEncoder::new(Vec::new(), Compression::fast()); + encoder.write_all(&encoded).map_err(Error::Gzip)?; + encoder.finish().map_err(Error::Gzip) + } + + /// Returns an HTTP response containing the gzipped protobuf payload. + pub fn serve_http(&self) -> Response { + match self.get_zipped_proto() { + Ok(body) => Response::builder() + .header( + CONTENT_TYPE, + HeaderValue::from_static(DEBUGGER_CONTENT_TYPE), + ) + .header( + CONTENT_DISPOSITION, + HeaderValue::from_static(DEBUGGER_FILENAME), + ) + .body(Body::from(body)) + .unwrap_or_else(|error| { + tracing::warn!(%error, "Error serving consensus debug"); + error_response() + }), + Err(error) => { + tracing::warn!(%error, "Error serving consensus debug"); + error_response() + } + } + } + + /// Returns a sink that stores completed QBFT sniffer instances. + pub fn sniffer(&self) -> SnifferSink { + let debugger = self.clone(); + Arc::new(move |instance| debugger.add_instance(instance)) + } + + /// Returns an axum router serving this debugger at `path`. + pub fn router(&self, path: &'static str) -> Router { + let debugger = self.clone(); + Router::new().route( + path, + get(move || { + let debugger = debugger.clone(); + async move { debugger.serve_http() } + }), + ) + } + + fn with_git_hash_and_max_buffer(git_hash: String, max_buffer_size: usize) -> Self { + Self { + inner: Arc::new(Inner { + git_hash, + max_buffer_size, + state: Mutex::default(), + }), + } + } +} + +impl Default for Debugger { + fn default() -> Self { + Self::new() + } +} + +fn error_response() -> Response { + let mut response = Response::new(Body::from(DEBUGGER_ERROR_BODY)); + *response.status_mut() = StatusCode::INTERNAL_SERVER_ERROR; + response +} + +#[cfg(test)] +mod tests { + use std::io::Read; + + use axum::body; + use flate2::read::GzDecoder; + use pluto_core::corepb::v1::consensus::{QbftConsensusMsg, QbftMsg, SniffedConsensusMsg}; + use prost_types::Timestamp; + + use super::*; + + #[tokio::test] + async fn debugger_serves_gzipped_sniffed_consensus_instances() { + let debugger = Debugger::with_git_hash_and_max_buffer("test-hash".to_string(), usize::MAX); + let instances = (0..10).map(sniffed_instance).collect::>(); + + for instance in instances.clone() { + debugger.add_instance(instance); + } + + let response = debugger.serve_http(); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers().get(CONTENT_TYPE), + Some(&HeaderValue::from_static(DEBUGGER_CONTENT_TYPE)) + ); + assert_eq!( + response.headers().get(CONTENT_DISPOSITION), + Some(&HeaderValue::from_static(DEBUGGER_FILENAME)) + ); + + let body = body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("debug response body is readable"); + let decoded = decode_gzipped_instances(&body); + + assert_eq!( + decoded, + SniffedConsensusInstances { + instances, + git_hash: "test-hash".to_string(), + } + ); + } + + #[test] + fn add_instance_drops_oldest_instances_when_capacity_is_exceeded() { + let first = sniffed_instance(1); + let second = sniffed_instance(2); + let third = sniffed_instance(3); + let max_buffer = second + .encoded_len() + .checked_add(third.encoded_len()) + .expect("test instances fit usize"); + let debugger = Debugger::with_git_hash_and_max_buffer("test-hash".to_string(), max_buffer); + + debugger.add_instance(first); + debugger.add_instance(second.clone()); + debugger.add_instance(third.clone()); + + let decoded = decode_gzipped_instances( + &debugger + .get_zipped_proto() + .expect("debugger payload should encode"), + ); + + assert_eq!(decoded.instances, vec![second, third]); + } + + #[test] + fn new_debugger_sets_git_hash() { + let debugger = Debugger::new(); + let decoded = decode_gzipped_instances( + &debugger + .get_zipped_proto() + .expect("debugger payload should encode"), + ); + + assert_eq!(decoded.git_hash, version::git_commit().0); + } + + #[test] + fn cloned_debugger_shares_buffer() { + let debugger = Debugger::with_git_hash_and_max_buffer("test-hash".to_string(), usize::MAX); + let cloned = debugger.clone(); + let instance = sniffed_instance(1); + + cloned.add_instance(instance.clone()); + + let decoded = decode_gzipped_instances( + &debugger + .get_zipped_proto() + .expect("debugger payload should encode"), + ); + + assert_eq!(decoded.instances, vec![instance]); + } + + #[test] + fn sniffer_adds_instances() { + let debugger = Debugger::with_git_hash_and_max_buffer("test-hash".to_string(), usize::MAX); + let sniffer = debugger.sniffer(); + let instance = sniffed_instance(1); + + sniffer(instance.clone()); + + let decoded = decode_gzipped_instances( + &debugger + .get_zipped_proto() + .expect("debugger payload should encode"), + ); + + assert_eq!(decoded.instances, vec![instance]); + } + + #[test] + fn router_constructs_debug_endpoint() { + let debugger = Debugger::with_git_hash_and_max_buffer("test-hash".to_string(), usize::MAX); + let _router = debugger.router("/debug/consensus"); + } + + fn decode_gzipped_instances(bytes: &[u8]) -> SniffedConsensusInstances { + let mut decoder = GzDecoder::new(bytes); + let mut decoded = Vec::new(); + decoder + .read_to_end(&mut decoded) + .expect("gzip payload should decode"); + SniffedConsensusInstances::decode(decoded.as_slice()) + .expect("sniffed consensus instances should decode") + } + + fn sniffed_instance(seed: i64) -> SniffedConsensusInstance { + SniffedConsensusInstance { + started_at: Some(Timestamp { + seconds: seed, + nanos: 0, + }), + nodes: 4, + peer_idx: seed, + msgs: vec![SniffedConsensusMsg { + timestamp: Some(Timestamp { + seconds: seed + .checked_add(1) + .expect("test timestamp increment fits i64"), + nanos: 0, + }), + msg: Some(QbftConsensusMsg { + msg: Some(QbftMsg { + r#type: seed, + peer_idx: seed, + round: seed, + prepared_round: seed, + ..Default::default() + }), + justification: vec![ + QbftMsg { + round: seed + .checked_add(1) + .expect("test justification round fits i64"), + ..Default::default() + }, + QbftMsg { + round: seed + .checked_add(2) + .expect("test justification round fits i64"), + ..Default::default() + }, + ], + values: Vec::new(), + }), + }], + protocol_id: "test-protocol".to_string(), + } + } +} diff --git a/crates/consensus/src/lib.rs b/crates/consensus/src/lib.rs index 491f076a..4ace4e99 100644 --- a/crates/consensus/src/lib.rs +++ b/crates/consensus/src/lib.rs @@ -4,6 +4,12 @@ //! This crate implements the consensus algorithms and protocols required for //! coordinating validator operations across the distributed network. +/// Consensus protocol controller. +pub mod controller; + +/// Consensus debug message buffer. +pub mod debugger; + /// Consensus protocols. pub mod protocols; @@ -16,3 +22,6 @@ pub mod qbft; /// Consensus round timers. pub mod timer; + +/// Swappable consensus implementation wrapper. +pub mod wrapper; diff --git a/crates/consensus/src/qbft/component.rs b/crates/consensus/src/qbft/component.rs index 13524a87..5767346e 100644 --- a/crates/consensus/src/qbft/component.rs +++ b/crates/consensus/src/qbft/component.rs @@ -21,6 +21,7 @@ use crate::{ use pluto_core::{ corepb::v1::{consensus as pbconsensus, core as pbcore, priority as pbpriority}, deadline::{AddOutcome, DeadlinerHandle}, + gater::DutyGaterFn, qbft, types::{Duty, DutyType}, }; @@ -41,9 +42,6 @@ pub type Broadcaster = Arc< + 'static, >; -/// Duty admission gate. -pub type DutyGater = Arc bool + Send + Sync + 'static>; - /// Sink for completed sniffer instances. pub type SnifferSink = Arc; @@ -76,8 +74,10 @@ pub struct Config { pub privkey: SecretKey, /// Duty deadline scheduler. pub deadliner: DeadlinerHandle, + /// Expired-duty receiver paired with `deadliner`. + pub expired_rx: mpsc::Receiver, /// Duty admission gate. - pub duty_gater: DutyGater, + pub duty_gater: DutyGaterFn, /// External message broadcaster. pub broadcaster: Broadcaster, /// Completed sniffer sink. @@ -263,13 +263,14 @@ pub struct Consensus { local_peer_idx: i64, privkey: SecretKey, deadliner: DeadlinerHandle, - duty_gater: DutyGater, + expired_rx: Mutex>>, + duty_gater: DutyGaterFn, broadcaster: Broadcaster, sniffer: SnifferSink, timer_func: RoundTimerFunc, compare_attestations: bool, subscribers: SubscriberSet, - instances: Mutex>>>, + instances: Arc>>>>, } impl Consensus { @@ -297,13 +298,14 @@ impl Consensus { local_peer_idx: config.local_peer_idx, privkey: config.privkey, deadliner: config.deadliner, + expired_rx: Mutex::new(Some(config.expired_rx)), duty_gater: config.duty_gater, broadcaster: config.broadcaster, sniffer: config.sniffer, timer_func: config.timer_func, compare_attestations: config.compare_attestations, subscribers: SubscriberSet::default(), - instances: Mutex::default(), + instances: Arc::new(Mutex::default()), }) } @@ -417,17 +419,31 @@ impl Consensus { } /// Runs the internal expired-duty cleanup loop until cancellation. - pub fn start( - self: Arc, - mut expired_rx: mpsc::Receiver, - ct: CancellationToken, - ) -> JoinHandle<()> { + /// + /// Must be called exactly once: it `take()`s `expired_rx` and panics on a + /// second call, since multiple starts signal bad wiring. A future redesign + /// separating construction from starting would make this a compile-time + /// guarantee. + pub fn start(&self, ct: CancellationToken) -> JoinHandle<()> { + let mut expired_rx = self + .expired_rx + .lock() + .unwrap_or_else(PoisonError::into_inner) + .take() + .expect("start must be called exactly once"); + let instances = Arc::clone(&self.instances); + tokio::spawn(async move { loop { tokio::select! { () = ct.cancelled() => return, duty = expired_rx.recv() => match duty { - Some(duty) => self.delete_instance_io(&duty), + Some(duty) => { + instances + .lock() + .unwrap_or_else(PoisonError::into_inner) + .remove(&duty); + } None => return, }, } @@ -448,6 +464,7 @@ impl Consensus { } /// Drops cached I/O for a completed or expired duty instance. + #[cfg(test)] pub(crate) fn delete_instance_io(&self, duty: &Duty) { self.instances .lock() @@ -539,6 +556,45 @@ impl Consensus { } } +impl crate::wrapper::Consensus for Consensus { + fn protocol_id(&self) -> String { + self.protocol_id().to_string() + } + + fn start(&self, ct: CancellationToken) { + drop(Consensus::start(self, ct)); + } + + fn participate( + &self, + ct: CancellationToken, + duty: Duty, + ) -> BoxFuture<'_, crate::wrapper::Result<()>> { + Box::pin(async move { + Consensus::participate(self, duty, &ct) + .await + .map_err(|err| Box::new(err) as Box) + }) + } + + fn propose( + &self, + ct: CancellationToken, + duty: Duty, + value: pbcore::UnsignedDataSet, + ) -> BoxFuture<'_, crate::wrapper::Result<()>> { + Box::pin(async move { + Consensus::propose(self, duty, value, &ct) + .await + .map_err(|err| Box::new(err) as Box) + }) + } + + fn subscribe(&self, subscriber: crate::wrapper::Subscriber) { + Consensus::subscribe(self, subscriber); + } +} + /// Extracts the domain duty from a validated raw QBFT message. fn duty_from_msg(msg: &pbconsensus::QbftMsg) -> Result { let duty = msg.duty.as_ref().ok_or(Error::InvalidConsensusMessage)?; @@ -613,12 +669,19 @@ pub(crate) mod tests { #[tokio::test] async fn start_deletes_expired_instance_io_until_cancelled() { - let consensus = Arc::new(consensus(0, true)); + let (expired_tx, expired_rx) = mpsc::channel(1); + let consensus = Arc::new( + Consensus::new(Config { + peers: peers(), + expired_rx, + ..config_base(false) + }) + .unwrap(), + ); let duty = duty(); let first = consensus.get_instance_io(duty.clone()); let cancel = CancellationToken::new(); - let (expired_tx, expired_rx) = mpsc::channel(1); - let task = Arc::clone(&consensus).start(expired_rx, cancel.clone()); + let task = consensus.start(cancel.clone()); expired_tx.send(duty.clone()).await.unwrap(); tokio::time::timeout( @@ -1237,7 +1300,7 @@ pub(crate) mod tests { pub(crate) fn config_base(never_expiring: bool) -> Config { let cancel = CancellationToken::new(); - let (deadliner, _expired_rx) = if never_expiring { + let (deadliner, expired_rx) = if never_expiring { DeadlinerTask::start( cancel, "qbft-test", @@ -1252,6 +1315,7 @@ pub(crate) mod tests { local_peer_idx: 0, privkey: secret_key(1), deadliner, + expired_rx, duty_gater: Arc::new(|_| true), broadcaster: Arc::new(|_, _| Box::pin(async { Ok(()) })), sniffer: Arc::new(|_| {}), diff --git a/crates/consensus/src/qbft/mod.rs b/crates/consensus/src/qbft/mod.rs index 6ce9bc90..0c2ff9ed 100644 --- a/crates/consensus/src/qbft/mod.rs +++ b/crates/consensus/src/qbft/mod.rs @@ -5,7 +5,7 @@ pub(crate) mod definition; pub(crate) mod runner; pub use component::{ - BroadcastResult, Broadcaster, Config, Consensus, DutyGater, Error, Peer, Result, SnifferSink, + BroadcastResult, Broadcaster, Config, Consensus, Error, Peer, Result, SnifferSink, SubscriberResult, }; pub use runner::{Error as RunnerError, Result as RunnerResult}; diff --git a/crates/consensus/src/qbft/p2p.rs b/crates/consensus/src/qbft/p2p.rs index 70659774..f8175f09 100644 --- a/crates/consensus/src/qbft/p2p.rs +++ b/crates/consensus/src/qbft/p2p.rs @@ -36,6 +36,14 @@ use pluto_p2p::p2p_context::P2PContext; use super::Consensus; +/// Caps the wire size of an incoming `QbftConsensusMsg`, well below the 128 MB +/// default p2p frame limit. A legitimate message carries at most a handful of +/// small justification sub-messages (bounded in `handle`) plus its values, the +/// largest of which is a single block proposal (a few MB on mainnet); 32 MB +/// leaves ample margin while bounding the receive/decode/allocation cost a +/// malicious peer can inflict per message. +pub const MAX_CONSENSUS_MSG_SIZE: usize = 32 * 1024 * 1024; + /// Charon-compatible inbound receive timeout. pub const RECEIVE_TIMEOUT: Duration = Duration::from_secs(5); /// Charon-compatible outbound send timeout. @@ -380,7 +388,7 @@ where let msg = pluto_p2p::proto::read_protobuf_with_max_size::( stream, - pluto_p2p::proto::MAX_MESSAGE_SIZE, + MAX_CONSENSUS_MSG_SIZE, ) .await .map_err(InboundError::Read)?; @@ -862,6 +870,42 @@ mod tests { Ok(()) } + #[tokio::test] + async fn inbound_rejects_message_exceeding_max_consensus_size() -> TestResult<()> { + // Frame declaring one byte over the cap; read_length_delimited rejects on + // the varint length prefix before allocating or reading the body, so no + // oversized payload is needed. + let mut varint = Vec::new(); + let mut remaining = MAX_CONSENSUS_MSG_SIZE + 1; + loop { + let mut byte = u8::try_from(remaining & 0x7f).expect("7-bit masked value fits in u8"); + remaining >>= 7; + if remaining != 0 { + byte |= 0x80; + } + varint.push(byte); + if remaining == 0 { + break; + } + } + let mut stream = Cursor::new(varint); + + let error = read_and_handle_inbound( + &mut stream, + Arc::new(consensus(0, true)), + CancellationToken::new(), + RECEIVE_TIMEOUT, + ) + .await + .expect_err("oversized inbound message must be rejected"); + + assert!( + matches!(&error, InboundError::Read(io) if io.to_string().contains("too large")), + "expected read size error, got {error:?}" + ); + Ok(()) + } + #[tokio::test] async fn outbound_broadcast_skips_self_and_targets_non_self_peers() -> TestResult<()> { let keys = test_keys()?; diff --git a/crates/consensus/src/qbft/qbft_run_test.rs b/crates/consensus/src/qbft/qbft_run_test.rs index 342dbdc0..8840395b 100644 --- a/crates/consensus/src/qbft/qbft_run_test.rs +++ b/crates/consensus/src/qbft/qbft_run_test.rs @@ -91,7 +91,6 @@ async fn qbft_consensus_with_silent_round_one_leader_decides() { let (decided_tx, mut decided_rx) = mpsc::unbounded_channel(); let ct = CancellationToken::new(); let start_ct = CancellationToken::new(); - let mut expired_txs = Vec::with_capacity(active_nodes.len()); let mut start_tasks = Vec::with_capacity(active_nodes.len()); for (node_idx, node) in active_nodes.iter().enumerate() { @@ -101,9 +100,7 @@ async fn qbft_consensus_with_silent_round_one_leader_decides() { Ok(()) }); - let (expired_tx, expired_rx) = mpsc::channel(1); - expired_txs.push(expired_tx); - start_tasks.push(Arc::clone(node).start(expired_rx, start_ct.clone())); + start_tasks.push(node.start(start_ct.clone())); } drop(decided_tx); @@ -140,7 +137,6 @@ async fn qbft_consensus_with_silent_round_one_leader_decides() { ct.cancel(); start_ct.cancel(); - drop(expired_txs); for task in start_tasks { task.await.unwrap(); } @@ -155,7 +151,6 @@ async fn qbft_priority_consensus() { let duty = Duty::new(SlotNumber::new(1), DutyType::InfoSync); let ct = CancellationToken::new(); let start_ct = CancellationToken::new(); - let mut expired_txs = Vec::with_capacity(active_nodes.len()); let mut start_tasks = Vec::with_capacity(active_nodes.len()); for (node_idx, node) in active_nodes.iter().enumerate() { @@ -165,9 +160,7 @@ async fn qbft_priority_consensus() { Ok(()) }); - let (expired_tx, expired_rx) = mpsc::channel(1); - expired_txs.push(expired_tx); - start_tasks.push(Arc::clone(node).start(expired_rx, start_ct.clone())); + start_tasks.push(node.start(start_ct.clone())); } drop(decided_tx); @@ -200,7 +193,6 @@ async fn qbft_priority_consensus() { ct.cancel(); start_ct.cancel(); - drop(expired_txs); for task in start_tasks { task.await.unwrap(); } @@ -216,7 +208,6 @@ async fn qbft_consensus_participate_then_late_propose() { let duty = Duty::new(SlotNumber::new(1), DutyType::Attester); let ct = CancellationToken::new(); let start_ct = CancellationToken::new(); - let mut expired_txs = Vec::with_capacity(active_nodes.len()); let mut start_tasks = Vec::with_capacity(active_nodes.len()); for (node_idx, node) in active_nodes.iter().enumerate() { @@ -226,9 +217,7 @@ async fn qbft_consensus_participate_then_late_propose() { Ok(()) }); - let (expired_tx, expired_rx) = mpsc::channel(1); - expired_txs.push(expired_tx); - start_tasks.push(Arc::clone(node).start(expired_rx, start_ct.clone())); + start_tasks.push(node.start(start_ct.clone())); } drop(decided_tx); @@ -278,7 +267,6 @@ async fn qbft_consensus_participate_then_late_propose() { ct.cancel(); start_ct.cancel(); - drop(expired_txs); for task in start_tasks { task.await.unwrap(); } @@ -299,7 +287,6 @@ async fn qbft_consensus_attester_compare_mismatch_does_not_decide() { let duty = Duty::new(SlotNumber::new(1), DutyType::Attester); let ct = CancellationToken::new(); let start_ct = CancellationToken::new(); - let mut expired_txs = Vec::with_capacity(active_nodes.len()); let mut start_tasks = Vec::with_capacity(active_nodes.len()); for (node_idx, node) in active_nodes.iter().enumerate() { @@ -309,9 +296,7 @@ async fn qbft_consensus_attester_compare_mismatch_does_not_decide() { Ok(()) }); - let (expired_tx, expired_rx) = mpsc::channel(1); - expired_txs.push(expired_tx); - start_tasks.push(Arc::clone(node).start(expired_rx, start_ct.clone())); + start_tasks.push(node.start(start_ct.clone())); } drop(decided_tx); @@ -333,7 +318,6 @@ async fn qbft_consensus_attester_compare_mismatch_does_not_decide() { assert!(decided_rx.try_recv().is_err()); start_ct.cancel(); - drop(expired_txs); for task in start_tasks { task.await.unwrap(); } @@ -356,7 +340,6 @@ async fn run_qbft_consensus( let duty = Duty::new(SlotNumber::new(1), DutyType::Attester); let ct = CancellationToken::new(); let start_ct = CancellationToken::new(); - let mut expired_txs = Vec::with_capacity(active_nodes.len()); let mut start_tasks = Vec::with_capacity(active_nodes.len()); for (node_idx, node) in active_nodes.iter().enumerate() { @@ -366,9 +349,7 @@ async fn run_qbft_consensus( Ok(()) }); - let (expired_tx, expired_rx) = mpsc::channel(1); - expired_txs.push(expired_tx); - start_tasks.push(Arc::clone(node).start(expired_rx, start_ct.clone())); + start_tasks.push(node.start(start_ct.clone())); } drop(decided_tx); @@ -404,7 +385,6 @@ async fn run_qbft_consensus( ct.cancel(); start_ct.cancel(); - drop(expired_txs); for task in start_tasks { task.await.unwrap(); } diff --git a/crates/consensus/src/wrapper.rs b/crates/consensus/src/wrapper.rs new file mode 100644 index 00000000..a69401ab --- /dev/null +++ b/crates/consensus/src/wrapper.rs @@ -0,0 +1,228 @@ +//! Swappable consensus implementation wrapper. + +use std::{ + error::Error as StdError, + sync::{Arc, PoisonError, RwLock}, +}; + +use futures::future::BoxFuture; +use pluto_core::{corepb::v1::core as pbcore, types::Duty}; +use tokio_util::sync::CancellationToken; + +/// Consensus wrapper result. +pub type Result = std::result::Result>; + +/// Subscriber callback result. +pub type SubscriberResult = Result<()>; + +/// Subscriber callback for decided unsigned duty data. +pub type Subscriber = + Box SubscriberResult + Send + Sync + 'static>; + +/// Consensus implementation interface. +pub trait Consensus: Send + Sync { + /// Returns the consensus protocol ID. + fn protocol_id(&self) -> String; + + /// Starts the consensus implementation. + fn start(&self, ct: CancellationToken); + + /// Starts participating in a consensus instance. + fn participate(&self, ct: CancellationToken, duty: Duty) -> BoxFuture<'_, Result<()>>; + + /// Proposes unsigned duty data for a consensus instance. + fn propose( + &self, + ct: CancellationToken, + duty: Duty, + value: pbcore::UnsignedDataSet, + ) -> BoxFuture<'_, Result<()>>; + + /// Registers a callback for decided unsigned duty data. + fn subscribe(&self, subscriber: Subscriber); +} + +/// Wrapper that forwards calls to the current consensus implementation. +pub struct ConsensusWrapper { + implementation: RwLock>, +} + +impl ConsensusWrapper { + /// Wraps a consensus implementation. + pub fn new(implementation: Arc) -> Self { + Self { + implementation: RwLock::new(implementation), + } + } + + /// Sets the current consensus implementation. + pub fn set_impl(&self, implementation: Arc) { + *self + .implementation + .write() + .unwrap_or_else(PoisonError::into_inner) = implementation; + } + + /// Returns the current consensus protocol ID. + pub fn protocol_id(&self) -> String { + self.current().protocol_id() + } + + /// Starts the current consensus implementation. + pub fn start(&self, ct: CancellationToken) { + self.current().start(ct); + } + + /// Starts participating in a consensus instance. + pub async fn participate(&self, ct: CancellationToken, duty: Duty) -> Result<()> { + self.current().participate(ct, duty).await + } + + /// Proposes unsigned duty data for a consensus instance. + pub async fn propose( + &self, + ct: CancellationToken, + duty: Duty, + value: pbcore::UnsignedDataSet, + ) -> Result<()> { + self.current().propose(ct, duty, value).await + } + + /// Registers a callback for decided unsigned duty data. + pub fn subscribe(&self, subscriber: Subscriber) { + self.current().subscribe(subscriber); + } + + fn current(&self) -> Arc { + self.implementation + .read() + .unwrap_or_else(PoisonError::into_inner) + .clone() + } +} + +#[cfg(test)] +mod tests { + use std::sync::Mutex; + + use futures::FutureExt as _; + use pluto_core::{ + corepb::v1::core as pbcore, + types::{Duty, SlotNumber}, + }; + + use crate::protocols::QBFT_V2_PROTOCOL_ID; + + use super::*; + + #[tokio::test] + async fn new_consensus_wrapper_forwards_to_current_impl() { + let ct = CancellationToken::new(); + let duty = Duty::new_randao_duty(SlotNumber::new(123)); + let value = pbcore::UnsignedDataSet::default(); + let first = Arc::new(TestConsensus::new(QBFT_V2_PROTOCOL_ID)); + let wrapper = ConsensusWrapper::new(first.clone()); + + assert_eq!(wrapper.protocol_id(), QBFT_V2_PROTOCOL_ID); + + wrapper + .participate(ct.clone(), duty.clone()) + .await + .expect("participate forwards"); + wrapper + .propose(ct.clone(), duty.clone(), value) + .await + .expect("propose forwards"); + + let subscribed = Arc::new(Mutex::new(Vec::new())); + let subscribed_clone = Arc::clone(&subscribed); + wrapper.subscribe(Box::new(move |duty, _| { + subscribed_clone + .lock() + .unwrap_or_else(PoisonError::into_inner) + .push(duty); + Ok(()) + })); + + wrapper.start(ct); + + assert_eq!( + first.calls(), + vec!["participate", "propose", "subscribe", "start"] + ); + assert_eq!( + subscribed + .lock() + .unwrap_or_else(PoisonError::into_inner) + .as_slice(), + &[duty] + ); + + let second = Arc::new(TestConsensus::new("foobar")); + wrapper.set_impl(second); + + assert_eq!(wrapper.protocol_id(), "foobar"); + } + + struct TestConsensus { + protocol_id: String, + calls: Mutex>, + } + + impl TestConsensus { + fn new(protocol_id: &str) -> Self { + Self { + protocol_id: protocol_id.to_string(), + calls: Mutex::default(), + } + } + + fn calls(&self) -> Vec<&'static str> { + self.calls + .lock() + .unwrap_or_else(PoisonError::into_inner) + .clone() + } + + fn record(&self, call: &'static str) { + self.calls + .lock() + .unwrap_or_else(PoisonError::into_inner) + .push(call); + } + } + + impl Consensus for TestConsensus { + fn protocol_id(&self) -> String { + self.protocol_id.clone() + } + + fn start(&self, _ct: CancellationToken) { + self.record("start"); + } + + fn participate(&self, _ct: CancellationToken, _duty: Duty) -> BoxFuture<'_, Result<()>> { + self.record("participate"); + async { Ok(()) }.boxed() + } + + fn propose( + &self, + _ct: CancellationToken, + _duty: Duty, + _value: pbcore::UnsignedDataSet, + ) -> BoxFuture<'_, Result<()>> { + self.record("propose"); + async { Ok(()) }.boxed() + } + + fn subscribe(&self, subscriber: Subscriber) { + self.record("subscribe"); + subscriber( + Duty::new_randao_duty(SlotNumber::new(123)), + pbcore::UnsignedDataSet::default(), + ) + .expect("test subscriber succeeds"); + } + } +} diff --git a/crates/core/src/gater.rs b/crates/core/src/gater.rs new file mode 100644 index 00000000..4afafeee --- /dev/null +++ b/crates/core/src/gater.rs @@ -0,0 +1,311 @@ +//! Duty gater — rejects duties whose type is invalid or that are too far in the +//! future. + +use std::sync::Arc; + +use chrono::{DateTime, Utc}; +use pluto_eth2api::{EthBeaconNodeApiClient, EthBeaconNodeApiClientError}; + +use crate::{ + clock::{ChronoClock, Clock}, + types::Duty, +}; + +/// Shared, callable duty-gating predicate: the value form that the wire +/// components (parsigex, consensus) accept and invoke per duty. +pub type DutyGaterFn = Arc bool + Send + Sync + 'static>; + +/// Default number of epochs into the future for which duties are accepted. +const DEFAULT_ALLOWED_FUTURE_EPOCHS: u64 = 2; + +/// Errors returned while constructing a [`DutyGater`]. +#[derive(Debug, thiserror::Error)] +pub enum GaterError { + /// Failed to fetch beacon node configuration. + #[error("Failed to fetch beacon node configuration: {0}")] + BeaconNodeConfigError(#[from] EthBeaconNodeApiClientError), + + /// The slot duration is not a positive whole number of milliseconds + /// (sub-millisecond, or too large to fit `u64`), so it cannot be used as a + /// divisor in the millisecond-resolution epoch arithmetic. + #[error("Slot duration is not a positive number of milliseconds")] + InvalidSlotDuration, +} + +/// Result type for gater operations. +pub type Result = std::result::Result; + +/// Gates duties by type and recency. +/// +/// [`DutyGater::allows`] returns `true` only when a duty may be processed. It +/// rejects duties received from peers over the wire whose type is invalid or +/// whose epoch is more than `allowed_future_epochs` beyond the current epoch. +/// It does **not** reject duties in the past — that is the responsibility of +/// the [`crate::deadline`] component. +pub struct DutyGater { + genesis_time: DateTime, + /// Slot duration in milliseconds. Always ≥ 1, enforced in + /// [`DutyGater::with_options`]. + slot_duration_ms: u64, + /// Slots per epoch. Guaranteed non-zero by the `fetch_slots_config` + /// contract. + slots_per_epoch: u64, + allowed_future_epochs: u64, + clock: Box, +} + +impl DutyGater { + /// Builds a gater from a beacon node client using production defaults: a + /// real wall clock and a `DEFAULT_ALLOWED_FUTURE_EPOCHS` future-epoch + /// budget. + pub async fn new(client: &EthBeaconNodeApiClient) -> Result { + Self::with_options(client, Box::new(ChronoClock), DEFAULT_ALLOWED_FUTURE_EPOCHS).await + } + + /// Builds a gater with an injected clock and future-epoch budget. The + /// single fetch path shared with [`DutyGater::new`]; the overrides + /// exist for tests. + async fn with_options( + client: &EthBeaconNodeApiClient, + clock: Box, + allowed_future_epochs: u64, + ) -> Result { + let genesis_time = client.fetch_genesis_time().await?; + let (slot_duration, slots_per_epoch) = client.fetch_slots_config().await?; + + // Work in whole milliseconds. `as_millis()` is u128 (SECONDS_PER_SLOT + // keeps it tiny); reject a zero (sub-millisecond) or overflowing value + // rather than divide by zero in `current_epoch`. + let slot_duration_ms = u64::try_from(slot_duration.as_millis()) + .ok() + .filter(|&ms| ms != 0) + .ok_or(GaterError::InvalidSlotDuration)?; + + Ok(Self { + genesis_time, + slot_duration_ms, + slots_per_epoch, + allowed_future_epochs, + clock, + }) + } + + /// Returns `true` if `duty` may be processed: its type is valid and its + /// epoch is no more than `allowed_future_epochs` beyond the current epoch. + #[must_use] + pub fn allows(&self, duty: &Duty) -> bool { + if !duty.duty_type.is_valid() { + return false; + } + + let duty_epoch = duty + .slot + .inner() + .checked_div(self.slots_per_epoch) + .expect("slots_per_epoch is non-zero (fetch_slots_config contract)"); + + duty_epoch + <= self + .current_epoch() + .saturating_add(self.allowed_future_epochs) + } + + /// Converts this gater into the shared callable [`DutyGaterFn`] consumed by + /// the wire components. + #[must_use] + pub fn into_fn(self) -> DutyGaterFn { + let gater = Arc::new(self); + Arc::new(move |duty: &Duty| gater.allows(duty)) + } + + /// Current epoch derived from the injected clock and genesis time. + fn current_epoch(&self) -> u64 { + let elapsed_ms = self + .clock + .now() + .signed_duration_since(self.genesis_time) + .num_milliseconds(); + + // Pre-genesis instants clamp to epoch 0: the gater is only built after + // genesis, so this path is unreachable in practice, and treating a + // negative elapsed time as a huge future slot would be nonsense. + let elapsed_ms = u64::try_from(elapsed_ms).unwrap_or(0); + + let current_slot = elapsed_ms + .checked_div(self.slot_duration_ms) + .expect("slot_duration_ms >= 1 (enforced in with_options)"); + + current_slot + .checked_div(self.slots_per_epoch) + .expect("slots_per_epoch is non-zero (fetch_slots_config contract)") + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use chrono::{DateTime, Utc}; + use pluto_testutil::BeaconMock; + + use super::*; + use crate::types::{DutyType, SlotNumber}; + + /// A fixed clock returning `now` regardless of when it is called. + fn fixed_clock(now: DateTime) -> Box { + Box::new(move || now) + } + + /// Builds a gater from hand-set configuration and a fixed clock, for + /// non-async coverage that needs no beacon node. + fn gater( + genesis_time: DateTime, + slot_duration_ms: u64, + slots_per_epoch: u64, + allowed_future_epochs: u64, + now: DateTime, + ) -> DutyGater { + DutyGater { + genesis_time, + slot_duration_ms, + slots_per_epoch, + allowed_future_epochs, + clock: fixed_clock(now), + } + } + + fn attester(slot: u64) -> Duty { + Duty::new_attester_duty(SlotNumber::new(slot)) + } + + fn duty_with_type(slot: u64, duty_type: DutyType) -> Duty { + Duty { + slot: SlotNumber::new(slot), + duty_type, + } + } + + /// genesis == now (current epoch 0), 1s slots, 2 slots/epoch, 2 future + /// epochs allowed ⇒ slots 0-5 accepted. + #[tokio::test] + async fn duty_gater() { + // Genesis round-trips through the beacon API as whole seconds, so pin + // `now` to a whole second to make the injected clock equal genesis. + let now = DateTime::from_timestamp(Utc::now().timestamp(), 0).expect("valid timestamp"); + + let bmock = BeaconMock::builder() + .genesis_time(now) + .slot_duration(Duration::from_secs(1)) + .slots_per_epoch(2) + .build() + .await + .expect("build beacon mock"); + + let gater = DutyGater::with_options(bmock.client(), fixed_clock(now), 2) + .await + .expect("build gater"); + + // Allowed: slots 0-5 (epochs 0, 1, 2 ≤ budget 2). + for slot in 0..=5 { + assert!( + gater.allows(&attester(slot)), + "slot {slot} should be allowed" + ); + } + + // Disallowed: slot 6 onwards (epoch 3+). + for slot in [6, 7, 1000] { + assert!( + !gater.allows(&attester(slot)), + "slot {slot} should be disallowed" + ); + } + + // Invalid duty types are rejected regardless of slot. + assert!(!gater.allows(&duty_with_type(0, DutyType::Unknown))); + assert!(!gater.allows(&duty_with_type( + 1, + DutyType::DutySentinel(Box::new(DutyType::Attester)) + ))); + } + + /// Smoke test of the public `new` entrypoint (real `ChronoClock`, default + /// budget) against a mainnet-like 12s/32-slot config. Genesis is pinned to + /// ~now, so `current_epoch` stays 0 for the whole test (epochs are 384s + /// long) and the default future-epoch budget of 2 is locked in: slot 96 + /// (epoch 3) would only be allowed if the default were 3. + #[tokio::test] + async fn new_defaults() { + let now = DateTime::from_timestamp(Utc::now().timestamp(), 0).expect("valid timestamp"); + + let bmock = BeaconMock::builder() + .genesis_time(now) + .slot_duration(Duration::from_secs(12)) + .slots_per_epoch(32) + .build() + .await + .expect("build beacon mock"); + + let gater = DutyGater::new(bmock.client()).await.expect("build gater"); + + assert!(gater.allows(&attester(0))); // current epoch + assert!(gater.allows(&attester(95))); // epoch 2 (= budget) + assert!(!gater.allows(&attester(96))); // epoch 3 (> budget) + } + + /// Non-async coverage of the epoch boundary with a non-zero current epoch + /// (the async test above only exercises current epoch 0). + #[test] + fn epoch_boundary() { + let genesis = DateTime::from_timestamp(1_600_000_000, 0).expect("valid timestamp"); + // 100s after genesis at 1s slots ⇒ slot 100 ⇒ epoch 3 (32 slots/epoch). + let now = DateTime::from_timestamp(1_600_000_100, 0).expect("valid timestamp"); + // Budget = current epoch 3 + 2 = 5 ⇒ duty epoch ≤ 5 (slot ≤ 191) allowed. + let gater = gater(genesis, 1_000, 32, 2, now); + + assert!(gater.allows(&attester(96))); // current epoch (3) + assert!(gater.allows(&attester(128))); // N+1 + assert!(gater.allows(&attester(160))); // N+2 start + assert!(gater.allows(&attester(191))); // N+2 end + assert!(!gater.allows(&attester(192))); // N+3 + assert!(!gater.allows(&attester(10_000))); + } + + /// Pre-genesis instants clamp to epoch 0. + #[test] + fn pre_genesis_clamps_to_epoch_zero() { + let genesis = DateTime::from_timestamp(1_600_000_100, 0).expect("valid timestamp"); + let now = DateTime::from_timestamp(1_600_000_000, 0).expect("valid timestamp"); + // Budget = epoch 0 + 2 = 2 ⇒ slot ≤ 95 (epoch ≤ 2) allowed at 32 slots/epoch. + let gater = gater(genesis, 1_000, 32, 2, now); + + assert!(gater.allows(&attester(0))); + assert!(gater.allows(&attester(95))); // epoch 2 + assert!(!gater.allows(&attester(96))); // epoch 3 + } + + /// `into_fn` yields a callable predicate equivalent to `allows`, usable + /// where a `DutyGaterFn` (e.g. `pluto_parsigex::DutyGater`) is expected. + #[test] + fn into_fn_matches_allows() { + let genesis = DateTime::from_timestamp(1_600_000_000, 0).expect("valid timestamp"); + let now = DateTime::from_timestamp(1_600_000_100, 0).expect("valid timestamp"); + let gater_fn: DutyGaterFn = gater(genesis, 1_000, 32, 2, now).into_fn(); + + assert!(gater_fn(&attester(191))); + assert!(!gater_fn(&attester(192))); + assert!(!gater_fn(&duty_with_type(0, DutyType::Unknown))); + } + + #[test] + fn invalid_type_rejected() { + let genesis = DateTime::from_timestamp(1_600_000_000, 0).expect("valid timestamp"); + let gater = gater(genesis, 1_000, 32, 2, genesis); + + assert!(!gater.allows(&duty_with_type(0, DutyType::Unknown))); + assert!(!gater.allows(&duty_with_type( + 0, + DutyType::DutySentinel(Box::new(DutyType::Attester)) + ))); + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index f41562a0..56d4a688 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -26,6 +26,10 @@ pub mod deadline; /// Clock abstraction over the current time. pub mod clock; +/// Duty gater — rejects duties whose type is invalid or that are too far in the +/// future. +pub mod gater; + /// parsigdb pub mod parsigdb; diff --git a/crates/dkg/src/exchanger.rs b/crates/dkg/src/exchanger.rs index 67eabd2b..e5153ff3 100644 --- a/crates/dkg/src/exchanger.rs +++ b/crates/dkg/src/exchanger.rs @@ -49,6 +49,7 @@ use tracing::warn; use pluto_core::{ deadline::{DeadlinerTask, NeverExpiringCalculator}, + gater::DutyGaterFn, parsigdb::memory::{ InternalSubscriberError, MemDB, MemDBError, internal_subscriber, threshold_subscriber, }, @@ -101,8 +102,6 @@ impl Default for DataByPubkey { } } -type DutyGaterFn = Arc bool + Send + Sync + 'static>; - /// Errors returned by exchanger operations. #[derive(Debug, thiserror::Error)] pub enum ExchangerError { @@ -331,7 +330,9 @@ mod tests { use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; - use super::{Exchanger, SIG_DEPOSIT_DATA, SIG_LOCK, SIG_VALIDATOR_REG, SigTypeStore}; + use super::{ + DutyGaterFn, Exchanger, SIG_DEPOSIT_DATA, SIG_LOCK, SIG_VALIDATOR_REG, SigTypeStore, + }; fn available_tcp_port() -> anyhow::Result { let listener = TcpListener::bind("127.0.0.1:0")?; @@ -375,7 +376,7 @@ mod tests { let p2p_context = P2PContext::new(peer_ids.to_vec()); let verifier: pluto_parsigex::Verifier = Arc::new(|_duty, _pk, _psig| Box::pin(async { Ok(()) })); - let duty_gater: pluto_parsigex::DutyGater = + let duty_gater: DutyGaterFn = Arc::new(|duty: &pluto_core::types::Duty| duty.duty_type == DutyType::Signature); ParsexBehaviour::new(ParsexConfig::new( @@ -515,7 +516,7 @@ mod tests { let verifier: pluto_parsigex::Verifier = Arc::new(|_duty, _pk, _psig| Box::pin(async { Ok(()) })); - let duty_gater: pluto_parsigex::DutyGater = + let duty_gater: DutyGaterFn = Arc::new(|duty: &pluto_core::types::Duty| duty.duty_type == DutyType::Signature); let config = ParsexConfig::new(peer_ids[i], p2p_context.clone(), verifier, duty_gater); diff --git a/crates/parsigex/examples/parsigex.rs b/crates/parsigex/examples/parsigex.rs index 13481c59..06663a60 100644 --- a/crates/parsigex/examples/parsigex.rs +++ b/crates/parsigex/examples/parsigex.rs @@ -68,6 +68,7 @@ use libp2p::{ }; use pluto_cluster::lock::Lock; use pluto_core::{ + gater::DutyGaterFn, signeddata::SignedRandao, types::{Duty, DutyType, ParSignedDataSet, PubKey, SlotNumber}, }; @@ -81,7 +82,7 @@ use pluto_p2p::{ peer::peer_id_from_key, relay::{RelayManager, RelayManagerEvent}, }; -use pluto_parsigex::{self as parsigex, DutyGater, Event, Handle, Verifier}; +use pluto_parsigex::{self as parsigex, Event, Handle, Verifier}; use pluto_tracing::TracingConfig; use tokio::fs; use tokio_util::sync::CancellationToken; @@ -247,7 +248,7 @@ async fn main() -> Result<()> { let verifier: Verifier = std::sync::Arc::new(|_duty, _pubkey, _data| Box::pin(async { Ok(()) })); - let duty_gater: DutyGater = std::sync::Arc::new(|duty| duty.duty_type != DutyType::Unknown); + let duty_gater: DutyGaterFn = std::sync::Arc::new(|duty| duty.duty_type != DutyType::Unknown); let handle_slot = std::sync::Arc::new(tokio::sync::Mutex::new(1_u64)); let p2p_config = P2PConfig { diff --git a/crates/parsigex/src/behaviour.rs b/crates/parsigex/src/behaviour.rs index 5247127c..a243ba72 100644 --- a/crates/parsigex/src/behaviour.rs +++ b/crates/parsigex/src/behaviour.rs @@ -22,7 +22,10 @@ use libp2p::{ }; use tokio::sync::{RwLock, mpsc, oneshot}; -use pluto_core::types::{Duty, ParSignedData, ParSignedDataSet, PubKey}; +use pluto_core::{ + gater::DutyGaterFn, + types::{Duty, ParSignedData, ParSignedDataSet, PubKey}, +}; use pluto_p2p::p2p_context::P2PContext; use super::{Handler, encode_message}; @@ -39,9 +42,6 @@ pub type VerifyFuture = pub type Verifier = Arc VerifyFuture + Send + Sync + 'static>; -/// Duty gate callback type. -pub type DutyGater = Arc bool + Send + Sync + 'static>; - /// Future returned by received subscriber callbacks. pub type ReceivedSubFuture = Pin + Send + 'static>>; @@ -187,7 +187,7 @@ pub struct Config { peer_id: PeerId, p2p_context: P2PContext, verifier: Verifier, - duty_gater: DutyGater, + duty_gater: DutyGaterFn, timeout: Duration, } @@ -197,7 +197,7 @@ impl Config { peer_id: PeerId, p2p_context: P2PContext, verifier: Verifier, - duty_gater: DutyGater, + duty_gater: DutyGaterFn, ) -> Self { Self { peer_id, diff --git a/crates/parsigex/src/handler.rs b/crates/parsigex/src/handler.rs index c7788b49..44203f2a 100644 --- a/crates/parsigex/src/handler.rs +++ b/crates/parsigex/src/handler.rs @@ -19,9 +19,12 @@ use libp2p::{ }; use tokio::time::timeout; -use pluto_core::types::{Duty, ParSignedDataSet}; +use pluto_core::{ + gater::DutyGaterFn, + types::{Duty, ParSignedDataSet}, +}; -use super::{DutyGater, PROTOCOL_NAME, Verifier, protocol}; +use super::{PROTOCOL_NAME, Verifier, protocol}; use crate::error::Failure; /// Command sent from the behaviour to a handler. @@ -78,14 +81,14 @@ type ActiveFuture = BoxFuture<'static, Option>; pub struct Handler { timeout: Duration, verifier: Verifier, - duty_gater: DutyGater, + duty_gater: DutyGaterFn, pending_open: VecDeque, active_futures: FuturesUnordered, } impl Handler { /// Creates a new handler for one connection. - pub fn new(timeout: Duration, verifier: Verifier, duty_gater: DutyGater) -> Self { + pub fn new(timeout: Duration, verifier: Verifier, duty_gater: DutyGaterFn) -> Self { Self { timeout, verifier, @@ -247,7 +250,7 @@ impl ConnectionHandler for Handler { async fn do_recv( mut stream: libp2p::swarm::Stream, verifier: Verifier, - duty_gater: DutyGater, + duty_gater: DutyGaterFn, ) -> Result<(Duty, ParSignedDataSet), Failure> { let bytes = protocol::recv_message(&mut stream) .await diff --git a/crates/parsigex/src/lib.rs b/crates/parsigex/src/lib.rs index e1abdfd3..4ff310ec 100644 --- a/crates/parsigex/src/lib.rs +++ b/crates/parsigex/src/lib.rs @@ -6,8 +6,7 @@ mod handler; mod protocol; pub use behaviour::{ - Behaviour, Config, DutyGater, Event, Handle, ReceivedSub, ReceivedSubFuture, Verifier, - received_subscriber, + Behaviour, Config, Event, Handle, ReceivedSub, ReceivedSubFuture, Verifier, received_subscriber, }; pub use error::{Error, Failure, Result, VerifyError}; pub use handler::Handler; From 9b6671e29896a246e0a14048d7e7b16001f22c9b Mon Sep 17 00:00:00 2001 From: "emlautarom1-agent[bot]" <292495798+emlautarom1-agent[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 13:17:28 -0300 Subject: [PATCH 22/30] refactor(core): replace graffiti client map with a match Avoid allocating a HashMap on every fetch_beacon_node_token call by resolving the product token to its graffiti code via a match. Per review feedback on PR #454. --- crates/core/src/fetcher/graffiti.rs | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/crates/core/src/fetcher/graffiti.rs b/crates/core/src/fetcher/graffiti.rs index 744e15c2..3cfa3310 100644 --- a/crates/core/src/fetcher/graffiti.rs +++ b/crates/core/src/fetcher/graffiti.rs @@ -24,17 +24,19 @@ pub enum GraffitiError { LengthMismatch, } -/// Maps beacon node product tokens (the first `/`-separated component of the -/// node version string) to their two-letter graffiti code. -pub fn client_graffiti_mappings() -> HashMap<&'static str, &'static str> { - HashMap::from([ - ("teku", "TK"), - ("Lighthouse", "LH"), - ("Lodestar", "LS"), - ("Prysm", "PY"), - ("Nimbus", "NB"), - ("Grandine", "GD"), - ]) +/// Maps a beacon node product token (the first `/`-separated component of the +/// node version string) to its two-letter graffiti code, returning an empty +/// string for an unrecognized client. +pub fn client_graffiti_token(product_token: &str) -> &'static str { + match product_token { + "teku" => "TK", + "Lighthouse" => "LH", + "Lodestar" => "LS", + "Prysm" => "PY", + "Nimbus" => "NB", + "Grandine" => "GD", + _ => "", + } } /// Builds per-validator graffiti used when proposing blocks. @@ -144,10 +146,7 @@ async fn fetch_beacon_node_token(eth2_cl: &EthBeaconNodeApiClient) -> String { let product_token = version.split('/').next().unwrap_or_default(); - client_graffiti_mappings() - .get(product_token) - .map(|token| (*token).to_string()) - .unwrap_or_default() + client_graffiti_token(product_token).to_string() } /// Fetches the beacon node version string (e.g. `Lighthouse/v0.1.5 (Linux From 8f4432f5c74ca7cc28ce7acc2138ff69fd61308f Mon Sep 17 00:00:00 2001 From: "emlautarom1-agent[bot]" <292495798+emlautarom1-agent[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 13:17:56 -0300 Subject: [PATCH 23/30] refactor(core): remove unused FetcherError::UnsupportedProposalVersion VersionedProposal::try_from matches every ConsensusVersion exhaustively, so this variant is never constructed. Per review feedback on PR #454. --- crates/core/src/fetcher/mod.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/crates/core/src/fetcher/mod.rs b/crates/core/src/fetcher/mod.rs index 21e2f175..b11ee86b 100644 --- a/crates/core/src/fetcher/mod.rs +++ b/crates/core/src/fetcher/mod.rs @@ -9,7 +9,7 @@ use graffiti::GraffitiBuilder; use std::{collections::HashMap, future::Future, pin::Pin, sync::Arc}; use pluto_eth2api::{ - ConsensusVersion, EthBeaconNodeApiClient, EthBeaconNodeApiClientError, + EthBeaconNodeApiClient, EthBeaconNodeApiClientError, GetAggregatedAttestationV2Request, GetAggregatedAttestationV2Response, GetAggregatedAttestationV2ResponseResponseData, ProduceAttestationDataRequest, ProduceAttestationDataResponse, ProduceBlockV3Request, ProduceBlockV3Response, @@ -127,10 +127,6 @@ pub enum FetcherError { #[error("decode proposal: {0}")] SignedData(#[from] SignedDataError), - /// A versioned proposal had an unsupported fork version. - #[error("unsupported proposal version: {0:?}")] - UnsupportedProposalVersion(ConsensusVersion), - /// A signed data value could not produce a signature. #[error("signature: {0}")] Signature(String), From 67731dfeaf7532248f51d65a4abe76904b00f764 Mon Sep 17 00:00:00 2001 From: "emlautarom1-agent[bot]" <292495798+emlautarom1-agent[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 13:18:25 -0300 Subject: [PATCH 24/30] refactor(core): make FetcherError::Signature carry SignedDataError Wrap the source SignedDataError via #[source] instead of stringifying it, preserving the error chain. Per review feedback on PR #454. --- crates/core/src/fetcher/mod.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/core/src/fetcher/mod.rs b/crates/core/src/fetcher/mod.rs index b11ee86b..8d1de17d 100644 --- a/crates/core/src/fetcher/mod.rs +++ b/crates/core/src/fetcher/mod.rs @@ -129,7 +129,7 @@ pub enum FetcherError { /// A signed data value could not produce a signature. #[error("signature: {0}")] - Signature(String), + Signature(#[source] SignedDataError), } /// Result alias for fetcher operations. @@ -343,9 +343,7 @@ impl Fetcher { let randao_data = self .query_agg_sig_db(Duty::new_randao_duty(slot.into()), *pubkey) .await?; - let randao = randao_data - .signature() - .map_err(|e| FetcherError::Signature(e.to_string()))?; + let randao = randao_data.signature().map_err(FetcherError::Signature)?; // Maximum priority to builder blocks when the builder is enabled. let builder_boost_factor: u64 = if self.builder_enabled { u64::MAX } else { 0 }; From b90be60191e7f85ea8ad6057d5639efa20fce53b Mon Sep 17 00:00:00 2001 From: "emlautarom1-agent[bot]" <292495798+emlautarom1-agent[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 13:19:28 -0300 Subject: [PATCH 25/30] refactor(core): implement TryFrom for duty definitions Switch AttesterDutyDefinition and SyncCommitteeDutyDefinition from a direct TryInto impl to TryFrom, matching the sibling ProposerDutyDefinition and getting TryInto for free via the blanket impl. Per review feedback on PR #454. --- crates/core/src/types.rs | 47 +++++++++++++++++++++++----------------- 1 file changed, 27 insertions(+), 20 deletions(-) diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index 0d9f4479..b7af6f3c 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -439,36 +439,41 @@ pub struct AttesterDutyDefinition { pub duty: AttesterDuty, } -impl TryInto - for pluto_eth2api::types::GetAttesterDutiesResponseResponseDatum +impl TryFrom + for AttesterDutyDefinition { type Error = pluto_eth2api::EthBeaconNodeApiClientError; - fn try_into(self) -> Result { - let pubkey = PubKey::try_from(self.pubkey.as_str()) + fn try_from( + value: pluto_eth2api::types::GetAttesterDutiesResponseResponseDatum, + ) -> Result { + let pubkey = PubKey::try_from(value.pubkey.as_str()) .map_err(|_| pluto_eth2api::EthBeaconNodeApiClientError::ParseError("pubkey".into()))?; - let validator_index = self.validator_index.parse::().map_err(|_| { + let validator_index = value.validator_index.parse::().map_err(|_| { pluto_eth2api::EthBeaconNodeApiClientError::ParseError("validator_index".into()) })?; - let slot = self + let slot = value .slot .parse::() .map_err(|_| pluto_eth2api::EthBeaconNodeApiClientError::ParseError("slot".into()))?; - let committee_index = self.committee_index.parse::().map_err(|_| { + let committee_index = value.committee_index.parse::().map_err(|_| { pluto_eth2api::EthBeaconNodeApiClientError::ParseError("committee_index".into()) })?; - let committee_length = self.committee_length.parse::().map_err(|_| { + let committee_length = value.committee_length.parse::().map_err(|_| { pluto_eth2api::EthBeaconNodeApiClientError::ParseError("committee_length".into()) })?; - let committees_at_slot = self.committees_at_slot.parse::().map_err(|_| { + let committees_at_slot = value.committees_at_slot.parse::().map_err(|_| { pluto_eth2api::EthBeaconNodeApiClientError::ParseError("committees_at_slot".into()) })?; let validator_committee_index = - self.validator_committee_index.parse::().map_err(|_| { - pluto_eth2api::EthBeaconNodeApiClientError::ParseError( - "validator_committee_index".into(), - ) - })?; + value + .validator_committee_index + .parse::() + .map_err(|_| { + pluto_eth2api::EthBeaconNodeApiClientError::ParseError( + "validator_committee_index".into(), + ) + })?; Ok(AttesterDutyDefinition { pubkey, @@ -532,18 +537,20 @@ pub struct SyncCommitteeDutyDefinition { pub validator_sync_committee_indices: Vec, } -impl TryInto - for pluto_eth2api::types::GetSyncCommitteeDutiesResponseResponseDatum +impl TryFrom + for SyncCommitteeDutyDefinition { type Error = pluto_eth2api::EthBeaconNodeApiClientError; - fn try_into(self) -> Result { - let pubkey = PubKey::try_from(self.pubkey.as_str()) + fn try_from( + value: pluto_eth2api::types::GetSyncCommitteeDutiesResponseResponseDatum, + ) -> Result { + let pubkey = PubKey::try_from(value.pubkey.as_str()) .map_err(|_| pluto_eth2api::EthBeaconNodeApiClientError::ParseError("pubkey".into()))?; - let validator_index = self.validator_index.parse::().map_err(|_| { + let validator_index = value.validator_index.parse::().map_err(|_| { pluto_eth2api::EthBeaconNodeApiClientError::ParseError("validator_index".into()) })?; - let validator_sync_committee_indices = self + let validator_sync_committee_indices = value .validator_sync_committee_indices .iter() .map(|idx| { From 8c0c1a13d3a1480fa62bf7491c700a6a805d60ad Mon Sep 17 00:00:00 2001 From: "emlautarom1-agent[bot]" <292495798+emlautarom1-agent[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 13:20:50 -0300 Subject: [PATCH 26/30] test(core): split new_graffiti_builder into focused tests Break the multi-scenario builder test into one test per scenario (length mismatch, nil, single/multiple graffiti with/without client append), sharing a test_pubkeys helper. Per review feedback on PR #454. --- crates/core/src/fetcher/graffiti.rs | 43 ++++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 7 deletions(-) diff --git a/crates/core/src/fetcher/graffiti.rs b/crates/core/src/fetcher/graffiti.rs index 3cfa3310..28de3b3c 100644 --- a/crates/core/src/fetcher/graffiti.rs +++ b/crates/core/src/fetcher/graffiti.rs @@ -249,16 +249,21 @@ mod tests { assert_eq!(builder.get_graffiti(&pubkeys[2]), super::default_graffiti()); } - #[tokio::test] - async fn new_graffiti_builder() { - let pubkeys = [ + /// Three distinct pubkeys used across the `GraffitiBuilder::new` tests. + fn test_pubkeys() -> [PubKey; 3] { + [ PubKey::new([1u8; PK_LEN]), PubKey::new([2u8; PK_LEN]), PubKey::new([3u8; PK_LEN]), - ]; + ] + } - // graffiti length greater than pubkeys. + #[tokio::test] + async fn new_rejects_mismatched_graffiti_length() { + let pubkeys = test_pubkeys(); let mock = BeaconMock::builder().build().await.expect("build mock"); + + // graffiti length greater than pubkeys. let graffiti = vec![ "a".repeat(10), "b".repeat(15), @@ -272,14 +277,24 @@ mod tests { let graffiti = vec!["a".repeat(10), "b".repeat(15)]; let result = GraffitiBuilder::new(&pubkeys, Some(&graffiti), false, mock.client()).await; assert!(matches!(result, Err(GraffitiError::LengthMismatch))); + } + + #[tokio::test] + async fn new_with_nil_graffiti_uses_default() { + let pubkeys = test_pubkeys(); + let mock = BeaconMock::builder().build().await.expect("build mock"); - // nil graffiti. let builder = GraffitiBuilder::new(&pubkeys, None, false, mock.client()) .await .expect("build builder"); for pubkey in &pubkeys { assert_eq!(builder.get_graffiti(pubkey), super::default_graffiti()); } + } + + #[tokio::test] + async fn new_single_graffiti_with_append() { + let pubkeys = test_pubkeys(); // single graffiti with append (Grandine -> GD). let mock = mock_with_version("Grandine/v2.1.4 (Linux x86_64)").await; @@ -296,8 +311,12 @@ mod tests { for pubkey in &pubkeys { assert_eq!(builder.get_graffiti(pubkey), expected); } + } + + #[tokio::test] + async fn new_single_graffiti_without_append() { + let pubkeys = test_pubkeys(); - // single graffiti without append. let mock = mock_with_version("Teku/v4.2.1 (Linux x86_64)").await; let graffiti = "y".repeat(GRAFFITI_LEN); let builder = GraffitiBuilder::new( @@ -312,6 +331,11 @@ mod tests { for pubkey in &pubkeys { assert_eq!(builder.get_graffiti(pubkey), expected); } + } + + #[tokio::test] + async fn new_multiple_graffiti_with_append() { + let pubkeys = test_pubkeys(); // multiple graffiti with append (Prysm -> PY). let mock = mock_with_version("Prysm/v0.2.7 (Linux x86_64)").await; @@ -327,6 +351,11 @@ mod tests { let expected = graffiti_bytes(&format!("{}{OBOL_TOKEN}PY", graffiti[idx])); assert_eq!(builder.get_graffiti(pubkey), expected); } + } + + #[tokio::test] + async fn new_multiple_graffiti_without_append() { + let pubkeys = test_pubkeys(); // multiple graffiti without append (empty version -> empty token). let mock = mock_with_version("").await; From 19f9376f1ee825f85b4438edaeb9b0baa6b1f770 Mon Sep 17 00:00:00 2001 From: "emlautarom1-agent[bot]" <292495798+emlautarom1-agent[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 13:23:14 -0300 Subject: [PATCH 27/30] refactor(core): inline the hex_0x helper at its call sites Replace the trivial hex_0x wrapper with inline format\!("0x{}", hex::encode(..)) at each caller. Per review feedback on PR #454. --- crates/core/src/fetcher/mod.rs | 38 +++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/crates/core/src/fetcher/mod.rs b/crates/core/src/fetcher/mod.rs index 8d1de17d..7d1a7eca 100644 --- a/crates/core/src/fetcher/mod.rs +++ b/crates/core/src/fetcher/mod.rs @@ -9,11 +9,11 @@ use graffiti::GraffitiBuilder; use std::{collections::HashMap, future::Future, pin::Pin, sync::Arc}; use pluto_eth2api::{ - EthBeaconNodeApiClient, EthBeaconNodeApiClientError, - GetAggregatedAttestationV2Request, GetAggregatedAttestationV2Response, - GetAggregatedAttestationV2ResponseResponseData, ProduceAttestationDataRequest, - ProduceAttestationDataResponse, ProduceBlockV3Request, ProduceBlockV3Response, - ProduceSyncCommitteeContributionRequest, ProduceSyncCommitteeContributionResponse, + EthBeaconNodeApiClient, EthBeaconNodeApiClientError, GetAggregatedAttestationV2Request, + GetAggregatedAttestationV2Response, GetAggregatedAttestationV2ResponseResponseData, + ProduceAttestationDataRequest, ProduceAttestationDataResponse, ProduceBlockV3Request, + ProduceBlockV3Response, ProduceSyncCommitteeContributionRequest, + ProduceSyncCommitteeContributionResponse, spec::{ConversionError, altair, phase0}, versioned, }; @@ -352,8 +352,8 @@ impl Fetcher { let request = ProduceBlockV3Request::builder() .slot(slot.to_string()) - .randao_reveal(hex_0x(&randao)) - .graffiti(hex_0x(&graffiti)) + .randao_reveal(format!("0x{}", hex::encode(randao))) + .graffiti(format!("0x{}", hex::encode(graffiti))) .builder_boost_factor(builder_boost_factor.to_string()) .build() .map_err(EthBeaconNodeApiClientError::RequestError)?; @@ -471,7 +471,7 @@ impl Fetcher { data_root: phase0::Root, ) -> Result { let request = GetAggregatedAttestationV2Request::builder() - .attestation_data_root(hex_0x(&data_root)) + .attestation_data_root(format!("0x{}", hex::encode(data_root))) .slot(slot.to_string()) .committee_index(comm_idx.to_string()) .build() @@ -507,7 +507,7 @@ impl Fetcher { let request = ProduceSyncCommitteeContributionRequest::builder() .slot(slot.to_string()) .subcommittee_index(subcomm_idx.to_string()) - .beacon_block_root(hex_0x(&block_root)) + .beacon_block_root(format!("0x{}", hex::encode(block_root))) .build() .map_err(EthBeaconNodeApiClientError::RequestError)?; @@ -553,11 +553,6 @@ fn downcast(data: &dyn SignedData) -> Option<&T> { (data as &dyn std::any::Any).downcast_ref::() } -/// Formats bytes as a `0x`-prefixed lowercase hex string. -fn hex_0x(bytes: &[u8]) -> String { - format!("0x{}", hex::encode(bytes)) -} - /// Builds a versioned attestation payload from the beacon node's aggregate /// attestation response. /// @@ -1328,8 +1323,14 @@ mod tests { ]); let by_root = HashMap::from([ - (hex_0x(&att_a.data.tree_hash_root().0), att_a.clone()), - (hex_0x(&att_b.data.tree_hash_root().0), att_b.clone()), + ( + format!("0x{}", hex::encode(att_a.data.tree_hash_root().0)), + att_a.clone(), + ), + ( + format!("0x{}", hex::encode(att_b.data.tree_hash_root().0)), + att_b.clone(), + ), ]); let mock = BeaconMock::builder() @@ -1382,7 +1383,10 @@ mod tests { (pk_b, attester_def(att.data.index, 0)), ]); - let by_root = HashMap::from([(hex_0x(&att.data.tree_hash_root().0), att.clone())]); + let by_root = HashMap::from([( + format!("0x{}", hex::encode(att.data.tree_hash_root().0)), + att.clone(), + )]); let mock = BeaconMock::builder() .spec(aggregator_spec()) From 806d2ba23b350eb868915e2dd6393b661ff0b513 Mon Sep 17 00:00:00 2001 From: "emlautarom1-agent[bot]" <292495798+emlautarom1-agent[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 13:23:45 -0300 Subject: [PATCH 28/30] docs(core): note the deliberate fee-recipient serialization swallow Add a NOTE explaining that serialization errors in fee_recipient_mismatch are intentionally swallowed (best-effort, warn-only verification) and still surface via the missing-address guard. Per review feedback on PR #454. --- crates/core/src/fetcher/mod.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/core/src/fetcher/mod.rs b/crates/core/src/fetcher/mod.rs index 7d1a7eca..8cad684a 100644 --- a/crates/core/src/fetcher/mod.rs +++ b/crates/core/src/fetcher/mod.rs @@ -608,6 +608,11 @@ fn fee_recipient_mismatch( return None; } + // NOTE: serialization errors here (and `proposal_body`'s `unwrap_or(Null)`) + // are deliberately swallowed — this is best-effort, warn-only verification + // mirroring Go's `verifyFeeRecipient`. A failure collapses to a value that + // carries no `fee_recipient`, which falls through to the guard below and + // surfaces as a `debug_assert!`/`warn!`, so a regression is not invisible. let value = serde_json::to_value(proposal_body(&proposal.block)).ok()?; // Unblinded blocks carry `execution_payload`; blinded blocks carry From a33ec25ccccf28574fd3b4275847c6cd5ec6974e Mon Sep 17 00:00:00 2001 From: Lautaro Emanuel Date: Fri, 19 Jun 2026 14:03:04 -0300 Subject: [PATCH 29/30] Simplify `verify_fee_recipient` - Remove unnecessary serialization --- crates/core/src/fetcher/mod.rs | 106 ++++++++++----------------------- 1 file changed, 33 insertions(+), 73 deletions(-) diff --git a/crates/core/src/fetcher/mod.rs b/crates/core/src/fetcher/mod.rs index 8cad684a..562d0f3c 100644 --- a/crates/core/src/fetcher/mod.rs +++ b/crates/core/src/fetcher/mod.rs @@ -14,7 +14,7 @@ use pluto_eth2api::{ ProduceAttestationDataRequest, ProduceAttestationDataResponse, ProduceBlockV3Request, ProduceBlockV3Response, ProduceSyncCommitteeContributionRequest, ProduceSyncCommitteeContributionResponse, - spec::{ConversionError, altair, phase0}, + spec::{ConversionError, altair, bellatrix::ExecutionAddress, phase0}, versioned, }; use pluto_eth2util::eth2exp::{self, Eth2ExpError}; @@ -48,7 +48,7 @@ pub type AwaitAttDataFunc = Arc CallbackFuture + Send + Sync>; /// Fee recipient resolver: returns the configured fee recipient for a pubkey. -pub type FeeRecipientFunc = Arc String + Send + Sync>; +pub type FeeRecipientFunc = Arc ExecutionAddress + Send + Sync>; /// Errors returned while fetching duty data. #[derive(Debug, thiserror::Error)] @@ -584,85 +584,48 @@ fn attestation_payload( /// Logs a warning when the fee recipient is not correctly populated in the /// proposal. Fee recipient is unavailable in forks earlier than Bellatrix. -fn verify_fee_recipient(proposal: &VersionedProposal, fee_recipient_address: &str) { +fn verify_fee_recipient(proposal: &VersionedProposal, fee_recipient_address: &ExecutionAddress) { if let Some((expected, actual)) = fee_recipient_mismatch(proposal, fee_recipient_address) { tracing::warn!( - expected = %expected, - actual = %actual, + expected = format!("0x{}", hex::encode(expected)), + actual = format!("0x{}", hex::encode(actual)), "Proposal with unexpected fee recipient address" ); } } /// Returns `Some((expected, actual))` when the proposal's fee recipient differs -/// (case-insensitively) from `fee_recipient_address`. Returns `None` for forks +/// from `fee_recipient_address`. Returns `None` for forks /// without a fee recipient (pre-Bellatrix) or when the addresses match. fn fee_recipient_mismatch( proposal: &VersionedProposal, - fee_recipient_address: &str, -) -> Option<(String, String)> { - if matches!( - proposal.version(), - versioned::DataVersion::Phase0 | versioned::DataVersion::Altair - ) { - return None; - } + fee_recipient_address: &ExecutionAddress, +) -> Option<(ExecutionAddress, ExecutionAddress)> { + let actual_addr = proposal_block_fee_recipient(&proposal.block)?; - // NOTE: serialization errors here (and `proposal_body`'s `unwrap_or(Null)`) - // are deliberately swallowed — this is best-effort, warn-only verification - // mirroring Go's `verifyFeeRecipient`. A failure collapses to a value that - // carries no `fee_recipient`, which falls through to the guard below and - // surfaces as a `debug_assert!`/`warn!`, so a regression is not invisible. - let value = serde_json::to_value(proposal_body(&proposal.block)).ok()?; - - // Unblinded blocks carry `execution_payload`; blinded blocks carry - // `execution_payload_header`. Both expose `fee_recipient`. - let Some(actual_addr) = value - .get("execution_payload") - .or_else(|| value.get("execution_payload_header")) - .and_then(|payload| payload.get("fee_recipient")) - .and_then(|addr| addr.as_str()) - else { - // Every Bellatrix+ proposal carries a fee recipient, so reaching here - // means the payload shape changed (e.g. a renamed key) and the - // traversal above silently stopped matching. Fail loudly in dev/test - // and warn in production rather than reporting a false "no mismatch". - debug_assert!( - false, - "Bellatrix+ proposal yielded no fee recipient address; \ - execution payload shape may have changed" - ); - tracing::warn!( - version = ?proposal.version(), - "Bellatrix+ proposal yielded no extractable fee recipient address" - ); - return None; - }; - - if actual_addr.eq_ignore_ascii_case(fee_recipient_address) { + if actual_addr == *fee_recipient_address { None } else { - Some((fee_recipient_address.to_string(), actual_addr.to_string())) + Some((*fee_recipient_address, actual_addr)) } } -/// Returns the block body as a JSON value, used by [`verify_fee_recipient`]. -fn proposal_body(block: &ProposalBlock) -> serde_json::Value { - let body = match block { - ProposalBlock::Phase0(b) => serde_json::to_value(&b.body), - ProposalBlock::Altair(b) => serde_json::to_value(&b.body), - ProposalBlock::Bellatrix(b) => serde_json::to_value(&b.body), - ProposalBlock::BellatrixBlinded(b) => serde_json::to_value(&b.body), - ProposalBlock::Capella(b) => serde_json::to_value(&b.body), - ProposalBlock::CapellaBlinded(b) => serde_json::to_value(&b.body), - ProposalBlock::Deneb { block, .. } => serde_json::to_value(&block.body), - ProposalBlock::DenebBlinded(b) => serde_json::to_value(&b.body), - ProposalBlock::Electra { block, .. } => serde_json::to_value(&block.body), - ProposalBlock::ElectraBlinded(b) => serde_json::to_value(&b.body), - ProposalBlock::Fulu { block, .. } => serde_json::to_value(&block.body), - ProposalBlock::FuluBlinded(b) => serde_json::to_value(&b.body), - }; - body.unwrap_or(serde_json::Value::Null) +/// Extracts the fee recipient from a proposal block, if available. Returns +/// `None` for pre-Bellatrix blocks or if the fee recipient cannot be extracted. +fn proposal_block_fee_recipient(block: &ProposalBlock) -> Option<[u8; 20]> { + match block { + ProposalBlock::Bellatrix(b) => Some(b.body.execution_payload.fee_recipient), + ProposalBlock::BellatrixBlinded(b) => Some(b.body.execution_payload_header.fee_recipient), + ProposalBlock::Capella(b) => Some(b.body.execution_payload.fee_recipient), + ProposalBlock::CapellaBlinded(b) => Some(b.body.execution_payload_header.fee_recipient), + ProposalBlock::Deneb { block, .. } => Some(block.body.execution_payload.fee_recipient), + ProposalBlock::DenebBlinded(b) => Some(b.body.execution_payload_header.fee_recipient), + ProposalBlock::Electra { block, .. } => Some(block.body.execution_payload.fee_recipient), + ProposalBlock::ElectraBlinded(b) => Some(b.body.execution_payload_header.fee_recipient), + ProposalBlock::Fulu { block, .. } => Some(block.body.execution_payload.fee_recipient), + ProposalBlock::FuluBlinded(b) => Some(b.body.execution_payload_header.fee_recipient), + _ => None, + } } /// Tracks which pubkeys were selected/resolved for aggregation duties so the @@ -750,7 +713,7 @@ mod tests { /// Fee-recipient stub for tests that don't exercise fee-recipient /// verification. fn stub_fee_recipient() -> FeeRecipientFunc { - Arc::new(|_| String::new()) + Arc::new(|_| ExecutionAddress::default()) } /// AggSigDB stub for tests whose duty path never queries it. @@ -981,18 +944,15 @@ mod tests { execution_payload_value: alloy::primitives::U256::ZERO, }; - // A different address is reported as a mismatch; the proposal's own - // fee recipient matches itself (case-insensitively). - let (_, actual) = fee_recipient_mismatch(&proposal, "0xdead") - .unwrap_or_else(|| panic!("{name}: expected a mismatch against 0xdead")); + // A different address is reported as a mismatch. + // the proposal's own fee recipient matches itself. + let some_fee_recipient = [0xFF; 20]; + let (_, actual) = fee_recipient_mismatch(&proposal, &some_fee_recipient) + .unwrap_or_else(|| panic!("{name}: expected a mismatch")); assert!( fee_recipient_mismatch(&proposal, &actual).is_none(), "{name}: should match its own fee recipient", ); - assert!( - fee_recipient_mismatch(&proposal, &actual.to_uppercase()).is_none(), - "{name}: should match case-insensitively", - ); } } From 6c1bf9bdd09b890254796b5f3245a8faf33b8ca8 Mon Sep 17 00:00:00 2001 From: Lautaro Emanuel Date: Fri, 19 Jun 2026 18:02:16 -0300 Subject: [PATCH 30/30] Update lockfile --- Cargo.lock | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e0ff90bb..bc4b3917 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1307,15 +1307,15 @@ checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" [[package]] name = "bitcoin-io" -version = "0.1.100" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11301df0b06f22dea7bb1916403fdd88a371031e495c49b8f96931b28189e175" +checksum = "2dee39a0ee5b4095224a0cfc6bf4cc1baf0f9624b96b367e53b66d974e51d953" [[package]] name = "bitcoin_hashes" -version = "0.14.100" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c9901a56e133a1fc86eeb1113e2591f45f4682451ca893bff494d2f88918e3f" +checksum = "4ed83caece3afc59919481b33b472e1432d1abc4641ed9100be142ef5110b406" dependencies = [ "bitcoin-io", "hex-conservative", @@ -1329,9 +1329,9 @@ checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "bitvec" -version = "1.0.1" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" dependencies = [ "funty", "radium", @@ -1479,9 +1479,9 @@ dependencies = [ [[package]] name = "borsh" -version = "1.6.1" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" +checksum = "2f3f6da4992df95bbcd9af42a6c7dcb994498fc9048230405f3b36ff7cd3f145" dependencies = [ "borsh-derive", "bytes", @@ -1490,9 +1490,9 @@ dependencies = [ [[package]] name = "borsh-derive" -version = "1.6.1" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59" +checksum = "3ae8fb4fb5740e4b2c4884ff95f5f32f5e8479db1e8fd8eb49ddbe09eb09bb7c" dependencies = [ "once_cell", "proc-macro-crate", @@ -1598,9 +1598,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.64" +version = "1.2.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" dependencies = [ "find-msvc-tools", "jobserver", @@ -3435,7 +3435,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.57.0", + "windows-core 0.62.2", ] [[package]] @@ -5638,7 +5638,7 @@ dependencies = [ "prost-types 0.14.4", "rand 0.8.6", "regex", - "reqwest 0.13.3", + "reqwest 0.13.4", "serde", "serde_json", "test-case",