From 5e0331ff49d440c00397dce6c9fa7df385e9fe91 Mon Sep 17 00:00:00 2001 From: "emlautarom1-agent[bot]" <292495798+emlautarom1-agent[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 14:41:35 -0300 Subject: [PATCH 1/6] feat(core): encode/decode UnsignedDataSet proto for all duty types Add the all-duty-type proto codec covering attester, proposer, aggregator, and sync-contribution unsigned data. The encoder/decoder is wire-compatible with Charon's SSZ-first UnsignedDataSet format (with legacy JSON fallback on decode), unblocking non-attester duties through consensus. --- Cargo.lock | 1 + crates/core/Cargo.toml | 1 + crates/core/src/ssz_codec.rs | 264 +++++++++++++++ crates/core/src/unsigneddata.rs | 580 +++++++++++++++++++++++++++++++- 4 files changed, 843 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8f13ed09..28221855 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5642,6 +5642,7 @@ dependencies = [ "dyn-clone", "dyn-eq", "ethereum_ssz", + "ethereum_ssz_derive", "futures", "hex", "pluto-build-proto", diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index a58d4458..297c259b 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -37,6 +37,7 @@ tracing.workspace = true pluto-eth2util.workspace = true pluto-ssz.workspace = true ssz.workspace = true +ssz_derive.workspace = true tree_hash.workspace = true url.workspace = true diff --git a/crates/core/src/ssz_codec.rs b/crates/core/src/ssz_codec.rs index 860e61d9..9678782f 100644 --- a/crates/core/src/ssz_codec.rs +++ b/crates/core/src/ssz_codec.rs @@ -581,6 +581,270 @@ fn decode_proposal_block( }) } +// =========================================================================== +// Unsigned duty data SSZ (mirrors charon/core/ssz.go for the unsigned types) +// =========================================================================== + +// Helper containers for the unsigned Deneb/Electra/Fulu block contents. +// +// Charon's `eth2api.VersionedProposal` SSZ marshals the unsigned +// `BlockContents` (`{block, kzg_proofs, blobs}`) for the Deneb+ forks. Pluto's +// spec module only exposes the *signed* `BlockContents`, so we declare the +// unsigned equivalents here. The SSZ derive produces the same variable-length +// container layout (offsets + appended field bodies) as Go's fastssz, so the +// bytes are interop-compatible. Field order must match `SignedBlockContents`: +// `block`, `kzg_proofs`, `blobs`. + +/// Unsigned Deneb block contents (`{block, kzg_proofs, blobs}`). +#[derive(Debug, Clone, PartialEq, Eq, ssz_derive::Encode, ssz_derive::Decode)] +struct DenebBlockContents { + block: deneb::BeaconBlock, + kzg_proofs: Vec, + blobs: Vec, +} + +/// Unsigned Electra/Fulu block contents (`{block, kzg_proofs, blobs}`). Fulu +/// reuses the Electra beacon-block layout. +#[derive(Debug, Clone, PartialEq, Eq, ssz_derive::Encode, ssz_derive::Decode)] +struct ElectraBlockContents { + block: electra::BeaconBlock, + kzg_proofs: Vec, + blobs: Vec, +} + +/// Encodes the SSZ body of an unsigned proposal block (no versioned header), +/// selecting the per-fork layout from the [`UnsignedProposalBlock`] variant. +fn encode_unsigned_proposal_block( + block: &crate::signeddata::ProposalBlock, +) -> Result, SszCodecError> { + use crate::signeddata::ProposalBlock; + Ok(match block { + ProposalBlock::Phase0(b) => b.as_ssz_bytes(), + ProposalBlock::Altair(b) => b.as_ssz_bytes(), + ProposalBlock::Bellatrix(b) => b.as_ssz_bytes(), + ProposalBlock::BellatrixBlinded(b) => b.as_ssz_bytes(), + ProposalBlock::Capella(b) => b.as_ssz_bytes(), + ProposalBlock::CapellaBlinded(b) => b.as_ssz_bytes(), + ProposalBlock::Deneb { + block, + kzg_proofs, + blobs, + } => DenebBlockContents { + block: (**block).clone(), + kzg_proofs: kzg_proofs.clone(), + blobs: blobs.clone(), + } + .as_ssz_bytes(), + ProposalBlock::DenebBlinded(b) => b.as_ssz_bytes(), + ProposalBlock::Electra { + block, + kzg_proofs, + blobs, + } => ElectraBlockContents { + block: (**block).clone(), + kzg_proofs: kzg_proofs.clone(), + blobs: blobs.clone(), + } + .as_ssz_bytes(), + ProposalBlock::ElectraBlinded(b) => b.as_ssz_bytes(), + ProposalBlock::Fulu { + block, + kzg_proofs, + blobs, + } => ElectraBlockContents { + block: (**block).clone(), + kzg_proofs: kzg_proofs.clone(), + blobs: blobs.clone(), + } + .as_ssz_bytes(), + ProposalBlock::FuluBlinded(b) => b.as_ssz_bytes(), + }) +} + +/// Decodes the SSZ body of an unsigned proposal block (no versioned header), +/// selecting the per-fork layout from `(version, blinded)`. +fn decode_unsigned_proposal_block( + version: DataVersion, + blinded: bool, + bytes: &[u8], +) -> Result { + use crate::signeddata::ProposalBlock; + Ok(match (version, blinded) { + (DataVersion::Phase0, _) => { + ProposalBlock::Phase0(phase0::BeaconBlock::from_ssz_bytes(bytes)?) + } + (DataVersion::Altair, _) => { + ProposalBlock::Altair(altair::BeaconBlock::from_ssz_bytes(bytes)?) + } + (DataVersion::Bellatrix, false) => { + ProposalBlock::Bellatrix(bellatrix::BeaconBlock::from_ssz_bytes(bytes)?) + } + (DataVersion::Bellatrix, true) => { + ProposalBlock::BellatrixBlinded(bellatrix::BlindedBeaconBlock::from_ssz_bytes(bytes)?) + } + (DataVersion::Capella, false) => { + ProposalBlock::Capella(capella::BeaconBlock::from_ssz_bytes(bytes)?) + } + (DataVersion::Capella, true) => { + ProposalBlock::CapellaBlinded(capella::BlindedBeaconBlock::from_ssz_bytes(bytes)?) + } + (DataVersion::Deneb, false) => { + let contents = DenebBlockContents::from_ssz_bytes(bytes)?; + ProposalBlock::Deneb { + block: Box::new(contents.block), + kzg_proofs: contents.kzg_proofs, + blobs: contents.blobs, + } + } + (DataVersion::Deneb, true) => { + ProposalBlock::DenebBlinded(deneb::BlindedBeaconBlock::from_ssz_bytes(bytes)?) + } + (DataVersion::Electra, false) => { + let contents = ElectraBlockContents::from_ssz_bytes(bytes)?; + ProposalBlock::Electra { + block: Box::new(contents.block), + kzg_proofs: contents.kzg_proofs, + blobs: contents.blobs, + } + } + (DataVersion::Electra, true) => { + ProposalBlock::ElectraBlinded(electra::BlindedBeaconBlock::from_ssz_bytes(bytes)?) + } + (DataVersion::Fulu, false) => { + let contents = ElectraBlockContents::from_ssz_bytes(bytes)?; + ProposalBlock::Fulu { + block: Box::new(contents.block), + kzg_proofs: contents.kzg_proofs, + blobs: contents.blobs, + } + } + // Fulu blinded blocks share the Electra blinded layout. + (DataVersion::Fulu, true) => { + ProposalBlock::FuluBlinded(electra::BlindedBeaconBlock::from_ssz_bytes(bytes)?) + } + (DataVersion::Unknown, _) => return Err(SszCodecError::UnknownVersion(u64::MAX)), + }) +} + +/// Encodes an unsigned +/// [`VersionedProposal`](crate::signeddata::VersionedProposal) to SSZ binary +/// with the Charon versioned-blinded header (`version(8) + blinded(1) + +/// offset(4)`), matching `core.VersionedProposal.MarshalSSZ`. +/// +/// The `consensus_block_value`/`execution_payload_value` fields are *not* part +/// of the SSZ wire format (Charon's `eth2api.VersionedProposal` does not +/// serialize them), so they are dropped here and defaulted on decode — exactly +/// as in Charon. +pub fn encode_versioned_proposal( + vp: &crate::signeddata::VersionedProposal, +) -> Result, SszCodecError> { + let version = encode_version(vp.version())?; + let blinded: u8 = u8::from(vp.is_blinded()); + let inner = encode_unsigned_proposal_block(&vp.block)?; + + let mut buf = Vec::with_capacity(VERSIONED_SIGNED_PROPOSAL_HEADER as usize + inner.len()); + buf.extend_from_slice(&version); + buf.push(blinded); + buf.extend_from_slice(&encode_u32(VERSIONED_SIGNED_PROPOSAL_HEADER)); + buf.extend_from_slice(&inner); + Ok(buf) +} + +/// Decodes an unsigned +/// [`VersionedProposal`](crate::signeddata::VersionedProposal) from SSZ binary +/// with the Charon versioned-blinded header. +pub fn decode_versioned_proposal( + bytes: &[u8], +) -> Result { + require(bytes, VERSIONED_SIGNED_PROPOSAL_HEADER as usize)?; + let version = decode_version(&bytes[0..8])?; + let blinded = bytes[8] != 0; + let offset = decode_u32(&bytes[9..13])?; + if offset != VERSIONED_SIGNED_PROPOSAL_HEADER { + return Err(SszCodecError::InvalidOffset { + expected: VERSIONED_SIGNED_PROPOSAL_HEADER, + got: offset, + }); + } + + let inner = &bytes[VERSIONED_SIGNED_PROPOSAL_HEADER as usize..]; + let block = decode_unsigned_proposal_block(version, blinded, inner)?; + + Ok(crate::signeddata::VersionedProposal { + block, + // Block values are not carried in the SSZ wire format; Charon defaults + // them to zero on decode (the validatorapi later overrides them). + consensus_block_value: alloy::primitives::U256::ZERO, + execution_payload_value: alloy::primitives::U256::ZERO, + }) +} + +/// Encodes an unsigned +/// [`VersionedAggregatedAttestation`](crate::signeddata::VersionedAggregatedAttestation) +/// to SSZ binary with the Charon versioned header (`version(8) + offset(4)`), +/// matching `core.VersionedAggregatedAttestation.MarshalSSZ` +/// (`marshalSSZVersionedTo` — no validator index, no blinded flag). +pub fn encode_versioned_aggregated_attestation( + va: &crate::signeddata::VersionedAggregatedAttestation, +) -> Result, SszCodecError> { + let version = encode_version(va.0.version)?; + let inner = encode_attestation_payload(va.0.attestation.as_ref())?; + + let mut buf = Vec::with_capacity(VERSIONED_SIGNED_AGGREGATE_HEADER as usize + inner.len()); + buf.extend_from_slice(&version); + buf.extend_from_slice(&encode_u32(VERSIONED_SIGNED_AGGREGATE_HEADER)); + buf.extend_from_slice(&inner); + Ok(buf) +} + +/// Decodes an unsigned +/// [`VersionedAggregatedAttestation`](crate::signeddata::VersionedAggregatedAttestation) +/// from SSZ binary with the Charon versioned header (`version(8) + offset(4)`). +pub fn decode_versioned_aggregated_attestation( + bytes: &[u8], +) -> Result { + require(bytes, VERSIONED_SIGNED_AGGREGATE_HEADER as usize)?; + let version = decode_version(&bytes[0..8])?; + let offset = decode_u32(&bytes[8..12])?; + if offset != VERSIONED_SIGNED_AGGREGATE_HEADER { + return Err(SszCodecError::InvalidOffset { + expected: VERSIONED_SIGNED_AGGREGATE_HEADER, + got: offset, + }); + } + + let inner = &bytes[VERSIONED_SIGNED_AGGREGATE_HEADER as usize..]; + let attestation = decode_attestation_payload(version, inner)?; + + Ok(crate::signeddata::VersionedAggregatedAttestation( + versioned::VersionedAttestation { + version, + validator_index: None, + attestation: Some(attestation), + }, + )) +} + +/// Encodes an unsigned +/// [`SyncContribution`](crate::signeddata::SyncContribution) to SSZ binary. The +/// inner `altair::SyncCommitteeContribution` is a fixed-size SSZ container with +/// no Charon versioned header (matching `core.SyncContribution.MarshalSSZ`). +pub fn encode_sync_contribution( + sc: &crate::signeddata::SyncContribution, +) -> Result, SszCodecError> { + Ok(sc.0.as_ssz_bytes()) +} + +/// Decodes an unsigned +/// [`SyncContribution`](crate::signeddata::SyncContribution) from SSZ binary. +pub fn decode_sync_contribution( + bytes: &[u8], +) -> Result { + Ok(crate::signeddata::SyncContribution( + altair::SyncCommitteeContribution::from_ssz_bytes(bytes)?, + )) +} + // =========================================================================== // Tests // =========================================================================== diff --git a/crates/core/src/unsigneddata.rs b/crates/core/src/unsigneddata.rs index 66315a07..0b2b76b7 100644 --- a/crates/core/src/unsigneddata.rs +++ b/crates/core/src/unsigneddata.rs @@ -5,7 +5,7 @@ use std::collections::HashMap; use pluto_eth2api::spec::phase0; use pluto_ssz::decode::{decode_u32, decode_u64}; use serde::{Deserialize, Deserializer, de}; -use ssz::Decode; +use ssz::{Decode, Encode}; use crate::{ ParSigExCodecError, @@ -14,6 +14,7 @@ use crate::{ AttestationData, AttesterDuty, SyncContribution, VersionedAggregatedAttestation, VersionedProposal, }, + ssz_codec, types::{DutyType, PubKey}, }; @@ -21,7 +22,7 @@ const ATTESTATION_DATA_SSZ_OFFSET: usize = 8; const ATTESTER_DUTY_SSZ_SIZE: usize = 96; /// Unsigned duty data variant — matches Go's `core.UnsignedData` interface. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum UnsignedDutyData { /// Unsigned proposal (DutyProposer). Proposal(Box), @@ -37,8 +38,77 @@ pub enum UnsignedDutyData { /// `core.UnsignedDataSet`. pub type UnsignedDataSet = HashMap; +/// Converts a domain unsigned-data-set into its protobuf wire form. +/// +/// Mirrors charon's `UnsignedDataSetToProto` + `marshal`: every supported +/// unsigned-data type is SSZ-capable, and charon enables SSZ marshalling by +/// default (since v0.17), so each entry is encoded as SSZ binary using the +/// byte layout from `charon/core/ssz.go`. The decode counterpart +/// ([`unsigned_duty_data_from_proto`]) accepts both SSZ and the legacy JSON +/// encoding, matching charon's `unmarshal`. +pub fn unsigned_data_set_to_proto( + set: &UnsignedDataSet, +) -> Result { + let mut inner = std::collections::BTreeMap::new(); + for (pubkey, data) in set { + inner.insert(pubkey.to_string(), marshal_unsigned_duty_data(data)?.into()); + } + + Ok(pbcore::UnsignedDataSet { set: inner }) +} + +/// SSZ-marshals a single unsigned duty data value, matching charon's `marshal` +/// (SSZ-first; every variant here is SSZ-capable). +fn marshal_unsigned_duty_data(data: &UnsignedDutyData) -> Result, ParSigExCodecError> { + Ok(match data { + UnsignedDutyData::Attestation(att) => encode_attestation_data_ssz(att)?, + UnsignedDutyData::Proposal(proposal) => ssz_codec::encode_versioned_proposal(proposal)?, + UnsignedDutyData::AggAttestation(agg) => { + ssz_codec::encode_versioned_aggregated_attestation(agg)? + } + UnsignedDutyData::SyncContribution(contribution) => { + ssz_codec::encode_sync_contribution(contribution)? + } + }) +} + +/// SSZ-encodes an [`AttestationData`] using charon's layout: +/// `offset(4)=8 + offset(4) + AttestationData SSZ + AttesterDuty SSZ`, where +/// the `AttesterDuty` body is a 48-byte zero pubkey followed by six +/// little-endian `u64` fields (`charon/core/ssz.go` `attesterDutySSZ`). The +/// leading pubkey is zeroed because pluto's [`AttesterDuty`] omits it (it is +/// recovered from the aggregation bits downstream), matching the attester +/// decode path. +fn encode_attestation_data_ssz(att: &AttestationData) -> Result, ParSigExCodecError> { + let overflow = || ParSigExCodecError::UnsignedData("attestation data too large".to_string()); + + let attestation = att.data.as_ssz_bytes(); + let data_offset = ATTESTATION_DATA_SSZ_OFFSET; + let duty_offset = data_offset + .checked_add(attestation.len()) + .ok_or_else(overflow)?; + let capacity = duty_offset + .checked_add(ATTESTER_DUTY_SSZ_SIZE) + .ok_or_else(overflow)?; + let data_offset = u32::try_from(data_offset).map_err(|_| overflow())?; + let duty_offset = u32::try_from(duty_offset).map_err(|_| overflow())?; + + let mut out = Vec::with_capacity(capacity); + out.extend_from_slice(&data_offset.to_le_bytes()); + out.extend_from_slice(&duty_offset.to_le_bytes()); + out.extend_from_slice(&attestation); + // AttesterDuty: 48-byte pubkey (zeroed) + 6 u64 fields. + out.extend_from_slice(&[0u8; 48]); + out.extend_from_slice(&att.duty.slot.to_le_bytes()); + out.extend_from_slice(&att.duty.validator_index.to_le_bytes()); + out.extend_from_slice(&att.duty.committee_index.to_le_bytes()); + out.extend_from_slice(&att.duty.committee_length.to_le_bytes()); + out.extend_from_slice(&att.duty.committees_at_slot.to_le_bytes()); + out.extend_from_slice(&att.duty.validator_committee_index.to_le_bytes()); + Ok(out) +} + /// Converts an unsigned-data-set protobuf into domain unsigned duty data. -/// Currently decodes attester data; other duty types return unsupported. pub fn unsigned_data_set_from_proto( duty_type: &DutyType, set: &pbcore::UnsignedDataSet, @@ -63,10 +133,107 @@ fn unsigned_duty_data_from_proto( ) -> Result { match duty_type { DutyType::Attester => decode_attestation_data(data).map(UnsignedDutyData::Attestation), + DutyType::Proposer => decode_versioned_proposal(data) + .map(Box::new) + .map(UnsignedDutyData::Proposal), + DutyType::Aggregator => { + decode_aggregated_attestation(data).map(UnsignedDutyData::AggAttestation) + } + DutyType::SyncContribution => { + decode_sync_contribution(data).map(UnsignedDutyData::SyncContribution) + } _ => Err(ParSigExCodecError::UnsupportedDutyType), } } +/// Returns `true` when the trimmed byte slice begins with `{`, indicating JSON +/// — mirrors charon's `unmarshal`, which only attempts JSON when the SSZ decode +/// fails *and* the payload has a JSON prefix. +fn looks_like_json(data: &[u8]) -> bool { + data.iter().find(|b| !b.is_ascii_whitespace()).copied() == Some(b'{') +} + +/// Decodes an unsigned [`VersionedProposal`], SSZ-first with JSON fallback +/// (charon `DutyProposer` branch of `unmarshalUnsignedData`). +fn decode_versioned_proposal(data: &[u8]) -> Result { + if !looks_like_json(data) + && let Ok(proposal) = ssz_codec::decode_versioned_proposal(data) + { + return Ok(proposal); + } + + if looks_like_json(data) { + let decoded: VersionedProposalJson = + serde_json::from_slice(data).map_err(ParSigExCodecError::from)?; + return decoded.into_versioned_proposal(); + } + + Err(ParSigExCodecError::UnsignedData( + "unmarshal proposal".to_string(), + )) +} + +/// Decodes an unsigned aggregated attestation, SSZ-first with JSON fallback +/// (charon `DutyAggregator` branch). Charon tries the *versioned* aggregated +/// attestation first, then falls back to the non-versioned +/// `AggregatedAttestation` (a raw `phase0::Attestation`). Pluto only models the +/// versioned variant, so a non-versioned attestation is wrapped as a phase0 +/// versioned attestation (functionally equivalent). +fn decode_aggregated_attestation( + data: &[u8], +) -> Result { + if !looks_like_json(data) { + if let Ok(agg) = ssz_codec::decode_versioned_aggregated_attestation(data) { + return Ok(agg); + } + // Fallback: non-versioned `AggregatedAttestation` (raw phase0 SSZ). + if let Ok(att) = phase0::Attestation::from_ssz_bytes(data) { + return Ok(wrap_phase0_aggregated_attestation(att)); + } + return Err(ParSigExCodecError::UnsignedData( + "unmarshal aggregated attestation".to_string(), + )); + } + + // JSON fallback: versioned wrapper first, then non-versioned attestation. + if let Ok(decoded) = serde_json::from_slice::(data) { + return decoded.into_versioned_aggregated_attestation(); + } + let att: phase0::Attestation = + serde_json::from_slice(data).map_err(ParSigExCodecError::from)?; + Ok(wrap_phase0_aggregated_attestation(att)) +} + +/// Wraps a non-versioned phase0 attestation as a phase0 +/// [`VersionedAggregatedAttestation`]. +fn wrap_phase0_aggregated_attestation(att: phase0::Attestation) -> VersionedAggregatedAttestation { + use pluto_eth2api::versioned::{AttestationPayload, DataVersion, VersionedAttestation}; + VersionedAggregatedAttestation(VersionedAttestation { + version: DataVersion::Phase0, + validator_index: None, + attestation: Some(AttestationPayload::Phase0(att)), + }) +} + +/// Decodes an unsigned [`SyncContribution`], SSZ-first with JSON fallback +/// (charon `DutySyncContribution` branch). +fn decode_sync_contribution(data: &[u8]) -> Result { + if !looks_like_json(data) + && let Ok(contribution) = ssz_codec::decode_sync_contribution(data) + { + return Ok(contribution); + } + + if looks_like_json(data) { + let contribution = serde_json::from_slice(data).map_err(ParSigExCodecError::from)?; + return Ok(SyncContribution(contribution)); + } + + Err(ParSigExCodecError::UnsignedData( + "unmarshal sync contribution".to_string(), + )) +} + fn decode_attestation_data(data: &[u8]) -> Result { if let Ok(data) = decode_attestation_data_ssz(data) { return Ok(data); @@ -145,6 +312,162 @@ fn decode_attester_duty_ssz(data: &[u8]) -> Result Result { + use alloy::primitives::U256; + use pluto_eth2api::versioned::DataVersion; + + use crate::signeddata::ProposalBlock; + + let block = match (self.version, self.blinded) { + (DataVersion::Phase0, false) => ProposalBlock::Phase0(parse_json(self.block)?), + (DataVersion::Altair, false) => ProposalBlock::Altair(parse_json(self.block)?), + (DataVersion::Bellatrix, false) => ProposalBlock::Bellatrix(parse_json(self.block)?), + (DataVersion::Bellatrix, true) => { + ProposalBlock::BellatrixBlinded(parse_json(self.block)?) + } + (DataVersion::Capella, false) => ProposalBlock::Capella(parse_json(self.block)?), + (DataVersion::Capella, true) => ProposalBlock::CapellaBlinded(parse_json(self.block)?), + (DataVersion::Deneb, false) => ProposalBlock::Deneb { + block: Box::new(parse_json(block_field(&self.block)?)?), + kzg_proofs: parse_default(field(&self.block, "kzg_proofs"))?, + blobs: parse_default(field(&self.block, "blobs"))?, + }, + (DataVersion::Deneb, true) => ProposalBlock::DenebBlinded(parse_json(self.block)?), + (DataVersion::Electra, false) => ProposalBlock::Electra { + block: Box::new(parse_json(block_field(&self.block)?)?), + kzg_proofs: parse_default(field(&self.block, "kzg_proofs"))?, + blobs: parse_default(field(&self.block, "blobs"))?, + }, + (DataVersion::Electra, true) => ProposalBlock::ElectraBlinded(parse_json(self.block)?), + (DataVersion::Fulu, false) => ProposalBlock::Fulu { + block: Box::new(parse_json(block_field(&self.block)?)?), + kzg_proofs: parse_default(field(&self.block, "kzg_proofs"))?, + blobs: parse_default(field(&self.block, "blobs"))?, + }, + (DataVersion::Fulu, true) => ProposalBlock::FuluBlinded(parse_json(self.block)?), + (DataVersion::Phase0 | DataVersion::Altair, true) => { + return Err(ParSigExCodecError::UnsignedData( + "pre-merge block cannot be blinded".to_string(), + )); + } + (DataVersion::Unknown, _) => { + return Err(ParSigExCodecError::UnsignedData( + "unknown proposal version".to_string(), + )); + } + }; + + Ok(VersionedProposal { + block, + consensus_block_value: U256::ZERO, + execution_payload_value: U256::ZERO, + }) + } +} + +/// Deserializes a JSON value into `T`. +fn parse_json( + value: serde_json::Value, +) -> Result { + serde_json::from_value(value).map_err(ParSigExCodecError::from) +} + +/// Returns the `block` field of a Deneb+ versioned block-contents object. +fn block_field(value: &serde_json::Value) -> Result { + value + .get("block") + .cloned() + .ok_or_else(|| ParSigExCodecError::UnsignedData("proposal missing block".to_string())) +} + +/// Returns the named field as an owned value, defaulting to `null` when absent. +fn field(value: &serde_json::Value, name: &str) -> serde_json::Value { + value.get(name).cloned().unwrap_or(serde_json::Value::Null) +} + +/// Deserializes a JSON value into `T`, defaulting to `T::default` for `null` +/// or missing fields (matches charon's optional `kzg_proofs`/`blobs`). +fn parse_default( + value: serde_json::Value, +) -> Result { + if value.is_null() { + return Ok(T::default()); + } + serde_json::from_value(value).map_err(ParSigExCodecError::from) +} + +/// JSON fallback wrapper for the unsigned [`VersionedAggregatedAttestation`], +/// mirroring charon's `versionedRawAttestationJSON` +/// (`{version, validator_index, attestation}`). +#[derive(Deserialize)] +struct VersionedAggregatedAttestationJson { + #[serde(with = "pluto_eth2api::spec::serde_legacy_data_version")] + version: pluto_eth2api::versioned::DataVersion, + #[serde(default)] + validator_index: Option, + attestation: serde_json::Value, +} + +impl VersionedAggregatedAttestationJson { + fn into_versioned_aggregated_attestation( + self, + ) -> Result { + use pluto_eth2api::{ + spec::{electra, phase0}, + versioned::{AttestationPayload, DataVersion, VersionedAttestation}, + }; + + let validator_index = match self.validator_index { + Some(serde_json::Value::String(s)) => Some(s.parse::().map_err(|_| { + ParSigExCodecError::UnsignedData("invalid validator index".to_string()) + })?), + Some(serde_json::Value::Null) | None => None, + Some(other) => Some(serde_json::from_value(other).map_err(ParSigExCodecError::from)?), + }; + + let phase0_att = |value| -> Result { parse_json(value) }; + let electra_att = |value| -> Result { parse_json(value) }; + + let attestation = match self.version { + DataVersion::Phase0 => AttestationPayload::Phase0(phase0_att(self.attestation)?), + DataVersion::Altair => AttestationPayload::Altair(phase0_att(self.attestation)?), + DataVersion::Bellatrix => AttestationPayload::Bellatrix(phase0_att(self.attestation)?), + DataVersion::Capella => AttestationPayload::Capella(phase0_att(self.attestation)?), + DataVersion::Deneb => AttestationPayload::Deneb(phase0_att(self.attestation)?), + DataVersion::Electra => AttestationPayload::Electra(electra_att(self.attestation)?), + DataVersion::Fulu => AttestationPayload::Fulu(electra_att(self.attestation)?), + DataVersion::Unknown => { + return Err(ParSigExCodecError::UnsignedData( + "unknown attestation version".to_string(), + )); + } + }; + + Ok(VersionedAggregatedAttestation(VersionedAttestation { + version: self.version, + validator_index, + attestation: Some(attestation), + })) + } +} + #[derive(Deserialize)] struct AttestationDataJson { attestation_data: phase0::AttestationData, @@ -327,4 +650,255 @@ mod tests { .into(), } } + + // ── all-duty-type round trips ────────────────────────────────────── + + use pluto_eth2api::{ + spec::{altair, phase0 as p0}, + versioned, + }; + use pluto_ssz::{BitList, BitVector}; + + use crate::signeddata::{ProposalBlock, SyncContribution, VersionedAggregatedAttestation}; + + /// The SSZ encoder must reproduce charon's `AttestationData` byte layout — + /// it must be identical to the standalone test helper (which mirrors + /// `charon/core/ssz.go`), so peers and pluto agree on the wire bytes. + #[test] + fn attester_ssz_encoding_matches_charon_layout() { + let data = att_data(123, 4, 5); + assert_eq!( + encode_attestation_data_ssz(&data).unwrap(), + attestation_proto_bytes(&data).to_vec() + ); + } + + fn sample_versioned_proposal_phase0(slot: u64) -> VersionedProposal { + let block = p0::BeaconBlock { + slot, + proposer_index: 2, + parent_root: [0x11; 32], + state_root: [0x22; 32], + body: p0::BeaconBlockBody { + randao_reveal: [0x33; 96], + eth1_data: p0::ETH1Data { + deposit_root: [0x44; 32], + deposit_count: 0, + block_hash: [0x55; 32], + }, + graffiti: [0x66; 32], + proposer_slashings: vec![].into(), + attester_slashings: vec![].into(), + attestations: vec![].into(), + deposits: vec![].into(), + voluntary_exits: vec![].into(), + }, + }; + VersionedProposal { + block: ProposalBlock::Phase0(block), + consensus_block_value: alloy::primitives::U256::ZERO, + execution_payload_value: alloy::primitives::U256::ZERO, + } + } + + fn sample_versioned_aggregated_attestation() -> VersionedAggregatedAttestation { + VersionedAggregatedAttestation(versioned::VersionedAttestation { + version: versioned::DataVersion::Deneb, + validator_index: None, + attestation: Some(versioned::AttestationPayload::Deneb(p0::Attestation { + aggregation_bits: BitList::with_bits(16, &[1, 3]), + data: att_data(99, 7, 8).data, + signature: [0x77; 96], + })), + }) + } + + fn sample_sync_contribution() -> SyncContribution { + SyncContribution(altair::SyncCommitteeContribution { + slot: 200, + beacon_block_root: [0xab; 32], + subcommittee_index: 2, + aggregation_bits: BitVector::with_bits(&[0, 5]), + signature: [0xcd; 96], + }) + } + + /// Encodes a single-entry [`UnsignedDataSet`] and decodes it back for the + /// given duty type, asserting the round trip preserves the value. + fn assert_round_trip(duty_type: DutyType, pubkey: PubKey, data: UnsignedDutyData) { + let mut set = UnsignedDataSet::new(); + set.insert(pubkey, data.clone()); + + let proto = unsigned_data_set_to_proto(&set).unwrap(); + let decoded = unsigned_data_set_from_proto(&duty_type, &proto).unwrap(); + + // Default-marshalling is SSZ (charon parity): the entry must not be JSON. + let bytes = proto.set.get(&pubkey.to_string()).unwrap(); + assert_ne!(bytes.first(), Some(&b'{'), "default encoding must be SSZ"); + + assert_eq!(decoded.get(&pubkey), Some(&data)); + } + + #[test] + fn round_trip_attester() { + let pubkey = random_core_pub_key(); + assert_round_trip( + DutyType::Attester, + pubkey, + UnsignedDutyData::Attestation(att_data(123, 4, 5)), + ); + } + + #[test] + fn round_trip_proposer() { + let pubkey = random_core_pub_key(); + assert_round_trip( + DutyType::Proposer, + pubkey, + UnsignedDutyData::Proposal(Box::new(sample_versioned_proposal_phase0(42))), + ); + } + + #[test] + fn round_trip_aggregator() { + let pubkey = random_core_pub_key(); + assert_round_trip( + DutyType::Aggregator, + pubkey, + UnsignedDutyData::AggAttestation(sample_versioned_aggregated_attestation()), + ); + } + + #[test] + fn round_trip_sync_contribution() { + let pubkey = random_core_pub_key(); + assert_round_trip( + DutyType::SyncContribution, + pubkey, + UnsignedDutyData::SyncContribution(sample_sync_contribution()), + ); + } + + /// The proposer JSON fallback (legacy, pre-SSZ charon) decodes the + /// `{version, block, blinded}` wrapper. + #[test] + fn proposer_json_fallback_decodes() { + let pubkey = random_core_pub_key(); + let proposal = sample_versioned_proposal_phase0(7); + let ProposalBlock::Phase0(block) = &proposal.block else { + panic!("expected phase0 block"); + }; + let value = serde_json::json!({ + "version": "phase0", + "blinded": false, + "block": block, + }); + let proto = pbcore::UnsignedDataSet { + set: [( + pubkey.to_string(), + Bytes::from(serde_json::to_vec(&value).unwrap()), + )] + .into(), + }; + + let decoded = unsigned_data_set_from_proto(&DutyType::Proposer, &proto).unwrap(); + match decoded.get(&pubkey).unwrap() { + UnsignedDutyData::Proposal(decoded) => assert_eq!(decoded.block, proposal.block), + other => panic!("unexpected unsigned data: {other:?}"), + } + } + + /// The aggregator JSON fallback decodes the + /// `{version, validator_index, attestation}` wrapper. + #[test] + fn aggregator_json_fallback_decodes() { + let pubkey = random_core_pub_key(); + let agg = sample_versioned_aggregated_attestation(); + let versioned::AttestationPayload::Deneb(att) = agg.0.attestation.as_ref().unwrap() else { + panic!("expected deneb attestation"); + }; + let value = serde_json::json!({ + "version": "deneb", + "attestation": att, + }); + let proto = pbcore::UnsignedDataSet { + set: [( + pubkey.to_string(), + Bytes::from(serde_json::to_vec(&value).unwrap()), + )] + .into(), + }; + + let decoded = unsigned_data_set_from_proto(&DutyType::Aggregator, &proto).unwrap(); + match decoded.get(&pubkey).unwrap() { + UnsignedDutyData::AggAttestation(decoded) => assert_eq!(decoded, &agg), + other => panic!("unexpected unsigned data: {other:?}"), + } + } + + /// The sync-contribution JSON fallback decodes the bare contribution + /// object. + #[test] + fn sync_contribution_json_fallback_decodes() { + let pubkey = random_core_pub_key(); + let contribution = sample_sync_contribution(); + let proto = pbcore::UnsignedDataSet { + set: [( + pubkey.to_string(), + Bytes::from(serde_json::to_vec(&contribution.0).unwrap()), + )] + .into(), + }; + + let decoded = unsigned_data_set_from_proto(&DutyType::SyncContribution, &proto).unwrap(); + match decoded.get(&pubkey).unwrap() { + UnsignedDutyData::SyncContribution(decoded) => assert_eq!(decoded, &contribution), + other => panic!("unexpected unsigned data: {other:?}"), + } + } + + /// A non-versioned (raw phase0) aggregated attestation — what older charon + /// nodes send — decodes into a phase0 [`VersionedAggregatedAttestation`]. + #[test] + fn aggregator_non_versioned_ssz_fallback() { + let pubkey = random_core_pub_key(); + let att = p0::Attestation { + aggregation_bits: BitList::with_bits(8, &[0, 2]), + data: att_data(55, 1, 2).data, + signature: [0x99; 96], + }; + let proto = pbcore::UnsignedDataSet { + set: [(pubkey.to_string(), Bytes::from(att.as_ssz_bytes()))].into(), + }; + + let decoded = unsigned_data_set_from_proto(&DutyType::Aggregator, &proto).unwrap(); + match decoded.get(&pubkey).unwrap() { + UnsignedDutyData::AggAttestation(decoded) => { + assert_eq!(decoded.0.version, versioned::DataVersion::Phase0); + assert_eq!( + decoded.0.attestation, + Some(versioned::AttestationPayload::Phase0(att)) + ); + } + other => panic!("unexpected unsigned data: {other:?}"), + } + } + + #[test] + fn unsigned_data_set_to_proto_round_trips_full_set() { + // Two attester entries in a single set survive an encode→decode round + // trip, exercising the map plumbing in `unsigned_data_set_to_proto`. + let pk1 = random_core_pub_key(); + let pk2 = random_core_pub_key(); + let mut set = UnsignedDataSet::new(); + set.insert(pk1, UnsignedDutyData::Attestation(att_data(1, 2, 3))); + set.insert(pk2, UnsignedDutyData::Attestation(att_data(4, 5, 6))); + + let proto = unsigned_data_set_to_proto(&set).unwrap(); + assert_eq!(proto.set.len(), 2); + let decoded = unsigned_data_set_from_proto(&DutyType::Attester, &proto).unwrap(); + assert_eq!(decoded.len(), 2); + assert_eq!(decoded.get(&pk1), set.get(&pk1)); + assert_eq!(decoded.get(&pk2), set.get(&pk2)); + } } From ee39cc503279eb92ffd7b2c1d408ae5425d7cf68 Mon Sep 17 00:00:00 2001 From: "emlautarom1-agent[bot]" <292495798+emlautarom1-agent[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 18:37:06 -0300 Subject: [PATCH 2/6] refactor(core): reuse VersionedAttestation Deserialize for agg JSON fallback The aggregator JSON fallback re-implemented the per-fork dispatch and validator-index parsing already provided by signeddata::VersionedAttestation's Deserialize impl. Since VersionedAggregatedAttestation is a newtype over the same inner type, deserialize directly into VersionedAttestation and rewrap, dropping the redundant VersionedAggregatedAttestationJson wrapper. This also gains the version/payload validation from VersionedAttestation::new. --- crates/core/src/unsigneddata.rs | 62 +++------------------------------ 1 file changed, 5 insertions(+), 57 deletions(-) diff --git a/crates/core/src/unsigneddata.rs b/crates/core/src/unsigneddata.rs index 0b2b76b7..ddafe807 100644 --- a/crates/core/src/unsigneddata.rs +++ b/crates/core/src/unsigneddata.rs @@ -196,8 +196,11 @@ fn decode_aggregated_attestation( } // JSON fallback: versioned wrapper first, then non-versioned attestation. - if let Ok(decoded) = serde_json::from_slice::(data) { - return decoded.into_versioned_aggregated_attestation(); + // The versioned wrapper shares `signeddata::VersionedAttestation`'s + // `Deserialize` impl (same `{version, validator_index, attestation}` shape), + // which also validates the version/payload via `VersionedAttestation::new`. + if let Ok(decoded) = serde_json::from_slice::(data) { + return Ok(VersionedAggregatedAttestation(decoded.0)); } let att: phase0::Attestation = serde_json::from_slice(data).map_err(ParSigExCodecError::from)?; @@ -413,61 +416,6 @@ fn parse_default( serde_json::from_value(value).map_err(ParSigExCodecError::from) } -/// JSON fallback wrapper for the unsigned [`VersionedAggregatedAttestation`], -/// mirroring charon's `versionedRawAttestationJSON` -/// (`{version, validator_index, attestation}`). -#[derive(Deserialize)] -struct VersionedAggregatedAttestationJson { - #[serde(with = "pluto_eth2api::spec::serde_legacy_data_version")] - version: pluto_eth2api::versioned::DataVersion, - #[serde(default)] - validator_index: Option, - attestation: serde_json::Value, -} - -impl VersionedAggregatedAttestationJson { - fn into_versioned_aggregated_attestation( - self, - ) -> Result { - use pluto_eth2api::{ - spec::{electra, phase0}, - versioned::{AttestationPayload, DataVersion, VersionedAttestation}, - }; - - let validator_index = match self.validator_index { - Some(serde_json::Value::String(s)) => Some(s.parse::().map_err(|_| { - ParSigExCodecError::UnsignedData("invalid validator index".to_string()) - })?), - Some(serde_json::Value::Null) | None => None, - Some(other) => Some(serde_json::from_value(other).map_err(ParSigExCodecError::from)?), - }; - - let phase0_att = |value| -> Result { parse_json(value) }; - let electra_att = |value| -> Result { parse_json(value) }; - - let attestation = match self.version { - DataVersion::Phase0 => AttestationPayload::Phase0(phase0_att(self.attestation)?), - DataVersion::Altair => AttestationPayload::Altair(phase0_att(self.attestation)?), - DataVersion::Bellatrix => AttestationPayload::Bellatrix(phase0_att(self.attestation)?), - DataVersion::Capella => AttestationPayload::Capella(phase0_att(self.attestation)?), - DataVersion::Deneb => AttestationPayload::Deneb(phase0_att(self.attestation)?), - DataVersion::Electra => AttestationPayload::Electra(electra_att(self.attestation)?), - DataVersion::Fulu => AttestationPayload::Fulu(electra_att(self.attestation)?), - DataVersion::Unknown => { - return Err(ParSigExCodecError::UnsignedData( - "unknown attestation version".to_string(), - )); - } - }; - - Ok(VersionedAggregatedAttestation(VersionedAttestation { - version: self.version, - validator_index, - attestation: Some(attestation), - })) - } -} - #[derive(Deserialize)] struct AttestationDataJson { attestation_data: phase0::AttestationData, From 8f1934766b0aec45c0bf8fc372e4266e6daa863a Mon Sep 17 00:00:00 2001 From: "emlautarom1-agent[bot]" <292495798+emlautarom1-agent[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 18:39:35 -0300 Subject: [PATCH 3/6] refactor(core): add Deserialize for VersionedProposal, dedup proposer JSON The proposer JSON fallback re-implemented the per-fork (version, blinded) dispatch that VersionedSignedProposal's Deserialize already does. Add a Deserialize impl for the unsigned VersionedProposal in signeddata next to its signed sibling, sharing the VersionedRawBlockJson wrapper, and have the codec just call serde_json::from_slice. Drops the one-off VersionedProposalJson wrapper and its parse_json/block_field/field/parse_default helpers. --- crates/core/src/signeddata.rs | 98 +++++++++++++++++++++++++++++ crates/core/src/unsigneddata.rs | 107 +------------------------------- 2 files changed, 101 insertions(+), 104 deletions(-) diff --git a/crates/core/src/signeddata.rs b/crates/core/src/signeddata.rs index 65e0aa7e..e4bd8ed0 100644 --- a/crates/core/src/signeddata.rs +++ b/crates/core/src/signeddata.rs @@ -100,6 +100,18 @@ struct VersionedRawAggregateAndProofJson { aggregate_and_proof: T, } +/// Raw JSON wrapper for the unsigned Deneb+ block contents +/// (`{block, kzg_proofs, blobs}`). `kzg_proofs`/`blobs` are optional and +/// tolerate `null` (matching charon's optional fields). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct UnsignedBlockContentsJson { + block: B, + #[serde(default)] + kzg_proofs: Option>, + #[serde(default)] + blobs: Option>, +} + /// Converts an ETH2 signature to a core signature. pub fn sig_from_eth2(sig: phase0::BLSSignature) -> Signature { sig @@ -1375,6 +1387,92 @@ impl VersionedProposal { } } +impl<'de> Deserialize<'de> for VersionedProposal { + /// Mirrors charon's `VersionedProposal.UnmarshalJSON`: dispatches the raw + /// `block` JSON to the per-fork [`ProposalBlock`] variant selected by + /// `(version, blinded)`. Shares the `{version, block, blinded}` raw wrapper + /// with [`VersionedSignedProposal`]. Block reward values are not present in + /// the JSON form and default to zero (the validatorapi overrides them). + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let raw = VersionedRawBlockJson::::deserialize(deserializer)?; + let version = raw.version; + let blinded = raw.blinded; + + let block_contents = |value: serde_json::Value| { + serde_json::from_value::>(value) + }; + + let block = match (version, blinded) { + (versioned::DataVersion::Phase0, false) => { + serde_json::from_value(raw.block).map(ProposalBlock::Phase0) + } + (versioned::DataVersion::Altair, false) => { + serde_json::from_value(raw.block).map(ProposalBlock::Altair) + } + (versioned::DataVersion::Bellatrix, false) => { + serde_json::from_value(raw.block).map(ProposalBlock::Bellatrix) + } + (versioned::DataVersion::Bellatrix, true) => { + serde_json::from_value(raw.block).map(ProposalBlock::BellatrixBlinded) + } + (versioned::DataVersion::Capella, false) => { + serde_json::from_value(raw.block).map(ProposalBlock::Capella) + } + (versioned::DataVersion::Capella, true) => { + serde_json::from_value(raw.block).map(ProposalBlock::CapellaBlinded) + } + (versioned::DataVersion::Deneb, false) => block_contents(raw.block).and_then(|c| { + Ok(ProposalBlock::Deneb { + block: Box::new(serde_json::from_value(c.block)?), + kzg_proofs: c.kzg_proofs.unwrap_or_default(), + blobs: c.blobs.unwrap_or_default(), + }) + }), + (versioned::DataVersion::Deneb, true) => { + serde_json::from_value(raw.block).map(ProposalBlock::DenebBlinded) + } + (versioned::DataVersion::Electra, false) => block_contents(raw.block).and_then(|c| { + Ok(ProposalBlock::Electra { + block: Box::new(serde_json::from_value(c.block)?), + kzg_proofs: c.kzg_proofs.unwrap_or_default(), + blobs: c.blobs.unwrap_or_default(), + }) + }), + (versioned::DataVersion::Electra, true) => { + serde_json::from_value(raw.block).map(ProposalBlock::ElectraBlinded) + } + (versioned::DataVersion::Fulu, false) => block_contents(raw.block).and_then(|c| { + Ok(ProposalBlock::Fulu { + block: Box::new(serde_json::from_value(c.block)?), + kzg_proofs: c.kzg_proofs.unwrap_or_default(), + blobs: c.blobs.unwrap_or_default(), + }) + }), + (versioned::DataVersion::Fulu, true) => { + serde_json::from_value(raw.block).map(ProposalBlock::FuluBlinded) + } + (versioned::DataVersion::Phase0 | versioned::DataVersion::Altair, true) => { + return Err(serde::de::Error::custom( + "pre-merge block cannot be blinded", + )); + } + (versioned::DataVersion::Unknown, _) => { + return Err(serde::de::Error::custom(SignedDataError::UnknownVersion)); + } + } + .map_err(serde::de::Error::custom)?; + + Ok(VersionedProposal { + block, + consensus_block_value: U256::ZERO, + execution_payload_value: U256::ZERO, + }) + } +} + impl TryFrom<&ProduceBlockV3ResponseResponse> for VersionedProposal { type Error = SignedDataError; diff --git a/crates/core/src/unsigneddata.rs b/crates/core/src/unsigneddata.rs index ddafe807..89d6497e 100644 --- a/crates/core/src/unsigneddata.rs +++ b/crates/core/src/unsigneddata.rs @@ -163,9 +163,9 @@ fn decode_versioned_proposal(data: &[u8]) -> Result Result Result { - use alloy::primitives::U256; - use pluto_eth2api::versioned::DataVersion; - - use crate::signeddata::ProposalBlock; - - let block = match (self.version, self.blinded) { - (DataVersion::Phase0, false) => ProposalBlock::Phase0(parse_json(self.block)?), - (DataVersion::Altair, false) => ProposalBlock::Altair(parse_json(self.block)?), - (DataVersion::Bellatrix, false) => ProposalBlock::Bellatrix(parse_json(self.block)?), - (DataVersion::Bellatrix, true) => { - ProposalBlock::BellatrixBlinded(parse_json(self.block)?) - } - (DataVersion::Capella, false) => ProposalBlock::Capella(parse_json(self.block)?), - (DataVersion::Capella, true) => ProposalBlock::CapellaBlinded(parse_json(self.block)?), - (DataVersion::Deneb, false) => ProposalBlock::Deneb { - block: Box::new(parse_json(block_field(&self.block)?)?), - kzg_proofs: parse_default(field(&self.block, "kzg_proofs"))?, - blobs: parse_default(field(&self.block, "blobs"))?, - }, - (DataVersion::Deneb, true) => ProposalBlock::DenebBlinded(parse_json(self.block)?), - (DataVersion::Electra, false) => ProposalBlock::Electra { - block: Box::new(parse_json(block_field(&self.block)?)?), - kzg_proofs: parse_default(field(&self.block, "kzg_proofs"))?, - blobs: parse_default(field(&self.block, "blobs"))?, - }, - (DataVersion::Electra, true) => ProposalBlock::ElectraBlinded(parse_json(self.block)?), - (DataVersion::Fulu, false) => ProposalBlock::Fulu { - block: Box::new(parse_json(block_field(&self.block)?)?), - kzg_proofs: parse_default(field(&self.block, "kzg_proofs"))?, - blobs: parse_default(field(&self.block, "blobs"))?, - }, - (DataVersion::Fulu, true) => ProposalBlock::FuluBlinded(parse_json(self.block)?), - (DataVersion::Phase0 | DataVersion::Altair, true) => { - return Err(ParSigExCodecError::UnsignedData( - "pre-merge block cannot be blinded".to_string(), - )); - } - (DataVersion::Unknown, _) => { - return Err(ParSigExCodecError::UnsignedData( - "unknown proposal version".to_string(), - )); - } - }; - - Ok(VersionedProposal { - block, - consensus_block_value: U256::ZERO, - execution_payload_value: U256::ZERO, - }) - } -} - -/// Deserializes a JSON value into `T`. -fn parse_json( - value: serde_json::Value, -) -> Result { - serde_json::from_value(value).map_err(ParSigExCodecError::from) -} - -/// Returns the `block` field of a Deneb+ versioned block-contents object. -fn block_field(value: &serde_json::Value) -> Result { - value - .get("block") - .cloned() - .ok_or_else(|| ParSigExCodecError::UnsignedData("proposal missing block".to_string())) -} - -/// Returns the named field as an owned value, defaulting to `null` when absent. -fn field(value: &serde_json::Value, name: &str) -> serde_json::Value { - value.get(name).cloned().unwrap_or(serde_json::Value::Null) -} - -/// Deserializes a JSON value into `T`, defaulting to `T::default` for `null` -/// or missing fields (matches charon's optional `kzg_proofs`/`blobs`). -fn parse_default( - value: serde_json::Value, -) -> Result { - if value.is_null() { - return Ok(T::default()); - } - serde_json::from_value(value).map_err(ParSigExCodecError::from) -} - #[derive(Deserialize)] struct AttestationDataJson { attestation_data: phase0::AttestationData, From a535d4a610570d2b3cd9595ea4c78eb73654ca2a Mon Sep 17 00:00:00 2001 From: "emlautarom1-agent[bot]" <292495798+emlautarom1-agent[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 18:40:29 -0300 Subject: [PATCH 4/6] refactor(core): share looks_like_json helper across codecs looks_like_json was defined three times (a nested fn in parsigex_codec and two copies in unsigneddata, one of them inline). Promote the parsigex_codec copy to a pub(crate) fn and reuse it everywhere. --- crates/core/src/parsigex_codec.rs | 13 +++++++------ crates/core/src/unsigneddata.rs | 10 ++-------- 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/crates/core/src/parsigex_codec.rs b/crates/core/src/parsigex_codec.rs index b8e7c8ec..034f7530 100644 --- a/crates/core/src/parsigex_codec.rs +++ b/crates/core/src/parsigex_codec.rs @@ -165,16 +165,17 @@ pub(crate) fn serialize_signed_data(data: &dyn SignedData) -> Result, Pa Err(ParSigExCodecError::UnsupportedDutyType) } +/// Returns `true` when the trimmed byte slice starts with `{`, indicating JSON +/// data. Charon's `unmarshal` uses this prefix check to choose between the JSON +/// and SSZ decode paths. +pub(crate) fn looks_like_json(bytes: &[u8]) -> bool { + bytes.iter().find(|b| !b.is_ascii_whitespace()).copied() == Some(b'{') +} + pub(crate) fn deserialize_signed_data( duty_type: &DutyType, bytes: &[u8], ) -> Result, ParSigExCodecError> { - /// Returns `true` when the trimmed byte slice starts with `{`, indicating - /// JSON data. - fn looks_like_json(bytes: &[u8]) -> bool { - bytes.iter().find(|b| !b.is_ascii_whitespace()).copied() == Some(b'{') - } - macro_rules! deserialize_json { ($ty:ty) => { serde_json::from_slice::<$ty>(bytes) diff --git a/crates/core/src/unsigneddata.rs b/crates/core/src/unsigneddata.rs index 89d6497e..bdbbbcb8 100644 --- a/crates/core/src/unsigneddata.rs +++ b/crates/core/src/unsigneddata.rs @@ -10,6 +10,7 @@ use ssz::{Decode, Encode}; use crate::{ ParSigExCodecError, corepb::v1::core as pbcore, + parsigex_codec::looks_like_json, signeddata::{ AttestationData, AttesterDuty, SyncContribution, VersionedAggregatedAttestation, VersionedProposal, @@ -146,13 +147,6 @@ fn unsigned_duty_data_from_proto( } } -/// Returns `true` when the trimmed byte slice begins with `{`, indicating JSON -/// — mirrors charon's `unmarshal`, which only attempts JSON when the SSZ decode -/// fails *and* the payload has a JSON prefix. -fn looks_like_json(data: &[u8]) -> bool { - data.iter().find(|b| !b.is_ascii_whitespace()).copied() == Some(b'{') -} - /// Decodes an unsigned [`VersionedProposal`], SSZ-first with JSON fallback /// (charon `DutyProposer` branch of `unmarshalUnsignedData`). fn decode_versioned_proposal(data: &[u8]) -> Result { @@ -242,7 +236,7 @@ fn decode_attestation_data(data: &[u8]) -> Result Date: Tue, 30 Jun 2026 18:40:59 -0300 Subject: [PATCH 5/6] docs(core): explain why the attester SSZ codec isn't derived charon writes a two-slot offset table for AttestationData even though both fields are fixed-size; ssz_derive would omit it, so the manual layout is required for wire compatibility. Document this to prevent a future regression. --- crates/core/src/unsigneddata.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/core/src/unsigneddata.rs b/crates/core/src/unsigneddata.rs index bdbbbcb8..22d616c0 100644 --- a/crates/core/src/unsigneddata.rs +++ b/crates/core/src/unsigneddata.rs @@ -80,6 +80,14 @@ fn marshal_unsigned_duty_data(data: &UnsignedDutyData) -> Result, ParSig /// leading pubkey is zeroed because pluto's [`AttesterDuty`] omits it (it is /// recovered from the aggregation bits downstream), matching the attester /// decode path. +/// +/// This is hand-rolled rather than derived with `ssz_derive` on purpose: charon +/// emits a two-slot offset table (`4 + 4`) here even though both +/// `AttestationData` and `AttesterDuty` are *fixed*-size (`charon/core/ssz.go` +/// `AttestationData.MarshalSSZTo`). `ssz_derive` omits offsets for all-fixed +/// containers, so a derived `{data, duty}` struct would drop the 8-byte prefix +/// and break wire-compat. (Contrast the Deneb+ block contents in `ssz_codec`, +/// whose fields are all variable-length, so deriving is correct there.) fn encode_attestation_data_ssz(att: &AttestationData) -> Result, ParSigExCodecError> { let overflow = || ParSigExCodecError::UnsignedData("attestation data too large".to_string()); From 2117844f006e3c26258f50b82c88c11b6f345517 Mon Sep 17 00:00:00 2001 From: Lautaro Emanuel Date: Wed, 1 Jul 2026 14:02:35 -0300 Subject: [PATCH 6/6] refactor(core): improve SSZ-first deserialization logic and JSON fallback handling --- crates/core/src/parsigex_codec.rs | 106 ++++++++++++++++++++++-------- crates/core/src/unsigneddata.rs | 85 ++++++++++++++++-------- 2 files changed, 138 insertions(+), 53 deletions(-) diff --git a/crates/core/src/parsigex_codec.rs b/crates/core/src/parsigex_codec.rs index 034f7530..ac732cb7 100644 --- a/crates/core/src/parsigex_codec.rs +++ b/crates/core/src/parsigex_codec.rs @@ -2,8 +2,10 @@ //! //! Implements Charon-compatible `marshal`/`unmarshal` semantics: SSZ-capable //! types are serialized as SSZ binary; all other types use JSON. On -//! deserialization the codec checks for a JSON `{` prefix first — if present, -//! it decodes as JSON. Otherwise it tries SSZ for SSZ-capable types. +//! deserialization the codec tries SSZ first for SSZ-capable types and only +//! falls back to JSON when the SSZ decode fails *and* the payload looks like +//! JSON (a `{` prefix) — matching charon's `unmarshal` (`core/proto.go`). The +//! `{` prefix is never used to skip SSZ, since valid SSZ can begin with `0x7B`. use std::any::Any; @@ -165,9 +167,12 @@ pub(crate) fn serialize_signed_data(data: &dyn SignedData) -> Result, Pa Err(ParSigExCodecError::UnsupportedDutyType) } -/// Returns `true` when the trimmed byte slice starts with `{`, indicating JSON -/// data. Charon's `unmarshal` uses this prefix check to choose between the JSON -/// and SSZ decode paths. +/// Returns `true` when the first non-whitespace byte is `{`, indicating JSON +/// data. Charon's `unmarshal` (`core/proto.go`) uses this prefix check only to +/// gate the JSON fallback *after* an SSZ decode has failed — never to skip SSZ. +/// A valid SSZ payload can legitimately begin with `0x7B` (e.g. a fixed-size +/// container whose leading `u64` has low byte 123), so it must not be treated +/// as a positive "this is JSON" signal. pub(crate) fn looks_like_json(bytes: &[u8]) -> bool { bytes.iter().find(|b| !b.is_ascii_whitespace()).copied() == Some(b'{') } @@ -184,18 +189,9 @@ pub(crate) fn deserialize_signed_data( }; } - // Core logic matching Go's `unmarshal`: - // - If data starts with `{`, it is JSON — skip SSZ, decode as JSON. - // - Otherwise, try SSZ decode for SSZ-capable types. - let is_json = looks_like_json(bytes); - match duty_type { // -- Attester: SSZ-capable (non-versioned + versioned) -- DutyType::Attester => { - if is_json { - return deserialize_json!(Attestation) - .or_else(|_| deserialize_json!(VersionedAttestation)); - } // Try SSZ non-versioned Attestation first. if let Ok(att) = ssz_codec::decode_phase0_attestation(bytes) { return Ok(Box::new(Attestation::new(att))); @@ -206,19 +202,23 @@ pub(crate) fn deserialize_signed_data( .map_err(|e| ParSigExCodecError::SignedData(e.to_string()))?; return Ok(Box::new(wrapped)); } + if looks_like_json(bytes) { + return deserialize_json!(Attestation) + .or_else(|_| deserialize_json!(VersionedAttestation)); + } Err(ParSigExCodecError::UnsupportedDutyType) } // -- Proposer: SSZ-capable (versioned header + inner SSZ) -- DutyType::Proposer => { - if is_json { - return deserialize_json!(VersionedSignedProposal); - } if let Ok(vp) = ssz_codec::decode_versioned_signed_proposal(bytes) { let wrapped = VersionedSignedProposal::new(vp) .map_err(|e| ParSigExCodecError::SignedData(e.to_string()))?; return Ok(Box::new(wrapped)); } + if looks_like_json(bytes) { + return deserialize_json!(VersionedSignedProposal); + } Err(ParSigExCodecError::UnsupportedDutyType) } @@ -241,10 +241,6 @@ pub(crate) fn deserialize_signed_data( // -- Aggregator: SSZ-capable (non-versioned + versioned) -- DutyType::Aggregator => { - if is_json { - return deserialize_json!(SignedAggregateAndProof) - .or_else(|_| deserialize_json!(VersionedSignedAggregateAndProof)); - } // Try SSZ non-versioned SignedAggregateAndProof first. if let Ok(sap) = ssz_codec::decode_phase0_signed_aggregate_and_proof(bytes) { return Ok(Box::new(SignedAggregateAndProof::new(sap))); @@ -253,17 +249,21 @@ pub(crate) fn deserialize_signed_data( if let Ok(va) = ssz_codec::decode_versioned_signed_aggregate_and_proof(bytes) { return Ok(Box::new(VersionedSignedAggregateAndProof::new(va))); } + if looks_like_json(bytes) { + return deserialize_json!(SignedAggregateAndProof) + .or_else(|_| deserialize_json!(VersionedSignedAggregateAndProof)); + } Err(ParSigExCodecError::UnsupportedDutyType) } // -- SyncMessage: SSZ-capable -- DutyType::SyncMessage => { - if is_json { - return deserialize_json!(SignedSyncMessage); - } if let Ok(msg) = ssz_codec::decode_sync_committee_message(bytes) { return Ok(Box::new(SignedSyncMessage::new(msg))); } + if looks_like_json(bytes) { + return deserialize_json!(SignedSyncMessage); + } Err(ParSigExCodecError::UnsupportedDutyType) } @@ -272,12 +272,12 @@ pub(crate) fn deserialize_signed_data( // -- SyncContribution: SSZ-capable -- DutyType::SyncContribution => { - if is_json { - return deserialize_json!(SignedSyncContributionAndProof); - } if let Ok(scp) = ssz_codec::decode_signed_contribution_and_proof(bytes) { return Ok(Box::new(SignedSyncContributionAndProof::new(scp))); } + if looks_like_json(bytes) { + return deserialize_json!(SignedSyncContributionAndProof); + } Err(ParSigExCodecError::UnsupportedDutyType) } @@ -395,6 +395,58 @@ mod tests { assert_eq!(scp, decoded); } + /// Regression: `SyncCommitteeMessage`'s leading `u64` slot makes its SSZ + /// begin with `0x7B` (`{`) when `slot % 256 == 123`. SSZ must still win + /// over the JSON fallback (charon `unmarshal` tries SSZ first). + #[test] + fn ssz_sync_message_with_leading_brace_decodes_as_ssz() { + let msg = SignedSyncMessage::new(altair::SyncCommitteeMessage { + slot: 0x7B, // little-endian u64 → first SSZ byte is `{` + beacon_block_root: [0xdd; 32], + validator_index: 50, + signature: [0xee; 96], + }); + let bytes = serialize_signed_data(&msg).unwrap(); + assert_eq!( + bytes.first(), + Some(&b'{'), + "leading SSZ byte should be 0x7B" + ); + let decoded: SignedSyncMessage = + downcast(deserialize_signed_data(&DutyType::SyncMessage, &bytes).unwrap()); + assert_eq!(msg, decoded); + } + + /// Regression: `SignedContributionAndProof`'s leading `u64` aggregator + /// index makes its SSZ begin with `0x7B` (`{`) when `index % 256 == + /// 123`. SSZ must still win over the JSON fallback. + #[test] + fn ssz_signed_sync_contribution_with_leading_brace_decodes_as_ssz() { + let scp = SignedSyncContributionAndProof::new(altair::SignedContributionAndProof { + message: altair::ContributionAndProof { + aggregator_index: 0x7B, // little-endian u64 → first SSZ byte is `{` + contribution: altair::SyncCommitteeContribution { + slot: 200, + beacon_block_root: [0xab; 32], + subcommittee_index: 2, + aggregation_bits: BitVector::with_bits(&[0, 5]), + signature: [0xcd; 96], + }, + selection_proof: [0xef; 96], + }, + signature: [0xfa; 96], + }); + let bytes = serialize_signed_data(&scp).unwrap(); + assert_eq!( + bytes.first(), + Some(&b'{'), + "leading SSZ byte should be 0x7B" + ); + let decoded: SignedSyncContributionAndProof = + downcast(deserialize_signed_data(&DutyType::SyncContribution, &bytes).unwrap()); + assert_eq!(scp, decoded); + } + /// SSZ-capable types: SignedAggregateAndProof round-trip. #[test] fn marshal_unmarshal_ssz_signed_aggregate_and_proof() { diff --git a/crates/core/src/unsigneddata.rs b/crates/core/src/unsigneddata.rs index 22d616c0..414f98ab 100644 --- a/crates/core/src/unsigneddata.rs +++ b/crates/core/src/unsigneddata.rs @@ -158,9 +158,7 @@ fn unsigned_duty_data_from_proto( /// Decodes an unsigned [`VersionedProposal`], SSZ-first with JSON fallback /// (charon `DutyProposer` branch of `unmarshalUnsignedData`). fn decode_versioned_proposal(data: &[u8]) -> Result { - if !looks_like_json(data) - && let Ok(proposal) = ssz_codec::decode_versioned_proposal(data) - { + if let Ok(proposal) = ssz_codec::decode_versioned_proposal(data) { return Ok(proposal); } @@ -184,29 +182,26 @@ fn decode_versioned_proposal(data: &[u8]) -> Result Result { - if !looks_like_json(data) { - if let Ok(agg) = ssz_codec::decode_versioned_aggregated_attestation(data) { - return Ok(agg); - } - // Fallback: non-versioned `AggregatedAttestation` (raw phase0 SSZ). - if let Ok(att) = phase0::Attestation::from_ssz_bytes(data) { - return Ok(wrap_phase0_aggregated_attestation(att)); - } - return Err(ParSigExCodecError::UnsignedData( - "unmarshal aggregated attestation".to_string(), - )); + if let Ok(agg) = ssz_codec::decode_versioned_aggregated_attestation(data) { + return Ok(agg); + } + if let Ok(att) = phase0::Attestation::from_ssz_bytes(data) { + return Ok(wrap_phase0_aggregated_attestation(att)); } - // JSON fallback: versioned wrapper first, then non-versioned attestation. - // The versioned wrapper shares `signeddata::VersionedAttestation`'s - // `Deserialize` impl (same `{version, validator_index, attestation}` shape), - // which also validates the version/payload via `VersionedAttestation::new`. - if let Ok(decoded) = serde_json::from_slice::(data) { - return Ok(VersionedAggregatedAttestation(decoded.0)); + if looks_like_json(data) { + if let Ok(decoded) = serde_json::from_slice::(data) + { + return Ok(VersionedAggregatedAttestation(decoded.0)); + } + let att: phase0::Attestation = + serde_json::from_slice(data).map_err(ParSigExCodecError::from)?; + return Ok(wrap_phase0_aggregated_attestation(att)); } - let att: phase0::Attestation = - serde_json::from_slice(data).map_err(ParSigExCodecError::from)?; - Ok(wrap_phase0_aggregated_attestation(att)) + + Err(ParSigExCodecError::UnsignedData( + "unmarshal aggregated attestation".to_string(), + )) } /// Wraps a non-versioned phase0 attestation as a phase0 @@ -223,9 +218,7 @@ fn wrap_phase0_aggregated_attestation(att: phase0::Attestation) -> VersionedAggr /// Decodes an unsigned [`SyncContribution`], SSZ-first with JSON fallback /// (charon `DutySyncContribution` branch). fn decode_sync_contribution(data: &[u8]) -> Result { - if !looks_like_json(data) - && let Ok(contribution) = ssz_codec::decode_sync_contribution(data) - { + if let Ok(contribution) = ssz_codec::decode_sync_contribution(data) { return Ok(contribution); } @@ -628,6 +621,46 @@ mod tests { ); } + /// Regression: `SyncCommitteeContribution` is a fixed-size SSZ container + /// whose leading field is a little-endian `u64` slot, so its SSZ encoding + /// begins with `0x7B` (`{`) whenever `slot % 256 == 123`. A + /// `{`-prefix-first dispatch would misroute such a valid SSZ payload to + /// JSON and fail; charon tries SSZ first (`core/proto.go` `unmarshal`), + /// so this must round-trip. + #[test] + fn sync_contribution_ssz_leading_brace_round_trips() { + let contribution = SyncContribution(altair::SyncCommitteeContribution { + slot: 0x7B, // little-endian u64 → first SSZ byte is `{` + beacon_block_root: [0xab; 32], + subcommittee_index: 2, + aggregation_bits: BitVector::with_bits(&[0, 5]), + signature: [0xcd; 96], + }); + + // The SSZ encoding really does begin with `{` (the flaw's trigger). + let encoded = ssz_codec::encode_sync_contribution(&contribution).unwrap(); + assert_eq!( + encoded.first(), + Some(&b'{'), + "leading SSZ byte should be 0x7B" + ); + + let pubkey = random_core_pub_key(); + let mut set = UnsignedDataSet::new(); + set.insert( + pubkey, + UnsignedDutyData::SyncContribution(contribution.clone()), + ); + + let proto = unsigned_data_set_to_proto(&set).unwrap(); + let decoded = unsigned_data_set_from_proto(&DutyType::SyncContribution, &proto).unwrap(); + + assert_eq!( + decoded.get(&pubkey), + Some(&UnsignedDutyData::SyncContribution(contribution)), + ); + } + /// The proposer JSON fallback (legacy, pre-SSZ charon) decodes the /// `{version, block, blinded}` wrapper. #[test]