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/parsigex_codec.rs b/crates/core/src/parsigex_codec.rs index b8e7c8ec..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,16 +167,20 @@ pub(crate) fn serialize_signed_data(data: &dyn SignedData) -> Result, Pa Err(ParSigExCodecError::UnsupportedDutyType) } +/// 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'{') +} + 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) @@ -183,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))); @@ -205,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) } @@ -240,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))); @@ -252,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) } @@ -271,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) } @@ -394,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/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/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..414f98ab 100644 --- a/crates/core/src/unsigneddata.rs +++ b/crates/core/src/unsigneddata.rs @@ -5,15 +5,17 @@ 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, corepb::v1::core as pbcore, + parsigex_codec::looks_like_json, signeddata::{ AttestationData, AttesterDuty, SyncContribution, VersionedAggregatedAttestation, VersionedProposal, }, + ssz_codec, types::{DutyType, PubKey}, }; @@ -21,7 +23,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 +39,85 @@ 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. +/// +/// 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()); + + 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,16 +142,102 @@ 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), } } +/// Decodes an unsigned [`VersionedProposal`], SSZ-first with JSON fallback +/// (charon `DutyProposer` branch of `unmarshalUnsignedData`). +fn decode_versioned_proposal(data: &[u8]) -> Result { + if let Ok(proposal) = ssz_codec::decode_versioned_proposal(data) { + return Ok(proposal); + } + + if looks_like_json(data) { + // Reuses `VersionedProposal`'s `Deserialize` impl (shared per-fork JSON + // dispatch in `signeddata`). + return serde_json::from_slice(data).map_err(ParSigExCodecError::from); + } + + 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 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)); + } + + 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)); + } + + Err(ParSigExCodecError::UnsignedData( + "unmarshal aggregated attestation".to_string(), + )) +} + +/// 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 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); } - if data.iter().find(|b| !b.is_ascii_whitespace()).copied() == Some(b'{') { + if looks_like_json(data) { let decoded: AttestationDataJson = serde_json::from_slice(data).map_err(ParSigExCodecError::from)?; return Ok(AttestationData { @@ -327,4 +492,295 @@ 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()), + ); + } + + /// 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] + 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)); + } }