diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 1076ade4..1129075f 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -26,13 +26,17 @@ pub mod deadline; /// parsigdb pub mod parsigdb; -mod parsigex_codec; +/// `Marshal` trait + `register_signed_data_codecs!` table. +pub mod marshal; + +pub(crate) mod parsigex_codec; // SSZ codec operates on compile-time-constant byte sizes and offsets. // Arithmetic is bounded and casts from `usize` to `u32` are safe because all // sizes are well below `u32::MAX`. #[allow(clippy::arithmetic_side_effects, clippy::cast_possible_truncation)] pub(crate) mod ssz_codec; +pub use marshal::{Marshal, MarshalError}; pub use parsigex_codec::ParSigExCodecError; /// Test utilities. diff --git a/crates/core/src/marshal.rs b/crates/core/src/marshal.rs new file mode 100644 index 00000000..06a40e64 --- /dev/null +++ b/crates/core/src/marshal.rs @@ -0,0 +1,416 @@ +//! `Marshal` trait, error type, and the `register_signed_data_codecs!` table +//! that wires every [`SignedData`](crate::types::SignedData) type into the +//! parsigex serialization path. +//! +//! The macro is the *only* legal way to make a type usable as `SignedData`: +//! it emits the `Marshal` impl, an entry in the duty-keyed dispatch table +//! consumed by [`crate::parsigex_codec::deserialize_signed_data`], and +//! generated round-trip / json-fallback / duty-dispatch tests. + +/// Codec error reported by [`Marshal`] implementations. +#[derive(Debug, thiserror::Error)] +pub enum MarshalError { + /// SSZ codec failure. + #[error("ssz: {0}")] + Ssz(String), + /// JSON codec failure. + #[error("json: {0}")] + Json(#[from] serde_json::Error), + /// Custom error message bubbled up from a wrapper constructor. + #[error("custom: {0}")] + Custom(String), + /// All registered codecs failed to decode the bytes. + #[error("all codecs failed")] + AllFailed, +} + +impl From for MarshalError { + fn from(err: crate::ssz_codec::SszCodecError) -> Self { + MarshalError::Ssz(err.to_string()) + } +} + +impl From for MarshalError { + fn from(err: ssz::DecodeError) -> Self { + MarshalError::Ssz(format!("{err:?}")) + } +} + +impl From for MarshalError { + fn from(err: crate::signeddata::SignedDataError) -> Self { + MarshalError::Custom(err.to_string()) + } +} + +/// Self-describing serialize/deserialize trait for `SignedData` types. +/// +/// `marshal` is dyn-compatible (vtable dispatch on `&dyn SignedData`). +/// `unmarshal` is statically dispatched (`Self: Sized`); duty-keyed decode for +/// `Box` lives in +/// [`crate::parsigex_codec::deserialize_signed_data`] and uses the table +/// registered by [`register_signed_data_codecs!`]. +pub trait Marshal { + /// Serializes this value to the wire format chosen by its registered + /// codec. + fn marshal(&self) -> Result, MarshalError>; + + /// Deserializes a value of `Self` from bytes encoded by [`Self::marshal`]. + fn unmarshal(bytes: &[u8]) -> Result + where + Self: Sized; +} + +/// Returns `true` when the trimmed byte slice starts with `{`, indicating +/// JSON object data. +#[doc(hidden)] +#[must_use] +pub fn looks_like_json(bytes: &[u8]) -> bool { + bytes.iter().find(|b| !b.is_ascii_whitespace()).copied() == Some(b'{') +} + +/// Single row in the duty-keyed dispatch table: `(duty, priority, decoder)`. +/// +/// `priority` orders the decoders within one `DutyType` — lower runs first. +/// Each `decoder` consumes raw bytes and returns a `Box`. +#[doc(hidden)] +pub type DispatchEntry = ( + crate::types::DutyType, + u8, + fn(&[u8]) -> Result, MarshalError>, +); + +// --------------------------------------------------------------------------- +// register_signed_data_codecs! — single source of truth for SignedData wiring +// --------------------------------------------------------------------------- + +/// Registers `SignedData` types and emits, for each entry: +/// +/// 1. An `impl Marshal` whose codec choice (json / ssz_then_json) is the single +/// source of truth for both encode and decode. +/// 2. An entry in `dispatch_table()` consumed by +/// [`crate::parsigex_codec::deserialize_signed_data`] when an entry has a +/// `duty:` field. +/// 3. Auto-generated round-trip, json-fallback, and duty-dispatch tests under +/// `#[cfg(test)] mod generated_codec_tests::`. +/// +/// Entry forms: +/// +/// ```text +/// // SSZ-then-JSON, registered for duty dispatch +/// Foo { +/// duty: , priority: , +/// codec: ssz_then_json(, ), +/// sample: Foo>, +/// }, +/// +/// // JSON-only, registered for duty dispatch (priority defaults to 0) +/// Bar { +/// duty: , +/// codec: json, +/// sample: Bar>, +/// }, +/// +/// // Marshal impl + tests, no duty dispatch +/// Baz { +/// codec: ssz_then_json(, ), +/// sample: Baz>, +/// }, +/// ``` +/// +/// `encode_fn` and `decode_fn` are paths to functions with signatures +/// `fn(&Self) -> Result, E1>` and `fn(&[u8]) -> Result` +/// respectively, where `MarshalError: From + From`. Wrappers whose +/// inner SSZ helpers operate on the inner field (e.g. `phase0::Attestation` +/// rather than `Attestation`) get a one-line adapter at the top of +/// `signeddata.rs`. +#[macro_export] +macro_rules! register_signed_data_codecs { + ( $( $ty:ident { $($body:tt)* } ),* $(,)? ) => { + // 1) Marshal impls. + $( $crate::__marshal_impl! { $ty , $($body)* } )* + + // 2) Duty-keyed dispatch table. Built once on first call and cached. + #[doc(hidden)] + pub(crate) fn dispatch_table() -> &'static [$crate::marshal::DispatchEntry] { + static TABLE: ::std::sync::OnceLock<::std::vec::Vec<$crate::marshal::DispatchEntry>> = + ::std::sync::OnceLock::new(); + TABLE + .get_or_init(|| { + let mut __table: ::std::vec::Vec<$crate::marshal::DispatchEntry> = + ::std::vec::Vec::new(); + $( $crate::__dispatch_push! { __table , $ty , $($body)* } )* + __table + }) + .as_slice() + } + + // 3) Generated tests (one inner module per entry). + #[cfg(test)] + #[allow(non_snake_case)] + mod generated_codec_tests { + $( $crate::__generated_tests! { $ty , $($body)* } )* + } + }; +} + +/// Marshal impl for a single entry. +/// +/// `ssz_then_json(enc, dec)` expects the following signatures, so that the +/// macro can directly delegate without further glue: +/// +/// ```text +/// fn enc(value: &Self) -> Result, E1> where MarshalError: From; +/// fn dec(bytes: &[u8]) -> Result where MarshalError: From; +/// ``` +/// +/// Wrappers whose inner SSZ helpers operate on the inner field +/// (e.g. `phase0::Attestation` rather than `Attestation`) get a one-line +/// adapter at the top of `signeddata.rs`. +#[doc(hidden)] +#[macro_export] +macro_rules! __marshal_impl { + // ssz_then_json + ( + $ty:ident, + $( duty: $_duty:ident , $( priority: $_prio:literal , )? )? + codec: ssz_then_json($enc:path, $dec:path $(,)?), + sample: $_sample:path $(,)? + ) => { + impl $crate::marshal::Marshal for $ty { + fn marshal( + &self, + ) -> ::std::result::Result<::std::vec::Vec, $crate::marshal::MarshalError> { + $enc(self).map_err(::core::convert::Into::into) + } + + fn unmarshal(bytes: &[u8]) -> ::std::result::Result + where + Self: ::core::marker::Sized, + { + if $crate::marshal::looks_like_json(bytes) { + return ::serde_json::from_slice::(bytes) + .map_err($crate::marshal::MarshalError::from); + } + $dec(bytes).map_err(::core::convert::Into::into) + } + } + }; + + // json + ( + $ty:ident, + $( duty: $_duty:ident , $( priority: $_prio:literal , )? )? + codec: json, + sample: $_sample:path $(,)? + ) => { + impl $crate::marshal::Marshal for $ty { + fn marshal( + &self, + ) -> ::std::result::Result<::std::vec::Vec, $crate::marshal::MarshalError> { + ::serde_json::to_vec(self).map_err($crate::marshal::MarshalError::from) + } + + fn unmarshal(bytes: &[u8]) -> ::std::result::Result + where + Self: ::core::marker::Sized, + { + ::serde_json::from_slice::(bytes).map_err($crate::marshal::MarshalError::from) + } + } + }; +} + +/// Pushes one row into the dispatch table being built. Entries without a +/// `duty:` field expand to nothing so they don't appear in the table. +#[doc(hidden)] +#[macro_export] +macro_rules! __dispatch_push { + // duty + explicit priority + ( + $table:ident, $ty:ident, + duty: $duty:ident , priority: $prio:literal , + codec: $_codec:ident $(($($_codec_arg:tt)*))? , + sample: $_sample:path $(,)? + ) => { + $table.push(( + $crate::types::DutyType::$duty, + $prio, + (|bytes: &[u8]| -> ::std::result::Result< + ::std::boxed::Box, + $crate::marshal::MarshalError, + > { + <$ty as $crate::marshal::Marshal>::unmarshal(bytes).map(|v| { + ::std::boxed::Box::new(v) as ::std::boxed::Box + }) + }) as fn(&[u8]) -> ::std::result::Result< + ::std::boxed::Box, + $crate::marshal::MarshalError, + >, + )); + }; + + // duty only — priority defaults to 0 + ( + $table:ident, $ty:ident, + duty: $duty:ident , + codec: $codec:ident $(($($codec_arg:tt)*))? , + sample: $sample:path $(,)? + ) => { + $crate::__dispatch_push! { + $table, $ty, + duty: $duty , priority: 0 , + codec: $codec $(($($codec_arg)*))? , + sample: $sample, + } + }; + + // no duty — no dispatch entry + ( + $table:ident, $ty:ident, + codec: $_codec:ident $(($($_codec_arg:tt)*))? , + sample: $_sample:path $(,)? + ) => {}; +} + +/// Generated tests for a single entry. Emits a per-type module with up to +/// three `#[test]` functions — `roundtrip`, `json_fallback` (only for +/// `ssz_then_json` codecs), and `duty_dispatch` (only when a duty is set). +#[doc(hidden)] +#[macro_export] +macro_rules! __generated_tests { + // ssz_then_json with duty (+ optional priority) + ( + $ty:ident, + duty: $duty:ident , $( priority: $_prio:literal , )? + codec: ssz_then_json $(($($_codec_arg:tt)*))? , + sample: $sample:path $(,)? + ) => { + #[allow(non_snake_case)] + mod $ty { + use super::super::*; + + #[test] + fn roundtrip() { + let v: $ty = $sample(); + let bytes = <$ty as $crate::marshal::Marshal>::marshal(&v).unwrap(); + let back = <$ty as $crate::marshal::Marshal>::unmarshal(&bytes).unwrap(); + assert_eq!(v, back); + } + + #[test] + fn json_fallback() { + let v: $ty = $sample(); + let bytes = ::serde_json::to_vec(&v).unwrap(); + assert_eq!(bytes.first(), Some(&b'{')); + let back = <$ty as $crate::marshal::Marshal>::unmarshal(&bytes).unwrap(); + assert_eq!(v, back); + } + + #[test] + fn duty_dispatch() { + let v: $ty = $sample(); + let bytes = <$ty as $crate::marshal::Marshal>::marshal(&v).unwrap(); + let duty = $crate::types::DutyType::$duty; + let boxed = $crate::parsigex_codec::deserialize_signed_data(&duty, &bytes).unwrap(); + let any = boxed as ::std::boxed::Box; + let back = *any.downcast::<$ty>().expect("type mismatch in dispatch"); + assert_eq!(v, back); + } + } + }; + + // json with duty (+ optional priority) + ( + $ty:ident, + duty: $duty:ident , $( priority: $_prio:literal , )? + codec: json, + sample: $sample:path $(,)? + ) => { + #[allow(non_snake_case)] + mod $ty { + use super::super::*; + + #[test] + fn roundtrip() { + let v: $ty = $sample(); + let bytes = <$ty as $crate::marshal::Marshal>::marshal(&v).unwrap(); + let back = <$ty as $crate::marshal::Marshal>::unmarshal(&bytes).unwrap(); + assert_eq!(v, back); + } + + #[test] + fn duty_dispatch() { + let v: $ty = $sample(); + let bytes = <$ty as $crate::marshal::Marshal>::marshal(&v).unwrap(); + let duty = $crate::types::DutyType::$duty; + let boxed = $crate::parsigex_codec::deserialize_signed_data(&duty, &bytes).unwrap(); + let any = boxed as ::std::boxed::Box; + let back = *any.downcast::<$ty>().expect("type mismatch in dispatch"); + assert_eq!(v, back); + } + } + }; + + // ssz_then_json without duty + ( + $ty:ident, + codec: ssz_then_json $(($($_codec_arg:tt)*))? , + sample: $sample:path $(,)? + ) => { + #[allow(non_snake_case)] + mod $ty { + use super::super::*; + + #[test] + fn roundtrip() { + let v: $ty = $sample(); + let bytes = <$ty as $crate::marshal::Marshal>::marshal(&v).unwrap(); + let back = <$ty as $crate::marshal::Marshal>::unmarshal(&bytes).unwrap(); + assert_eq!(v, back); + } + + #[test] + fn json_fallback() { + let v: $ty = $sample(); + let bytes = ::serde_json::to_vec(&v).unwrap(); + assert_eq!(bytes.first(), Some(&b'{')); + let back = <$ty as $crate::marshal::Marshal>::unmarshal(&bytes).unwrap(); + assert_eq!(v, back); + } + } + }; + + // json without duty + ( + $ty:ident, + codec: json, + sample: $sample:path $(,)? + ) => { + #[allow(non_snake_case)] + mod $ty { + use super::super::*; + + #[test] + fn roundtrip() { + let v: $ty = $sample(); + let bytes = <$ty as $crate::marshal::Marshal>::marshal(&v).unwrap(); + let back = <$ty as $crate::marshal::Marshal>::unmarshal(&bytes).unwrap(); + assert_eq!(v, back); + } + } + }; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn looks_like_json_ignores_leading_whitespace() { + assert!(looks_like_json(b"{\"a\":1}")); + assert!(looks_like_json(b" \n\t{\"a\":1}")); + assert!(!looks_like_json(b"\x00\x01\x02")); + assert!(!looks_like_json(b"")); + assert!(!looks_like_json(b"\"x\"")); + } +} diff --git a/crates/core/src/parsigex_codec.rs b/crates/core/src/parsigex_codec.rs index 02cd56c1..069e6e47 100644 --- a/crates/core/src/parsigex_codec.rs +++ b/crates/core/src/parsigex_codec.rs @@ -1,21 +1,15 @@ //! Partial signature exchange codec helpers used by core types. //! -//! 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. - -use std::any::Any; +//! Wire format (Charon-compatible): SSZ binary for SSZ-capable types, JSON for +//! everything else; on decode, a leading `{` flips the path to JSON. The +//! per-type codec choice and the duty-keyed decoder dispatch table both come +//! from the `register_signed_data_codecs!` invocation in `signeddata.rs` — +//! this module is just the thin glue. use crate::{ - signeddata::{ - Attestation, BeaconCommitteeSelection, SignedAggregateAndProof, SignedRandao, - SignedSyncContributionAndProof, SignedSyncMessage, SignedVoluntaryExit, - SyncCommitteeSelection, VersionedAttestation, VersionedSignedAggregateAndProof, - VersionedSignedProposal, VersionedSignedValidatorRegistration, - }, - ssz_codec, - types::{DutyType, Signature, SignedData}, + marshal::MarshalError, + signeddata, ssz_codec, + types::{DutyType, SignedData}, }; /// Error type for partial signature exchange codec operations. @@ -70,203 +64,69 @@ pub enum ParSigExCodecError { InvalidSignature(String), } -pub(crate) fn serialize_signed_data(data: &dyn SignedData) -> Result, ParSigExCodecError> { - let any = data as &dyn Any; - - // --------------------------------------------------------------- - // SSZ-capable types — encode as SSZ binary (matching Go `marshal`) - // --------------------------------------------------------------- - - // phase0::Attestation (non-versioned, raw SSZ) - if let Some(value) = any.downcast_ref::() { - return Ok(ssz_codec::encode_phase0_attestation(&value.0)?); - } - - // VersionedAttestation (versioned header + inner SSZ) - if let Some(value) = any.downcast_ref::() { - return Ok(ssz_codec::encode_versioned_attestation(&value.0)?); - } - - // phase0::SignedAggregateAndProof (non-versioned, raw SSZ) - if let Some(value) = any.downcast_ref::() { - return Ok(ssz_codec::encode_phase0_signed_aggregate_and_proof( - &value.0, - )?); - } - - // VersionedSignedAggregateAndProof (versioned header + inner SSZ) - if let Some(value) = any.downcast_ref::() { - return Ok(ssz_codec::encode_versioned_signed_aggregate_and_proof( - &value.0, - )?); - } - - // altair::SyncCommitteeMessage (non-versioned, all fixed) - if let Some(value) = any.downcast_ref::() { - return Ok(ssz_codec::encode_sync_committee_message(&value.0)?); - } - - // altair::SignedContributionAndProof (non-versioned, all fixed) - if let Some(value) = any.downcast_ref::() { - return Ok(ssz_codec::encode_signed_contribution_and_proof(&value.0)?); - } - - // --------------------------------------------------------------- - // JSON-only types - // --------------------------------------------------------------- - - macro_rules! serialize_json { - ($ty:ty) => { - if let Some(value) = any.downcast_ref::<$ty>() { - return Ok(serde_json::to_vec(value)?); +impl From for ParSigExCodecError { + fn from(err: MarshalError) -> Self { + match err { + MarshalError::Json(e) => ParSigExCodecError::Serialize(e), + MarshalError::Ssz(msg) => { + ParSigExCodecError::SszCodec(ssz_codec::SszCodecError::Decode(msg)) } - }; - } - - // VersionedSignedProposal (versioned header + inner SSZ) - if let Some(value) = any.downcast_ref::() { - return Ok(ssz_codec::encode_versioned_signed_proposal(&value.0)?); + MarshalError::Custom(msg) => ParSigExCodecError::SignedData(msg), + MarshalError::AllFailed => ParSigExCodecError::UnsupportedDutyType, + } } +} - serialize_json!(VersionedSignedValidatorRegistration); - serialize_json!(SignedVoluntaryExit); - serialize_json!(SignedRandao); - serialize_json!(Signature); - serialize_json!(BeaconCommitteeSelection); - serialize_json!(SyncCommitteeSelection); - - Err(ParSigExCodecError::UnsupportedDutyType) +pub(crate) fn serialize_signed_data(data: &dyn SignedData) -> Result, ParSigExCodecError> { + data.marshal().map_err(ParSigExCodecError::from) } 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) - .map(|value| Box::new(value) as Box) - .map_err(ParSigExCodecError::from) - }; - } - - // 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); - + // Duties that never reach the dispatch table. 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))); - } - // Try SSZ versioned Attestation. - if let Ok(va) = ssz_codec::decode_versioned_attestation(bytes) { - let wrapped = VersionedAttestation::new(va) - .map_err(|e| ParSigExCodecError::SignedData(e.to_string()))?; - return Ok(Box::new(wrapped)); - } - 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)); - } - Err(ParSigExCodecError::UnsupportedDutyType) - } - - DutyType::BuilderProposer => Err(ParSigExCodecError::DeprecatedBuilderProposer), - - // -- BuilderRegistration: JSON-only -- - DutyType::BuilderRegistration => deserialize_json!(VersionedSignedValidatorRegistration), - - // -- Exit: JSON-only -- - DutyType::Exit => deserialize_json!(SignedVoluntaryExit), - - // -- Randao: JSON-only -- - DutyType::Randao => deserialize_json!(SignedRandao), - - // -- Signature: JSON-only -- - DutyType::Signature => deserialize_json!(Signature), - - // -- PrepareAggregator: JSON-only -- - DutyType::PrepareAggregator => deserialize_json!(BeaconCommitteeSelection), - - // -- 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))); - } - // Try SSZ versioned. - if let Ok(va) = ssz_codec::decode_versioned_signed_aggregate_and_proof(bytes) { - return Ok(Box::new(VersionedSignedAggregateAndProof::new(va))); - } - 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))); - } - Err(ParSigExCodecError::UnsupportedDutyType) + DutyType::BuilderProposer => return Err(ParSigExCodecError::DeprecatedBuilderProposer), + DutyType::Unknown | DutyType::InfoSync | DutyType::DutySentinel(_) => { + return Err(ParSigExCodecError::UnsupportedDutyType); } - - // -- PrepareSyncContribution: JSON-only -- - DutyType::PrepareSyncContribution => deserialize_json!(SyncCommitteeSelection), - - // -- 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))); - } - Err(ParSigExCodecError::UnsupportedDutyType) + _ => {} + } + + // Walk the table in declared priority order. A decoder failing to parse + // its prioritized format is not fatal — JSON-only payloads at one + // priority and SSZ payloads at another can both legitimately appear for + // duties with multiple registered entries (e.g. `Attester` = + // `Attestation` then `VersionedAttestation`). + let mut last_err: Option = None; + for (entry_duty, _priority, decoder) in signeddata::dispatch_table() { + if entry_duty != duty_type { + continue; } - - DutyType::Unknown | DutyType::InfoSync | DutyType::DutySentinel(_) => { - Err(ParSigExCodecError::UnsupportedDutyType) + match decoder(bytes) { + Ok(value) => return Ok(value), + Err(e) => last_err = Some(e), } } + + Err(last_err + .map(ParSigExCodecError::from) + .unwrap_or(ParSigExCodecError::UnsupportedDutyType)) } #[cfg(test)] mod tests { + //! Round-trip / duty-dispatch tests for the registered `SignedData` types + //! are auto-generated by `register_signed_data_codecs!` in + //! `signeddata.rs`. Tests here cover only behavior specific to this thin + //! wrapper — duties handled outside the dispatch table and the JSON + //! fallback that crosses priority boundaries. + use super::*; - use pluto_eth2api::{ - spec::{altair, phase0}, - versioned, - }; - use pluto_ssz::{BitList, BitVector}; + use crate::signeddata::{Attestation, VersionedAttestation}; + use pluto_eth2api::{spec::phase0, versioned}; + use pluto_ssz::BitList; fn sample_attestation_data() -> phase0::AttestationData { phase0::AttestationData { @@ -290,25 +150,25 @@ mod tests { *any.downcast::().expect("type mismatch in downcast") } - /// SSZ-capable types serialize as SSZ binary and can be deserialized back. #[test] - fn marshal_unmarshal_ssz_attestation() { - let att = Attestation::new(phase0::Attestation { - aggregation_bits: BitList::with_bits(8, &[0, 2]), - data: sample_attestation_data(), - signature: [0x11; 96], - }); - let bytes = serialize_signed_data(&att).unwrap(); - // SSZ bytes should NOT start with '{'. - assert_ne!(bytes.first(), Some(&b'{')); - let decoded: Attestation = - downcast(deserialize_signed_data(&DutyType::Attester, &bytes).unwrap()); - assert_eq!(att, decoded); + fn deprecated_builder_proposer_returns_specific_error() { + let err = deserialize_signed_data(&DutyType::BuilderProposer, b"any").unwrap_err(); + assert!(matches!(err, ParSigExCodecError::DeprecatedBuilderProposer)); } - /// SSZ-capable types: versioned attestation round-trip. #[test] - fn marshal_unmarshal_ssz_versioned_attestation() { + fn unknown_duty_unsupported() { + for duty in [DutyType::Unknown, DutyType::InfoSync] { + let err = deserialize_signed_data(&duty, b"{}").unwrap_err(); + assert!(matches!(err, ParSigExCodecError::UnsupportedDutyType)); + } + } + + /// JSON at priority 1 still decodes when priority 0's JSON form fails — + /// an Attester payload that is JSON-encoded `VersionedAttestation` should + /// not be rejected by `Attestation`'s schema mismatch. + #[test] + fn json_falls_through_priorities() { let inner = versioned::VersionedAttestation { version: versioned::DataVersion::Deneb, validator_index: None, @@ -319,140 +179,27 @@ mod tests { })), }; let va = VersionedAttestation::new(inner).unwrap(); - let bytes = serialize_signed_data(&va).unwrap(); - assert_ne!(bytes.first(), Some(&b'{')); + let json_bytes = serde_json::to_vec(&va).unwrap(); + assert_eq!(json_bytes.first(), Some(&b'{')); + // priority 0 (`Attestation`) cannot decode this JSON; priority 1 + // (`VersionedAttestation`) can. let decoded: VersionedAttestation = - downcast(deserialize_signed_data(&DutyType::Attester, &bytes).unwrap()); + downcast(deserialize_signed_data(&DutyType::Attester, &json_bytes).unwrap()); assert_eq!(va, decoded); } - /// SSZ-capable types: SyncMessage round-trip. - #[test] - fn marshal_unmarshal_ssz_sync_message() { - let msg = SignedSyncMessage::new(altair::SyncCommitteeMessage { - slot: 100, - beacon_block_root: [0xdd; 32], - validator_index: 50, - signature: [0xee; 96], - }); - let bytes = serialize_signed_data(&msg).unwrap(); - assert_ne!(bytes.first(), Some(&b'{')); - let decoded: SignedSyncMessage = - downcast(deserialize_signed_data(&DutyType::SyncMessage, &bytes).unwrap()); - assert_eq!(msg, decoded); - } - - /// SSZ-capable types: SignedSyncContributionAndProof round-trip. - #[test] - fn marshal_unmarshal_ssz_signed_sync_contribution() { - let scp = SignedSyncContributionAndProof::new(altair::SignedContributionAndProof { - message: altair::ContributionAndProof { - aggregator_index: 33, - 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_ne!(bytes.first(), Some(&b'{')); - let decoded: SignedSyncContributionAndProof = - downcast(deserialize_signed_data(&DutyType::SyncContribution, &bytes).unwrap()); - assert_eq!(scp, decoded); - } - - /// SSZ-capable types: SignedAggregateAndProof round-trip. + /// SSZ at priority 1 still decodes when priority 0's SSZ form fails. #[test] - fn marshal_unmarshal_ssz_signed_aggregate_and_proof() { - let sap = SignedAggregateAndProof::new(phase0::SignedAggregateAndProof { - message: phase0::AggregateAndProof { - aggregator_index: 99, - aggregate: phase0::Attestation { - aggregation_bits: BitList::with_bits(8, &[2]), - data: sample_attestation_data(), - signature: [0x33; 96], - }, - selection_proof: [0x44; 96], - }, - signature: [0x55; 96], - }); - let bytes = serialize_signed_data(&sap).unwrap(); - assert_ne!(bytes.first(), Some(&b'{')); - let decoded: SignedAggregateAndProof = - downcast(deserialize_signed_data(&DutyType::Aggregator, &bytes).unwrap()); - assert_eq!(sap, decoded); - } - - /// JSON-only types still serialize as JSON. - #[test] - fn marshal_unmarshal_json_randao() { - let randao = SignedRandao::new(10, [0x99; 96]); - let bytes = serialize_signed_data(&randao).unwrap(); - // JSON bytes should start with '{'. - assert_eq!(bytes.first(), Some(&b'{')); - let decoded: SignedRandao = - downcast(deserialize_signed_data(&DutyType::Randao, &bytes).unwrap()); - assert_eq!(randao, decoded); - } - - /// JSON data can still be deserialized for SSZ-capable types (fallback). - #[test] - fn json_fallback_for_ssz_capable_attestation() { + fn ssz_falls_through_priorities() { let att = Attestation::new(phase0::Attestation { - aggregation_bits: BitList::with_bits(8, &[0]), + aggregation_bits: BitList::with_bits(8, &[0, 2]), data: sample_attestation_data(), signature: [0x11; 96], }); - // Force JSON encoding. - let json_bytes = serde_json::to_vec(&att).unwrap(); - assert_eq!(json_bytes.first(), Some(&b'{')); - // Deserialize should fall back to JSON and succeed. + let bytes = serialize_signed_data(&att).unwrap(); + assert_ne!(bytes.first(), Some(&b'{')); let decoded: Attestation = - downcast(deserialize_signed_data(&DutyType::Attester, &json_bytes).unwrap()); + downcast(deserialize_signed_data(&DutyType::Attester, &bytes).unwrap()); assert_eq!(att, decoded); } - - /// JSON data can still be deserialized for SSZ-capable SyncMessage - /// (fallback). - #[test] - fn json_fallback_for_ssz_capable_sync_message() { - let msg = SignedSyncMessage::new(altair::SyncCommitteeMessage { - slot: 5, - beacon_block_root: [0xaa; 32], - validator_index: 3, - signature: [0xbb; 96], - }); - let json_bytes = serde_json::to_vec(&msg).unwrap(); - let decoded: SignedSyncMessage = - downcast(deserialize_signed_data(&DutyType::SyncMessage, &json_bytes).unwrap()); - assert_eq!(msg, decoded); - } - - /// JSON data can still be deserialized for SSZ-capable Aggregator - /// (fallback). - #[test] - fn json_fallback_for_ssz_capable_aggregator() { - let sap = SignedAggregateAndProof::new(phase0::SignedAggregateAndProof { - message: phase0::AggregateAndProof { - aggregator_index: 1, - aggregate: phase0::Attestation { - aggregation_bits: BitList::with_bits(4, &[0]), - data: sample_attestation_data(), - signature: [0x11; 96], - }, - selection_proof: [0x22; 96], - }, - signature: [0x33; 96], - }); - let json_bytes = serde_json::to_vec(&sap).unwrap(); - assert_eq!(json_bytes.first(), Some(&b'{')); - let decoded: SignedAggregateAndProof = - downcast(deserialize_signed_data(&DutyType::Aggregator, &json_bytes).unwrap()); - assert_eq!(sap, decoded); - } } diff --git a/crates/core/src/signeddata.rs b/crates/core/src/signeddata.rs index 9c253c41..396485ab 100644 --- a/crates/core/src/signeddata.rs +++ b/crates/core/src/signeddata.rs @@ -1115,6 +1115,407 @@ impl SignedSyncContributionAndProof { } } +// --------------------------------------------------------------------------- +// Codec adapters — bridge wrapper types to their SSZ form. +// Non-versioned types delegate directly to the upstream `ssz` crate's +// `Encode`/`Decode` traits. Versioned types go through `ssz_codec` helpers +// that own the Charon-specific framing (version + offset header). +// One short adapter per `SignedData` type lets `register_signed_data_codecs!` +// receive a `(enc_fn, dec_fn)` pair with uniform signatures. +// --------------------------------------------------------------------------- + +use crate::{marshal::MarshalError, ssz_codec}; +use ssz::{Decode, Encode}; + +fn enc_attestation(value: &Attestation) -> Result, MarshalError> { + Ok(value.0.as_ssz_bytes()) +} + +fn dec_attestation(bytes: &[u8]) -> Result { + Ok(Attestation::new(phase0::Attestation::from_ssz_bytes( + bytes, + )?)) +} + +fn enc_versioned_attestation(value: &VersionedAttestation) -> Result, MarshalError> { + Ok(ssz_codec::encode_versioned_attestation(&value.0)?) +} + +fn dec_versioned_attestation(bytes: &[u8]) -> Result { + let inner = ssz_codec::decode_versioned_attestation(bytes)?; + Ok(VersionedAttestation::new(inner)?) +} + +fn enc_signed_aggregate_and_proof( + value: &SignedAggregateAndProof, +) -> Result, MarshalError> { + Ok(value.0.as_ssz_bytes()) +} + +fn dec_signed_aggregate_and_proof(bytes: &[u8]) -> Result { + Ok(SignedAggregateAndProof::new( + phase0::SignedAggregateAndProof::from_ssz_bytes(bytes)?, + )) +} + +fn enc_versioned_signed_aggregate_and_proof( + value: &VersionedSignedAggregateAndProof, +) -> Result, MarshalError> { + Ok(ssz_codec::encode_versioned_signed_aggregate_and_proof( + &value.0, + )?) +} + +fn dec_versioned_signed_aggregate_and_proof( + bytes: &[u8], +) -> Result { + Ok(VersionedSignedAggregateAndProof::new( + ssz_codec::decode_versioned_signed_aggregate_and_proof(bytes)?, + )) +} + +fn enc_signed_sync_message(value: &SignedSyncMessage) -> Result, MarshalError> { + Ok(value.0.as_ssz_bytes()) +} + +fn dec_signed_sync_message(bytes: &[u8]) -> Result { + Ok(SignedSyncMessage::new( + altair::SyncCommitteeMessage::from_ssz_bytes(bytes)?, + )) +} + +fn enc_sync_contribution_and_proof( + value: &SyncContributionAndProof, +) -> Result, MarshalError> { + Ok(value.0.as_ssz_bytes()) +} + +fn dec_sync_contribution_and_proof(bytes: &[u8]) -> Result { + Ok(SyncContributionAndProof::new( + altair::ContributionAndProof::from_ssz_bytes(bytes)?, + )) +} + +fn enc_signed_sync_contribution_and_proof( + value: &SignedSyncContributionAndProof, +) -> Result, MarshalError> { + Ok(value.0.as_ssz_bytes()) +} + +fn dec_signed_sync_contribution_and_proof( + bytes: &[u8], +) -> Result { + Ok(SignedSyncContributionAndProof::new( + altair::SignedContributionAndProof::from_ssz_bytes(bytes)?, + )) +} + +fn enc_versioned_signed_proposal(value: &VersionedSignedProposal) -> Result, MarshalError> { + Ok(ssz_codec::encode_versioned_signed_proposal(&value.0)?) +} + +fn dec_versioned_signed_proposal(bytes: &[u8]) -> Result { + let inner = ssz_codec::decode_versioned_signed_proposal(bytes)?; + Ok(VersionedSignedProposal::new(inner)?) +} + +// --------------------------------------------------------------------------- +// Single source of truth for `SignedData` ↔ bytes wiring. Adding a new +// `SignedData` type means *adding a row here* — Marshal impl, dispatch entry, +// and round-trip / json-fallback / duty-dispatch tests are all generated. +// --------------------------------------------------------------------------- + +#[cfg(test)] +use crate::signeddata::codec_samples as samples; + +crate::register_signed_data_codecs! { + // Attester duty: SSZ-capable, two priorities (non-versioned first, then + // the versioned wrapper as a fallback decoder). + Attestation { + duty: Attester, priority: 0, + codec: ssz_then_json(enc_attestation, dec_attestation), + sample: samples::sample_attestation, + }, + VersionedAttestation { + duty: Attester, priority: 1, + codec: ssz_then_json(enc_versioned_attestation, dec_versioned_attestation), + sample: samples::sample_versioned_attestation, + }, + + // Aggregator duty: SSZ-capable, non-versioned first then versioned. + SignedAggregateAndProof { + duty: Aggregator, priority: 0, + codec: ssz_then_json(enc_signed_aggregate_and_proof, dec_signed_aggregate_and_proof), + sample: samples::sample_signed_aggregate_and_proof, + }, + VersionedSignedAggregateAndProof { + duty: Aggregator, priority: 1, + codec: ssz_then_json( + enc_versioned_signed_aggregate_and_proof, + dec_versioned_signed_aggregate_and_proof, + ), + sample: samples::sample_versioned_signed_aggregate_and_proof, + }, + + // SyncMessage: SSZ-capable. + SignedSyncMessage { + duty: SyncMessage, + codec: ssz_then_json(enc_signed_sync_message, dec_signed_sync_message), + sample: samples::sample_signed_sync_message, + }, + + // SyncContribution: SSZ-capable. + SignedSyncContributionAndProof { + duty: SyncContribution, + codec: ssz_then_json( + enc_signed_sync_contribution_and_proof, + dec_signed_sync_contribution_and_proof, + ), + sample: samples::sample_signed_sync_contribution_and_proof, + }, + + // SyncContributionAndProof is `SignedData` but never reached through the + // duty-dispatch table — it is only ever serialized, since the + // SyncContribution duty decodes into `SignedSyncContributionAndProof`. We + // still register it so the Marshal contract is enforced and the round-trip + // test runs. + SyncContributionAndProof { + codec: ssz_then_json(enc_sync_contribution_and_proof, dec_sync_contribution_and_proof), + sample: samples::sample_sync_contribution_and_proof, + }, + + // Proposer duty: SSZ-capable. + VersionedSignedProposal { + duty: Proposer, + codec: ssz_then_json(enc_versioned_signed_proposal, dec_versioned_signed_proposal), + sample: samples::sample_versioned_signed_proposal, + }, + + // JSON-only types. + VersionedSignedValidatorRegistration { + duty: BuilderRegistration, + codec: json, + sample: samples::sample_versioned_signed_validator_registration, + }, + SignedVoluntaryExit { + duty: Exit, + codec: json, + sample: samples::sample_signed_voluntary_exit, + }, + SignedRandao { + duty: Randao, + codec: json, + sample: samples::sample_signed_randao, + }, + Signature { + duty: Signature, + codec: json, + sample: samples::sample_signature, + }, + BeaconCommitteeSelection { + duty: PrepareAggregator, + codec: json, + sample: samples::sample_beacon_committee_selection, + }, + SyncCommitteeSelection { + duty: PrepareSyncContribution, + codec: json, + sample: samples::sample_sync_committee_selection, + }, +} + +// --------------------------------------------------------------------------- +// Sample fixtures — small, deterministic, sufficient to drive the +// macro-generated round-trip tests. Larger fixtures (Go-byte-fixture goldens, +// per-fork variants) live in `mod tests`. +// --------------------------------------------------------------------------- + +#[cfg(test)] +pub(crate) mod codec_samples { + use super::*; + use pluto_eth2api::spec::{altair, phase0}; + use pluto_ssz::{BitList, BitVector}; + + fn attestation_data() -> phase0::AttestationData { + phase0::AttestationData { + slot: 1, + index: 2, + beacon_block_root: [0x11; 32], + source: phase0::Checkpoint { + epoch: 3, + root: [0x22; 32], + }, + target: phase0::Checkpoint { + epoch: 4, + root: [0x33; 32], + }, + } + } + + fn phase0_attestation() -> phase0::Attestation { + phase0::Attestation { + aggregation_bits: BitList::with_bits(8, &[0, 2]), + data: attestation_data(), + signature: [0x44; 96], + } + } + + fn phase0_signed_aggregate_and_proof() -> phase0::SignedAggregateAndProof { + phase0::SignedAggregateAndProof { + message: phase0::AggregateAndProof { + aggregator_index: 9, + aggregate: phase0_attestation(), + selection_proof: [0x55; 96], + }, + signature: [0x66; 96], + } + } + + pub(crate) fn sample_attestation() -> Attestation { + Attestation::new(phase0_attestation()) + } + + pub(crate) fn sample_versioned_attestation() -> VersionedAttestation { + VersionedAttestation::new(versioned::VersionedAttestation { + version: versioned::DataVersion::Deneb, + validator_index: Some(7), + attestation: Some(versioned::AttestationPayload::Deneb(phase0_attestation())), + }) + .expect("valid versioned attestation sample") + } + + pub(crate) fn sample_signed_aggregate_and_proof() -> SignedAggregateAndProof { + SignedAggregateAndProof::new(phase0_signed_aggregate_and_proof()) + } + + pub(crate) fn sample_versioned_signed_aggregate_and_proof() -> VersionedSignedAggregateAndProof + { + VersionedSignedAggregateAndProof::new(versioned::VersionedSignedAggregateAndProof { + version: versioned::DataVersion::Phase0, + aggregate_and_proof: versioned::SignedAggregateAndProofPayload::Phase0( + phase0_signed_aggregate_and_proof(), + ), + }) + } + + pub(crate) fn sample_signed_sync_message() -> SignedSyncMessage { + SignedSyncMessage::new(altair::SyncCommitteeMessage { + slot: 100, + beacon_block_root: [0x77; 32], + validator_index: 50, + signature: [0x88; 96], + }) + } + + fn altair_contribution_and_proof() -> altair::ContributionAndProof { + altair::ContributionAndProof { + aggregator_index: 11, + contribution: altair::SyncCommitteeContribution { + slot: 200, + beacon_block_root: [0xaa; 32], + subcommittee_index: 2, + aggregation_bits: BitVector::with_bits(&[0, 5]), + signature: [0xbb; 96], + }, + selection_proof: [0xcc; 96], + } + } + + pub(crate) fn sample_sync_contribution_and_proof() -> SyncContributionAndProof { + SyncContributionAndProof::new(altair_contribution_and_proof()) + } + + pub(crate) fn sample_signed_sync_contribution_and_proof() -> SignedSyncContributionAndProof { + SignedSyncContributionAndProof::new(altair::SignedContributionAndProof { + message: altair_contribution_and_proof(), + signature: [0xdd; 96], + }) + } + + pub(crate) fn sample_versioned_signed_proposal() -> VersionedSignedProposal { + VersionedSignedProposal::new(versioned::VersionedSignedProposal { + version: versioned::DataVersion::Phase0, + blinded: false, + block: versioned::SignedProposalBlock::Phase0(phase0::SignedBeaconBlock { + message: phase0::BeaconBlock { + slot: 1, + proposer_index: 2, + parent_root: [0x11; 32], + state_root: [0x22; 32], + body: phase0::BeaconBlockBody { + randao_reveal: [0x33; 96], + eth1_data: phase0::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(), + }, + }, + signature: [0x77; 96], + }), + }) + .expect("valid versioned signed proposal sample") + } + + pub(crate) fn sample_versioned_signed_validator_registration() + -> VersionedSignedValidatorRegistration { + VersionedSignedValidatorRegistration::new(versioned::VersionedSignedValidatorRegistration { + version: versioned::BuilderVersion::V1, + v1: Some(v1::SignedValidatorRegistration { + message: v1::ValidatorRegistration { + fee_recipient: [0xee; 20], + gas_limit: 30_000_000, + timestamp: 1_700_000_000, + pubkey: [0xab; 48], + }, + signature: [0xcd; 96], + }), + }) + .expect("valid validator registration sample") + } + + pub(crate) fn sample_signed_voluntary_exit() -> SignedVoluntaryExit { + SignedVoluntaryExit::new(phase0::SignedVoluntaryExit { + message: phase0::VoluntaryExit { + epoch: 42, + validator_index: 7, + }, + signature: [0xa1; 96], + }) + } + + pub(crate) fn sample_signed_randao() -> SignedRandao { + SignedRandao::new(10, [0x99; 96]) + } + + pub(crate) fn sample_signature() -> Signature { + Signature::new([0x42; 96]) + } + + pub(crate) fn sample_beacon_committee_selection() -> BeaconCommitteeSelection { + BeaconCommitteeSelection::new(v1::BeaconCommitteeSelection { + slot: 100, + validator_index: 5, + selection_proof: [0xb1; 96], + }) + } + + pub(crate) fn sample_sync_committee_selection() -> SyncCommitteeSelection { + SyncCommitteeSelection::new(v1::SyncCommitteeSelection { + slot: 100, + validator_index: 5, + subcommittee_index: 3, + selection_proof: [0xc1; 96], + }) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/core/src/ssz_codec.rs b/crates/core/src/ssz_codec.rs index 2ae992d9..703b9daf 100644 --- a/crates/core/src/ssz_codec.rs +++ b/crates/core/src/ssz_codec.rs @@ -2,7 +2,10 @@ //! `marshal`/`unmarshal` behaviour. //! //! Charon serializes SSZ-capable types using SSZ binary encoding with custom -//! headers for versioned types. JSON-only types are handled elsewhere (see +//! headers for versioned types. Non-versioned SSZ types are framed by the +//! upstream `ssz` crate's [`Encode`]/[`Decode`] traits and don't need +//! Charon-specific helpers — adapters in `signeddata.rs` call those traits +//! directly. JSON-only types are handled elsewhere (see //! [`parsigex_codec`](super::parsigex_codec)). use pluto_eth2api::{ @@ -69,81 +72,6 @@ fn require(bytes: &[u8], need: usize) -> Result<(), SszCodecError> { } } -// =========================================================================== -// Non-versioned SSZ-capable types -// =========================================================================== - -// Using ethereum_ssz derived Encode/Decode: just delegate to `as_ssz_bytes` -// and `from_ssz_bytes`. - -/// Encodes a `phase0::Attestation` to SSZ binary. -pub fn encode_phase0_attestation(att: &phase0::Attestation) -> Result, SszCodecError> { - Ok(att.as_ssz_bytes()) -} - -/// Decodes a `phase0::Attestation` from SSZ binary. -pub fn decode_phase0_attestation(bytes: &[u8]) -> Result { - Ok(phase0::Attestation::from_ssz_bytes(bytes)?) -} - -/// Encodes a `phase0::SignedAggregateAndProof` to SSZ binary. -pub fn encode_phase0_signed_aggregate_and_proof( - sap: &phase0::SignedAggregateAndProof, -) -> Result, SszCodecError> { - Ok(sap.as_ssz_bytes()) -} - -/// Decodes a `phase0::SignedAggregateAndProof` from SSZ binary. -pub fn decode_phase0_signed_aggregate_and_proof( - bytes: &[u8], -) -> Result { - Ok(phase0::SignedAggregateAndProof::from_ssz_bytes(bytes)?) -} - -/// Encodes an `altair::SyncCommitteeMessage` to SSZ binary. -pub fn encode_sync_committee_message( - msg: &altair::SyncCommitteeMessage, -) -> Result, SszCodecError> { - Ok(msg.as_ssz_bytes()) -} - -/// Decodes an `altair::SyncCommitteeMessage` from SSZ binary. -pub fn decode_sync_committee_message( - bytes: &[u8], -) -> Result { - Ok(altair::SyncCommitteeMessage::from_ssz_bytes(bytes)?) -} - -/// Encodes an `altair::ContributionAndProof` to SSZ binary. -#[cfg(test)] -pub fn encode_contribution_and_proof( - cap: &altair::ContributionAndProof, -) -> Result, SszCodecError> { - Ok(cap.as_ssz_bytes()) -} - -/// Decodes an `altair::ContributionAndProof` from SSZ binary. -#[cfg(test)] -pub fn decode_contribution_and_proof( - bytes: &[u8], -) -> Result { - Ok(altair::ContributionAndProof::from_ssz_bytes(bytes)?) -} - -/// Encodes an `altair::SignedContributionAndProof` to SSZ binary. -pub fn encode_signed_contribution_and_proof( - scp: &altair::SignedContributionAndProof, -) -> Result, SszCodecError> { - Ok(scp.as_ssz_bytes()) -} - -/// Decodes an `altair::SignedContributionAndProof` from SSZ binary. -pub fn decode_signed_contribution_and_proof( - bytes: &[u8], -) -> Result { - Ok(altair::SignedContributionAndProof::from_ssz_bytes(bytes)?) -} - // =========================================================================== // Versioned type helpers // =========================================================================== @@ -160,6 +88,10 @@ fn decode_version(bytes: &[u8]) -> Result { DataVersion::from_legacy_u64(raw).map_err(|_| SszCodecError::UnknownVersion(raw)) } +fn unknown_version_decoded() -> SszCodecError { + SszCodecError::Decode("data version is Unknown".to_string()) +} + // --------------------------------------------------------------------------- // VersionedAttestation // Two header formats (Charon added validator_index in a later version): @@ -173,11 +105,15 @@ const VERSIONED_ATTESTATION_VAL_IDX_HEADER: u32 = 20; /// Encodes a `VersionedAttestation` to SSZ binary with Charon versioned /// header. Uses the 20-byte header when `validator_index` is set, otherwise /// falls back to the legacy 12-byte header. -pub fn encode_versioned_attestation( +pub(crate) fn encode_versioned_attestation( va: &versioned::VersionedAttestation, ) -> Result, SszCodecError> { let version = encode_version(va.version)?; - let inner = encode_attestation_payload(va.attestation.as_ref())?; + let attestation = va + .attestation + .as_ref() + .ok_or_else(|| SszCodecError::Decode("missing attestation payload".to_string()))?; + let inner = encode_attestation_payload(attestation); if let Some(val_idx) = va.validator_index { let mut buf = @@ -199,7 +135,7 @@ pub fn encode_versioned_attestation( /// Decodes a `VersionedAttestation` from SSZ binary with Charon versioned /// header. Tries the 20-byte validator_index format first, falling back to the /// legacy 12-byte format when the offset field doesn't match. -pub fn decode_versioned_attestation( +pub(crate) fn decode_versioned_attestation( bytes: &[u8], ) -> Result { match try_decode_versioned_attestation_with_val_idx(bytes) { @@ -253,23 +189,14 @@ fn decode_versioned_attestation_no_val_idx( }) } -fn encode_attestation_payload( - attestation: Option<&AttestationPayload>, -) -> Result, SszCodecError> { +fn encode_attestation_payload(attestation: &AttestationPayload) -> Vec { match attestation { - Some( - AttestationPayload::Phase0(att) - | AttestationPayload::Altair(att) - | AttestationPayload::Bellatrix(att) - | AttestationPayload::Capella(att) - | AttestationPayload::Deneb(att), - ) => Ok(att.as_ssz_bytes()), - Some(AttestationPayload::Electra(att) | AttestationPayload::Fulu(att)) => { - Ok(att.as_ssz_bytes()) - } - None => Err(SszCodecError::Decode( - "missing attestation payload".to_string(), - )), + AttestationPayload::Phase0(att) + | AttestationPayload::Altair(att) + | AttestationPayload::Bellatrix(att) + | AttestationPayload::Capella(att) + | AttestationPayload::Deneb(att) => att.as_ssz_bytes(), + AttestationPayload::Electra(att) | AttestationPayload::Fulu(att) => att.as_ssz_bytes(), } } @@ -299,7 +226,7 @@ fn decode_attestation_payload( DataVersion::Fulu => Ok(AttestationPayload::Fulu( electra::Attestation::from_ssz_bytes(inner)?, )), - DataVersion::Unknown => Err(SszCodecError::UnknownVersion(u64::MAX)), + DataVersion::Unknown => Err(unknown_version_decoded()), } } @@ -312,7 +239,7 @@ const VERSIONED_SIGNED_AGGREGATE_HEADER: u32 = 12; /// Encodes a `VersionedSignedAggregateAndProof` to SSZ binary with Charon /// versioned header. -pub fn encode_versioned_signed_aggregate_and_proof( +pub(crate) fn encode_versioned_signed_aggregate_and_proof( va: &versioned::VersionedSignedAggregateAndProof, ) -> Result, SszCodecError> { let version = encode_version(va.version)?; @@ -336,7 +263,7 @@ pub fn encode_versioned_signed_aggregate_and_proof( /// Decodes a `VersionedSignedAggregateAndProof` from SSZ binary with Charon /// versioned header. -pub fn decode_versioned_signed_aggregate_and_proof( +pub(crate) fn decode_versioned_signed_aggregate_and_proof( bytes: &[u8], ) -> Result { require(bytes, VERSIONED_SIGNED_AGGREGATE_HEADER as usize)?; @@ -372,7 +299,7 @@ pub fn decode_versioned_signed_aggregate_and_proof( DataVersion::Fulu => SignedAggregateAndProofPayload::Fulu( electra::SignedAggregateAndProof::from_ssz_bytes(inner)?, ), - DataVersion::Unknown => return Err(SszCodecError::UnknownVersion(u64::MAX)), + DataVersion::Unknown => return Err(unknown_version_decoded()), }; Ok(versioned::VersionedSignedAggregateAndProof { @@ -390,7 +317,7 @@ const VERSIONED_SIGNED_PROPOSAL_HEADER: u32 = 13; /// Encodes a `VersionedSignedProposal` to SSZ binary with Charon versioned /// header. -pub fn encode_versioned_signed_proposal( +pub(crate) fn encode_versioned_signed_proposal( vp: &versioned::VersionedSignedProposal, ) -> Result, SszCodecError> { let version = encode_version(vp.version)?; @@ -407,7 +334,7 @@ pub fn encode_versioned_signed_proposal( /// Decodes a `VersionedSignedProposal` from SSZ binary with Charon versioned /// header. -pub fn decode_versioned_signed_proposal( +pub(crate) fn decode_versioned_signed_proposal( bytes: &[u8], ) -> Result { require(bytes, VERSIONED_SIGNED_PROPOSAL_HEADER as usize)?; @@ -492,7 +419,7 @@ fn decode_proposal_block( (DataVersion::Fulu, true) => SignedProposalBlock::FuluBlinded( electra::SignedBlindedBeaconBlock::from_ssz_bytes(bytes)?, ), - (DataVersion::Unknown, _) => return Err(SszCodecError::UnknownVersion(u64::MAX)), + (DataVersion::Unknown, _) => return Err(unknown_version_decoded()), }) } @@ -521,102 +448,6 @@ mod tests { } } - #[test] - fn roundtrip_phase0_attestation() { - let att = phase0::Attestation { - aggregation_bits: BitList::with_bits(16, &[0, 3, 7]), - data: sample_attestation_data(), - signature: [0x11; 96], - }; - let encoded = encode_phase0_attestation(&att).unwrap(); - let decoded = decode_phase0_attestation(&encoded).unwrap(); - assert_eq!(att, decoded); - } - - #[test] - fn roundtrip_electra_attestation() { - let att = electra::Attestation { - aggregation_bits: BitList::with_bits(32, &[1, 5, 10]), - data: sample_attestation_data(), - signature: [0x22; 96], - committee_bits: BitVector::with_bits(&[0, 3]), - }; - let encoded = att.as_ssz_bytes(); - let decoded = electra::Attestation::from_ssz_bytes(&encoded).unwrap(); - assert_eq!(att, decoded); - } - - #[test] - fn roundtrip_phase0_signed_aggregate_and_proof() { - let sap = phase0::SignedAggregateAndProof { - message: phase0::AggregateAndProof { - aggregator_index: 99, - aggregate: phase0::Attestation { - aggregation_bits: BitList::with_bits(8, &[2, 4]), - data: sample_attestation_data(), - signature: [0x33; 96], - }, - selection_proof: [0x44; 96], - }, - signature: [0x55; 96], - }; - let encoded = encode_phase0_signed_aggregate_and_proof(&sap).unwrap(); - let decoded = decode_phase0_signed_aggregate_and_proof(&encoded).unwrap(); - assert_eq!(sap, decoded); - } - - #[test] - fn roundtrip_sync_committee_message() { - let msg = altair::SyncCommitteeMessage { - slot: 100, - beacon_block_root: [0xdd; 32], - validator_index: 50, - signature: [0xee; 96], - }; - let encoded = encode_sync_committee_message(&msg).unwrap(); - let decoded = decode_sync_committee_message(&encoded).unwrap(); - assert_eq!(msg, decoded); - } - - #[test] - fn roundtrip_contribution_and_proof() { - let cap = altair::ContributionAndProof { - aggregator_index: 33, - 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], - }; - let encoded = encode_contribution_and_proof(&cap).unwrap(); - let decoded = decode_contribution_and_proof(&encoded).unwrap(); - assert_eq!(cap, decoded); - } - - #[test] - fn roundtrip_signed_contribution_and_proof() { - let scp = altair::SignedContributionAndProof { - message: altair::ContributionAndProof { - aggregator_index: 33, - 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 encoded = encode_signed_contribution_and_proof(&scp).unwrap(); - let decoded = decode_signed_contribution_and_proof(&encoded).unwrap(); - assert_eq!(scp, decoded); - } - #[test] fn roundtrip_versioned_attestation_phase0() { let va = versioned::VersionedAttestation { @@ -746,7 +577,8 @@ mod tests { let go_bytes = read_go_fixture("attestation_phase0"); // Decode Go SSZ bytes -> Rust type. - let decoded = decode_phase0_attestation(&go_bytes).expect("decode Go attestation fixture"); + let decoded = + phase0::Attestation::from_ssz_bytes(&go_bytes).expect("decode Go attestation fixture"); // Verify fields match expected values. assert_eq!(decoded.data.slot, 42); @@ -757,7 +589,7 @@ mod tests { assert_eq!(decoded.signature, [0x11; 96]); // Re-encode and verify byte-for-byte match. - let rust_bytes = encode_phase0_attestation(&decoded).unwrap(); + let rust_bytes = decoded.as_ssz_bytes(); assert_eq!( rust_bytes, go_bytes, "Rust SSZ output must match Go SSZ output" @@ -768,7 +600,7 @@ mod tests { fn go_fixture_signed_aggregate_and_proof() { let go_bytes = read_go_fixture("signed_aggregate_and_proof"); - let decoded = decode_phase0_signed_aggregate_and_proof(&go_bytes) + let decoded = phase0::SignedAggregateAndProof::from_ssz_bytes(&go_bytes) .expect("decode Go signed_aggregate_and_proof fixture"); assert_eq!(decoded.message.aggregator_index, 99); @@ -776,7 +608,7 @@ mod tests { assert_eq!(decoded.message.selection_proof, [0x44; 96]); assert_eq!(decoded.message.aggregate.signature, [0x33; 96]); - let rust_bytes = encode_phase0_signed_aggregate_and_proof(&decoded).unwrap(); + let rust_bytes = decoded.as_ssz_bytes(); assert_eq!( rust_bytes, go_bytes, "Rust SSZ output must match Go SSZ output" diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index 8d971f7d..c0fe54bf 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -536,8 +536,17 @@ impl AsRef<[u8; SIG_LEN]> for Signature { } } -/// Signed data type -pub trait SignedData: Any + DynClone + DynEq + StdDebug + Send + Sync { +/// Signed data type. +/// +/// All implementors must also implement [`Marshal`](crate::marshal::Marshal), +/// which is enforced at compile time via the `SignedData: Marshal` supertrait +/// bound. The only legal way to add a new `SignedData` type is to register it +/// through [`register_signed_data_codecs!`](crate::register_signed_data_codecs) +/// in `signeddata.rs`, which wires the codec, dispatch entry, and tests in +/// one place. +pub trait SignedData: + Any + DynClone + DynEq + StdDebug + Send + Sync + crate::marshal::Marshal +{ /// signature returns the signed duty data's signature. fn signature(&self) -> Result; @@ -1019,6 +1028,16 @@ mod tests { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] struct MockSignedData; + impl crate::marshal::Marshal for MockSignedData { + fn marshal(&self) -> Result, crate::marshal::MarshalError> { + serde_json::to_vec(self).map_err(crate::marshal::MarshalError::from) + } + + fn unmarshal(bytes: &[u8]) -> Result { + serde_json::from_slice(bytes).map_err(crate::marshal::MarshalError::from) + } + } + impl SignedData for MockSignedData { fn signature(&self) -> Result { Ok(Signature::new([42u8; SIG_LEN])) diff --git a/docs/plans/marshal-crate.md b/docs/plans/marshal-crate.md new file mode 100644 index 00000000..01fb9913 --- /dev/null +++ b/docs/plans/marshal-crate.md @@ -0,0 +1,292 @@ +# Plan — `Marshal` trait + `register_signed_data_codecs!` table + +Status: drafted, awaiting approval before implementation. + +## Goal + +Make round-trip wiring of `SignedData` types *impossible to forget*. Today, +`crates/core/src/parsigex_codec.rs` hand-codes serialize/deserialize for each +type in two long blocks. Adding a new type requires editing both halves, +choosing a codec consistent across both, and remembering to write a round-trip +test. Any of those steps can be skipped silently. + +This plan introduces: + +- A small `Marshal` trait so that serialization is a method call on + `SignedData` types. +- A single `register_signed_data_codecs!` table in `signeddata.rs` that is the + *only* legal way to make a type usable as `SignedData`. +- Auto-generated round-trip tests so that the codec-symmetry contract is + checked by CI for every entry. + +No new crate. No proc-macro. No `inventory`. Pure `macro_rules!` inside +`pluto-core`. + +## Why this shape + +Solidity comes from generated *tests*, not generated *dispatch*. The +single-table `macro_rules!` approach makes the following failures impossible +to ship: + +| Failure mode | Caught by | +| -------------------------------------------- | ------------------------------------------------------------ | +| New `SignedData` type, never wired | Compile error — the type has no `Marshal` impl | +| Serialize and deserialize disagree on codec | Generated round-trip test per entry | +| New `DutyType` variant, dispatch not updated | Exhaustive `match DutyType` (already today) | +| JSON-encoded payload no longer decodes | Generated JSON-fallback test per `ssz_then_json` entry | +| Registered to the wrong duty / priority | Generated duty-dispatch test per entry | +| Wire format drifts from Go Charon | Existing Go fixture tests in `ssz_codec.rs` | + +### Rejected alternative — proc-macro + `inventory` + +An earlier draft proposed a `pluto-marshal` / `marshal-derive` crate pair with +separate `#[marshal(...)]` and `#[duty(...)]` attributes, registered via +`inventory::submit!`. Rejected because: + +- **Asymmetric codecs go undetected.** Without auto-generated round-trip + tests, a type can advertise one codec on the encode side and a different + one on the decode side; nothing fails to compile. +- **`inventory` registration is link-time, not compile-time.** A missed + submission silently degrades to "unsupported duty" at runtime instead of + being a build error. +- **Two new crates, a new dep, and worse error messages on macro misfire**, in + exchange for no additional safety vs. a `macro_rules!` table — the test + generation, not the dispatch shape, is what closes the holes. + +## File layout (no new crates) + +```text +crates/core/src/ + marshal.rs # NEW: Marshal trait, MarshalError, helpers, the + # register_signed_data_codecs! macro + signeddata.rs # invokes register_signed_data_codecs! once + parsigex_codec.rs # collapses to two thin functions + ssz_codec.rs # unchanged +``` + +## Trait (`crates/core/src/marshal.rs`) + +```rust +pub trait Marshal { + fn marshal(&self) -> Result, MarshalError>; + + fn unmarshal(bytes: &[u8]) -> Result + where + Self: Sized; +} + +#[derive(Debug, thiserror::Error)] +pub enum MarshalError { + #[error("ssz: {0}")] Ssz(String), + #[error("json: {0}")] Json(#[from] serde_json::Error), + #[error("custom: {0}")] Custom(String), + #[error("all codecs failed")] AllFailed, +} + +#[doc(hidden)] +pub fn looks_like_json(bytes: &[u8]) -> bool { /* `{`-prefix sniff */ } +``` + +Dyn-compatibility: + +- `marshal(&self)` — vtable-dispatchable, works on `dyn Marshal` / + `Box`. +- `unmarshal -> Self` — guarded by `where Self: Sized`, statically dispatched + only. The duty-keyed `Box` decode path lives in + `parsigex_codec.rs` and uses the same generated table (see below); no + separate registry crate needed. + +`SignedData` becomes `pub trait SignedData: Marshal + ... { ... }`, so any +existing `Box` value can call `.marshal()` directly. + +## The macro + +`register_signed_data_codecs! { ... }` is invoked exactly once, in +`signeddata.rs`, with a row per `SignedData` type: + +```rust +register_signed_data_codecs! { + Attestation { + duty: Attester, + priority: 0, + codec: ssz_then_json( + ssz_codec::encode_phase0_attestation, + ssz_codec::decode_phase0_attestation, + ), + sample: sample_phase0_attestation, + }, + VersionedAttestation { + duty: Attester, + priority: 1, + codec: ssz_then_json( + ssz_codec::encode_versioned_attestation, + ssz_codec::decode_versioned_attestation, + ), + sample: sample_versioned_attestation, + }, + SignedAggregateAndProof { + duty: Aggregator, priority: 0, + codec: ssz_then_json(...), sample: ..., + }, + VersionedSignedAggregateAndProof { + duty: Aggregator, priority: 1, + codec: ssz_then_json(...), sample: ..., + }, + SignedSyncMessage { + duty: SyncMessage, priority: 0, + codec: ssz_then_json(...), sample: ..., + }, + SignedSyncContributionAndProof { + duty: SyncContribution, priority: 0, + codec: ssz_then_json(...), sample: ..., + }, + VersionedSignedProposal { + duty: Proposer, priority: 0, + codec: ssz_then_json(...), sample: ..., + }, + + VersionedSignedValidatorRegistration { + duty: BuilderRegistration, + codec: json, + sample: ..., + }, + SignedVoluntaryExit { duty: Exit, codec: json, sample: ... }, + SignedRandao { duty: Randao, codec: json, sample: ... }, + Signature { duty: Signature, codec: json, sample: ... }, + BeaconCommitteeSelection { duty: PrepareAggregator, codec: json, sample: ... }, + SyncCommitteeSelection { duty: PrepareSyncContribution,codec: json, sample: ... }, +} +``` + +### Codec forms + +```text +codec: json +codec: ssz // uses ssz::{Encode,Decode} on Self +codec: ssz_then_json(enc_fn, dec_fn) // custom SSZ + JSON fallback on decode +codec: json_then_ssz(enc_fn, dec_fn) // JSON-first variant if ever needed +``` + +Custom codec function signatures, exactly: + +```rust +fn enc_fn(value: &Self) -> Result, impl Into>; +fn dec_fn(bytes: &[u8]) -> Result>; +``` + +Wrapper newtypes (e.g. `Attestation(phase0::Attestation)`) whose existing +helpers take the inner field get a one-line adapter at the top of +`signeddata.rs`. No field-accessor magic in the macro. + +### What the macro emits per entry + +For `Foo { duty: D, priority: P, codec: ssz_then_json(enc, dec), sample: s }`: + +1. **`impl Marshal for Foo`** — `marshal` calls `enc(self)`, `unmarshal` does + the JSON-sniff then `dec(bytes)` then JSON fallback. Single source of + truth for the codec choice. +2. **A registry entry** — a `const fn` slot consumed by + `parsigex_codec::deserialize_signed_data` to dispatch by `(duty, priority)`. + Implemented as a generated `pub(crate) fn dispatch_table()` returning a + `&'static [(DutyType, u8, fn(&[u8]) -> Result, _>)]`. +3. **`#[test] fn roundtrip_foo()`** in a `#[cfg(test)] mod generated_tests` + block: `let v = s(); let bytes = v.marshal().unwrap(); let back = + Foo::unmarshal(&bytes).unwrap(); assert_eq!(v, back);` +4. **`#[test] fn json_fallback_foo()`** when the codec is `ssz_then_json` / + `json_then_ssz`: encode via `serde_json::to_vec`, decode via `unmarshal`, + assert equal. This catches "type silently dropped JSON support". +5. **`#[test] fn duty_dispatch_foo()`**: builds the sample, marshals it, then + calls `parsigex_codec::deserialize_signed_data(duty, &bytes)` and downcasts + to `Foo`, asserting equal. This catches "registered to the wrong duty" and + "priority order is wrong". + +### What `parsigex_codec.rs` looks like after + +```rust +pub fn serialize_signed_data( + data: &dyn SignedData, +) -> Result, ParSigExCodecError> { + data.marshal().map_err(Into::into) +} + +pub fn deserialize_signed_data( + duty: &DutyType, + bytes: &[u8], +) -> Result, ParSigExCodecError> { + let is_json = pluto_marshal::looks_like_json(bytes); + for (entry_duty, _priority, decoder) in marshal::dispatch_table() { + if entry_duty != duty { continue; } + match decoder(bytes) { + Ok(v) => return Ok(v), + Err(_) if !is_json => continue, // try next priority + Err(e) => return Err(e.into()), + } + } + Err(ParSigExCodecError::UnsupportedDutyType) +} +``` + +The duty-handling for `BuilderProposer` (deprecated) and +`Unknown`/`InfoSync`/`DutySentinel(_)` stays as explicit early-returns before +the table walk. The match on `DutyType` remains exhaustive. + +## Migration steps + +1. Add `crates/core/src/marshal.rs` with the trait, error, helper, and macro. + Macro stub first; flesh out per-entry expansion incrementally. +2. Make `SignedData: Marshal`. +3. Add adapter helpers in `signeddata.rs` for wrappers whose `ssz_codec` + helpers take the inner field (`encode_phase0_attestation` etc.). One-line + each. +4. Add `sample_*` fns near the existing test fixtures in `signeddata.rs` (or + a new `signeddata::samples` module) so they're reachable both by the + generated tests and by callers who already write hand tests. +5. Invoke `register_signed_data_codecs! { ... }` once, listing every + `SignedData` type. +6. Replace `parsigex_codec::serialize_signed_data` / `deserialize_signed_data` + with the thin wrappers shown above. Keep the existing public signatures so + no caller changes. +7. Delete the now-redundant hand round-trip tests in + `crates/core/src/parsigex_codec.rs` that the generated tests subsume — + keep the Go-fixture tests in `ssz_codec.rs` untouched. +8. Run the full workspace gates from `AGENTS.md`. + +## Quality gates + +- `cargo +nightly fmt --all --check` +- `cargo clippy --workspace --all-targets --all-features -- -D warnings` +- `cargo test --workspace --all-features` — generated round-trip, + json-fallback, and duty-dispatch tests must all pass. +- Existing Go-fixture tests in `ssz_codec.rs` must pass byte-for-byte. +- `cargo deny check` + +## Open decisions + +1. **Trait module path.** `pluto_core::marshal::{Marshal, MarshalError}` vs. + re-export at `pluto_core::Marshal`. I lean on a `marshal` submodule with a + re-export at the crate root for the trait and error types only. +2. **Sample functions** live where? Options: + - Inline in `signeddata.rs` next to the `register_*!` invocation + (everything in one file, ~50 small fns). + - In `crates/core/src/signeddata/samples.rs` to keep `signeddata.rs` + readable. + I lean toward option 2 since the file is already large. +3. **Sealed-trait extra check.** Should we go further and seal `SignedData` so + that adding a new struct that *implements* `SignedData` outside the macro + table is impossible? It's an extra layer of "no one can forget"; downside + is mild boilerplate. Worth doing if the team is OK with it. +4. **Sample-fn signature.** `fn() -> Self` (simple) vs. + `fn(seed: u64) -> Self` (lets the generated test do + property-style multi-sample round-trips). I'd start with `fn() -> Self` and + add a second seeded sample only if it pulls its weight. + +## Out of scope + +- Replacing the SSZ codec helpers in `ssz_codec.rs`. Charon-versioned-header + logic stays as bespoke functions; the macro just delegates to them. +- Changing the wire format. Behavior is byte-for-byte identical to today. +- Touching the proto-level `parsigex` codec — this plan only refactors the + in-memory `SignedData` ↔ bytes step. +- Reusing `Marshal` outside `SignedData`. The trait lives in `pluto-core`; + if a second user appears later, the trait can be lifted into a + `pluto-marshal` crate at that point with no behavior change.