From 0e99f8a358f45b1a9a16b817f4ebe6281bd06d0e Mon Sep 17 00:00:00 2001 From: Quang Le Date: Mon, 20 Apr 2026 16:37:41 +0700 Subject: [PATCH 1/8] refactor(eth2api): message root --- crates/eth2api/src/spec/altair.rs | 27 ++++++++++ crates/eth2api/src/spec/phase0.rs | 8 +++ crates/eth2api/src/v1.rs | 27 ++++++++++ crates/eth2api/src/versioned.rs | 87 +++++++++++++++++++++++++++++++ 4 files changed, 149 insertions(+) diff --git a/crates/eth2api/src/spec/altair.rs b/crates/eth2api/src/spec/altair.rs index 3b8b6f44..783d11fc 100644 --- a/crates/eth2api/src/spec/altair.rs +++ b/crates/eth2api/src/spec/altair.rs @@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize}; use serde_with::serde_as; +use tree_hash::TreeHash; use tree_hash_derive::TreeHash; use pluto_ssz::BitVector; @@ -172,6 +173,32 @@ pub struct SyncAggregatorSelectionData { pub subcommittee_index: u64, } +impl SyncCommitteeMessage { + /// Returns the message root signed by sync committee messages. + pub fn message_root(&self) -> phase0::Root { + self.beacon_block_root + } +} + +impl ContributionAndProof { + /// Returns the message root used for sync committee selection proofs. + pub fn selection_proof_message_root(&self) -> phase0::Root { + SyncAggregatorSelectionData { + slot: self.contribution.slot, + subcommittee_index: self.contribution.subcommittee_index, + } + .tree_hash_root() + .0 + } +} + +impl SignedContributionAndProof { + /// Returns the SSZ message root of the unsigned contribution-and-proof payload. + pub fn message_root(&self) -> phase0::Root { + self.message.tree_hash_root().0 + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/eth2api/src/spec/phase0.rs b/crates/eth2api/src/spec/phase0.rs index 7ea80968..43a5e0af 100644 --- a/crates/eth2api/src/spec/phase0.rs +++ b/crates/eth2api/src/spec/phase0.rs @@ -3,6 +3,7 @@ //! See: use serde::{Deserialize, Serialize}; use serde_with::serde_as; +use tree_hash::TreeHash; use tree_hash_derive::TreeHash; pub use pluto_ssz::{BitList, SszList, SszVector}; @@ -147,6 +148,13 @@ pub struct SigningData { pub domain: Domain, } +impl SignedVoluntaryExit { + /// Returns the SSZ message root of the unsigned voluntary exit. + pub fn message_root(&self) -> Root { + self.message.tree_hash_root().0 + } +} + /// ETH1 voting data. /// /// Spec: diff --git a/crates/eth2api/src/v1.rs b/crates/eth2api/src/v1.rs index 917719c9..19a77f2d 100644 --- a/crates/eth2api/src/v1.rs +++ b/crates/eth2api/src/v1.rs @@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize}; use serde_with::serde_as; +use tree_hash::TreeHash; use tree_hash_derive::TreeHash; use crate::spec::{ @@ -79,6 +80,32 @@ pub struct SyncCommitteeSelection { pub selection_proof: BLSSignature, } +impl ValidatorRegistration { + /// Returns the SSZ message root of the unsigned builder registration. + pub fn message_root(&self) -> crate::spec::phase0::Root { + self.tree_hash_root().0 + } +} + +impl BeaconCommitteeSelection { + /// Returns the message root used for aggregation selection proofs. + pub fn message_root(&self) -> crate::spec::phase0::Root { + self.slot.tree_hash_root().0 + } +} + +impl SyncCommitteeSelection { + /// Returns the message root used for sync committee selection proofs. + pub fn message_root(&self) -> crate::spec::phase0::Root { + crate::spec::altair::SyncAggregatorSelectionData { + slot: self.slot, + subcommittee_index: self.subcommittee_index, + } + .tree_hash_root() + .0 + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/eth2api/src/versioned.rs b/crates/eth2api/src/versioned.rs index c4559ec5..99ae731b 100644 --- a/crates/eth2api/src/versioned.rs +++ b/crates/eth2api/src/versioned.rs @@ -1,6 +1,7 @@ //! Versioned wrappers and version enums used by signeddata flows. use serde::{Deserialize, Serialize}; +use tree_hash::TreeHash; pub use crate::spec::{BuilderVersion, DataVersion}; use crate::{ @@ -315,6 +316,18 @@ impl SignedAggregateAndProofPayload { .into_bytes(), } } + + /// Returns the SSZ message root of the unsigned aggregate-and-proof payload. + pub fn message_root(&self) -> phase0::Root { + match self { + Self::Phase0(payload) + | Self::Altair(payload) + | Self::Bellatrix(payload) + | Self::Capella(payload) + | Self::Deneb(payload) => payload.message.tree_hash_root().0, + Self::Electra(payload) | Self::Fulu(payload) => payload.message.tree_hash_root().0, + } + } } /// Versioned signed validator registration wrapper. @@ -325,3 +338,77 @@ pub struct VersionedSignedValidatorRegistration { /// V1 payload. pub v1: Option, } + +impl VersionedSignedAggregateAndProof { + /// Returns the SSZ message root of the wrapped payload. + pub fn message_root(&self) -> Option { + if self.version == DataVersion::Unknown { + return None; + } + + Some(self.aggregate_and_proof.message_root()) + } +} + +impl VersionedSignedValidatorRegistration { + /// Returns the SSZ message root of the wrapped builder registration. + pub fn message_root(&self) -> Option { + match self.version { + BuilderVersion::V1 => self.v1.as_ref().map(|value| value.message.message_root()), + BuilderVersion::Unknown => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_fixtures; + + #[test] + fn versioned_signed_aggregate_and_proof_message_root_delegates_to_payload() { + let signed = electra::SignedAggregateAndProof { + message: electra::AggregateAndProof { + aggregator_index: 456, + aggregate: serde_json::from_str( + test_fixtures::VECTORS.electra_oversized_attestation_json, + ) + .expect("electra attestation"), + selection_proof: test_fixtures::seq::<96>(0xE0), + }, + signature: test_fixtures::seq::<96>(0xE1), + }; + let expected = signed.message.tree_hash_root().0; + + let wrapped = VersionedSignedAggregateAndProof { + version: DataVersion::Electra, + aggregate_and_proof: SignedAggregateAndProofPayload::Electra(signed), + }; + + assert_eq!(wrapped.message_root(), Some(expected)); + } + + #[test] + fn versioned_signed_validator_registration_message_root_matches_v1_message() { + let message = v1::ValidatorRegistration { + fee_recipient: test_fixtures::seq::<20>(0xD1), + gas_limit: 30_000_000, + timestamp: 1_700_000_789, + pubkey: test_fixtures::seq::<48>(0xD2), + }; + let signed = v1::SignedValidatorRegistration { + message: message.clone(), + signature: test_fixtures::seq::<96>(0xD3), + }; + let expected = message.message_root(); + + assert_eq!( + VersionedSignedValidatorRegistration { + version: BuilderVersion::V1, + v1: Some(signed), + } + .message_root(), + Some(expected) + ); + } +} From be93be35911b63b1588749bccfceb2505f23de42 Mon Sep 17 00:00:00 2001 From: Quang Le Date: Mon, 20 Apr 2026 16:40:36 +0700 Subject: [PATCH 2/8] refactor(core): signeddate using new message root --- crates/core/src/signeddata.rs | 48 +++++++++-------------------------- 1 file changed, 12 insertions(+), 36 deletions(-) diff --git a/crates/core/src/signeddata.rs b/crates/core/src/signeddata.rs index 8e1d1bba..0aafeffe 100644 --- a/crates/core/src/signeddata.rs +++ b/crates/core/src/signeddata.rs @@ -585,7 +585,7 @@ impl SignedData for SignedVoluntaryExit { } fn message_root(&self) -> Result<[u8; 32], SignedDataError> { - Ok(hash_root(&self.0.message)) + Ok(self.0.message_root()) } } @@ -668,12 +668,10 @@ impl SignedData for VersionedSignedValidatorRegistration { fn message_root(&self) -> Result<[u8; 32], SignedDataError> { match self.0.version { - versioned::BuilderVersion::V1 => { - let Some(v1) = self.0.v1.as_ref() else { - return Err(SignedDataError::MissingV1Registration); - }; - Ok(hash_root(&v1.message)) - } + versioned::BuilderVersion::V1 => self + .0 + .message_root() + .ok_or(SignedDataError::MissingV1Registration), versioned::BuilderVersion::Unknown => Err(SignedDataError::UnknownVersion), } } @@ -745,7 +743,7 @@ impl SignedData for SignedRandao { } fn message_root(&self) -> Result<[u8; 32], SignedDataError> { - Ok(hash_root(&self.0)) + Ok(self.0.message_root()) } } @@ -788,7 +786,7 @@ impl SignedData for BeaconCommitteeSelection { } fn message_root(&self) -> Result<[u8; 32], SignedDataError> { - Ok(hash_root(&self.0.slot)) + Ok(self.0.message_root()) } } @@ -824,12 +822,7 @@ impl SignedData for SyncCommitteeSelection { } fn message_root(&self) -> Result<[u8; 32], SignedDataError> { - let data = altair::SyncAggregatorSelectionData { - slot: self.0.slot, - subcommittee_index: self.0.subcommittee_index, - }; - - Ok(hash_root(&data)) + Ok(self.0.message_root()) } } @@ -950,19 +943,7 @@ impl SignedData for VersionedSignedAggregateAndProof { return Err(SignedDataError::UnknownVersion); } - Ok(match &self.0.aggregate_and_proof { - versioned::SignedAggregateAndProofPayload::Phase0(payload) - | versioned::SignedAggregateAndProofPayload::Altair(payload) - | versioned::SignedAggregateAndProofPayload::Bellatrix(payload) - | versioned::SignedAggregateAndProofPayload::Capella(payload) - | versioned::SignedAggregateAndProofPayload::Deneb(payload) => { - hash_root(&payload.message) - } - versioned::SignedAggregateAndProofPayload::Electra(payload) - | versioned::SignedAggregateAndProofPayload::Fulu(payload) => { - hash_root(&payload.message) - } - }) + self.0.message_root().ok_or(SignedDataError::UnknownVersion) } } @@ -1043,7 +1024,7 @@ impl SignedData for SignedSyncMessage { } fn message_root(&self) -> Result<[u8; 32], SignedDataError> { - Ok(self.0.beacon_block_root) + Ok(self.0.message_root()) } } @@ -1079,12 +1060,7 @@ impl SignedData for SyncContributionAndProof { } fn message_root(&self) -> Result<[u8; 32], SignedDataError> { - let data = altair::SyncAggregatorSelectionData { - slot: self.0.contribution.slot, - subcommittee_index: self.0.contribution.subcommittee_index, - }; - - Ok(hash_root(&data)) + Ok(self.0.selection_proof_message_root()) } } @@ -1120,7 +1096,7 @@ impl SignedData for SignedSyncContributionAndProof { } fn message_root(&self) -> Result<[u8; 32], SignedDataError> { - Ok(hash_root(&self.0.message)) + Ok(self.0.message_root()) } } From b0aa630b33a52cc204e431c240ce4b72e7bef4dd Mon Sep 17 00:00:00 2001 From: Quang Le Date: Mon, 20 Apr 2026 17:29:13 +0700 Subject: [PATCH 3/8] feat(eth2api): update extension --- crates/eth2api/src/extensions.rs | 453 +++++++++++++++++++++++++++---- 1 file changed, 403 insertions(+), 50 deletions(-) diff --git a/crates/eth2api/src/extensions.rs b/crates/eth2api/src/extensions.rs index 86d56310..2253cc0e 100644 --- a/crates/eth2api/src/extensions.rs +++ b/crates/eth2api/src/extensions.rs @@ -4,6 +4,7 @@ use crate::{ }; use chrono::{DateTime, Utc}; use std::{collections::HashMap, time}; +use tree_hash::TreeHash; /// Error that can occur when using the /// [`EthBeaconNodeApiClient`]. @@ -25,6 +26,10 @@ pub enum EthBeaconNodeApiClientError { /// Zero slot duration or slots per epoch in network spec #[error("Zero slot duration or slots per epoch in network spec")] ZeroSlotDurationOrSlotsPerEpoch, + + /// Domain type not found in the beacon spec response + #[error("Domain type not found: {0}")] + DomainTypeNotFound(DomainName), } const FORKS: [ConsensusVersion; 6] = [ @@ -46,6 +51,193 @@ pub struct ForkSchedule { pub epoch: phase0::Epoch, } +/// Domain name as defined in the consensus and builder specs. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum DomainName { + /// `DOMAIN_BEACON_PROPOSER` + BeaconProposer, + /// `DOMAIN_BEACON_ATTESTER` + BeaconAttester, + /// `DOMAIN_RANDAO` + Randao, + /// `DOMAIN_VOLUNTARY_EXIT` + VoluntaryExit, + /// `DOMAIN_APPLICATION_BUILDER` + ApplicationBuilder, + /// `DOMAIN_SELECTION_PROOF` + SelectionProof, + /// `DOMAIN_AGGREGATE_AND_PROOF` + AggregateAndProof, + /// `DOMAIN_SYNC_COMMITTEE` + SyncCommittee, + /// `DOMAIN_SYNC_COMMITTEE_SELECTION_PROOF` + SyncCommitteeSelectionProof, + /// `DOMAIN_CONTRIBUTION_AND_PROOF` + ContributionAndProof, + /// `DOMAIN_DEPOSIT` + Deposit, + /// `DOMAIN_BLOB_SIDECAR` + BlobSidecar, +} + +impl std::fmt::Display for DomainName { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_spec_key()) + } +} + +impl DomainName { + /// Returns the spec key used in `/eth/v1/config/spec`. + pub const fn as_spec_key(self) -> &'static str { + match self { + Self::BeaconProposer => "DOMAIN_BEACON_PROPOSER", + Self::BeaconAttester => "DOMAIN_BEACON_ATTESTER", + Self::Randao => "DOMAIN_RANDAO", + Self::VoluntaryExit => "DOMAIN_VOLUNTARY_EXIT", + Self::ApplicationBuilder => "DOMAIN_APPLICATION_BUILDER", + Self::SelectionProof => "DOMAIN_SELECTION_PROOF", + Self::AggregateAndProof => "DOMAIN_AGGREGATE_AND_PROOF", + Self::SyncCommittee => "DOMAIN_SYNC_COMMITTEE", + Self::SyncCommitteeSelectionProof => "DOMAIN_SYNC_COMMITTEE_SELECTION_PROOF", + Self::ContributionAndProof => "DOMAIN_CONTRIBUTION_AND_PROOF", + Self::Deposit => "DOMAIN_DEPOSIT", + Self::BlobSidecar => "DOMAIN_BLOB_SIDECAR", + } + } +} + +fn decode_fixed_hex(value: &str) -> Result<[u8; N], EthBeaconNodeApiClientError> { + let value = value.strip_prefix("0x").unwrap_or(value); + let bytes = hex::decode(value).map_err(|_| EthBeaconNodeApiClientError::UnexpectedType)?; + + bytes + .try_into() + .map_err(|_| EthBeaconNodeApiClientError::UnexpectedType) +} + +fn fork_schedule_from_spec( + spec_data: &serde_json::Value, +) -> Result, EthBeaconNodeApiClientError> { + fn fetch_fork( + fork: &ConsensusVersion, + spec_data: &serde_json::Value, + ) -> Result { + let version_field = format!("{}_FORK_VERSION", fork.to_string().to_uppercase()); + let version = spec_data + .as_object() + .and_then(|o| o.get(&version_field)) + .and_then(|f| f.as_str()) + .ok_or(EthBeaconNodeApiClientError::UnexpectedType) + .and_then(decode_fixed_hex)?; + + let epoch_field = format!("{}_FORK_EPOCH", fork.to_string().to_uppercase()); + let epoch = spec_data + .as_object() + .and_then(|o| o.get(&epoch_field)) + .and_then(|v| v.as_str()) + .and_then(|s| s.parse::().ok()) + .ok_or(EthBeaconNodeApiClientError::UnexpectedType)?; + + Ok(ForkSchedule { version, epoch }) + } + + let mut result = HashMap::new(); + for fork in FORKS { + let fork_schedule = fetch_fork(&fork, spec_data)?; + result.insert(fork, fork_schedule); + } + + Ok(result) +} + +/// Computes the final 32-byte beacon domain from domain type, fork version, and genesis root. +pub fn compute_domain( + domain_type: phase0::DomainType, + fork_version: phase0::Version, + genesis_validators_root: phase0::Root, +) -> phase0::Domain { + let fork_data = phase0::ForkData { + current_version: fork_version, + genesis_validators_root, + }; + let fork_data_root = fork_data.tree_hash_root(); + + let mut domain = phase0::Domain::default(); + domain[..phase0::DOMAIN_TYPE_LEN].copy_from_slice(&domain_type); + domain[phase0::DOMAIN_TYPE_LEN..] + .copy_from_slice(&fork_data_root.0[..(phase0::DOMAIN_LEN - phase0::DOMAIN_TYPE_LEN)]); + + domain +} + +/// Computes the builder domain using `GENESIS_FORK_VERSION` and a zero validators root. +pub fn compute_builder_domain( + domain_type: phase0::DomainType, + genesis_fork_version: phase0::Version, +) -> phase0::Domain { + compute_domain(domain_type, genesis_fork_version, phase0::Root::default()) +} + +/// Resolves the domain type from the beacon spec. +pub fn resolve_domain_type( + spec_data: &serde_json::Value, + name: DomainName, +) -> Result { + let raw = spec_data + .as_object() + .and_then(|o| o.get(name.as_spec_key())) + .and_then(|value| value.as_str()) + .ok_or(EthBeaconNodeApiClientError::DomainTypeNotFound(name))?; + + decode_fixed_hex(raw) +} + +/// Resolves the active fork version at the given epoch. +pub fn resolve_fork_version( + epoch: phase0::Epoch, + genesis_fork_version: phase0::Version, + fork_schedule: &HashMap, +) -> phase0::Version { + fork_schedule + .values() + .filter(|fork| fork.epoch <= epoch) + .max_by_key(|fork| fork.epoch) + .map(|fork| fork.version) + .unwrap_or(genesis_fork_version) +} + +/// Resolves the final domain for the provided domain name and epoch. +pub fn resolve_domain( + spec_data: &serde_json::Value, + fork_schedule: &HashMap, + genesis_fork_version: phase0::Version, + genesis_validators_root: phase0::Root, + name: DomainName, + epoch: phase0::Epoch, +) -> Result { + let domain_type = resolve_domain_type(spec_data, name)?; + + if name == DomainName::ApplicationBuilder { + return Ok(compute_builder_domain(domain_type, genesis_fork_version)); + } + + let fork_version = if name == DomainName::VoluntaryExit { + // EIP-7044: voluntary exits always use the Capella domain. + fork_schedule + .get(&ConsensusVersion::Capella) + .map(|fork| fork.version) + .unwrap_or(genesis_fork_version) + } else { + resolve_fork_version(epoch, genesis_fork_version, fork_schedule) + }; + + Ok(compute_domain( + domain_type, + fork_version, + genesis_validators_root, + )) +} + impl ValidatorStatus { /// Returns true if the validator is in one of the active states. pub fn is_active(&self) -> bool { @@ -59,16 +251,33 @@ impl ValidatorStatus { } impl EthBeaconNodeApiClient { + async fn fetch_spec_data(&self) -> Result { + match self.get_spec(GetSpecRequest {}).await? { + GetSpecResponse::Ok(spec) => Ok(spec.data), + _ => Err(EthBeaconNodeApiClientError::UnexpectedResponse), + } + } + + async fn fetch_genesis_data(&self) -> Result { + match self.get_genesis(GetGenesisRequest {}).await? { + GetGenesisResponse::Ok(genesis) => Ok(serde_json::json!({ + "genesis_time": genesis.data.genesis_time, + "genesis_validators_root": genesis.data.genesis_validators_root, + "genesis_fork_version": genesis.data.genesis_fork_version, + })), + _ => Err(EthBeaconNodeApiClientError::UnexpectedResponse), + } + } + /// Fetches the genesis time. pub async fn fetch_genesis_time(&self) -> Result, EthBeaconNodeApiClientError> { - let genesis = match self.get_genesis(GetGenesisRequest {}).await? { - GetGenesisResponse::Ok(genesis) => genesis, - _ => return Err(EthBeaconNodeApiClientError::UnexpectedResponse), - }; - - genesis - .data - .genesis_time + let genesis = self.fetch_genesis_data().await?; + let genesis_time = genesis + .get("genesis_time") + .and_then(serde_json::Value::as_str) + .ok_or(EthBeaconNodeApiClientError::UnexpectedType)?; + + genesis_time .parse() .map_err(|_| EthBeaconNodeApiClientError::UnexpectedType) .and_then(|timestamp| { @@ -81,13 +290,9 @@ impl EthBeaconNodeApiClient { pub async fn fetch_slots_config( &self, ) -> Result<(time::Duration, u64), EthBeaconNodeApiClientError> { - let spec = match self.get_spec(GetSpecRequest {}).await? { - GetSpecResponse::Ok(spec) => spec, - _ => return Err(EthBeaconNodeApiClientError::UnexpectedResponse), - }; + let spec = self.fetch_spec_data().await?; let slot_duration = spec - .data .as_object() .and_then(|o| o.get("SECONDS_PER_SLOT")) .and_then(|v| v.as_str()) @@ -96,7 +301,6 @@ impl EthBeaconNodeApiClient { .map(time::Duration::from_secs)?; let slots_per_epoch = spec - .data .as_object() .and_then(|o| o.get("SLOTS_PER_EPOCH")) .and_then(|v| v.as_str()) @@ -114,44 +318,193 @@ impl EthBeaconNodeApiClient { pub async fn fetch_fork_config( &self, ) -> Result, EthBeaconNodeApiClientError> { - fn fetch_fork( - fork: &ConsensusVersion, - spec_data: &serde_json::Value, - ) -> Result { - let version_field = format!("{}_FORK_VERSION", fork.to_string().to_uppercase()); - let version = spec_data - .as_object() - .and_then(|o| o.get(&version_field)) - .and_then(|f| f.as_str()) - .and_then(|hex| { - let hex = hex.strip_prefix("0x").unwrap_or(hex); - hex::decode(hex).ok() - }) - .and_then(|bytes| bytes.try_into().ok()) - .ok_or(EthBeaconNodeApiClientError::UnexpectedType)?; - - let epoch_field = format!("{}_FORK_EPOCH", fork.to_string().to_uppercase()); - let epoch = spec_data - .as_object() - .and_then(|o| o.get(&epoch_field)) - .and_then(|v| v.as_str()) - .and_then(|s| s.parse::().ok()) - .ok_or(EthBeaconNodeApiClientError::UnexpectedType)?; - - Ok(ForkSchedule { version, epoch }) - } + let spec = self.fetch_spec_data().await?; + fork_schedule_from_spec(&spec) + } - let spec = match self.get_spec(GetSpecRequest {}).await? { - GetSpecResponse::Ok(spec) => spec, - _ => return Err(EthBeaconNodeApiClientError::UnexpectedResponse), - }; + /// Fetches the genesis validators root from the beacon node. + pub async fn fetch_genesis_validators_root( + &self, + ) -> Result { + let genesis = self.fetch_genesis_data().await?; + let root = genesis + .get("genesis_validators_root") + .and_then(serde_json::Value::as_str) + .ok_or(EthBeaconNodeApiClientError::UnexpectedType)?; - let mut result = HashMap::new(); - for fork in FORKS.into_iter() { - let fork_schedule = fetch_fork(&fork, &spec.data)?; - result.insert(fork, fork_schedule); - } + decode_fixed_hex(root) + } + + /// Fetches the genesis fork version from the beacon node. + pub async fn fetch_genesis_fork_version( + &self, + ) -> Result { + let genesis = self.fetch_genesis_data().await?; + let version = genesis + .get("genesis_fork_version") + .and_then(serde_json::Value::as_str) + .ok_or(EthBeaconNodeApiClientError::UnexpectedType)?; + + decode_fixed_hex(version) + } + + /// Fetches the resolved beacon domain for the provided domain name and epoch. + pub async fn fetch_domain( + &self, + name: DomainName, + epoch: phase0::Epoch, + ) -> Result { + let spec = self.fetch_spec_data().await?; + let fork_schedule = fork_schedule_from_spec(&spec)?; + let genesis_fork_version = self.fetch_genesis_fork_version().await?; + let genesis_validators_root = self.fetch_genesis_validators_root().await?; + + resolve_domain( + &spec, + &fork_schedule, + genesis_fork_version, + genesis_validators_root, + name, + epoch, + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn spec_fixture() -> serde_json::Value { + json!({ + "DOMAIN_BEACON_PROPOSER": "0x00000000", + "DOMAIN_VOLUNTARY_EXIT": "0x04000000", + "DOMAIN_APPLICATION_BUILDER": "0x00000001", + "ALTAIR_FORK_VERSION": "0x01020304", + "ALTAIR_FORK_EPOCH": "10", + "BELLATRIX_FORK_VERSION": "0x02030405", + "BELLATRIX_FORK_EPOCH": "20", + "CAPELLA_FORK_VERSION": "0x03040506", + "CAPELLA_FORK_EPOCH": "30", + "DENEB_FORK_VERSION": "0x04050607", + "DENEB_FORK_EPOCH": "40", + "ELECTRA_FORK_VERSION": "0x05060708", + "ELECTRA_FORK_EPOCH": "50", + "FULU_FORK_VERSION": "0x06070809", + "FULU_FORK_EPOCH": "60" + }) + } + + #[test] + fn resolve_domain_uses_genesis_version_before_first_fork() { + let spec = spec_fixture(); + let fork_schedule = fork_schedule_from_spec(&spec).unwrap(); + let genesis_fork_version = [0x11, 0x22, 0x33, 0x44]; + let genesis_validators_root = [0xAA; 32]; + + let domain = resolve_domain( + &spec, + &fork_schedule, + genesis_fork_version, + genesis_validators_root, + DomainName::BeaconProposer, + 0, + ) + .unwrap(); + + assert_eq!( + domain, + compute_domain( + [0x00, 0x00, 0x00, 0x00], + genesis_fork_version, + genesis_validators_root, + ) + ); + } + + #[test] + fn resolve_domain_uses_latest_active_fork_version() { + let spec = spec_fixture(); + let fork_schedule = fork_schedule_from_spec(&spec).unwrap(); + let genesis_fork_version = [0x11, 0x22, 0x33, 0x44]; + let genesis_validators_root = [0xBB; 32]; + + let domain = resolve_domain( + &spec, + &fork_schedule, + genesis_fork_version, + genesis_validators_root, + DomainName::BeaconProposer, + 25, + ) + .unwrap(); + + assert_eq!( + domain, + compute_domain( + [0x00, 0x00, 0x00, 0x00], + [0x02, 0x03, 0x04, 0x05], + genesis_validators_root, + ) + ); + } + + #[test] + fn resolve_builder_domain_stays_constant_across_fork_schedule() { + let spec = spec_fixture(); + let fork_schedule = fork_schedule_from_spec(&spec).unwrap(); + let genesis_fork_version = [0x01, 0x01, 0x70, 0x00]; + + let at_genesis = resolve_domain( + &spec, + &fork_schedule, + genesis_fork_version, + [0xCC; 32], + DomainName::ApplicationBuilder, + 0, + ) + .unwrap(); + let post_forks = resolve_domain( + &spec, + &fork_schedule, + genesis_fork_version, + [0xDD; 32], + DomainName::ApplicationBuilder, + 1_000, + ) + .unwrap(); + + assert_eq!(at_genesis, post_forks); + assert_eq!( + hex::encode(at_genesis), + "000000015b83a23759c560b2d0c64576e1dcfc34ea94c4988f3e0d9f77f05387" + ); + } + + #[test] + fn resolve_voluntary_exit_domain_uses_capella_version_after_later_forks() { + let spec = spec_fixture(); + let fork_schedule = fork_schedule_from_spec(&spec).unwrap(); + let genesis_fork_version = [0x11, 0x22, 0x33, 0x44]; + let genesis_validators_root = [0xEE; 32]; + + let domain = resolve_domain( + &spec, + &fork_schedule, + genesis_fork_version, + genesis_validators_root, + DomainName::VoluntaryExit, + 1_000, + ) + .unwrap(); - Ok(result) + assert_eq!( + domain, + compute_domain( + [0x04, 0x00, 0x00, 0x00], + [0x03, 0x04, 0x05, 0x06], + genesis_validators_root, + ) + ); } } From 864ea1bf92df6f1a04241c8e82e6d86e755c55ff Mon Sep 17 00:00:00 2001 From: Quang Le Date: Mon, 20 Apr 2026 17:39:52 +0700 Subject: [PATCH 4/8] refactor(eth2util): registration uses compute_builder_domain --- crates/eth2api/src/extensions.rs | 8 +++++- crates/eth2util/src/registration.rs | 39 ++++------------------------- crates/eth2util/src/types.rs | 8 ++++++ 3 files changed, 20 insertions(+), 35 deletions(-) diff --git a/crates/eth2api/src/extensions.rs b/crates/eth2api/src/extensions.rs index 2253cc0e..858c3a41 100644 --- a/crates/eth2api/src/extensions.rs +++ b/crates/eth2api/src/extensions.rs @@ -170,7 +170,13 @@ pub fn compute_domain( domain } -/// Computes the builder domain using `GENESIS_FORK_VERSION` and a zero validators root. +/// Computes the builder domain using `GENESIS_FORK_VERSION` and a zero +/// validators root. +/// +/// Builder registrations do not use the fork-at-epoch beacon domain. +/// References: +/// - +/// - pub fn compute_builder_domain( domain_type: phase0::DomainType, genesis_fork_version: phase0::Version, diff --git a/crates/eth2util/src/registration.rs b/crates/eth2util/src/registration.rs index 8545a909..ea30aef9 100644 --- a/crates/eth2util/src/registration.rs +++ b/crates/eth2util/src/registration.rs @@ -1,11 +1,11 @@ use pluto_eth2api::{ + compute_builder_domain, spec::{ bellatrix::ExecutionAddress, - phase0::{BLSPubKey, Domain, DomainType, ForkData, Root, SigningData, Version}, + phase0::{BLSPubKey, DomainType, Root, Version}, }, v1::ValidatorRegistration, }; -use tree_hash::TreeHash; /// Default gas limit used in validator registration pre-generation. pub const DEFAULT_GAS_LIMIT: u64 = 30_000_000; @@ -47,41 +47,13 @@ fn execution_address_from_str(addr: &str) -> Result { Ok(address.0.0) } -/// Returns the validator registration signature domain. -/// `DOMAIN_APPLICATION_BUILDER` uses `GENESIS_FORK_VERSION` to compute domain. -/// Refer: -/// - https://github.com/ethereum/builder-specs/blob/100d4faf32e5dc672c963741769390ff09ab194a/specs/bellatrix/builder.md#signing -/// - https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/beacon-chain.md#compute_domain -fn get_registration_domain(genesis_fork_version: Version) -> Domain { - let fork_data = ForkData { - current_version: genesis_fork_version, - genesis_validators_root: Root::default(), /* GenesisValidatorsRoot is zero for validator - * registration. */ - }; - - let fork_data_root = fork_data.tree_hash_root(); - - let mut domain = Domain::default(); - domain[0..4].copy_from_slice(®ISTRATION_DOMAIN_TYPE); - domain[4..].copy_from_slice(&fork_data_root.0[..28]); - - domain -} - /// Returns the validator registration message signing root. pub fn get_message_signing_root( msg: &ValidatorRegistration, genesis_fork_version: Version, ) -> Root { - let msg_root = msg.tree_hash_root(); - let domain = get_registration_domain(genesis_fork_version); - - let signing_data = SigningData { - object_root: msg_root.0, - domain, - }; - - signing_data.tree_hash_root().0 + let domain = compute_builder_domain(REGISTRATION_DOMAIN_TYPE, genesis_fork_version); + crate::signing::compute_signing_root(msg.message_root(), domain) } #[cfg(test)] @@ -232,8 +204,7 @@ mod tests { .try_into() .unwrap(); - BlstImpl - .verify(&pubkey, &signing_root, &signature) + crate::signing::verify(&pubkey, signing_root, None, &signature) .expect("BLS signature verification failed"); } } diff --git a/crates/eth2util/src/types.rs b/crates/eth2util/src/types.rs index 60f31ede..a674b4fa 100644 --- a/crates/eth2util/src/types.rs +++ b/crates/eth2util/src/types.rs @@ -1,5 +1,6 @@ use pluto_eth2api::spec::phase0; use serde::{Deserialize, Serialize}; +use tree_hash::TreeHash; use tree_hash_derive::TreeHash; /// Signature of a corresponding epoch. @@ -15,6 +16,13 @@ pub struct SignedEpoch { pub signature: phase0::BLSSignature, } +impl SignedEpoch { + /// Returns the SSZ message root of the epoch payload. + pub fn message_root(&self) -> phase0::Root { + self.tree_hash_root().0 + } +} + #[cfg(test)] mod tests { use super::*; From 301b7e21a062df7d5b980bc70af44a42f2d61b36 Mon Sep 17 00:00:00 2001 From: Quang Le Date: Mon, 20 Apr 2026 18:23:48 +0700 Subject: [PATCH 5/8] feat(eth2util): implement signing --- crates/eth2api/src/extensions.rs | 197 ++++-------- crates/eth2api/src/versioned.rs | 35 +++ crates/eth2util/src/deposit/mod.rs | 17 -- crates/eth2util/src/deposit/types.rs | 24 +- crates/eth2util/src/lib.rs | 3 + crates/eth2util/src/registration.rs | 3 +- crates/eth2util/src/signing.rs | 430 +++++++++++++++++++++++++++ 7 files changed, 533 insertions(+), 176 deletions(-) create mode 100644 crates/eth2util/src/signing.rs diff --git a/crates/eth2api/src/extensions.rs b/crates/eth2api/src/extensions.rs index 858c3a41..ef05e937 100644 --- a/crates/eth2api/src/extensions.rs +++ b/crates/eth2api/src/extensions.rs @@ -29,7 +29,7 @@ pub enum EthBeaconNodeApiClientError { /// Domain type not found in the beacon spec response #[error("Domain type not found: {0}")] - DomainTypeNotFound(DomainName), + DomainTypeNotFound(String), } const FORKS: [ConsensusVersion; 6] = [ @@ -51,61 +51,6 @@ pub struct ForkSchedule { pub epoch: phase0::Epoch, } -/// Domain name as defined in the consensus and builder specs. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum DomainName { - /// `DOMAIN_BEACON_PROPOSER` - BeaconProposer, - /// `DOMAIN_BEACON_ATTESTER` - BeaconAttester, - /// `DOMAIN_RANDAO` - Randao, - /// `DOMAIN_VOLUNTARY_EXIT` - VoluntaryExit, - /// `DOMAIN_APPLICATION_BUILDER` - ApplicationBuilder, - /// `DOMAIN_SELECTION_PROOF` - SelectionProof, - /// `DOMAIN_AGGREGATE_AND_PROOF` - AggregateAndProof, - /// `DOMAIN_SYNC_COMMITTEE` - SyncCommittee, - /// `DOMAIN_SYNC_COMMITTEE_SELECTION_PROOF` - SyncCommitteeSelectionProof, - /// `DOMAIN_CONTRIBUTION_AND_PROOF` - ContributionAndProof, - /// `DOMAIN_DEPOSIT` - Deposit, - /// `DOMAIN_BLOB_SIDECAR` - BlobSidecar, -} - -impl std::fmt::Display for DomainName { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(self.as_spec_key()) - } -} - -impl DomainName { - /// Returns the spec key used in `/eth/v1/config/spec`. - pub const fn as_spec_key(self) -> &'static str { - match self { - Self::BeaconProposer => "DOMAIN_BEACON_PROPOSER", - Self::BeaconAttester => "DOMAIN_BEACON_ATTESTER", - Self::Randao => "DOMAIN_RANDAO", - Self::VoluntaryExit => "DOMAIN_VOLUNTARY_EXIT", - Self::ApplicationBuilder => "DOMAIN_APPLICATION_BUILDER", - Self::SelectionProof => "DOMAIN_SELECTION_PROOF", - Self::AggregateAndProof => "DOMAIN_AGGREGATE_AND_PROOF", - Self::SyncCommittee => "DOMAIN_SYNC_COMMITTEE", - Self::SyncCommitteeSelectionProof => "DOMAIN_SYNC_COMMITTEE_SELECTION_PROOF", - Self::ContributionAndProof => "DOMAIN_CONTRIBUTION_AND_PROOF", - Self::Deposit => "DOMAIN_DEPOSIT", - Self::BlobSidecar => "DOMAIN_BLOB_SIDECAR", - } - } -} - fn decode_fixed_hex(value: &str) -> Result<[u8; N], EthBeaconNodeApiClientError> { let value = value.strip_prefix("0x").unwrap_or(value); let bytes = hex::decode(value).map_err(|_| EthBeaconNodeApiClientError::UnexpectedType)?; @@ -187,13 +132,13 @@ pub fn compute_builder_domain( /// Resolves the domain type from the beacon spec. pub fn resolve_domain_type( spec_data: &serde_json::Value, - name: DomainName, + spec_key: &str, ) -> Result { let raw = spec_data .as_object() - .and_then(|o| o.get(name.as_spec_key())) + .and_then(|o| o.get(spec_key)) .and_then(|value| value.as_str()) - .ok_or(EthBeaconNodeApiClientError::DomainTypeNotFound(name))?; + .ok_or_else(|| EthBeaconNodeApiClientError::DomainTypeNotFound(spec_key.to_string()))?; decode_fixed_hex(raw) } @@ -212,22 +157,15 @@ pub fn resolve_fork_version( .unwrap_or(genesis_fork_version) } -/// Resolves the final domain for the provided domain name and epoch. -pub fn resolve_domain( - spec_data: &serde_json::Value, +fn resolve_domain( + domain_type: phase0::DomainType, + voluntary_exit_domain_type: phase0::DomainType, fork_schedule: &HashMap, genesis_fork_version: phase0::Version, genesis_validators_root: phase0::Root, - name: DomainName, epoch: phase0::Epoch, -) -> Result { - let domain_type = resolve_domain_type(spec_data, name)?; - - if name == DomainName::ApplicationBuilder { - return Ok(compute_builder_domain(domain_type, genesis_fork_version)); - } - - let fork_version = if name == DomainName::VoluntaryExit { +) -> phase0::Domain { + let fork_version = if domain_type == voluntary_exit_domain_type { // EIP-7044: voluntary exits always use the Capella domain. fork_schedule .get(&ConsensusVersion::Capella) @@ -237,11 +175,7 @@ pub fn resolve_domain( resolve_fork_version(epoch, genesis_fork_version, fork_schedule) }; - Ok(compute_domain( - domain_type, - fork_version, - genesis_validators_root, - )) + compute_domain(domain_type, fork_version, genesis_validators_root) } impl ValidatorStatus { @@ -328,6 +262,27 @@ impl EthBeaconNodeApiClient { fork_schedule_from_spec(&spec) } + /// Fetches the domain type with the provided config/spec key. + pub async fn fetch_domain_type( + &self, + spec_key: &str, + ) -> Result { + let spec = self.fetch_spec_data().await?; + resolve_domain_type(&spec, spec_key) + } + + /// Fetches the genesis domain for the provided domain type. + pub async fn fetch_genesis_domain( + &self, + domain_type: phase0::DomainType, + ) -> Result { + Ok(compute_domain( + domain_type, + self.fetch_genesis_fork_version().await?, + phase0::Root::default(), + )) + } + /// Fetches the genesis validators root from the beacon node. pub async fn fetch_genesis_validators_root( &self, @@ -354,25 +309,26 @@ impl EthBeaconNodeApiClient { decode_fixed_hex(version) } - /// Fetches the resolved beacon domain for the provided domain name and epoch. + /// Fetches the resolved beacon domain for the provided domain type and epoch. pub async fn fetch_domain( &self, - name: DomainName, + domain_type: phase0::DomainType, epoch: phase0::Epoch, ) -> Result { let spec = self.fetch_spec_data().await?; let fork_schedule = fork_schedule_from_spec(&spec)?; let genesis_fork_version = self.fetch_genesis_fork_version().await?; let genesis_validators_root = self.fetch_genesis_validators_root().await?; + let voluntary_exit_domain_type = resolve_domain_type(&spec, "DOMAIN_VOLUNTARY_EXIT")?; - resolve_domain( - &spec, + Ok(resolve_domain( + domain_type, + voluntary_exit_domain_type, &fork_schedule, genesis_fork_version, genesis_validators_root, - name, epoch, - ) + )) } } @@ -402,83 +358,35 @@ mod tests { } #[test] - fn resolve_domain_uses_genesis_version_before_first_fork() { + fn resolve_fork_version_uses_genesis_version_before_first_fork() { let spec = spec_fixture(); let fork_schedule = fork_schedule_from_spec(&spec).unwrap(); let genesis_fork_version = [0x11, 0x22, 0x33, 0x44]; - let genesis_validators_root = [0xAA; 32]; - - let domain = resolve_domain( - &spec, - &fork_schedule, - genesis_fork_version, - genesis_validators_root, - DomainName::BeaconProposer, - 0, - ) - .unwrap(); assert_eq!( - domain, - compute_domain( - [0x00, 0x00, 0x00, 0x00], - genesis_fork_version, - genesis_validators_root, - ) + resolve_fork_version(0, genesis_fork_version, &fork_schedule), + genesis_fork_version ); } #[test] - fn resolve_domain_uses_latest_active_fork_version() { + fn resolve_fork_version_uses_latest_active_fork_version() { let spec = spec_fixture(); let fork_schedule = fork_schedule_from_spec(&spec).unwrap(); let genesis_fork_version = [0x11, 0x22, 0x33, 0x44]; - let genesis_validators_root = [0xBB; 32]; - - let domain = resolve_domain( - &spec, - &fork_schedule, - genesis_fork_version, - genesis_validators_root, - DomainName::BeaconProposer, - 25, - ) - .unwrap(); assert_eq!( - domain, - compute_domain( - [0x00, 0x00, 0x00, 0x00], - [0x02, 0x03, 0x04, 0x05], - genesis_validators_root, - ) + resolve_fork_version(25, genesis_fork_version, &fork_schedule), + [0x02, 0x03, 0x04, 0x05] ); } #[test] - fn resolve_builder_domain_stays_constant_across_fork_schedule() { - let spec = spec_fixture(); - let fork_schedule = fork_schedule_from_spec(&spec).unwrap(); + fn compute_builder_domain_stays_constant() { let genesis_fork_version = [0x01, 0x01, 0x70, 0x00]; - let at_genesis = resolve_domain( - &spec, - &fork_schedule, - genesis_fork_version, - [0xCC; 32], - DomainName::ApplicationBuilder, - 0, - ) - .unwrap(); - let post_forks = resolve_domain( - &spec, - &fork_schedule, - genesis_fork_version, - [0xDD; 32], - DomainName::ApplicationBuilder, - 1_000, - ) - .unwrap(); + let at_genesis = compute_builder_domain([0x00, 0x00, 0x00, 0x01], genesis_fork_version); + let post_forks = compute_builder_domain([0x00, 0x00, 0x00, 0x01], genesis_fork_version); assert_eq!(at_genesis, post_forks); assert_eq!( @@ -488,21 +396,22 @@ mod tests { } #[test] - fn resolve_voluntary_exit_domain_uses_capella_version_after_later_forks() { + fn resolve_domain_uses_capella_for_voluntary_exit_domain_type() { let spec = spec_fixture(); let fork_schedule = fork_schedule_from_spec(&spec).unwrap(); let genesis_fork_version = [0x11, 0x22, 0x33, 0x44]; let genesis_validators_root = [0xEE; 32]; + let voluntary_exit_domain_type = + resolve_domain_type(&spec, "DOMAIN_VOLUNTARY_EXIT").unwrap(); let domain = resolve_domain( - &spec, + voluntary_exit_domain_type, + voluntary_exit_domain_type, &fork_schedule, genesis_fork_version, genesis_validators_root, - DomainName::VoluntaryExit, 1_000, - ) - .unwrap(); + ); assert_eq!( domain, diff --git a/crates/eth2api/src/versioned.rs b/crates/eth2api/src/versioned.rs index 99ae731b..95e4f8cb 100644 --- a/crates/eth2api/src/versioned.rs +++ b/crates/eth2api/src/versioned.rs @@ -259,6 +259,11 @@ pub enum SignedAggregateAndProofPayload { } impl SignedAggregateAndProofPayload { + /// Returns the attestation slot embedded in this payload. + pub fn slot(&self) -> phase0::Slot { + self.data().slot + } + /// Returns the BLS signature embedded in this payload. pub fn signature(&self) -> phase0::BLSSignature { match self { @@ -317,6 +322,18 @@ impl SignedAggregateAndProofPayload { } } + /// Returns the selection proof embedded in this payload. + pub fn selection_proof(&self) -> phase0::BLSSignature { + match self { + Self::Phase0(payload) + | Self::Altair(payload) + | Self::Bellatrix(payload) + | Self::Capella(payload) + | Self::Deneb(payload) => payload.message.selection_proof, + Self::Electra(payload) | Self::Fulu(payload) => payload.message.selection_proof, + } + } + /// Returns the SSZ message root of the unsigned aggregate-and-proof payload. pub fn message_root(&self) -> phase0::Root { match self { @@ -340,6 +357,24 @@ pub struct VersionedSignedValidatorRegistration { } impl VersionedSignedAggregateAndProof { + /// Returns the attestation slot of the wrapped payload. + pub fn slot(&self) -> Option { + if self.version == DataVersion::Unknown { + return None; + } + + Some(self.aggregate_and_proof.slot()) + } + + /// Returns the selection proof of the wrapped payload. + pub fn selection_proof(&self) -> Option { + if self.version == DataVersion::Unknown { + return None; + } + + Some(self.aggregate_and_proof.selection_proof()) + } + /// Returns the SSZ message root of the wrapped payload. pub fn message_root(&self) -> Option { if self.version == DataVersion::Unknown { diff --git a/crates/eth2util/src/deposit/mod.rs b/crates/eth2util/src/deposit/mod.rs index 4b4da3a9..968a8275 100644 --- a/crates/eth2util/src/deposit/mod.rs +++ b/crates/eth2util/src/deposit/mod.rs @@ -13,7 +13,6 @@ pub use types::*; use errors::Result; -use crate::network; use pluto_crypto::{ blst_impl::BlstImpl, tbls::Tbls, @@ -89,22 +88,6 @@ pub fn marshal_deposit_data( Ok(bytes) } -/// Returns the deposit signature domain. -pub(crate) fn get_deposit_domain(fork_version: Version) -> Domain { - let fork_data = ForkData { - current_version: fork_version, - genesis_validators_root: Root::default(), - }; - - let fork_data_root = fork_data.tree_hash_root(); - - let mut domain = Domain::default(); - domain[0..4].copy_from_slice(&DEPOSIT_DOMAIN_TYPE); - domain[4..32].copy_from_slice(&fork_data_root.0[0..28]); - - domain -} - /// Converts an Ethereum address to withdrawal credentials. pub(crate) fn withdrawal_creds_from_addr( addr: impl AsRef, diff --git a/crates/eth2util/src/deposit/types.rs b/crates/eth2util/src/deposit/types.rs index 5bfa2453..3566d165 100644 --- a/crates/eth2util/src/deposit/types.rs +++ b/crates/eth2util/src/deposit/types.rs @@ -1,8 +1,9 @@ +use crate::network; use serde::{Deserialize, Serialize}; +use tree_hash::TreeHash; pub use pluto_eth2api::spec::phase0::{ - DepositData, DepositMessage, Domain, ForkData, Gwei, Root, SigningData, Version, - WithdrawalCredentials, + DepositData, DepositMessage, Gwei, Root, Version, WithdrawalCredentials, }; /// DepositDataJson is the json representation of Deposit Data. @@ -61,26 +62,21 @@ pub fn get_message_signing_root( deposit_message: &DepositMessage, network: &str, ) -> super::Result { - use tree_hash::TreeHash; - - let msg_root = deposit_message.tree_hash_root(); - - let fork_version_bytes = super::network::network_to_fork_version_bytes(network)?; + let fork_version_bytes = network::network_to_fork_version_bytes(network)?; let fork_version: Version = fork_version_bytes.as_slice().try_into().map_err(|_| { - super::DepositError::NetworkError(super::network::NetworkError::InvalidForkVersion { + super::DepositError::NetworkError(network::NetworkError::InvalidForkVersion { fork_version: hex::encode(&fork_version_bytes), }) })?; - let domain = super::get_deposit_domain(fork_version); + let domain = + pluto_eth2api::compute_domain(super::DEPOSIT_DOMAIN_TYPE, fork_version, Root::default()); - let signing_data = SigningData { - object_root: msg_root.0, + Ok(crate::signing::compute_signing_root( + deposit_message.tree_hash_root().0, domain, - }; - - Ok(signing_data.tree_hash_root().0) + )) } #[cfg(test)] diff --git a/crates/eth2util/src/lib.rs b/crates/eth2util/src/lib.rs index f8aede98..7dffb14a 100644 --- a/crates/eth2util/src/lib.rs +++ b/crates/eth2util/src/lib.rs @@ -27,6 +27,9 @@ pub mod keystore; /// Validator registration for builder API. pub mod registration; +/// Shared eth2 signing helpers. +pub mod signing; + /// ETH2 Keymanager API client. pub mod keymanager; diff --git a/crates/eth2util/src/registration.rs b/crates/eth2util/src/registration.rs index ea30aef9..eb667878 100644 --- a/crates/eth2util/src/registration.rs +++ b/crates/eth2util/src/registration.rs @@ -204,7 +204,8 @@ mod tests { .try_into() .unwrap(); - crate::signing::verify(&pubkey, signing_root, None, &signature) + BlstImpl + .verify(&pubkey, &signing_root, &signature) .expect("BLS signature verification failed"); } } diff --git a/crates/eth2util/src/signing.rs b/crates/eth2util/src/signing.rs new file mode 100644 index 00000000..893a5bf3 --- /dev/null +++ b/crates/eth2util/src/signing.rs @@ -0,0 +1,430 @@ +use pluto_crypto::{ + blst_impl::BlstImpl, + tbls::Tbls, + types::{PublicKey, Signature}, +}; +use pluto_eth2api::{ + EthBeaconNodeApiClient, EthBeaconNodeApiClientError, + spec::phase0::{Domain, Epoch, Root, SigningData}, + versioned::VersionedSignedAggregateAndProof, +}; +use tree_hash::TreeHash; + +/// Domain name as defined in the consensus and builder specs. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum DomainName { + /// `DOMAIN_BEACON_PROPOSER` + BeaconProposer, + /// `DOMAIN_BEACON_ATTESTER` + BeaconAttester, + /// `DOMAIN_RANDAO` + Randao, + /// `DOMAIN_VOLUNTARY_EXIT` + VoluntaryExit, + /// `DOMAIN_APPLICATION_BUILDER` + ApplicationBuilder, + /// `DOMAIN_SELECTION_PROOF` + SelectionProof, + /// `DOMAIN_AGGREGATE_AND_PROOF` + AggregateAndProof, + /// `DOMAIN_SYNC_COMMITTEE` + SyncCommittee, + /// `DOMAIN_SYNC_COMMITTEE_SELECTION_PROOF` + SyncCommitteeSelectionProof, + /// `DOMAIN_CONTRIBUTION_AND_PROOF` + ContributionAndProof, + /// `DOMAIN_DEPOSIT` + Deposit, + /// `DOMAIN_BLOB_SIDECAR` + BlobSidecar, +} + +impl std::fmt::Display for DomainName { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_spec_key()) + } +} + +impl DomainName { + /// Returns the spec key used in `/eth/v1/config/spec`. + pub const fn as_spec_key(self) -> &'static str { + match self { + Self::BeaconProposer => "DOMAIN_BEACON_PROPOSER", + Self::BeaconAttester => "DOMAIN_BEACON_ATTESTER", + Self::Randao => "DOMAIN_RANDAO", + Self::VoluntaryExit => "DOMAIN_VOLUNTARY_EXIT", + Self::ApplicationBuilder => "DOMAIN_APPLICATION_BUILDER", + Self::SelectionProof => "DOMAIN_SELECTION_PROOF", + Self::AggregateAndProof => "DOMAIN_AGGREGATE_AND_PROOF", + Self::SyncCommittee => "DOMAIN_SYNC_COMMITTEE", + Self::SyncCommitteeSelectionProof => "DOMAIN_SYNC_COMMITTEE_SELECTION_PROOF", + Self::ContributionAndProof => "DOMAIN_CONTRIBUTION_AND_PROOF", + Self::Deposit => "DOMAIN_DEPOSIT", + Self::BlobSidecar => "DOMAIN_BLOB_SIDECAR", + } + } +} + +/// Signing error. +#[derive(Debug, thiserror::Error)] +pub enum SigningError { + /// Beacon-node domain lookup failed. + #[error(transparent)] + BeaconNode(#[from] EthBeaconNodeApiClientError), + + /// Slot/epoch helper failed. + #[error(transparent)] + Helper(#[from] crate::helpers::HelperError), + + /// Aggregate-and-proof version is unsupported. + #[error("unknown aggregate-and-proof version")] + UnknownAggregateAndProofVersion, + + /// Zero signature rejected before attempting BLS verification. + #[error("no signature found")] + ZeroSignature, + + /// Underlying BLS verification error. + #[error(transparent)] + Verification(#[from] pluto_crypto::types::Error), +} + +type Result = std::result::Result; + +/// Computes the eth2 signing root for a message root and domain. +pub(crate) fn compute_signing_root(message_root: Root, domain: Domain) -> Root { + SigningData { + object_root: message_root, + domain, + } + .tree_hash_root() + .0 +} + +/// Returns the beacon domain for the provided type. +pub async fn get_domain( + client: &EthBeaconNodeApiClient, + name: DomainName, + epoch: Epoch, +) -> Result { + let domain_type = client.fetch_domain_type(name.as_spec_key()).await?; + + if name == DomainName::ApplicationBuilder { + return Ok(client.fetch_genesis_domain(domain_type).await?); + } + + Ok(client.fetch_domain(domain_type, epoch).await?) +} + +/// Wraps the message root with the resolved domain and returns the signing-data root. +pub async fn get_data_root( + client: &EthBeaconNodeApiClient, + name: DomainName, + epoch: Epoch, + root: Root, +) -> Result { + Ok(compute_signing_root( + root, + get_domain(client, name, epoch).await?, + )) +} + +/// Verifies a signature against the resolved eth2 domain signing root. +pub async fn verify( + client: &EthBeaconNodeApiClient, + domain_name: DomainName, + epoch: Epoch, + message_root: Root, + signature: &Signature, + pubkey: &PublicKey, +) -> Result<()> { + if *signature == [0; 96] { + return Err(SigningError::ZeroSignature); + } + + let signing_root = get_data_root(client, domain_name, epoch, message_root).await?; + + BlstImpl.verify(pubkey, &signing_root, signature)?; + + Ok(()) +} + +/// Verifies the selection proof embedded in an aggregate-and-proof payload. +pub async fn verify_aggregate_and_proof_selection( + client: &EthBeaconNodeApiClient, + pubkey: &PublicKey, + agg: &VersionedSignedAggregateAndProof, +) -> Result<()> { + let slot = agg + .slot() + .ok_or(SigningError::UnknownAggregateAndProofVersion)?; + let epoch = crate::helpers::epoch_from_slot(client, slot).await?; + let message_root = slot.tree_hash_root().0; + let selection_proof = agg + .selection_proof() + .ok_or(SigningError::UnknownAggregateAndProofVersion)?; + + verify( + client, + DomainName::SelectionProof, + epoch, + message_root, + &selection_proof, + pubkey, + ) + .await +} + +#[cfg(test)] +mod tests { + use super::*; + use pluto_crypto::tbls::Tbls; + use pluto_eth2api::{ + compute_builder_domain, compute_domain, + spec::{bellatrix::ExecutionAddress, phase0::Version}, + v1::ValidatorRegistration, + }; + use serde_json::json; + use wiremock::{ + Mock, MockServer, ResponseTemplate, + matchers::{method, path}, + }; + + const BUILDER_DOMAIN_TYPE: [u8; 4] = [0x00, 0x00, 0x00, 0x01]; + + fn secret_key(hex_value: &str) -> pluto_crypto::types::PrivateKey { + let bytes = hex::decode(hex_value).unwrap(); + bytes.as_slice().try_into().unwrap() + } + + fn spec_fixture() -> serde_json::Value { + json!({ + "DOMAIN_BEACON_PROPOSER": "0x00000000", + "DOMAIN_VOLUNTARY_EXIT": "0x04000000", + "DOMAIN_APPLICATION_BUILDER": "0x00000001", + "ALTAIR_FORK_VERSION": "0x01020304", + "ALTAIR_FORK_EPOCH": "10", + "BELLATRIX_FORK_VERSION": "0x02030405", + "BELLATRIX_FORK_EPOCH": "20", + "CAPELLA_FORK_VERSION": "0x03040506", + "CAPELLA_FORK_EPOCH": "30", + "DENEB_FORK_VERSION": "0x04050607", + "DENEB_FORK_EPOCH": "40", + "ELECTRA_FORK_VERSION": "0x05060708", + "ELECTRA_FORK_EPOCH": "50", + "FULU_FORK_VERSION": "0x06070809", + "FULU_FORK_EPOCH": "60" + }) + } + + async fn mock_beacon_client() -> (MockServer, EthBeaconNodeApiClient) { + let server = MockServer::start().await; + let base_url = server.uri(); + + Mock::given(method("GET")) + .and(path("/eth/v1/config/spec")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "data": spec_fixture(), + }))) + .mount(&server) + .await; + + Mock::given(method("GET")) + .and(path("/eth/v1/beacon/genesis")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "data": { + "genesis_time": "0", + "genesis_validators_root": "0x0000000000000000000000000000000000000000000000000000000000000000", + "genesis_fork_version": "0x01017000", + } + }))) + .mount(&server) + .await; + + ( + server, + EthBeaconNodeApiClient::with_base_url(base_url).unwrap(), + ) + } + + #[test] + fn compute_signing_root_matches_registration_vector() { + let fee_recipient: ExecutionAddress = + hex::decode("000000000000000000000000000000000000dead") + .unwrap() + .as_slice() + .try_into() + .unwrap(); + let pubkey = hex::decode( + "86966350b672bd502bfbdb37a6ea8a7392e8fb7f5ebb5c5e2055f4ee168ebfab0fef63084f28c9f62c3ba71f825e527e", + ) + .unwrap() + .as_slice() + .try_into() + .unwrap(); + let message = ValidatorRegistration { + fee_recipient, + gas_limit: 30_000_000, + timestamp: 1_646_092_800, + pubkey, + }; + let genesis_fork_version: Version = [0x01, 0x01, 0x70, 0x00]; + let domain = compute_builder_domain(BUILDER_DOMAIN_TYPE, genesis_fork_version); + + let signing_root = compute_signing_root(message.message_root(), domain); + + assert_eq!( + hex::encode(signing_root), + "fc657efa54a1e050289ddc5a72fbb76c778ac383a3c73309082e01f132ba23a8" + ); + } + + #[tokio::test] + async fn get_domain_matches_builder_vector() { + let (_server, client) = mock_beacon_client().await; + + let domain = get_domain(&client, DomainName::ApplicationBuilder, 1_000) + .await + .unwrap(); + + assert_eq!( + hex::encode(domain), + "000000015b83a23759c560b2d0c64576e1dcfc34ea94c4988f3e0d9f77f05387" + ); + } + + #[tokio::test] + async fn get_domain_uses_capella_for_voluntary_exit() { + let (_server, client) = mock_beacon_client().await; + + let domain = get_domain(&client, DomainName::VoluntaryExit, 1_000) + .await + .unwrap(); + + assert_eq!( + domain, + compute_domain([0x04, 0x00, 0x00, 0x00], [0x03, 0x04, 0x05, 0x06], [0; 32]) + ); + } + + #[tokio::test] + async fn get_data_root_matches_registration_vector() { + let (_server, client) = mock_beacon_client().await; + + let fee_recipient: ExecutionAddress = + hex::decode("000000000000000000000000000000000000dead") + .unwrap() + .as_slice() + .try_into() + .unwrap(); + let pubkey = hex::decode( + "86966350b672bd502bfbdb37a6ea8a7392e8fb7f5ebb5c5e2055f4ee168ebfab0fef63084f28c9f62c3ba71f825e527e", + ) + .unwrap() + .as_slice() + .try_into() + .unwrap(); + let message = ValidatorRegistration { + fee_recipient, + gas_limit: 30_000_000, + timestamp: 1_646_092_800, + pubkey, + }; + + let signing_root = get_data_root( + &client, + DomainName::ApplicationBuilder, + 0, + message.message_root(), + ) + .await + .unwrap(); + + assert_eq!( + hex::encode(signing_root), + "fc657efa54a1e050289ddc5a72fbb76c778ac383a3c73309082e01f132ba23a8" + ); + } + + #[tokio::test] + async fn verify_accepts_valid_signature() { + let (_server, client) = mock_beacon_client().await; + + let secret = secret_key("345768c0245f1dc702df9e50e811002f61ebb2680b3d5931527ef59f96cbaf9b"); + let pubkey = BlstImpl.secret_to_public_key(&secret).unwrap(); + let fee_recipient: ExecutionAddress = + hex::decode("000000000000000000000000000000000000dead") + .unwrap() + .as_slice() + .try_into() + .unwrap(); + let message = ValidatorRegistration { + fee_recipient, + gas_limit: 30_000_000, + timestamp: 1_646_092_800, + pubkey, + }; + let message_root = message.message_root(); + let signing_root = get_data_root(&client, DomainName::ApplicationBuilder, 0, message_root) + .await + .unwrap(); + let signature = BlstImpl.sign(&secret, &signing_root).unwrap(); + + verify( + &client, + DomainName::ApplicationBuilder, + 0, + message_root, + &signature, + &pubkey, + ) + .await + .unwrap(); + } + + #[tokio::test] + async fn verify_rejects_zero_signature() { + let (_server, client) = mock_beacon_client().await; + let pubkey = [0x11; 48]; + let err = verify( + &client, + DomainName::ApplicationBuilder, + 0, + [0x22; 32], + &[0; 96], + &pubkey, + ) + .await + .unwrap_err(); + + assert!(matches!(err, SigningError::ZeroSignature)); + } + + #[tokio::test] + async fn verify_rejects_wrong_pubkey() { + let (_server, client) = mock_beacon_client().await; + + let secret = secret_key("345768c0245f1dc702df9e50e811002f61ebb2680b3d5931527ef59f96cbaf9b"); + let wrong_secret = + secret_key("01477d4bfbbcebe1fef8d4d6f624ecbb6e3178558bb1b0d6286c816c66842a6d"); + let pubkey = BlstImpl.secret_to_public_key(&wrong_secret).unwrap(); + let message_root = [0x55; 32]; + let signing_root = get_data_root(&client, DomainName::ApplicationBuilder, 0, message_root) + .await + .unwrap(); + let signature = BlstImpl.sign(&secret, &signing_root).unwrap(); + + let err = verify( + &client, + DomainName::ApplicationBuilder, + 0, + message_root, + &signature, + &pubkey, + ) + .await + .unwrap_err(); + + assert!(matches!(err, SigningError::Verification(_))); + } +} From 89d16f145030b713f1aa24bafbc8bb6a4aaea5ec Mon Sep 17 00:00:00 2001 From: Quang Le Date: Mon, 20 Apr 2026 18:28:19 +0700 Subject: [PATCH 6/8] fix: fmt --- crates/eth2api/src/extensions.rs | 6 ++++-- crates/eth2api/src/spec/altair.rs | 3 ++- crates/eth2api/src/versioned.rs | 3 ++- crates/eth2util/src/signing.rs | 3 ++- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/crates/eth2api/src/extensions.rs b/crates/eth2api/src/extensions.rs index 2a3a26fa..7ac05cf8 100644 --- a/crates/eth2api/src/extensions.rs +++ b/crates/eth2api/src/extensions.rs @@ -95,7 +95,8 @@ fn fork_schedule_from_spec( Ok(result) } -/// Computes the final 32-byte beacon domain from domain type, fork version, and genesis root. +/// Computes the final 32-byte beacon domain from domain type, fork version, and +/// genesis root. pub fn compute_domain( domain_type: phase0::DomainType, fork_version: phase0::Version, @@ -317,7 +318,8 @@ impl EthBeaconNodeApiClient { decode_fixed_hex(version) } - /// Fetches the resolved beacon domain for the provided domain type and epoch. + /// Fetches the resolved beacon domain for the provided domain type and + /// epoch. pub async fn fetch_domain( &self, domain_type: phase0::DomainType, diff --git a/crates/eth2api/src/spec/altair.rs b/crates/eth2api/src/spec/altair.rs index 783d11fc..d79839d2 100644 --- a/crates/eth2api/src/spec/altair.rs +++ b/crates/eth2api/src/spec/altair.rs @@ -193,7 +193,8 @@ impl ContributionAndProof { } impl SignedContributionAndProof { - /// Returns the SSZ message root of the unsigned contribution-and-proof payload. + /// Returns the SSZ message root of the unsigned contribution-and-proof + /// payload. pub fn message_root(&self) -> phase0::Root { self.message.tree_hash_root().0 } diff --git a/crates/eth2api/src/versioned.rs b/crates/eth2api/src/versioned.rs index 95e4f8cb..4d2f2547 100644 --- a/crates/eth2api/src/versioned.rs +++ b/crates/eth2api/src/versioned.rs @@ -334,7 +334,8 @@ impl SignedAggregateAndProofPayload { } } - /// Returns the SSZ message root of the unsigned aggregate-and-proof payload. + /// Returns the SSZ message root of the unsigned aggregate-and-proof + /// payload. pub fn message_root(&self) -> phase0::Root { match self { Self::Phase0(payload) diff --git a/crates/eth2util/src/signing.rs b/crates/eth2util/src/signing.rs index 893a5bf3..d796e96e 100644 --- a/crates/eth2util/src/signing.rs +++ b/crates/eth2util/src/signing.rs @@ -116,7 +116,8 @@ pub async fn get_domain( Ok(client.fetch_domain(domain_type, epoch).await?) } -/// Wraps the message root with the resolved domain and returns the signing-data root. +/// Wraps the message root with the resolved domain and returns the signing-data +/// root. pub async fn get_data_root( client: &EthBeaconNodeApiClient, name: DomainName, From 6022c0453723ea09baf79b02f82bda81ae0041a4 Mon Sep 17 00:00:00 2001 From: Quang Le Date: Mon, 20 Apr 2026 22:07:49 +0700 Subject: [PATCH 7/8] fix: reduce fetch genesis call --- crates/eth2api/src/extensions.rs | 41 ++++++++++++++++++++++---------- 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/crates/eth2api/src/extensions.rs b/crates/eth2api/src/extensions.rs index 7ac05cf8..1c87e41c 100644 --- a/crates/eth2api/src/extensions.rs +++ b/crates/eth2api/src/extensions.rs @@ -60,6 +60,23 @@ fn decode_fixed_hex(value: &str) -> Result<[u8; N], EthBeaconNod .map_err(|_| EthBeaconNodeApiClientError::UnexpectedType) } +fn parse_genesis_fork_version_and_validators_root( + genesis_data: &serde_json::Value, +) -> Result<(phase0::Version, phase0::Root), EthBeaconNodeApiClientError> { + let fork_version = genesis_data + .get("genesis_fork_version") + .and_then(serde_json::Value::as_str) + .ok_or(EthBeaconNodeApiClientError::UnexpectedType) + .and_then(decode_fixed_hex)?; + let validators_root = genesis_data + .get("genesis_validators_root") + .and_then(serde_json::Value::as_str) + .ok_or(EthBeaconNodeApiClientError::UnexpectedType) + .and_then(decode_fixed_hex)?; + + Ok((fork_version, validators_root)) +} + fn fork_schedule_from_spec( spec_data: &serde_json::Value, ) -> Result, EthBeaconNodeApiClientError> { @@ -285,9 +302,12 @@ impl EthBeaconNodeApiClient { &self, domain_type: phase0::DomainType, ) -> Result { + let genesis = self.fetch_genesis_data().await?; + let (genesis_fork_version, _) = parse_genesis_fork_version_and_validators_root(&genesis)?; + Ok(compute_domain( domain_type, - self.fetch_genesis_fork_version().await?, + genesis_fork_version, phase0::Root::default(), )) } @@ -297,12 +317,9 @@ impl EthBeaconNodeApiClient { &self, ) -> Result { let genesis = self.fetch_genesis_data().await?; - let root = genesis - .get("genesis_validators_root") - .and_then(serde_json::Value::as_str) - .ok_or(EthBeaconNodeApiClientError::UnexpectedType)?; + let (_, validators_root) = parse_genesis_fork_version_and_validators_root(&genesis)?; - decode_fixed_hex(root) + Ok(validators_root) } /// Fetches the genesis fork version from the beacon node. @@ -310,12 +327,9 @@ impl EthBeaconNodeApiClient { &self, ) -> Result { let genesis = self.fetch_genesis_data().await?; - let version = genesis - .get("genesis_fork_version") - .and_then(serde_json::Value::as_str) - .ok_or(EthBeaconNodeApiClientError::UnexpectedType)?; + let (fork_version, _) = parse_genesis_fork_version_and_validators_root(&genesis)?; - decode_fixed_hex(version) + Ok(fork_version) } /// Fetches the resolved beacon domain for the provided domain type and @@ -327,8 +341,9 @@ impl EthBeaconNodeApiClient { ) -> Result { let spec = self.fetch_spec_data().await?; let fork_schedule = fork_schedule_from_spec(&spec)?; - let genesis_fork_version = self.fetch_genesis_fork_version().await?; - let genesis_validators_root = self.fetch_genesis_validators_root().await?; + let genesis = self.fetch_genesis_data().await?; + let (genesis_fork_version, genesis_validators_root) = + parse_genesis_fork_version_and_validators_root(&genesis)?; let voluntary_exit_domain_type = resolve_domain_type(&spec, "DOMAIN_VOLUNTARY_EXIT")?; Ok(resolve_domain( From ffcbefb25cb1ad1af096ce6330ec515544f3bb81 Mon Sep 17 00:00:00 2001 From: Quang Le Date: Mon, 20 Apr 2026 22:16:14 +0700 Subject: [PATCH 8/8] test: add verify_rejects_wrong_message_root --- crates/eth2util/src/signing.rs | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/crates/eth2util/src/signing.rs b/crates/eth2util/src/signing.rs index d796e96e..60639a8e 100644 --- a/crates/eth2util/src/signing.rs +++ b/crates/eth2util/src/signing.rs @@ -428,4 +428,36 @@ mod tests { assert!(matches!(err, SigningError::Verification(_))); } + + #[tokio::test] + async fn verify_rejects_wrong_message_root() { + let (_server, client) = mock_beacon_client().await; + + let secret = secret_key("345768c0245f1dc702df9e50e811002f61ebb2680b3d5931527ef59f96cbaf9b"); + let pubkey = BlstImpl.secret_to_public_key(&secret).unwrap(); + let signed_message_root = [0x55; 32]; + let verified_message_root = [0x66; 32]; + let signing_root = get_data_root( + &client, + DomainName::ApplicationBuilder, + 0, + signed_message_root, + ) + .await + .unwrap(); + let signature = BlstImpl.sign(&secret, &signing_root).unwrap(); + + let err = verify( + &client, + DomainName::ApplicationBuilder, + 0, + verified_message_root, + &signature, + &pubkey, + ) + .await + .unwrap_err(); + + assert!(matches!(err, SigningError::Verification(_))); + } }