Add eip_3076 crate (#8206)

#7894


  Moves the `Interchange` format from `slashing_protection` and thus removes the dependency on `slashing_protection` from `eth2` which can now just depend on the slimmer `eip_3076` crate.


Co-Authored-By: Mac L <mjladson@pm.me>
This commit is contained in:
Mac L
2025-10-16 20:10:42 +04:00
committed by GitHub
parent d1e06dc40d
commit f13d0615fd
12 changed files with 155 additions and 18 deletions

View File

@@ -0,0 +1,20 @@
[package]
name = "eip_3076"
version = "0.1.0"
authors = ["Sigma Prime <contact@sigmaprime.io>"]
edition = { workspace = true }
[features]
default = []
arbitrary-fuzz = ["dep:arbitrary", "types/arbitrary"]
json = ["dep:serde_json"]
[dependencies]
arbitrary = { workspace = true, features = ["derive"], optional = true }
ethereum_serde_utils = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true, optional = true }
types = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }

260
common/eip_3076/src/lib.rs Normal file
View File

@@ -0,0 +1,260 @@
use serde::{Deserialize, Serialize};
use std::cmp::max;
use std::collections::{HashMap, HashSet};
#[cfg(feature = "json")]
use std::io;
use types::{Epoch, Hash256, PublicKeyBytes, Slot};
#[derive(Debug)]
pub enum Error {
MaxInconsistent,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
#[cfg_attr(feature = "arbitrary-fuzz", derive(arbitrary::Arbitrary))]
pub struct InterchangeMetadata {
#[serde(with = "serde_utils::quoted_u64::require_quotes")]
pub interchange_format_version: u64,
pub genesis_validators_root: Hash256,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
#[cfg_attr(feature = "arbitrary-fuzz", derive(arbitrary::Arbitrary))]
pub struct InterchangeData {
pub pubkey: PublicKeyBytes,
pub signed_blocks: Vec<SignedBlock>,
pub signed_attestations: Vec<SignedAttestation>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
#[cfg_attr(feature = "arbitrary-fuzz", derive(arbitrary::Arbitrary))]
pub struct SignedBlock {
#[serde(with = "serde_utils::quoted_u64::require_quotes")]
pub slot: Slot,
#[serde(skip_serializing_if = "Option::is_none")]
pub signing_root: Option<Hash256>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
#[cfg_attr(feature = "arbitrary-fuzz", derive(arbitrary::Arbitrary))]
pub struct SignedAttestation {
#[serde(with = "serde_utils::quoted_u64::require_quotes")]
pub source_epoch: Epoch,
#[serde(with = "serde_utils::quoted_u64::require_quotes")]
pub target_epoch: Epoch,
#[serde(skip_serializing_if = "Option::is_none")]
pub signing_root: Option<Hash256>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
#[cfg_attr(feature = "arbitrary-fuzz", derive(arbitrary::Arbitrary))]
pub struct Interchange {
pub metadata: InterchangeMetadata,
pub data: Vec<InterchangeData>,
}
impl Interchange {
#[cfg(feature = "json")]
pub fn from_json_str(json: &str) -> Result<Self, serde_json::Error> {
serde_json::from_str(json)
}
#[cfg(feature = "json")]
pub fn from_json_reader(mut reader: impl std::io::Read) -> Result<Self, io::Error> {
// We read the entire file into memory first, as this is *a lot* faster than using
// `serde_json::from_reader`. See https://github.com/serde-rs/json/issues/160
let mut json_str = String::new();
reader.read_to_string(&mut json_str)?;
Ok(Interchange::from_json_str(&json_str)?)
}
#[cfg(feature = "json")]
pub fn write_to(&self, writer: impl std::io::Write) -> Result<(), serde_json::Error> {
serde_json::to_writer(writer, self)
}
/// Do these two `Interchange`s contain the same data (ignoring ordering)?
pub fn equiv(&self, other: &Self) -> bool {
let self_set = self.data.iter().collect::<HashSet<_>>();
let other_set = other.data.iter().collect::<HashSet<_>>();
self.metadata == other.metadata && self_set == other_set
}
/// The number of entries in `data`.
pub fn len(&self) -> usize {
self.data.len()
}
/// Is the `data` part of the interchange completely empty?
pub fn is_empty(&self) -> bool {
self.len() == 0
}
/// Minify an interchange by constructing a synthetic block & attestation for each validator.
pub fn minify(&self) -> Result<Self, Error> {
// Map from pubkey to optional max block and max attestation.
let mut validator_data =
HashMap::<PublicKeyBytes, (Option<SignedBlock>, Option<SignedAttestation>)>::new();
for data in self.data.iter() {
// Existing maximum attestation and maximum block.
let (max_block, max_attestation) = validator_data
.entry(data.pubkey)
.or_insert_with(|| (None, None));
// Find maximum source and target epochs.
let max_source_epoch = data
.signed_attestations
.iter()
.map(|attestation| attestation.source_epoch)
.max();
let max_target_epoch = data
.signed_attestations
.iter()
.map(|attestation| attestation.target_epoch)
.max();
match (max_source_epoch, max_target_epoch) {
(Some(source_epoch), Some(target_epoch)) => {
if let Some(prev_max) = max_attestation {
prev_max.source_epoch = max(prev_max.source_epoch, source_epoch);
prev_max.target_epoch = max(prev_max.target_epoch, target_epoch);
} else {
*max_attestation = Some(SignedAttestation {
source_epoch,
target_epoch,
signing_root: None,
});
}
}
(None, None) => {}
_ => return Err(Error::MaxInconsistent),
};
// Find maximum block slot.
let max_block_slot = data.signed_blocks.iter().map(|block| block.slot).max();
if let Some(max_slot) = max_block_slot {
if let Some(prev_max) = max_block {
prev_max.slot = max(prev_max.slot, max_slot);
} else {
*max_block = Some(SignedBlock {
slot: max_slot,
signing_root: None,
});
}
}
}
let data = validator_data
.into_iter()
.map(|(pubkey, (maybe_block, maybe_att))| InterchangeData {
pubkey,
signed_blocks: maybe_block.into_iter().collect(),
signed_attestations: maybe_att.into_iter().collect(),
})
.collect();
Ok(Self {
metadata: self.metadata.clone(),
data,
})
}
}
#[cfg(feature = "json")]
#[cfg(test)]
mod tests {
use super::*;
use std::fs::File;
use tempfile::tempdir;
use types::FixedBytesExtended;
fn get_interchange() -> Interchange {
Interchange {
metadata: InterchangeMetadata {
interchange_format_version: 5,
genesis_validators_root: Hash256::from_low_u64_be(555),
},
data: vec![
InterchangeData {
pubkey: PublicKeyBytes::deserialize(&[1u8; 48]).unwrap(),
signed_blocks: vec![SignedBlock {
slot: Slot::new(100),
signing_root: Some(Hash256::from_low_u64_be(1)),
}],
signed_attestations: vec![SignedAttestation {
source_epoch: Epoch::new(0),
target_epoch: Epoch::new(5),
signing_root: Some(Hash256::from_low_u64_be(2)),
}],
},
InterchangeData {
pubkey: PublicKeyBytes::deserialize(&[2u8; 48]).unwrap(),
signed_blocks: vec![],
signed_attestations: vec![],
},
],
}
}
#[test]
fn test_roundtrip() {
let temp_dir = tempdir().unwrap();
let file_path = temp_dir.path().join("interchange.json");
let interchange = get_interchange();
let mut file = File::create(&file_path).unwrap();
interchange.write_to(&mut file).unwrap();
let file = File::open(&file_path).unwrap();
let from_file = Interchange::from_json_reader(file).unwrap();
assert_eq!(interchange, from_file);
}
#[test]
fn test_empty_roundtrip() {
let temp_dir = tempdir().unwrap();
let file_path = temp_dir.path().join("empty.json");
let empty = Interchange {
metadata: InterchangeMetadata {
interchange_format_version: 5,
genesis_validators_root: Hash256::zero(),
},
data: vec![],
};
let mut file = File::create(&file_path).unwrap();
empty.write_to(&mut file).unwrap();
let file = File::open(&file_path).unwrap();
let from_file = Interchange::from_json_reader(file).unwrap();
assert_eq!(empty, from_file);
}
#[test]
fn test_minify_roundtrip() {
let interchange = get_interchange();
let minified = interchange.minify().unwrap();
let temp_dir = tempdir().unwrap();
let file_path = temp_dir.path().join("minified.json");
let mut file = File::create(&file_path).unwrap();
minified.write_to(&mut file).unwrap();
let file = File::open(&file_path).unwrap();
let from_file = Interchange::from_json_reader(file).unwrap();
assert_eq!(minified, from_file);
}
}

View File

@@ -10,6 +10,7 @@ lighthouse = []
[dependencies]
derivative = { workspace = true }
eip_3076 = { workspace = true }
either = { workspace = true }
enr = { version = "0.13.0", features = ["ed25519"] }
eth2_keystore = { workspace = true }
@@ -29,7 +30,6 @@ reqwest-eventsource = "0.5.0"
sensitive_url = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
slashing_protection = { workspace = true }
ssz_types = { workspace = true }
test_random_derive = { path = "../../common/test_random_derive" }
types = { workspace = true }

View File

@@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize};
use types::{Address, Graffiti, PublicKeyBytes};
use zeroize::Zeroizing;
pub use slashing_protection::interchange::Interchange;
pub use eip_3076::Interchange;
#[derive(Debug, Deserialize, Serialize, PartialEq)]
pub struct GetFeeRecipientResponse {