From 04682f8ca35d3de146555c380937d182bde105d3 Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <273991985+varex83agent@users.noreply.github.com> Date: Thu, 25 Jun 2026 19:49:29 +0200 Subject: [PATCH 1/2] feat(core): port core/eth2signeddata Add the Eth2SignedData layer over the existing signed-data types: the signing DomainName and signing Epoch for each beacon-chain signed payload, plus verify_eth2_signed_data which resolves the upstream domain and runs BLS verification. Wires sigagg's new_verifier to it, replacing the no-op placeholder. Ports charon/core/eth2signeddata.go (v1.7.1). Co-Authored-By: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> --- crates/core/src/eth2signeddata.rs | 477 ++++++++++++++++++++++++++++++ crates/core/src/lib.rs | 3 + crates/core/src/sigagg.rs | 62 +++- crates/eth2api/src/versioned.rs | 18 ++ 4 files changed, 553 insertions(+), 7 deletions(-) create mode 100644 crates/core/src/eth2signeddata.rs diff --git a/crates/core/src/eth2signeddata.rs b/crates/core/src/eth2signeddata.rs new file mode 100644 index 00000000..2b97c693 --- /dev/null +++ b/crates/core/src/eth2signeddata.rs @@ -0,0 +1,477 @@ +//! Eth2 signed-data verification. +//! +//! Extends [`SignedData`] types that carry beacon-chain signatures with the +//! metadata needed to verify them: the signing [`DomainName`] and the signing +//! [`Epoch`]. [`verify_eth2_signed_data`] ties the two together with the +//! upstream beacon-node domain lookup and BLS verification. + +use std::any::Any; + +use async_trait::async_trait; +use pluto_crypto::types::PublicKey; +use pluto_eth2api::{client::EthBeaconNodeApiClient, spec::phase0::Epoch}; +use pluto_eth2util::{ + helpers::{self, HelperError}, + signing::{self, DomainName, SigningError}, +}; + +use crate::{ + signeddata::{ + Attestation, BeaconCommitteeSelection, SignedAggregateAndProof, SignedDataError, + SignedRandao, SignedSyncContributionAndProof, SignedSyncMessage, SignedVoluntaryExit, + SyncCommitteeSelection, VersionedAttestation, VersionedSignedAggregateAndProof, + VersionedSignedProposal, VersionedSignedValidatorRegistration, + }, + types::SignedData, +}; + +/// Error returned while resolving the signing epoch for, or verifying, an +/// [`Eth2SignedData`]. +#[derive(Debug, thiserror::Error)] +pub enum Eth2SignedDataError { + /// Failure while extracting the message root or epoch from the payload. + #[error(transparent)] + SignedData(#[from] SignedDataError), + + /// Beacon-node domain lookup or BLS verification failed. + #[error(transparent)] + Signing(#[from] SigningError), + + /// Slot-to-epoch conversion failed. + #[error(transparent)] + Helper(#[from] HelperError), +} + +/// Signed duty data that carries an eth2 beacon-chain signature. +/// +/// The signing root is the payload's [`SignedData::message_root`] wrapped with +/// the domain identified by [`Self::domain_name`] at the epoch returned by +/// [`Self::epoch`]. +#[async_trait] +pub trait Eth2SignedData: SignedData { + /// Returns the eth2 signing domain for this data. + fn domain_name(&self) -> DomainName; + + /// Returns the epoch at which the signing domain is resolved. + async fn epoch(&self, client: &EthBeaconNodeApiClient) -> Result; +} + +/// Verifies the eth2 signature associated with the given [`Eth2SignedData`]. +pub async fn verify_eth2_signed_data( + client: &EthBeaconNodeApiClient, + data: &dyn Eth2SignedData, + pubkey: &PublicKey, +) -> Result<(), Eth2SignedDataError> { + let epoch = data.epoch(client).await?; + let sig_root = data.message_root()?; + let signature = data.signature()?; + + signing::verify( + client, + data.domain_name(), + epoch, + sig_root, + &signature, + pubkey, + ) + .await?; + + Ok(()) +} + +/// Attempts to view a [`SignedData`] as an [`Eth2SignedData`], mirroring Go's +/// `data.(core.Eth2SignedData)` type assertion. Returns `None` for signed-data +/// variants without a beacon-chain signing domain (e.g. raw [`Signature`]). +/// +/// [`Signature`]: crate::types::Signature +pub fn as_eth2_signed_data(data: &dyn SignedData) -> Option<&dyn Eth2SignedData> { + let any = data as &dyn Any; + + if let Some(v) = any.downcast_ref::() { + return Some(v); + } + if let Some(v) = any.downcast_ref::() { + return Some(v); + } + if let Some(v) = any.downcast_ref::() { + return Some(v); + } + if let Some(v) = any.downcast_ref::() { + return Some(v); + } + if let Some(v) = any.downcast_ref::() { + return Some(v); + } + if let Some(v) = any.downcast_ref::() { + return Some(v); + } + if let Some(v) = any.downcast_ref::() { + return Some(v); + } + if let Some(v) = any.downcast_ref::() { + return Some(v); + } + if let Some(v) = any.downcast_ref::() { + return Some(v); + } + if let Some(v) = any.downcast_ref::() { + return Some(v); + } + if let Some(v) = any.downcast_ref::() { + return Some(v); + } + if let Some(v) = any.downcast_ref::() { + return Some(v); + } + + None +} + +#[async_trait] +impl Eth2SignedData for VersionedSignedProposal { + fn domain_name(&self) -> DomainName { + DomainName::BeaconProposer + } + + async fn epoch(&self, client: &EthBeaconNodeApiClient) -> Result { + if self.0.version == pluto_eth2api::versioned::DataVersion::Unknown { + return Err(SignedDataError::UnknownVersion.into()); + } + + Ok(helpers::epoch_from_slot(client, self.0.block.slot()).await?) + } +} + +#[async_trait] +impl Eth2SignedData for Attestation { + fn domain_name(&self) -> DomainName { + DomainName::BeaconAttester + } + + async fn epoch(&self, _client: &EthBeaconNodeApiClient) -> Result { + Ok(self.0.data.target.epoch) + } +} + +#[async_trait] +impl Eth2SignedData for VersionedAttestation { + fn domain_name(&self) -> DomainName { + DomainName::BeaconAttester + } + + async fn epoch(&self, _client: &EthBeaconNodeApiClient) -> Result { + let version = self.0.version; + if version == pluto_eth2api::versioned::DataVersion::Unknown { + return Err(SignedDataError::UnknownVersion.into()); + } + + let data = self + .0 + .attestation + .as_ref() + .ok_or(SignedDataError::MissingAttestation(version))? + .data(); + + Ok(data.target.epoch) + } +} + +#[async_trait] +impl Eth2SignedData for SignedVoluntaryExit { + fn domain_name(&self) -> DomainName { + DomainName::VoluntaryExit + } + + async fn epoch(&self, _client: &EthBeaconNodeApiClient) -> Result { + Ok(self.0.message.epoch) + } +} + +#[async_trait] +impl Eth2SignedData for VersionedSignedValidatorRegistration { + fn domain_name(&self) -> DomainName { + DomainName::ApplicationBuilder + } + + async fn epoch(&self, _client: &EthBeaconNodeApiClient) -> Result { + // Always use epoch 0 for DomainApplicationBuilder. + Ok(0) + } +} + +#[async_trait] +impl Eth2SignedData for SignedRandao { + fn domain_name(&self) -> DomainName { + DomainName::Randao + } + + async fn epoch(&self, _client: &EthBeaconNodeApiClient) -> Result { + Ok(self.0.epoch) + } +} + +#[async_trait] +impl Eth2SignedData for BeaconCommitteeSelection { + fn domain_name(&self) -> DomainName { + DomainName::SelectionProof + } + + async fn epoch(&self, client: &EthBeaconNodeApiClient) -> Result { + Ok(helpers::epoch_from_slot(client, self.0.slot).await?) + } +} + +#[async_trait] +impl Eth2SignedData for SignedAggregateAndProof { + fn domain_name(&self) -> DomainName { + DomainName::AggregateAndProof + } + + async fn epoch(&self, client: &EthBeaconNodeApiClient) -> Result { + Ok(helpers::epoch_from_slot(client, self.0.message.aggregate.data.slot).await?) + } +} + +#[async_trait] +impl Eth2SignedData for VersionedSignedAggregateAndProof { + fn domain_name(&self) -> DomainName { + DomainName::AggregateAndProof + } + + async fn epoch(&self, client: &EthBeaconNodeApiClient) -> Result { + let slot = self.0.slot().ok_or(SignedDataError::UnknownVersion)?; + + Ok(helpers::epoch_from_slot(client, slot).await?) + } +} + +#[async_trait] +impl Eth2SignedData for SignedSyncMessage { + fn domain_name(&self) -> DomainName { + DomainName::SyncCommittee + } + + async fn epoch(&self, client: &EthBeaconNodeApiClient) -> Result { + Ok(helpers::epoch_from_slot(client, self.0.slot).await?) + } +} + +#[async_trait] +impl Eth2SignedData for SignedSyncContributionAndProof { + fn domain_name(&self) -> DomainName { + DomainName::ContributionAndProof + } + + async fn epoch(&self, client: &EthBeaconNodeApiClient) -> Result { + Ok(helpers::epoch_from_slot(client, self.0.message.contribution.slot).await?) + } +} + +#[async_trait] +impl Eth2SignedData for SyncCommitteeSelection { + fn domain_name(&self) -> DomainName { + DomainName::SyncCommitteeSelectionProof + } + + async fn epoch(&self, client: &EthBeaconNodeApiClient) -> Result { + Ok(helpers::epoch_from_slot(client, self.0.slot).await?) + } +} + +#[cfg(test)] +mod tests { + use std::{fs, path::PathBuf}; + + use pluto_crypto::{blst_impl::BlstImpl, tbls::Tbls}; + use pluto_testutil::BeaconMock; + use serde::de::DeserializeOwned; + + use super::*; + use crate::types::{SIGNATURE_LENGTH, Signature}; + + fn fixture_path(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("testdata") + .join("signeddata") + .join(name) + } + + fn load(name: &str) -> T { + let json = fs::read_to_string(fixture_path(name)).unwrap(); + serde_json::from_str(&json).unwrap() + } + + /// Mirrors Go's `TestVerifyEth2SignedData`: resolve the epoch and message + /// root, BLS-sign the signing-domain data root, inject the signature, and + /// assert verification succeeds. + async fn assert_verifies(client: &EthBeaconNodeApiClient, data: T) + where + T: Eth2SignedData + Clone, + { + let epoch = data.epoch(client).await.unwrap(); + let root = data.message_root().unwrap(); + + let tbls = BlstImpl; + let mut rng = rand::thread_rng(); + let secret = tbls.generate_secret_key(&mut rng).unwrap(); + let pubkey = tbls.secret_to_public_key(&secret).unwrap(); + + let sig_data = signing::get_data_root(client, data.domain_name(), epoch, root) + .await + .unwrap(); + let sig: Signature = tbls.sign(&secret, &sig_data).unwrap(); + + let signed = data.set_signature(sig).unwrap(); + + verify_eth2_signed_data(client, &signed, &pubkey) + .await + .unwrap(); + } + + #[tokio::test] + async fn verify_beacon_block() { + let mock = BeaconMock::builder().build().await.unwrap(); + let data: VersionedSignedProposal = + load("TestJSONSerialisation_VersionedSignedProposal.json.golden"); + assert_verifies(mock.client(), data).await; + } + + #[tokio::test] + async fn verify_attestation() { + let mock = BeaconMock::builder().build().await.unwrap(); + let data: VersionedAttestation = + load("TestJSONSerialisation_VersionedAttestation.json.golden"); + assert_verifies(mock.client(), data).await; + } + + #[tokio::test] + async fn verify_randao() { + let mock = BeaconMock::builder().build().await.unwrap(); + let data: SignedRandao = load("TestJSONSerialisation_SignedRandao.json.golden"); + assert_verifies(mock.client(), data).await; + } + + #[tokio::test] + async fn verify_voluntary_exit() { + let mock = BeaconMock::builder().build().await.unwrap(); + let data: SignedVoluntaryExit = + load("TestJSONSerialisation_SignedVoluntaryExit.json.golden"); + assert_verifies(mock.client(), data).await; + } + + #[tokio::test] + async fn verify_registration() { + let mock = BeaconMock::builder().build().await.unwrap(); + let data: VersionedSignedValidatorRegistration = + load("VersionedSignedValidatorRegistration.v1.json"); + assert_verifies(mock.client(), data).await; + } + + #[tokio::test] + async fn verify_beacon_committee_selection() { + let mock = BeaconMock::builder().build().await.unwrap(); + let data: BeaconCommitteeSelection = + load("TestJSONSerialisation_BeaconCommitteeSelection.json.golden"); + assert_verifies(mock.client(), data).await; + } + + #[tokio::test] + async fn verify_aggregate_and_proof() { + let mock = BeaconMock::builder().build().await.unwrap(); + let data: VersionedSignedAggregateAndProof = + load("TestJSONSerialisation_VersionedSignedAggregateAndProof.json.golden"); + assert_verifies(mock.client(), data).await; + } + + #[tokio::test] + async fn verify_sync_committee_message() { + let mock = BeaconMock::builder().build().await.unwrap(); + let data: SignedSyncMessage = load("TestJSONSerialisation_SignedSyncMessage.json.golden"); + assert_verifies(mock.client(), data).await; + } + + #[tokio::test] + async fn verify_sync_contribution_and_proof() { + let mock = BeaconMock::builder().build().await.unwrap(); + let data: SignedSyncContributionAndProof = + load("TestJSONSerialisation_SignedSyncContributionAndProof.json.golden"); + assert_verifies(mock.client(), data).await; + } + + #[tokio::test] + async fn verify_sync_committee_selection() { + let mock = BeaconMock::builder().build().await.unwrap(); + let data: SyncCommitteeSelection = + load("TestJSONSerialisation_SyncCommitteeSelection.json.golden"); + assert_verifies(mock.client(), data).await; + } + + #[tokio::test] + async fn verify_rejects_wrong_pubkey() { + let mock = BeaconMock::builder().build().await.unwrap(); + let client = mock.client(); + let data: SignedRandao = load("TestJSONSerialisation_SignedRandao.json.golden"); + + let epoch = data.epoch(client).await.unwrap(); + let root = data.message_root().unwrap(); + + let tbls = BlstImpl; + let mut rng = rand::thread_rng(); + let secret = tbls.generate_secret_key(&mut rng).unwrap(); + let wrong_secret = tbls.generate_secret_key(&mut rng).unwrap(); + let wrong_pubkey = tbls.secret_to_public_key(&wrong_secret).unwrap(); + + let sig_data = signing::get_data_root(client, data.domain_name(), epoch, root) + .await + .unwrap(); + let sig: Signature = tbls.sign(&secret, &sig_data).unwrap(); + let signed = data.set_signature(sig).unwrap(); + + let err = verify_eth2_signed_data(client, &signed, &wrong_pubkey) + .await + .unwrap_err(); + + assert!(matches!(err, Eth2SignedDataError::Signing(_))); + } + + #[tokio::test] + async fn verify_rejects_zero_signature() { + let mock = BeaconMock::builder().build().await.unwrap(); + let client = mock.client(); + let data: SignedRandao = load("TestJSONSerialisation_SignedRandao.json.golden"); + + let pubkey = [0x11; 48]; + let signed = data.set_signature([0; SIGNATURE_LENGTH]).unwrap(); + + let err = verify_eth2_signed_data(client, &signed, &pubkey) + .await + .unwrap_err(); + + assert!(matches!( + err, + Eth2SignedDataError::Signing(SigningError::ZeroSignature) + )); + } + + #[test] + fn registration_always_uses_epoch_zero() { + // VersionedSignedValidatorRegistration uses DomainApplicationBuilder, + // which is fixed at epoch 0 regardless of the beacon client. + let data: VersionedSignedValidatorRegistration = + load("VersionedSignedValidatorRegistration.v1.json"); + assert_eq!(data.domain_name(), DomainName::ApplicationBuilder); + } + + #[test] + fn as_eth2_signed_data_views_typed_payloads() { + let randao: SignedRandao = load("TestJSONSerialisation_SignedRandao.json.golden"); + + // A typed payload is viewable as Eth2SignedData... + let boxed: Box = Box::new(randao); + assert!(as_eth2_signed_data(boxed.as_ref()).is_some()); + + // ...while a raw signature is not. + let sig: Box = Box::new([0u8; SIGNATURE_LENGTH] as Signature); + assert!(as_eth2_signed_data(sig.as_ref()).is_none()); + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 9f9e21ec..3cc8be78 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -14,6 +14,9 @@ pub mod unsigneddata; /// Signed data wrappers and helpers. pub mod signeddata; +/// Eth2 signed-data verification. +pub mod eth2signeddata; + /// Protobuf definitions. pub mod corepb; diff --git a/crates/core/src/sigagg.rs b/crates/core/src/sigagg.rs index 31c13f75..8f8c87b5 100644 --- a/crates/core/src/sigagg.rs +++ b/crates/core/src/sigagg.rs @@ -3,10 +3,12 @@ use std::{collections::HashMap, future::Future, pin::Pin, sync::Arc}; -use pluto_crypto::{blst_impl::BlstImpl, tbls::Tbls}; +use pluto_crypto::{blst_impl::BlstImpl, tbls::Tbls, types::PublicKey}; +use pluto_eth2api::client::EthBeaconNodeApiClient; use tracing::{debug, error, info_span}; use crate::{ + eth2signeddata::{Eth2SignedDataError, as_eth2_signed_data, verify_eth2_signed_data}, signeddata::{SignedDataError, VersionedAttestation}, types::{Duty, ParSignedData, PubKey, Signature, SignedData}, }; @@ -66,6 +68,23 @@ pub enum SigAggError { #[source] source: pluto_crypto::types::Error, }, + + /// The aggregated payload is not eth2 signed data. + #[error("invalid eth2 signed data")] + InvalidEth2SignedData, + + /// The core public key could not be converted to a BLS public key. + #[error("pubkey from core")] + PubkeyFromCore, + + /// Verification of the aggregated signature against the beacon chain + /// failed. + #[error("aggregate signature verification failed: {source}")] + VerificationFailed { + /// The underlying error. + #[source] + source: Eth2SignedDataError, + }, } /// Convenience alias for [`std::result::Result`] with [`SigAggError`]. @@ -228,12 +247,27 @@ impl Aggregator { /// Returns a [`VerifyFn`] that verifies the aggregated signature against the /// beacon chain. -/// -/// TODO: implement once `Eth2SignedData` and beacon-client verification are -/// ported (`core::types` has a placeholder — see types.rs TODO for -/// `Eth2SignedData`). For now callers can use a no-op or BLS-only verifier. -pub fn new_verifier() -> VerifyFn { - Arc::new(|_, _| Box::pin(async { Ok(()) })) +pub fn new_verifier(eth2_cl: Arc) -> VerifyFn { + Arc::new(move |pubkey: &PubKey, data: &dyn SignedData| { + let eth2_cl = eth2_cl.clone(); + // The future must be `'static`, so clone the borrowed inputs out of the + // call frame before entering the async block. + let tbls_pubkey = PublicKey::try_from(pubkey.as_ref()); + let owned: Box = dyn_clone::clone_box(data); + + Box::pin(async move { + let tbls_pubkey = tbls_pubkey.map_err(|_| SigAggError::PubkeyFromCore)?; + + let eth2_signed = + as_eth2_signed_data(owned.as_ref()).ok_or(SigAggError::InvalidEth2SignedData)?; + + verify_eth2_signed_data(ð2_cl, eth2_signed, &tbls_pubkey) + .await + .map_err(|source| SigAggError::VerificationFailed { source })?; + + Ok(()) + }) + }) } #[cfg(test)] @@ -256,6 +290,20 @@ mod tests { Arc::new(|_, _| Box::pin(async { Ok(()) })) } + #[tokio::test] + async fn new_verifier_rejects_non_eth2_data() { + let mock = pluto_testutil::BeaconMock::builder().build().await.unwrap(); + let eth2_cl = Arc::new(mock.client().clone()); + let verify = new_verifier(eth2_cl); + + let data = MockSignedData { + sig: [0u8; SIGNATURE_LENGTH], + }; + let err = verify(&PubKey::new([0x11; 48]), &data).await.unwrap_err(); + + assert!(matches!(err, SigAggError::InvalidEth2SignedData)); + } + #[derive(Debug, Clone, PartialEq, Eq)] struct MockSignedData { sig: [u8; SIGNATURE_LENGTH], diff --git a/crates/eth2api/src/versioned.rs b/crates/eth2api/src/versioned.rs index e3d0562e..34d36edc 100644 --- a/crates/eth2api/src/versioned.rs +++ b/crates/eth2api/src/versioned.rs @@ -121,6 +121,24 @@ impl SignedProposalBlock { } } + /// Returns the slot embedded in this proposal's block. + pub fn slot(&self) -> phase0::Slot { + match self { + Self::Phase0(block) => block.message.slot, + Self::Altair(block) => block.message.slot, + Self::Bellatrix(block) => block.message.slot, + Self::BellatrixBlinded(block) => block.message.slot, + Self::Capella(block) => block.message.slot, + Self::CapellaBlinded(block) => block.message.slot, + Self::Deneb(block) => block.signed_block.message.slot, + Self::DenebBlinded(block) => block.message.slot, + Self::Electra(block) => block.signed_block.message.slot, + Self::ElectraBlinded(block) => block.message.slot, + Self::Fulu(block) => block.signed_block.message.slot, + Self::FuluBlinded(block) => block.message.slot, + } + } + /// Converts blinded payload variants into blinded-wrapper payloads. pub fn into_blinded(self) -> Option { match self { From b8a2dc3abde582c7e74dc9aa6f4f2c2d6d8db043 Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <273991985+varex83agent@users.noreply.github.com> Date: Fri, 26 Jun 2026 17:40:45 +0200 Subject: [PATCH 2/2] refactor(core): address PR #501 review comments - eth2signeddata: check cheap message_root/signature before the (potentially client-bound) epoch lookup in verify_eth2_signed_data - eth2signeddata: add verification tests for the non-versioned Attestation and SignedAggregateAndProof payloads - sigagg: SigAggError::PubkeyFromCore now carries the inner conversion error instead of discarding it - sigagg: SigAggError::VerificationFailed uses #[from] for Eth2SignedDataError - types: drop stale "todo: add Eth2SignedData type" comment Co-Authored-By: Bohdan Ohorodnii --- crates/core/src/eth2signeddata.rs | 50 ++++++++++++++++++++++++++++++- crates/core/src/sigagg.rs | 23 +++++++------- crates/core/src/types.rs | 3 -- 3 files changed, 60 insertions(+), 16 deletions(-) diff --git a/crates/core/src/eth2signeddata.rs b/crates/core/src/eth2signeddata.rs index 2b97c693..999ae144 100644 --- a/crates/core/src/eth2signeddata.rs +++ b/crates/core/src/eth2signeddata.rs @@ -62,9 +62,9 @@ pub async fn verify_eth2_signed_data( data: &dyn Eth2SignedData, pubkey: &PublicKey, ) -> Result<(), Eth2SignedDataError> { - let epoch = data.epoch(client).await?; let sig_root = data.message_root()?; let signature = data.signature()?; + let epoch = data.epoch(client).await?; signing::verify( client, @@ -283,6 +283,7 @@ mod tests { use std::{fs, path::PathBuf}; use pluto_crypto::{blst_impl::BlstImpl, tbls::Tbls}; + use pluto_eth2api::spec::phase0; use pluto_testutil::BeaconMock; use serde::de::DeserializeOwned; @@ -301,6 +302,32 @@ mod tests { serde_json::from_str(&json).unwrap() } + /// The non-versioned `Attestation`/`SignedAggregateAndProof` wrappers have + /// no golden JSON fixture, so build a phase0 sample by hand. + fn sample_attestation_data() -> phase0::AttestationData { + phase0::AttestationData { + slot: 1, + index: 2, + beacon_block_root: [0x11; 32], + source: phase0::Checkpoint { + epoch: 3, + root: [0x22; 32], + }, + target: phase0::Checkpoint { + epoch: 4, + root: [0x33; 32], + }, + } + } + + fn sample_phase0_attestation() -> phase0::Attestation { + phase0::Attestation { + aggregation_bits: serde_json::from_str("\"0x0101\"").unwrap(), + data: sample_attestation_data(), + signature: [0x34; 96], + } + } + /// Mirrors Go's `TestVerifyEth2SignedData`: resolve the epoch and message /// root, BLS-sign the signing-domain data root, inject the signature, and /// assert verification succeeds. @@ -383,6 +410,27 @@ mod tests { assert_verifies(mock.client(), data).await; } + #[tokio::test] + async fn verify_phase0_attestation() { + let mock = BeaconMock::builder().build().await.unwrap(); + let data = Attestation::new(sample_phase0_attestation()); + assert_verifies(mock.client(), data).await; + } + + #[tokio::test] + async fn verify_phase0_aggregate_and_proof() { + let mock = BeaconMock::builder().build().await.unwrap(); + let data = SignedAggregateAndProof::new(phase0::SignedAggregateAndProof { + message: phase0::AggregateAndProof { + aggregator_index: 7, + aggregate: sample_phase0_attestation(), + selection_proof: [0x55; 96], + }, + signature: [0x66; 96], + }); + assert_verifies(mock.client(), data).await; + } + #[tokio::test] async fn verify_sync_committee_message() { let mock = BeaconMock::builder().build().await.unwrap(); diff --git a/crates/core/src/sigagg.rs b/crates/core/src/sigagg.rs index 8f8c87b5..04487b00 100644 --- a/crates/core/src/sigagg.rs +++ b/crates/core/src/sigagg.rs @@ -74,17 +74,17 @@ pub enum SigAggError { InvalidEth2SignedData, /// The core public key could not be converted to a BLS public key. - #[error("pubkey from core")] - PubkeyFromCore, - - /// Verification of the aggregated signature against the beacon chain - /// failed. - #[error("aggregate signature verification failed: {source}")] - VerificationFailed { + #[error("pubkey from core: {source}")] + PubkeyFromCore { /// The underlying error. #[source] - source: Eth2SignedDataError, + source: std::array::TryFromSliceError, }, + + /// Verification of the aggregated signature against the beacon chain + /// failed. + #[error("aggregate signature verification failed: {0}")] + VerificationFailed(#[from] Eth2SignedDataError), } /// Convenience alias for [`std::result::Result`] with [`SigAggError`]. @@ -256,14 +256,13 @@ pub fn new_verifier(eth2_cl: Arc) -> VerifyFn { let owned: Box = dyn_clone::clone_box(data); Box::pin(async move { - let tbls_pubkey = tbls_pubkey.map_err(|_| SigAggError::PubkeyFromCore)?; + let tbls_pubkey = + tbls_pubkey.map_err(|source| SigAggError::PubkeyFromCore { source })?; let eth2_signed = as_eth2_signed_data(owned.as_ref()).ok_or(SigAggError::InvalidEth2SignedData)?; - verify_eth2_signed_data(ð2_cl, eth2_signed, &tbls_pubkey) - .await - .map_err(|source| SigAggError::VerificationFailed { source })?; + verify_eth2_signed_data(ð2_cl, eth2_signed, &tbls_pubkey).await?; Ok(()) }) diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index bc1e5a37..882e2ef9 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -665,9 +665,6 @@ pub trait SignedData: Any + DynClone + DynEq + StdDebug + Send + Sync { dyn_eq::eq_trait_object!(SignedData); dyn_clone::clone_trait_object!(SignedData); -// todo: add Eth2SignedData type -// https://github.com/ObolNetwork/charon/blob/b3008103c5429b031b63518195f4c49db4e9a68d/core/types.go#L396 - /// ParSignedData is a partially signed duty data only signed by a single /// threshold BLS share. #[derive(Debug)]