diff --git a/crates/eth2api/src/versioned.rs b/crates/eth2api/src/versioned.rs index 4d2f2547..e3d0562e 100644 --- a/crates/eth2api/src/versioned.rs +++ b/crates/eth2api/src/versioned.rs @@ -9,6 +9,22 @@ use crate::{ v1, }; +/// Graffiti string used to mark synthetic blocks that must never be submitted. +pub const SYNTHETIC_BLOCK_GRAFFITI: &str = "SYNTHETIC BLOCK: DO NOT SUBMIT"; + +/// 32-byte graffiti used to mark synthetic blocks, left-aligned with zero +/// padding. +pub const SYNTHETIC_GRAFFITI: phase0::Root = { + let mut graffiti = [0u8; 32]; + let src = SYNTHETIC_BLOCK_GRAFFITI.as_bytes(); + let mut i = 0; + while i < src.len() { + graffiti[i] = src[i]; + i += 1; + } + graffiti +}; + /// Signed proposal wrapper across all supported forks. #[derive(Debug, Clone, PartialEq, Eq)] pub struct VersionedSignedProposal { @@ -87,6 +103,24 @@ impl SignedProposalBlock { } } + /// Returns the graffiti embedded in this proposal's block body. + pub fn graffiti(&self) -> phase0::Root { + match self { + Self::Phase0(block) => block.message.body.graffiti, + Self::Altair(block) => block.message.body.graffiti, + Self::Bellatrix(block) => block.message.body.graffiti, + Self::BellatrixBlinded(block) => block.message.body.graffiti, + Self::Capella(block) => block.message.body.graffiti, + Self::CapellaBlinded(block) => block.message.body.graffiti, + Self::Deneb(block) => block.signed_block.message.body.graffiti, + Self::DenebBlinded(block) => block.message.body.graffiti, + Self::Electra(block) => block.signed_block.message.body.graffiti, + Self::ElectraBlinded(block) => block.message.body.graffiti, + Self::Fulu(block) => block.signed_block.message.body.graffiti, + Self::FuluBlinded(block) => block.message.body.graffiti, + } + } + /// Converts blinded payload variants into blinded-wrapper payloads. pub fn into_blinded(self) -> Option { match self { @@ -106,6 +140,18 @@ impl SignedProposalBlock { } } +impl VersionedSignedProposal { + /// Returns `true` if this is a synthetic proposal, i.e. its block body + /// graffiti matches [`SYNTHETIC_GRAFFITI`]. + /// + /// Unifies Go's separate blinded/full checks: the payload enum already + /// carries both blinded and full variants, so a single graffiti comparison + /// covers every case. + pub fn is_synthetic(&self) -> bool { + self.block.graffiti() == SYNTHETIC_GRAFFITI + } +} + /// Signed blinded proposal wrapper across all supported forks. #[derive(Debug, Clone, PartialEq, Eq)] pub struct VersionedSignedBlindedProposal { @@ -401,6 +447,14 @@ mod tests { use super::*; use crate::test_fixtures; + #[test] + fn synthetic_graffiti_layout() { + let marker = SYNTHETIC_BLOCK_GRAFFITI.as_bytes(); + assert_eq!(&SYNTHETIC_GRAFFITI[..marker.len()], marker); + // Remaining bytes are zero-padded. + assert!(SYNTHETIC_GRAFFITI[marker.len()..].iter().all(|&b| b == 0)); + } + #[test] fn versioned_signed_aggregate_and_proof_message_root_delegates_to_payload() { let signed = electra::SignedAggregateAndProof { diff --git a/crates/ssz/src/lib.rs b/crates/ssz/src/lib.rs index 18294464..35a3b0fb 100644 --- a/crates/ssz/src/lib.rs +++ b/crates/ssz/src/lib.rs @@ -17,7 +17,7 @@ pub use helpers::{ from_0x_hex_str, left_pad, put_byte_list, put_bytes_n, put_hex_bytes_n, to_0x_hex, }; /// Generic SSZ list, vector, and bitfield wrappers. -pub use types::{BitList, BitVector, SszList, SszVector}; +pub use types::{BitList, BitVector, BitfieldError, SszList, SszVector}; /// Error type for SSZ binary encode/decode operations. #[derive(Debug, thiserror::Error)] diff --git a/crates/ssz/src/types.rs b/crates/ssz/src/types.rs index 217d0869..006d6dae 100644 --- a/crates/ssz/src/types.rs +++ b/crates/ssz/src/types.rs @@ -253,6 +253,15 @@ impl TreeHash for SszVector { const BIT_MASK: [u8; 8] = [0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80]; +/// Error returned by bitfield combinators that require operands of equal +/// length. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum BitfieldError { + /// The two bitlists have different bit lengths and cannot be combined. + #[error("bitlists are different lengths")] + DifferentLength, +} + /// SSZ variable-length bitfield with maximum capacity. #[derive(Debug, Clone, PartialEq, Eq)] pub struct BitList { @@ -344,6 +353,66 @@ impl BitList { len: capacity, } } + + /// Returns the bit at index `i`, or `false` if `i` is out of range. + pub fn bit_at(&self, i: usize) -> bool { + if i >= self.len { + return false; + } + self.bytes[i / 8] & BIT_MASK[i % 8] != 0 + } + + /// Sets the bit at index `i` to `value`; out-of-range indices are ignored. + pub fn set_bit_at(&mut self, i: usize, value: bool) { + if i >= self.len { + return; + } + if value { + self.bytes[i / 8] |= BIT_MASK[i % 8]; + } else { + self.bytes[i / 8] &= !BIT_MASK[i % 8]; + } + } + + /// Returns the indices of all set bits in ascending order. + pub fn bit_indices(&self) -> Vec { + (0..self.len).filter(|&i| self.bit_at(i)).collect() + } + + /// Returns `true` if every bit set in `other` is also set in `self`. + /// + /// Errors with [`BitfieldError::DifferentLength`] if the two bitlists do + /// not have the same bit length. + pub fn contains(&self, other: &Self) -> Result { + if self.len != other.len { + return Err(BitfieldError::DifferentLength); + } + Ok(other + .bytes + .iter() + .zip(&self.bytes) + .all(|(o, s)| o & s == *o)) + } + + /// Returns the bitwise OR (union) of `self` and `other`. + /// + /// Errors with [`BitfieldError::DifferentLength`] if the two bitlists do + /// not have the same bit length. + pub fn or(&self, other: &Self) -> Result { + if self.len != other.len { + return Err(BitfieldError::DifferentLength); + } + let bytes = self + .bytes + .iter() + .zip(&other.bytes) + .map(|(a, b)| a | b) + .collect(); + Ok(Self { + bytes, + len: self.len, + }) + } } impl Serialize for BitList { @@ -433,6 +502,31 @@ impl BitVector { } v } + + /// Returns the bit at index `i`, or `false` if `i` is out of range. + pub fn bit_at(&self, i: usize) -> bool { + if i >= SIZE { + return false; + } + self.bytes[i / 8] & BIT_MASK[i % 8] != 0 + } + + /// Sets the bit at index `i` to `value`; out-of-range indices are ignored. + pub fn set_bit_at(&mut self, i: usize, value: bool) { + if i >= SIZE { + return; + } + if value { + self.bytes[i / 8] |= BIT_MASK[i % 8]; + } else { + self.bytes[i / 8] &= !BIT_MASK[i % 8]; + } + } + + /// Returns the indices of all set bits in ascending order. + pub fn bit_indices(&self) -> Vec { + (0..SIZE).filter(|&i| self.bit_at(i)).collect() + } } impl Serialize for BitVector { @@ -560,4 +654,77 @@ mod tests { let vec: SszVector = vec![1, 2, 3].into(); assert_eq!(vec.as_ref(), &[1, 2, 3]); } + + #[test] + fn bitlist_bit_at_and_indices() { + let bl = BitList::<2048>::with_bits(3, &[0, 2]); + assert!(bl.bit_at(0)); + assert!(!bl.bit_at(1)); + assert!(bl.bit_at(2)); + // Out-of-range index reads as unset. + assert!(!bl.bit_at(3)); + assert!(!bl.bit_at(9001)); + assert_eq!(bl.bit_indices(), vec![0, 2]); + } + + #[test] + fn bitlist_bit_at_matches_ssz_round_trip() { + // SSZ byte 0x0D = sentinel at bit 3 ⇒ 3 data bits with bits 0 and 2 set, + // matching the bytes returned by `aggregation_bits()`. + let bl = BitList::<2048>::from_ssz_bytes(vec![0x0D]); + assert_eq!(bl.len(), 3); + assert_eq!(bl.bit_indices(), vec![0, 2]); + } + + #[test] + fn bitlist_set_bit_at() { + let mut bl = BitList::<2048>::with_bits(8, &[0]); + bl.set_bit_at(3, true); + assert_eq!(bl.bit_indices(), vec![0, 3]); + bl.set_bit_at(0, false); + assert_eq!(bl.bit_indices(), vec![3]); + // Out-of-range set is a no-op. + bl.set_bit_at(8, true); + assert_eq!(bl.bit_indices(), vec![3]); + } + + #[test] + fn bitlist_contains() { + let superset = BitList::<2048>::with_bits(4, &[0, 1, 2]); + let subset = BitList::<2048>::with_bits(4, &[0, 2]); + assert_eq!(superset.contains(&subset), Ok(true)); + assert_eq!(subset.contains(&superset), Ok(false)); + + let other_len = BitList::<2048>::with_bits(8, &[0]); + assert_eq!( + superset.contains(&other_len), + Err(BitfieldError::DifferentLength) + ); + } + + #[test] + fn bitlist_or() { + let a = BitList::<2048>::with_bits(4, &[0]); + let b = BitList::<2048>::with_bits(4, &[1, 3]); + assert_eq!(a.or(&b).unwrap().bit_indices(), vec![0, 1, 3]); + + let other_len = BitList::<2048>::with_bits(8, &[0]); + assert_eq!(a.or(&other_len), Err(BitfieldError::DifferentLength)); + } + + #[test] + fn bitvector_bit_ops() { + let mut bv = BitVector::<64>::with_bits(&[0, 2]); + assert!(bv.bit_at(0)); + assert!(!bv.bit_at(1)); + assert_eq!(bv.bit_indices(), vec![0, 2]); + + bv.set_bit_at(1, true); + assert_eq!(bv.bit_indices(), vec![0, 1, 2]); + + // Out-of-range access is a no-op / reads as unset. + bv.set_bit_at(64, true); + assert!(!bv.bit_at(64)); + assert_eq!(bv.bit_indices(), vec![0, 1, 2]); + } }