From 9e545472194e2d7b3f4cb5532fa6d0dde85959ff Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Wed, 13 May 2026 15:42:43 +0200 Subject: [PATCH 01/47] Dutydb first attempt --- crates/core/src/dutydb/memory.rs | 1025 ++++++++++++++++++++++++++++++ crates/core/src/dutydb/mod.rs | 7 + crates/core/src/lib.rs | 3 + crates/core/src/signeddata.rs | 157 ++++- 4 files changed, 1191 insertions(+), 1 deletion(-) create mode 100644 crates/core/src/dutydb/memory.rs create mode 100644 crates/core/src/dutydb/mod.rs diff --git a/crates/core/src/dutydb/memory.rs b/crates/core/src/dutydb/memory.rs new file mode 100644 index 00000000..5147ac69 --- /dev/null +++ b/crates/core/src/dutydb/memory.rs @@ -0,0 +1,1025 @@ +//! In-memory DutyDB implementation. +//! +//! Equivalent to charon/core/dutydb/memory.go. + +use std::{collections::HashMap, sync::Arc}; + +use pluto_eth2api::{spec::phase0, versioned}; +use tokio::sync::{Mutex, Notify}; +use tokio_util::sync::CancellationToken; +use tree_hash::TreeHash; + +use crate::{ + deadline::Deadliner, + signeddata::{ + AttestationData, SyncContribution, VersionedAggregatedAttestation, VersionedProposal, + }, + types::{Duty, DutyType, PubKey}, +}; + +/// Error type for DutyDB operations. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// Duty has already expired; data not stored. + #[error("not storing unsigned data for expired duty")] + ExpiredDuty, + + /// Proposer data set must contain at most one entry. + #[error("unexpected proposer data set length")] + UnexpectedProposerSetLength, + + /// DutyBuilderProposer is no longer supported. + #[error("deprecated duty DutyBuilderProposer")] + DeprecatedDutyBuilderProposer, + + /// Duty type is not stored by DutyDB. + #[error("unsupported duty type")] + UnsupportedDutyType, + + /// DB was shut down before the query could be answered. + #[error("dutydb shutdown")] + Shutdown, + + /// Two validators mapped to the same (slot, commIdx, valIdx) with different + /// public keys. + #[error("clashing public key")] + ClashingPublicKey, + + /// Two different attestation data objects for the same (slot, commIdx). + #[error("clashing attestation data")] + ClashingAttestationData, + + /// Mismatched source checkpoint when storing commIdx=0 compatibility entry. + #[error("clashing attestation data with hardcoded commidx=0 source")] + ClashingAttestationDataCommIdx0Source, + + /// Mismatched target checkpoint when storing commIdx=0 compatibility entry. + #[error("clashing attestation data with hardcoded commidx=0 target")] + ClashingAttestationDataCommIdx0Target, + + /// Two different aggregated attestations for the same slot+root key. + #[error("clashing data root")] + ClashingDataRoot, + + /// Two different sync contributions for the same (slot, subcommIdx, root). + #[error("clashing sync contributions")] + ClashingSyncContributions, + + /// Two different blocks for the same slot. + #[error("clashing blocks")] + ClashingBlocks, + + /// No public key found for the given (slot, commIdx, valIdx). + #[error("pubkey not found")] + PubKeyNotFound, + + /// Duty type is not handled by deleteDutyUnsafe. + #[error("unknown duty type")] + UnknownDutyType, + + /// The unsigned data provided does not match the expected type for + /// DutyProposer. + #[error("invalid versioned proposal")] + InvalidVersionedProposal, + + /// The unsigned data provided does not match the expected type for + /// DutyAttester. + #[error("invalid unsigned attestation data")] + InvalidAttestationData, + + /// The unsigned data provided does not match the expected type for + /// DutyAggregator. + #[error("invalid unsigned aggregated attestation")] + InvalidAggregatedAttestation, + + /// The unsigned data provided does not match the expected type for + /// DutySyncContribution. + #[error("invalid unsigned sync committee contribution")] + InvalidSyncContribution, +} + +/// Result type for DutyDB operations. +pub type Result = std::result::Result; + +/// Unsigned duty data variant — matches Go's `core.UnsignedData` interface. +#[derive(Debug, Clone)] +pub enum UnsignedDutyData { + /// Unsigned proposal (DutyProposer). + Proposal(Box), + /// Unsigned attestation data (DutyAttester). + Attestation(AttestationData), + /// Unsigned aggregated attestation (DutyAggregator). + AggAttestation(VersionedAggregatedAttestation), + /// Unsigned sync contribution (DutySyncContribution). + SyncContribution(SyncContribution), +} + +/// Map from public key to unsigned duty data, equivalent to Go's +/// `core.UnsignedDataSet`. +pub type UnsignedDataSet = HashMap; + +/// Lookup key for attestation data: (slot, committee index). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct AttKey { + slot: u64, + comm_idx: u64, +} + +/// Lookup key for public-key-by-attestation: (slot, committee index, validator +/// index). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct PkKey { + slot: u64, + comm_idx: u64, + val_idx: u64, +} + +/// Lookup key for aggregated attestations: (slot, attestation data root). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct AggKey { + slot: u64, + root: phase0::Root, +} + +/// Lookup key for sync contributions: (slot, subcommittee index, beacon block +/// root). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct ContribKey { + slot: u64, + subcomm_idx: u64, + root: phase0::Root, +} + +struct State { + att_duties: HashMap, + att_pub_keys: HashMap, + att_keys_by_slot: HashMap>, + + pro_duties: HashMap, + + agg_duties: HashMap, + agg_keys_by_slot: HashMap>, + + contrib_duties: HashMap, + contrib_keys_by_slot: HashMap>, + + deadliner_rx: Option>, +} + +use pluto_eth2api::spec::altair; + +/// In-memory DutyDB. +/// +/// Equivalent to charon's `MemDB`. Stores unsigned duty data and answers +/// blocking `await_*` queries when the relevant data becomes available. +pub struct MemDB { + state: Mutex, + att_notify: Arc, + pro_notify: Arc, + agg_notify: Arc, + contrib_notify: Arc, + cancel: CancellationToken, + deadliner: Arc, +} + +impl MemDB { + /// Creates a new in-memory DutyDB. + pub fn new(deadliner: Arc, cancel: CancellationToken) -> Self { + let deadliner_rx = deadliner.c(); + Self { + state: Mutex::new(State { + att_duties: HashMap::new(), + att_pub_keys: HashMap::new(), + att_keys_by_slot: HashMap::new(), + pro_duties: HashMap::new(), + agg_duties: HashMap::new(), + agg_keys_by_slot: HashMap::new(), + contrib_duties: HashMap::new(), + contrib_keys_by_slot: HashMap::new(), + deadliner_rx, + }), + att_notify: Arc::new(Notify::new()), + pro_notify: Arc::new(Notify::new()), + agg_notify: Arc::new(Notify::new()), + contrib_notify: Arc::new(Notify::new()), + cancel, + deadliner, + } + } + + /// Shuts down the DB, causing all pending `await_*` calls to return an + /// error. + pub fn shutdown(&self) { + self.cancel.cancel(); + } + + /// Stores unsigned duty data for the given duty, waking any pending + /// waiters. + pub async fn store(&self, duty: Duty, unsigned_set: UnsignedDataSet) -> Result<()> { + let mut state = self.state.lock().await; + + if !self.deadliner.add(duty.clone()).await { + return Err(Error::ExpiredDuty); + } + + match duty.duty_type { + DutyType::Proposer => { + if unsigned_set.len() > 1 { + return Err(Error::UnexpectedProposerSetLength); + } + for data in unsigned_set.values() { + let proposal = match data { + UnsignedDutyData::Proposal(p) => p.as_ref(), + _ => return Err(Error::InvalidVersionedProposal), + }; + store_proposal(&mut state, proposal)?; + } + self.pro_notify.notify_waiters(); + } + DutyType::BuilderProposer => return Err(Error::DeprecatedDutyBuilderProposer), + DutyType::Attester => { + for (pubkey, data) in &unsigned_set { + let att = match data { + UnsignedDutyData::Attestation(a) => a, + _ => return Err(Error::InvalidAttestationData), + }; + store_attestation(&mut state, *pubkey, att)?; + } + self.att_notify.notify_waiters(); + } + DutyType::Aggregator => { + for data in unsigned_set.values() { + let agg = match data { + UnsignedDutyData::AggAttestation(a) => a, + _ => return Err(Error::InvalidAggregatedAttestation), + }; + store_agg_attestation(&mut state, agg)?; + } + self.agg_notify.notify_waiters(); + } + DutyType::SyncContribution => { + for data in unsigned_set.values() { + let contrib = match data { + UnsignedDutyData::SyncContribution(c) => c, + _ => return Err(Error::InvalidSyncContribution), + }; + store_sync_contribution(&mut state, contrib)?; + } + self.contrib_notify.notify_waiters(); + } + _ => return Err(Error::UnsupportedDutyType), + } + + // Drain all expired duties that the deadliner has sent. + loop { + let expired = match state.deadliner_rx { + Some(ref mut rx) => match rx.try_recv() { + Ok(d) => d, + Err(_) => break, + }, + None => break, + }; + delete_duty(&mut state, expired)?; + } + + Ok(()) + } + + /// Blocks until a proposal for the given slot is available, then returns + /// it. + pub async fn await_proposal(&self, slot: u64) -> Result { + loop { + let notified = self.pro_notify.notified(); + tokio::pin!(notified); + notified.as_mut().enable(); + + { + let state = self.state.lock().await; + if let Some(p) = state.pro_duties.get(&slot) { + return Ok(p.clone()); + } + } + + tokio::select! { + biased; + _ = self.cancel.cancelled() => return Err(Error::Shutdown), + _ = &mut notified => {} + } + } + } + + /// Blocks until attestation data for the given slot and committee index is + /// available. + pub async fn await_attestation( + &self, + slot: u64, + comm_idx: u64, + ) -> Result { + let key = AttKey { slot, comm_idx }; + loop { + let notified = self.att_notify.notified(); + tokio::pin!(notified); + notified.as_mut().enable(); + + { + let state = self.state.lock().await; + if let Some(data) = state.att_duties.get(&key) { + return Ok(data.clone()); + } + } + + tokio::select! { + biased; + _ = self.cancel.cancelled() => return Err(Error::Shutdown), + _ = &mut notified => {} + } + } + } + + /// Blocks until an aggregated attestation for the given slot and + /// attestation root is available. + pub async fn await_agg_attestation( + &self, + slot: u64, + attestation_root: phase0::Root, + ) -> Result { + let key = AggKey { + slot, + root: attestation_root, + }; + loop { + let notified = self.agg_notify.notified(); + tokio::pin!(notified); + notified.as_mut().enable(); + + { + let state = self.state.lock().await; + if let Some(agg) = state.agg_duties.get(&key) { + return Ok(agg.0.clone()); + } + } + + tokio::select! { + biased; + _ = self.cancel.cancelled() => return Err(Error::Shutdown), + _ = &mut notified => {} + } + } + } + + /// Blocks until a sync contribution for the given slot, subcommittee index, + /// and beacon block root is available. + pub async fn await_sync_contribution( + &self, + slot: u64, + subcomm_idx: u64, + beacon_block_root: phase0::Root, + ) -> Result { + let key = ContribKey { + slot, + subcomm_idx, + root: beacon_block_root, + }; + loop { + let notified = self.contrib_notify.notified(); + tokio::pin!(notified); + notified.as_mut().enable(); + + { + let state = self.state.lock().await; + if let Some(c) = state.contrib_duties.get(&key) { + return Ok(c.clone()); + } + } + + tokio::select! { + biased; + _ = self.cancel.cancelled() => return Err(Error::Shutdown), + _ = &mut notified => {} + } + } + } + + /// Returns the public key of the validator that attested for the given + /// slot, committee index, and validator index. + pub async fn pub_key_by_attestation( + &self, + slot: u64, + comm_idx: u64, + val_idx: u64, + ) -> Result { + let state = self.state.lock().await; + state + .att_pub_keys + .get(&PkKey { + slot, + comm_idx, + val_idx, + }) + .copied() + .ok_or(Error::PubKeyNotFound) + } +} + +fn store_proposal(state: &mut State, proposal: &VersionedProposal) -> Result<()> { + let slot = proposal.slot(); + if let Some(existing) = state.pro_duties.get(&slot) { + if existing.root() != proposal.root() { + return Err(Error::ClashingBlocks); + } + } else { + state.pro_duties.insert(slot, proposal.clone()); + } + Ok(()) +} + +fn store_attestation(state: &mut State, pubkey: PubKey, att: &AttestationData) -> Result<()> { + let slot = att.data.slot; + let duty_slot = att.duty.slot; + let comm_idx = att.duty.committee_index; + let val_idx = att.duty.validator_index; + + // Store pubkey mapping for PubKeyByAttestation (actual committee index). + let pk_key = PkKey { + slot, + comm_idx, + val_idx, + }; + if let Some(&existing) = state.att_pub_keys.get(&pk_key) { + if existing != pubkey { + return Err(Error::ClashingPublicKey); + } + } else { + state.att_pub_keys.insert(pk_key, pubkey); + state + .att_keys_by_slot + .entry(duty_slot) + .or_default() + .push(pk_key); + } + + // Store attestation data for AwaitAttestation (actual committee index). + let att_key = AttKey { slot, comm_idx }; + if let Some(existing) = state.att_duties.get(&att_key) { + if existing.source != att.data.source + || existing.target != att.data.target + || existing.beacon_block_root != att.data.beacon_block_root + { + return Err(Error::ClashingAttestationData); + } + } else { + state.att_duties.insert(att_key, att.data.clone()); + } + + // Also store with commIdx=0 for post-Electra VC compatibility. + // See: https://ethereum.github.io/beacon-APIs/#/Validator/produceAttestationData + let pk_key0 = PkKey { + slot, + comm_idx: 0, + val_idx, + }; + if let Some(&existing) = state.att_pub_keys.get(&pk_key0) { + if existing != pubkey { + return Err(Error::ClashingPublicKey); + } + } else { + state.att_pub_keys.insert(pk_key0, pubkey); + state + .att_keys_by_slot + .entry(duty_slot) + .or_default() + .push(pk_key0); + } + + let att_key0 = AttKey { slot, comm_idx: 0 }; + if let Some(existing) = state.att_duties.get(&att_key0) { + if existing.source != att.data.source { + return Err(Error::ClashingAttestationDataCommIdx0Source); + } + if existing.target != att.data.target { + return Err(Error::ClashingAttestationDataCommIdx0Target); + } + } else { + state.att_duties.insert(att_key0, att.data.clone()); + } + + Ok(()) +} + +fn store_agg_attestation(state: &mut State, agg: &VersionedAggregatedAttestation) -> Result<()> { + let att_data = agg.data().ok_or(Error::InvalidAggregatedAttestation)?; + let root = att_data.tree_hash_root().0; + let slot = att_data.slot; + + let key = AggKey { slot, root }; + if let Some(existing) = state.agg_duties.get(&key) { + let existing_data = existing.data().ok_or(Error::InvalidAggregatedAttestation)?; + if existing_data.tree_hash_root().0 != root { + return Err(Error::ClashingDataRoot); + } + // Update with provided value (same root, potentially updated aggregation bits). + state.agg_duties.insert(key, agg.clone()); + } else { + state.agg_duties.insert(key, agg.clone()); + state.agg_keys_by_slot.entry(slot).or_default().push(key); + } + + Ok(()) +} + +fn store_sync_contribution(state: &mut State, contrib: &SyncContribution) -> Result<()> { + let inner = &contrib.0; + let existing_root = inner.tree_hash_root().0; + + let key = ContribKey { + slot: inner.slot, + subcomm_idx: inner.subcommittee_index, + root: inner.beacon_block_root, + }; + + if let Some(existing) = state.contrib_duties.get(&key) { + if existing.tree_hash_root().0 != existing_root { + return Err(Error::ClashingSyncContributions); + } + } else { + state.contrib_duties.insert(key, inner.clone()); + state + .contrib_keys_by_slot + .entry(inner.slot) + .or_default() + .push(key); + } + + Ok(()) +} + +fn delete_duty(state: &mut State, duty: Duty) -> Result<()> { + let slot = duty.slot.inner(); + match duty.duty_type { + DutyType::Proposer => { + state.pro_duties.remove(&slot); + } + DutyType::BuilderProposer => return Err(Error::DeprecatedDutyBuilderProposer), + DutyType::Attester => { + if let Some(keys) = state.att_keys_by_slot.remove(&slot) { + for key in keys { + state.att_pub_keys.remove(&key); + state.att_duties.remove(&AttKey { + slot: key.slot, + comm_idx: key.comm_idx, + }); + } + } + } + DutyType::Aggregator => { + if let Some(keys) = state.agg_keys_by_slot.remove(&slot) { + for key in keys { + state.agg_duties.remove(&key); + } + } + } + DutyType::SyncContribution => { + if let Some(keys) = state.contrib_keys_by_slot.remove(&slot) { + for key in keys { + state.contrib_duties.remove(&key); + } + } + } + _ => return Err(Error::UnknownDutyType), + } + Ok(()) +} + +#[cfg(test)] +pub(crate) mod tests { + use std::sync::Arc; + + use async_trait::async_trait; + use tokio_util::sync::CancellationToken; + + use super::*; + use crate::{ + signeddata::{AttesterDuty, ProposalBlock}, + testutils::random_core_pub_key, + types::{DutyType, SlotNumber}, + }; + + /// Deadliner that always accepts duties and never expires them. + pub(crate) struct NoopDeadliner; + + #[async_trait] + impl Deadliner for NoopDeadliner { + async fn add(&self, _duty: Duty) -> bool { + true + } + + fn c(&self) -> Option> { + None + } + } + + /// Deadliner that collects duties and can flush them to a channel on + /// demand. + pub(crate) struct TestDeadliner { + added: std::sync::Mutex>, + tx: tokio::sync::mpsc::Sender, + rx: std::sync::Mutex>>, + } + + impl TestDeadliner { + pub(crate) fn new() -> Arc { + let (tx, rx) = tokio::sync::mpsc::channel(64); + Arc::new(Self { + added: std::sync::Mutex::new(Vec::new()), + tx, + rx: std::sync::Mutex::new(Some(rx)), + }) + } + + /// Send all collected duties to the expiry channel. + pub(crate) async fn expire(&self) { + let duties: Vec = { + let mut added = self.added.lock().unwrap(); + std::mem::take(&mut *added) + }; + for duty in duties { + let _ = self.tx.send(duty).await; + } + } + } + + #[async_trait] + impl Deadliner for TestDeadliner { + async fn add(&self, duty: Duty) -> bool { + self.added.lock().unwrap().push(duty); + true + } + + fn c(&self) -> Option> { + self.rx.lock().unwrap().take() + } + } + + fn make_db() -> MemDB { + MemDB::new(Arc::new(NoopDeadliner), CancellationToken::new()) + } + + fn make_db_with_deadliner(deadliner: Arc) -> MemDB { + MemDB::new(deadliner, CancellationToken::new()) + } + + fn att_data(slot: u64, comm_idx: u64, val_idx: u64) -> AttestationData { + AttestationData { + data: phase0::AttestationData { + slot, + index: comm_idx, + beacon_block_root: [0u8; 32], + source: phase0::Checkpoint { + epoch: 0, + root: [0u8; 32], + }, + target: phase0::Checkpoint { + epoch: 0, + root: [0u8; 32], + }, + }, + duty: AttesterDuty { + slot, + validator_index: val_idx, + committee_index: comm_idx, + committee_length: 8, + committees_at_slot: 1, + validator_committee_index: val_idx, + }, + } + } + + fn phase0_proposal(slot: u64, proposer_index: u64) -> VersionedProposal { + use pluto_eth2api::spec::phase0 as p0; + + let block = p0::BeaconBlock { + slot, + proposer_index, + parent_root: [0u8; 32], + state_root: [0u8; 32], + body: p0::BeaconBlockBody { + randao_reveal: [0u8; 96], + eth1_data: p0::ETH1Data { + deposit_root: [0u8; 32], + deposit_count: 0, + block_hash: [0u8; 32], + }, + graffiti: [0u8; 32], + proposer_slashings: vec![].into(), + attester_slashings: vec![].into(), + attestations: vec![].into(), + deposits: vec![].into(), + voluntary_exits: vec![].into(), + }, + }; + VersionedProposal { + version: versioned::DataVersion::Phase0, + blinded: false, + block: ProposalBlock::Phase0(block), + } + } + + fn sync_contribution_fixture( + slot: u64, + subcomm_idx: u64, + root: phase0::Root, + ) -> SyncContribution { + SyncContribution(altair::SyncCommitteeContribution { + slot, + beacon_block_root: root, + subcommittee_index: subcomm_idx, + aggregation_bits: pluto_ssz::BitVector::default(), + signature: [0u8; 96], + }) + } + + fn random_root(seed: u8) -> phase0::Root { + [seed; 32] + } + + #[tokio::test] + async fn shutdown() { + let db = make_db(); + db.shutdown(); + + let err = db.await_proposal(999).await.unwrap_err(); + assert!( + err.to_string().contains("shutdown"), + "expected shutdown error, got: {err}" + ); + } + + #[tokio::test] + async fn mem_db() { + let db = make_db(); + + // Nothing in the DB yet. + assert!(db.pub_key_by_attestation(0, 0, 0).await.is_err()); + + const SLOT: u64 = 123; + const COMM_IDX: u64 = 456; + const V_IDX_A: u64 = 1; + const V_IDX_B: u64 = 2; + + let pk_a = random_core_pub_key(); + let pk_b = random_core_pub_key(); + + let duty = Duty::new(SlotNumber::new(SLOT), DutyType::Attester); + + let unsigned_a = att_data(SLOT, COMM_IDX, V_IDX_A); + let unsigned_b = att_data(SLOT, COMM_IDX, V_IDX_B); + + let mut set = UnsignedDataSet::new(); + set.insert(pk_a, UnsignedDutyData::Attestation(unsigned_a.clone())); + set.insert(pk_b, UnsignedDutyData::Attestation(unsigned_b.clone())); + + db.store(duty.clone(), set).await.unwrap(); + + // Idempotent re-store. + let mut set2 = UnsignedDataSet::new(); + set2.insert(pk_a, UnsignedDutyData::Attestation(unsigned_a.clone())); + db.store(duty, set2).await.unwrap(); + + let data = db.await_attestation(SLOT, COMM_IDX).await.unwrap(); + assert_eq!(data.slot, SLOT); + assert_eq!(data.index, COMM_IDX); + + let resolved_a = db + .pub_key_by_attestation(SLOT, COMM_IDX, V_IDX_A) + .await + .unwrap(); + assert_eq!(resolved_a, pk_a); + + let resolved_b = db + .pub_key_by_attestation(SLOT, COMM_IDX, V_IDX_B) + .await + .unwrap(); + assert_eq!(resolved_b, pk_b); + } + + #[tokio::test] + async fn mem_db_store_unsupported() { + let db = make_db(); + + let unsupported = [ + DutyType::Unknown, + DutyType::Signature, + DutyType::Exit, + DutyType::BuilderRegistration, + DutyType::Randao, + DutyType::PrepareAggregator, + DutyType::SyncMessage, + DutyType::PrepareSyncContribution, + DutyType::InfoSync, + ]; + + for duty_type in unsupported { + let duty_type_str = duty_type.to_string(); + let duty = Duty::new(SlotNumber::new(0), duty_type); + let err = db.store(duty, UnsignedDataSet::new()).await.unwrap_err(); + assert!( + err.to_string().contains("unsupported duty type"), + "expected unsupported duty type for {duty_type_str}, got: {err}" + ); + } + + let duty = Duty::new(SlotNumber::new(0), DutyType::BuilderProposer); + let err = db.store(duty, UnsignedDataSet::new()).await.unwrap_err(); + assert!( + matches!(err, Error::DeprecatedDutyBuilderProposer), + "expected DeprecatedDutyBuilderProposer, got: {err}" + ); + } + + #[tokio::test] + async fn mem_db_proposer() { + let db = Arc::new(make_db()); + let slots = [123u64, 456, 789]; + + let mut handles = Vec::new(); + for &slot in &slots { + let db = Arc::clone(&db); + handles.push(tokio::spawn(async move { db.await_proposal(slot).await })); + } + + for (i, &slot) in slots.iter().enumerate() { + let proposal = phase0_proposal(slot, u64::try_from(i).unwrap()); + let mut set = UnsignedDataSet::new(); + set.insert( + random_core_pub_key(), + UnsignedDutyData::Proposal(Box::new(proposal.clone())), + ); + db.store(Duty::new(SlotNumber::new(slot), DutyType::Proposer), set) + .await + .unwrap(); + } + + for handle in handles { + handle.await.unwrap().unwrap(); + } + } + + #[tokio::test] + async fn mem_db_sync_contribution() { + let db = Arc::new(make_db()); + + for i in 0..3u8 { + let slot = u64::from(i).saturating_add(100); + let subcomm_idx = u64::from(i); + let root = random_root(i); + + let contrib = sync_contribution_fixture(slot, subcomm_idx, root); + + let mut set = UnsignedDataSet::new(); + set.insert( + random_core_pub_key(), + UnsignedDutyData::SyncContribution(contrib.clone()), + ); + + db.store( + Duty::new(SlotNumber::new(slot), DutyType::SyncContribution), + set, + ) + .await + .unwrap(); + + let resp = db + .await_sync_contribution(slot, subcomm_idx, root) + .await + .unwrap(); + assert_eq!(resp.slot, slot); + assert_eq!(resp.subcommittee_index, subcomm_idx); + assert_eq!(resp.beacon_block_root, root); + } + } + + #[tokio::test] + async fn dutydb_shutdown() { + let db = make_db(); + db.shutdown(); + + let err = db + .await_sync_contribution(0, 0, [0u8; 32]) + .await + .unwrap_err(); + assert!(err.to_string().contains("shutdown")); + } + + #[tokio::test] + async fn clashing_sync_contributions() { + const SLOT: u64 = 123; + const SUBCOMM_IDX: u64 = 1; + let root = random_root(42); + + let db = make_db(); + let pubkey = random_core_pub_key(); + let duty = Duty::new(SlotNumber::new(SLOT), DutyType::SyncContribution); + + let contrib1 = sync_contribution_fixture(SLOT, SUBCOMM_IDX, root); + let mut contrib2 = sync_contribution_fixture(SLOT, SUBCOMM_IDX, root); + // Make them differ by changing the signature. + contrib2.0.signature = [1u8; 96]; + + let mut set1 = UnsignedDataSet::new(); + set1.insert(pubkey, UnsignedDutyData::SyncContribution(contrib1)); + db.store(duty.clone(), set1).await.unwrap(); + + let mut set2 = UnsignedDataSet::new(); + set2.insert(pubkey, UnsignedDutyData::SyncContribution(contrib2)); + let err = db.store(duty, set2).await.unwrap_err(); + assert!( + err.to_string().contains("clashing sync contributions"), + "got: {err}" + ); + } + + #[tokio::test] + async fn mem_db_clashing_blocks() { + const SLOT: u64 = 123; + let db = make_db(); + let pubkey = random_core_pub_key(); + let duty = Duty::new(SlotNumber::new(SLOT), DutyType::Proposer); + + let block1 = phase0_proposal(SLOT, 1); + let block2 = phase0_proposal(SLOT, 2); + + let mut set1 = UnsignedDataSet::new(); + set1.insert(pubkey, UnsignedDutyData::Proposal(Box::new(block1))); + db.store(duty.clone(), set1).await.unwrap(); + + let mut set2 = UnsignedDataSet::new(); + set2.insert(pubkey, UnsignedDutyData::Proposal(Box::new(block2))); + let err = db.store(duty, set2).await.unwrap_err(); + assert!(err.to_string().contains("clashing blocks"), "got: {err}"); + } + + #[tokio::test] + async fn mem_db_clash_proposer() { + const SLOT: u64 = 123; + let db = make_db(); + let pubkey = random_core_pub_key(); + let duty = Duty::new(SlotNumber::new(SLOT), DutyType::Proposer); + + let block = phase0_proposal(SLOT, 0); + + let mut set = UnsignedDataSet::new(); + set.insert(pubkey, UnsignedDutyData::Proposal(Box::new(block.clone()))); + db.store(duty.clone(), set.clone()).await.unwrap(); + + // Idempotent re-store. + db.store(duty.clone(), set).await.unwrap(); + + // Clashing block (different proposer index = different hash). + let block_b = phase0_proposal(SLOT, 99); + let mut set_b = UnsignedDataSet::new(); + set_b.insert(pubkey, UnsignedDutyData::Proposal(Box::new(block_b))); + let err = db.store(duty, set_b).await.unwrap_err(); + assert!(err.to_string().contains("clashing blocks"), "got: {err}"); + } + + #[tokio::test] + async fn duty_expiry() { + let deadliner = TestDeadliner::new(); + let db = make_db_with_deadliner(Arc::clone(&deadliner) as Arc); + + const SLOT: u64 = 123; + + let att = att_data(SLOT, 0, 0); + let mut set = UnsignedDataSet::new(); + set.insert( + random_core_pub_key(), + UnsignedDutyData::Attestation(att.clone()), + ); + db.store(Duty::new(SlotNumber::new(SLOT), DutyType::Attester), set) + .await + .unwrap(); + + // Should be findable now. + db.pub_key_by_attestation(SLOT, 0, 0).await.unwrap(); + + // Expire the duty. + deadliner.expire().await; + + // Trigger expiry processing by storing another duty. + let proposal = phase0_proposal(SLOT.saturating_add(1), 0); + let mut set2 = UnsignedDataSet::new(); + set2.insert( + random_core_pub_key(), + UnsignedDutyData::Proposal(Box::new(proposal)), + ); + db.store( + Duty::new(SlotNumber::new(SLOT.saturating_add(1)), DutyType::Proposer), + set2, + ) + .await + .unwrap(); + + // Should no longer be findable. + assert!(db.pub_key_by_attestation(SLOT, 0, 0).await.is_err()); + } +} diff --git a/crates/core/src/dutydb/mod.rs b/crates/core/src/dutydb/mod.rs new file mode 100644 index 00000000..601066f6 --- /dev/null +++ b/crates/core/src/dutydb/mod.rs @@ -0,0 +1,7 @@ +//! DutyDB — in-memory store for unsigned duty data. +//! +//! Equivalent to charon's `core/dutydb` package. + +pub mod memory; + +pub use memory::{Error, MemDB, Result, UnsignedDataSet, UnsignedDutyData}; diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 1076ade4..5b44a216 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -26,6 +26,9 @@ pub mod deadline; /// parsigdb pub mod parsigdb; +/// DutyDB — in-memory store for unsigned duty data. +pub mod dutydb; + 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 diff --git a/crates/core/src/signeddata.rs b/crates/core/src/signeddata.rs index 9c253c41..ce7cf374 100644 --- a/crates/core/src/signeddata.rs +++ b/crates/core/src/signeddata.rs @@ -5,7 +5,10 @@ use tree_hash::TreeHash; use base64::Engine as _; use pluto_eth2api::{ - spec::{altair, phase0, serde_legacy_builder_version, serde_legacy_data_version}, + spec::{ + altair, bellatrix, capella, deneb, electra, phase0, serde_legacy_builder_version, + serde_legacy_data_version, + }, v1, versioned, }; use pluto_eth2util::types::SignedEpoch; @@ -1115,6 +1118,158 @@ impl SignedSyncContributionAndProof { } } +/// Attester duty metadata associated with an attestation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AttesterDuty { + /// Slot for the duty. + pub slot: phase0::Slot, + /// Validator index. + pub validator_index: phase0::ValidatorIndex, + /// Committee index. + pub committee_index: u64, + /// Number of validators in the committee. + pub committee_length: u64, + /// Number of committees at this slot. + pub committees_at_slot: u64, + /// Validator's position within the committee. + pub validator_committee_index: u64, +} + +/// Unsigned attestation data paired with its duty. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AttestationData { + /// Raw attestation data. + pub data: phase0::AttestationData, + /// Associated attester duty. + pub duty: AttesterDuty, +} + +/// Versioned aggregated attestation (unsigned). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VersionedAggregatedAttestation(pub versioned::VersionedAttestation); + +impl VersionedAggregatedAttestation { + /// Returns the attestation data from the inner payload, if present. + pub fn data(&self) -> Option<&phase0::AttestationData> { + self.0.attestation.as_ref().map(|a| a.data()) + } +} + +/// Sync committee contribution (unsigned). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SyncContribution(pub altair::SyncCommitteeContribution); + +/// Unsigned proposal block across all supported forks. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProposalBlock { + /// Phase0 beacon block. + Phase0(phase0::BeaconBlock), + /// Altair beacon block. + Altair(altair::BeaconBlock), + /// Bellatrix beacon block. + Bellatrix(bellatrix::BeaconBlock), + /// Bellatrix blinded beacon block. + BellatrixBlinded(bellatrix::BlindedBeaconBlock), + /// Capella beacon block. + Capella(capella::BeaconBlock), + /// Capella blinded beacon block. + CapellaBlinded(capella::BlindedBeaconBlock), + /// Deneb beacon block with KZG proofs and blobs. + Deneb { + /// Beacon block. + block: Box, + /// KZG proofs. + kzg_proofs: Vec, + /// Blobs. + blobs: Vec, + }, + /// Deneb blinded beacon block. + DenebBlinded(deneb::BlindedBeaconBlock), + /// Electra beacon block with KZG proofs and blobs. + Electra { + /// Beacon block. + block: Box, + /// KZG proofs. + kzg_proofs: Vec, + /// Blobs. + blobs: Vec, + }, + /// Electra blinded beacon block. + ElectraBlinded(electra::BlindedBeaconBlock), + /// Fulu beacon block with KZG proofs and blobs (uses electra block type). + Fulu { + /// Beacon block. + block: Box, + /// KZG proofs. + kzg_proofs: Vec, + /// Blobs. + blobs: Vec, + }, + /// Fulu blinded beacon block (uses electra block type). + FuluBlinded(electra::BlindedBeaconBlock), +} + +impl ProposalBlock { + /// Returns the slot of this block. + pub fn slot(&self) -> phase0::Slot { + match self { + Self::Phase0(b) => b.slot, + Self::Altair(b) => b.slot, + Self::Bellatrix(b) => b.slot, + Self::BellatrixBlinded(b) => b.slot, + Self::Capella(b) => b.slot, + Self::CapellaBlinded(b) => b.slot, + Self::Deneb { block, .. } => block.slot, + Self::DenebBlinded(b) => b.slot, + Self::Electra { block, .. } => block.slot, + Self::ElectraBlinded(b) => b.slot, + Self::Fulu { block, .. } => block.slot, + Self::FuluBlinded(b) => b.slot, + } + } + + /// Returns the tree-hash root of this block. + pub fn root(&self) -> phase0::Root { + match self { + Self::Phase0(b) => b.tree_hash_root().0, + Self::Altair(b) => b.tree_hash_root().0, + Self::Bellatrix(b) => b.tree_hash_root().0, + Self::BellatrixBlinded(b) => b.tree_hash_root().0, + Self::Capella(b) => b.tree_hash_root().0, + Self::CapellaBlinded(b) => b.tree_hash_root().0, + Self::Deneb { block, .. } => block.tree_hash_root().0, + Self::DenebBlinded(b) => b.tree_hash_root().0, + Self::Electra { block, .. } => block.tree_hash_root().0, + Self::ElectraBlinded(b) => b.tree_hash_root().0, + Self::Fulu { block, .. } => block.tree_hash_root().0, + Self::FuluBlinded(b) => b.tree_hash_root().0, + } + } +} + +/// Unsigned versioned proposal across all supported forks. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VersionedProposal { + /// Fork version. + pub version: versioned::DataVersion, + /// True if this is a blinded proposal. + pub blinded: bool, + /// Unsigned block payload. + pub block: ProposalBlock, +} + +impl VersionedProposal { + /// Returns the slot of the proposal block. + pub fn slot(&self) -> phase0::Slot { + self.block.slot() + } + + /// Returns the tree-hash root of the proposal block. + pub fn root(&self) -> phase0::Root { + self.block.root() + } +} + #[cfg(test)] mod tests { use super::*; From d484a3b933ce16f347c2c572c885fd82fdbc89d6 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Wed, 13 May 2026 15:57:26 +0200 Subject: [PATCH 02/47] style improvements --- crates/core/src/dutydb/memory.rs | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/crates/core/src/dutydb/memory.rs b/crates/core/src/dutydb/memory.rs index 5147ac69..302c555a 100644 --- a/crates/core/src/dutydb/memory.rs +++ b/crates/core/src/dutydb/memory.rs @@ -4,7 +4,10 @@ use std::{collections::HashMap, sync::Arc}; -use pluto_eth2api::{spec::phase0, versioned}; +use pluto_eth2api::{ + spec::{altair, phase0}, + versioned, +}; use tokio::sync::{Mutex, Notify}; use tokio_util::sync::CancellationToken; use tree_hash::TreeHash; @@ -166,18 +169,16 @@ struct State { deadliner_rx: Option>, } -use pluto_eth2api::spec::altair; - /// In-memory DutyDB. /// /// Equivalent to charon's `MemDB`. Stores unsigned duty data and answers /// blocking `await_*` queries when the relevant data becomes available. pub struct MemDB { state: Mutex, - att_notify: Arc, - pro_notify: Arc, - agg_notify: Arc, - contrib_notify: Arc, + att_notify: Notify, + pro_notify: Notify, + agg_notify: Notify, + contrib_notify: Notify, cancel: CancellationToken, deadliner: Arc, } @@ -198,10 +199,10 @@ impl MemDB { contrib_keys_by_slot: HashMap::new(), deadliner_rx, }), - att_notify: Arc::new(Notify::new()), - pro_notify: Arc::new(Notify::new()), - agg_notify: Arc::new(Notify::new()), - contrib_notify: Arc::new(Notify::new()), + att_notify: Notify::new(), + pro_notify: Notify::new(), + agg_notify: Notify::new(), + contrib_notify: Notify::new(), cancel, deadliner, } @@ -517,19 +518,17 @@ fn store_agg_attestation(state: &mut State, agg: &VersionedAggregatedAttestation if existing_data.tree_hash_root().0 != root { return Err(Error::ClashingDataRoot); } - // Update with provided value (same root, potentially updated aggregation bits). - state.agg_duties.insert(key, agg.clone()); } else { - state.agg_duties.insert(key, agg.clone()); state.agg_keys_by_slot.entry(slot).or_default().push(key); } + state.agg_duties.insert(key, agg.clone()); Ok(()) } fn store_sync_contribution(state: &mut State, contrib: &SyncContribution) -> Result<()> { let inner = &contrib.0; - let existing_root = inner.tree_hash_root().0; + let contrib_root = inner.tree_hash_root().0; let key = ContribKey { slot: inner.slot, @@ -538,7 +537,7 @@ fn store_sync_contribution(state: &mut State, contrib: &SyncContribution) -> Res }; if let Some(existing) = state.contrib_duties.get(&key) { - if existing.tree_hash_root().0 != existing_root { + if existing.tree_hash_root().0 != contrib_root { return Err(Error::ClashingSyncContributions); } } else { From 33c0a21940b0c4735f4286e11a2deb5215d2894f Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Wed, 13 May 2026 16:45:09 +0200 Subject: [PATCH 03/47] await_data method --- crates/core/src/dutydb/memory.rs | 78 +++++++++----------------------- crates/core/src/dutydb/mod.rs | 2 - 2 files changed, 21 insertions(+), 59 deletions(-) diff --git a/crates/core/src/dutydb/memory.rs b/crates/core/src/dutydb/memory.rs index 302c555a..0780caf7 100644 --- a/crates/core/src/dutydb/memory.rs +++ b/crates/core/src/dutydb/memory.rs @@ -289,24 +289,8 @@ impl MemDB { /// Blocks until a proposal for the given slot is available, then returns /// it. pub async fn await_proposal(&self, slot: u64) -> Result { - loop { - let notified = self.pro_notify.notified(); - tokio::pin!(notified); - notified.as_mut().enable(); - - { - let state = self.state.lock().await; - if let Some(p) = state.pro_duties.get(&slot) { - return Ok(p.clone()); - } - } - - tokio::select! { - biased; - _ = self.cancel.cancelled() => return Err(Error::Shutdown), - _ = &mut notified => {} - } - } + self.await_data(&self.pro_notify, |s| s.pro_duties.get(&slot)) + .await } /// Blocks until attestation data for the given slot and committee index is @@ -317,24 +301,8 @@ impl MemDB { comm_idx: u64, ) -> Result { let key = AttKey { slot, comm_idx }; - loop { - let notified = self.att_notify.notified(); - tokio::pin!(notified); - notified.as_mut().enable(); - - { - let state = self.state.lock().await; - if let Some(data) = state.att_duties.get(&key) { - return Ok(data.clone()); - } - } - - tokio::select! { - biased; - _ = self.cancel.cancelled() => return Err(Error::Shutdown), - _ = &mut notified => {} - } - } + self.await_data(&self.att_notify, |s| s.att_duties.get(&key)) + .await } /// Blocks until an aggregated attestation for the given slot and @@ -348,24 +316,8 @@ impl MemDB { slot, root: attestation_root, }; - loop { - let notified = self.agg_notify.notified(); - tokio::pin!(notified); - notified.as_mut().enable(); - - { - let state = self.state.lock().await; - if let Some(agg) = state.agg_duties.get(&key) { - return Ok(agg.0.clone()); - } - } - - tokio::select! { - biased; - _ = self.cancel.cancelled() => return Err(Error::Shutdown), - _ = &mut notified => {} - } - } + self.await_data(&self.agg_notify, |s| s.agg_duties.get(&key).map(|a| &a.0)) + .await } /// Blocks until a sync contribution for the given slot, subcommittee index, @@ -381,15 +333,27 @@ impl MemDB { subcomm_idx, root: beacon_block_root, }; + self.await_data(&self.contrib_notify, |s| s.contrib_duties.get(&key)) + .await + } + + async fn await_data( + &self, + notify: &Notify, + lookup: impl for<'s> Fn(&'s State) -> Option<&'s V>, + ) -> Result + where + V: Clone, + { loop { - let notified = self.contrib_notify.notified(); + let notified = notify.notified(); tokio::pin!(notified); notified.as_mut().enable(); { let state = self.state.lock().await; - if let Some(c) = state.contrib_duties.get(&key) { - return Ok(c.clone()); + if let Some(v) = lookup(&state) { + return Ok(v.clone()); } } diff --git a/crates/core/src/dutydb/mod.rs b/crates/core/src/dutydb/mod.rs index 601066f6..6cdb9aa3 100644 --- a/crates/core/src/dutydb/mod.rs +++ b/crates/core/src/dutydb/mod.rs @@ -1,6 +1,4 @@ //! DutyDB — in-memory store for unsigned duty data. -//! -//! Equivalent to charon's `core/dutydb` package. pub mod memory; From cfc77a6fcb6e35574f5df534b852ca3e33a8f797 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Wed, 13 May 2026 16:54:10 +0200 Subject: [PATCH 04/47] rwlock instead of mutex --- crates/core/src/dutydb/memory.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/core/src/dutydb/memory.rs b/crates/core/src/dutydb/memory.rs index 0780caf7..a73056a4 100644 --- a/crates/core/src/dutydb/memory.rs +++ b/crates/core/src/dutydb/memory.rs @@ -8,7 +8,7 @@ use pluto_eth2api::{ spec::{altair, phase0}, versioned, }; -use tokio::sync::{Mutex, Notify}; +use tokio::sync::{Notify, RwLock}; use tokio_util::sync::CancellationToken; use tree_hash::TreeHash; @@ -174,7 +174,7 @@ struct State { /// Equivalent to charon's `MemDB`. Stores unsigned duty data and answers /// blocking `await_*` queries when the relevant data becomes available. pub struct MemDB { - state: Mutex, + state: RwLock, att_notify: Notify, pro_notify: Notify, agg_notify: Notify, @@ -188,7 +188,7 @@ impl MemDB { pub fn new(deadliner: Arc, cancel: CancellationToken) -> Self { let deadliner_rx = deadliner.c(); Self { - state: Mutex::new(State { + state: RwLock::new(State { att_duties: HashMap::new(), att_pub_keys: HashMap::new(), att_keys_by_slot: HashMap::new(), @@ -217,7 +217,7 @@ impl MemDB { /// Stores unsigned duty data for the given duty, waking any pending /// waiters. pub async fn store(&self, duty: Duty, unsigned_set: UnsignedDataSet) -> Result<()> { - let mut state = self.state.lock().await; + let mut state = self.state.write().await; if !self.deadliner.add(duty.clone()).await { return Err(Error::ExpiredDuty); @@ -351,7 +351,7 @@ impl MemDB { notified.as_mut().enable(); { - let state = self.state.lock().await; + let state = self.state.read().await; if let Some(v) = lookup(&state) { return Ok(v.clone()); } @@ -373,7 +373,7 @@ impl MemDB { comm_idx: u64, val_idx: u64, ) -> Result { - let state = self.state.lock().await; + let state = self.state.read().await; state .att_pub_keys .get(&PkKey { From d4e9e4ab2574175366fd184f8cacdad069174228 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Thu, 14 May 2026 11:04:36 +0200 Subject: [PATCH 05/47] renamed variables --- crates/core/src/dutydb/memory.rs | 35 ++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/crates/core/src/dutydb/memory.rs b/crates/core/src/dutydb/memory.rs index a73056a4..c8722977 100644 --- a/crates/core/src/dutydb/memory.rs +++ b/crates/core/src/dutydb/memory.rs @@ -125,7 +125,7 @@ pub type UnsignedDataSet = HashMap; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] struct AttKey { slot: u64, - comm_idx: u64, + committee_idx: u64, } /// Lookup key for public-key-by-attestation: (slot, committee index, validator @@ -133,8 +133,8 @@ struct AttKey { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] struct PkKey { slot: u64, - comm_idx: u64, - val_idx: u64, + committee_idx: u64, + validator_idx: u64, } /// Lookup key for aggregated attestations: (slot, attestation data root). @@ -300,7 +300,10 @@ impl MemDB { slot: u64, comm_idx: u64, ) -> Result { - let key = AttKey { slot, comm_idx }; + let key = AttKey { + slot, + committee_idx: comm_idx, + }; self.await_data(&self.att_notify, |s| s.att_duties.get(&key)) .await } @@ -378,8 +381,8 @@ impl MemDB { .att_pub_keys .get(&PkKey { slot, - comm_idx, - val_idx, + committee_idx: comm_idx, + validator_idx: val_idx, }) .copied() .ok_or(Error::PubKeyNotFound) @@ -407,8 +410,8 @@ fn store_attestation(state: &mut State, pubkey: PubKey, att: &AttestationData) - // Store pubkey mapping for PubKeyByAttestation (actual committee index). let pk_key = PkKey { slot, - comm_idx, - val_idx, + committee_idx: comm_idx, + validator_idx: val_idx, }; if let Some(&existing) = state.att_pub_keys.get(&pk_key) { if existing != pubkey { @@ -424,7 +427,10 @@ fn store_attestation(state: &mut State, pubkey: PubKey, att: &AttestationData) - } // Store attestation data for AwaitAttestation (actual committee index). - let att_key = AttKey { slot, comm_idx }; + let att_key = AttKey { + slot, + committee_idx: comm_idx, + }; if let Some(existing) = state.att_duties.get(&att_key) { if existing.source != att.data.source || existing.target != att.data.target @@ -440,8 +446,8 @@ fn store_attestation(state: &mut State, pubkey: PubKey, att: &AttestationData) - // See: https://ethereum.github.io/beacon-APIs/#/Validator/produceAttestationData let pk_key0 = PkKey { slot, - comm_idx: 0, - val_idx, + committee_idx: 0, + validator_idx: val_idx, }; if let Some(&existing) = state.att_pub_keys.get(&pk_key0) { if existing != pubkey { @@ -456,7 +462,10 @@ fn store_attestation(state: &mut State, pubkey: PubKey, att: &AttestationData) - .push(pk_key0); } - let att_key0 = AttKey { slot, comm_idx: 0 }; + let att_key0 = AttKey { + slot, + committee_idx: 0, + }; if let Some(existing) = state.att_duties.get(&att_key0) { if existing.source != att.data.source { return Err(Error::ClashingAttestationDataCommIdx0Source); @@ -529,7 +538,7 @@ fn delete_duty(state: &mut State, duty: Duty) -> Result<()> { state.att_pub_keys.remove(&key); state.att_duties.remove(&AttKey { slot: key.slot, - comm_idx: key.comm_idx, + committee_idx: key.committee_idx, }); } } From 5303946dcd4d84cb3262ee5e3b04707da36678dc Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Thu, 14 May 2026 13:50:28 +0200 Subject: [PATCH 06/47] State struct methods --- crates/core/src/dutydb/memory.rs | 325 +++++++++++++++++-------------- 1 file changed, 176 insertions(+), 149 deletions(-) diff --git a/crates/core/src/dutydb/memory.rs b/crates/core/src/dutydb/memory.rs index c8722977..7fef5938 100644 --- a/crates/core/src/dutydb/memory.rs +++ b/crates/core/src/dutydb/memory.rs @@ -228,12 +228,10 @@ impl MemDB { if unsigned_set.len() > 1 { return Err(Error::UnexpectedProposerSetLength); } - for data in unsigned_set.values() { - let proposal = match data { - UnsignedDutyData::Proposal(p) => p.as_ref(), - _ => return Err(Error::InvalidVersionedProposal), - }; - store_proposal(&mut state, proposal)?; + match unsigned_set.values().next() { + None => {} + Some(UnsignedDutyData::Proposal(p)) => state.store_proposal(p)?, + Some(_) => return Err(Error::InvalidVersionedProposal), } self.pro_notify.notify_waiters(); } @@ -244,7 +242,7 @@ impl MemDB { UnsignedDutyData::Attestation(a) => a, _ => return Err(Error::InvalidAttestationData), }; - store_attestation(&mut state, *pubkey, att)?; + state.store_attestation(*pubkey, att)?; } self.att_notify.notify_waiters(); } @@ -254,7 +252,7 @@ impl MemDB { UnsignedDutyData::AggAttestation(a) => a, _ => return Err(Error::InvalidAggregatedAttestation), }; - store_agg_attestation(&mut state, agg)?; + state.store_agg_attestation(agg)?; } self.agg_notify.notify_waiters(); } @@ -264,7 +262,7 @@ impl MemDB { UnsignedDutyData::SyncContribution(c) => c, _ => return Err(Error::InvalidSyncContribution), }; - store_sync_contribution(&mut state, contrib)?; + state.store_sync_contribution(contrib)?; } self.contrib_notify.notify_waiters(); } @@ -280,7 +278,7 @@ impl MemDB { }, None => break, }; - delete_duty(&mut state, expired)?; + state.delete_duty(expired)?; } Ok(()) @@ -389,177 +387,206 @@ impl MemDB { } } -fn store_proposal(state: &mut State, proposal: &VersionedProposal) -> Result<()> { - let slot = proposal.slot(); - if let Some(existing) = state.pro_duties.get(&slot) { - if existing.root() != proposal.root() { - return Err(Error::ClashingBlocks); +impl State { + fn store_proposal(&mut self, proposal: &VersionedProposal) -> Result<()> { + let slot = proposal.slot(); + if let Some(existing) = self.pro_duties.get(&slot) { + if existing.root() != proposal.root() { + return Err(Error::ClashingBlocks); + } + } else { + self.pro_duties.insert(slot, proposal.clone()); } - } else { - state.pro_duties.insert(slot, proposal.clone()); + Ok(()) } - Ok(()) -} -fn store_attestation(state: &mut State, pubkey: PubKey, att: &AttestationData) -> Result<()> { - let slot = att.data.slot; - let duty_slot = att.duty.slot; - let comm_idx = att.duty.committee_index; - let val_idx = att.duty.validator_index; - - // Store pubkey mapping for PubKeyByAttestation (actual committee index). - let pk_key = PkKey { - slot, - committee_idx: comm_idx, - validator_idx: val_idx, - }; - if let Some(&existing) = state.att_pub_keys.get(&pk_key) { - if existing != pubkey { - return Err(Error::ClashingPublicKey); - } - } else { - state.att_pub_keys.insert(pk_key, pubkey); - state - .att_keys_by_slot - .entry(duty_slot) - .or_default() - .push(pk_key); + fn store_attestation(&mut self, pubkey: PubKey, att: &AttestationData) -> Result<()> { + let slot = att.data.slot; + let duty_slot = att.duty.slot; + let comm_idx = att.duty.committee_index; + let val_idx = att.duty.validator_index; + + self.store_att_pubkey(slot, duty_slot, comm_idx, val_idx, pubkey)?; + self.store_att_data(slot, comm_idx, &att.data)?; + self.store_att_compat_commidx0(slot, duty_slot, val_idx, pubkey, &att.data)?; + + Ok(()) } - // Store attestation data for AwaitAttestation (actual committee index). - let att_key = AttKey { - slot, - committee_idx: comm_idx, - }; - if let Some(existing) = state.att_duties.get(&att_key) { - if existing.source != att.data.source - || existing.target != att.data.target - || existing.beacon_block_root != att.data.beacon_block_root - { - return Err(Error::ClashingAttestationData); + fn store_att_pubkey( + &mut self, + slot: u64, + duty_slot: u64, + comm_idx: u64, + val_idx: u64, + pubkey: PubKey, + ) -> Result<()> { + let pk_key = PkKey { + slot, + committee_idx: comm_idx, + validator_idx: val_idx, + }; + if let Some(&existing) = self.att_pub_keys.get(&pk_key) { + if existing != pubkey { + return Err(Error::ClashingPublicKey); + } + } else { + self.att_pub_keys.insert(pk_key, pubkey); + self.att_keys_by_slot + .entry(duty_slot) + .or_default() + .push(pk_key); } - } else { - state.att_duties.insert(att_key, att.data.clone()); + Ok(()) } - // Also store with commIdx=0 for post-Electra VC compatibility. - // See: https://ethereum.github.io/beacon-APIs/#/Validator/produceAttestationData - let pk_key0 = PkKey { - slot, - committee_idx: 0, - validator_idx: val_idx, - }; - if let Some(&existing) = state.att_pub_keys.get(&pk_key0) { - if existing != pubkey { - return Err(Error::ClashingPublicKey); + fn store_att_data( + &mut self, + slot: u64, + comm_idx: u64, + data: &phase0::AttestationData, + ) -> Result<()> { + let att_key = AttKey { + slot, + committee_idx: comm_idx, + }; + if let Some(existing) = self.att_duties.get(&att_key) { + if existing.source != data.source + || existing.target != data.target + || existing.beacon_block_root != data.beacon_block_root + { + return Err(Error::ClashingAttestationData); + } + } else { + self.att_duties.insert(att_key, data.clone()); } - } else { - state.att_pub_keys.insert(pk_key0, pubkey); - state - .att_keys_by_slot - .entry(duty_slot) - .or_default() - .push(pk_key0); + Ok(()) } - let att_key0 = AttKey { - slot, - committee_idx: 0, - }; - if let Some(existing) = state.att_duties.get(&att_key0) { - if existing.source != att.data.source { - return Err(Error::ClashingAttestationDataCommIdx0Source); + // Store pubkey and attestation data with commIdx=0 for post-Electra VC + // compatibility. See: https://ethereum.github.io/beacon-APIs/#/Validator/produceAttestationData + fn store_att_compat_commidx0( + &mut self, + slot: u64, + duty_slot: u64, + val_idx: u64, + pubkey: PubKey, + data: &phase0::AttestationData, + ) -> Result<()> { + let pk_key0 = PkKey { + slot, + committee_idx: 0, + validator_idx: val_idx, + }; + if let Some(&existing) = self.att_pub_keys.get(&pk_key0) { + if existing != pubkey { + return Err(Error::ClashingPublicKey); + } + } else { + self.att_pub_keys.insert(pk_key0, pubkey); + self.att_keys_by_slot + .entry(duty_slot) + .or_default() + .push(pk_key0); } - if existing.target != att.data.target { - return Err(Error::ClashingAttestationDataCommIdx0Target); + + let att_key0 = AttKey { + slot, + committee_idx: 0, + }; + if let Some(existing) = self.att_duties.get(&att_key0) { + if existing.source != data.source { + return Err(Error::ClashingAttestationDataCommIdx0Source); + } + if existing.target != data.target { + return Err(Error::ClashingAttestationDataCommIdx0Target); + } + } else { + self.att_duties.insert(att_key0, data.clone()); } - } else { - state.att_duties.insert(att_key0, att.data.clone()); + Ok(()) } - Ok(()) -} - -fn store_agg_attestation(state: &mut State, agg: &VersionedAggregatedAttestation) -> Result<()> { - let att_data = agg.data().ok_or(Error::InvalidAggregatedAttestation)?; - let root = att_data.tree_hash_root().0; - let slot = att_data.slot; + fn store_agg_attestation(&mut self, agg: &VersionedAggregatedAttestation) -> Result<()> { + let att_data = agg.data().ok_or(Error::InvalidAggregatedAttestation)?; + let root = att_data.tree_hash_root().0; + let slot = att_data.slot; - let key = AggKey { slot, root }; - if let Some(existing) = state.agg_duties.get(&key) { - let existing_data = existing.data().ok_or(Error::InvalidAggregatedAttestation)?; - if existing_data.tree_hash_root().0 != root { - return Err(Error::ClashingDataRoot); + let key = AggKey { slot, root }; + if let Some(existing) = self.agg_duties.get(&key) { + let existing_data = existing.data().ok_or(Error::InvalidAggregatedAttestation)?; + if existing_data.tree_hash_root().0 != root { + return Err(Error::ClashingDataRoot); + } + } else { + self.agg_keys_by_slot.entry(slot).or_default().push(key); } - } else { - state.agg_keys_by_slot.entry(slot).or_default().push(key); - } - state.agg_duties.insert(key, agg.clone()); + self.agg_duties.insert(key, agg.clone()); - Ok(()) -} + Ok(()) + } -fn store_sync_contribution(state: &mut State, contrib: &SyncContribution) -> Result<()> { - let inner = &contrib.0; - let contrib_root = inner.tree_hash_root().0; + fn store_sync_contribution(&mut self, contrib: &SyncContribution) -> Result<()> { + let inner = &contrib.0; + let contrib_root = inner.tree_hash_root().0; - let key = ContribKey { - slot: inner.slot, - subcomm_idx: inner.subcommittee_index, - root: inner.beacon_block_root, - }; + let key = ContribKey { + slot: inner.slot, + subcomm_idx: inner.subcommittee_index, + root: inner.beacon_block_root, + }; - if let Some(existing) = state.contrib_duties.get(&key) { - if existing.tree_hash_root().0 != contrib_root { - return Err(Error::ClashingSyncContributions); + if let Some(existing) = self.contrib_duties.get(&key) { + if existing.tree_hash_root().0 != contrib_root { + return Err(Error::ClashingSyncContributions); + } + } else { + self.contrib_duties.insert(key, inner.clone()); + self.contrib_keys_by_slot + .entry(inner.slot) + .or_default() + .push(key); } - } else { - state.contrib_duties.insert(key, inner.clone()); - state - .contrib_keys_by_slot - .entry(inner.slot) - .or_default() - .push(key); - } - Ok(()) -} + Ok(()) + } -fn delete_duty(state: &mut State, duty: Duty) -> Result<()> { - let slot = duty.slot.inner(); - match duty.duty_type { - DutyType::Proposer => { - state.pro_duties.remove(&slot); - } - DutyType::BuilderProposer => return Err(Error::DeprecatedDutyBuilderProposer), - DutyType::Attester => { - if let Some(keys) = state.att_keys_by_slot.remove(&slot) { - for key in keys { - state.att_pub_keys.remove(&key); - state.att_duties.remove(&AttKey { - slot: key.slot, - committee_idx: key.committee_idx, - }); + fn delete_duty(&mut self, duty: Duty) -> Result<()> { + let slot = duty.slot.inner(); + match duty.duty_type { + DutyType::Proposer => { + self.pro_duties.remove(&slot); + } + DutyType::BuilderProposer => return Err(Error::DeprecatedDutyBuilderProposer), + DutyType::Attester => { + if let Some(keys) = self.att_keys_by_slot.remove(&slot) { + for key in keys { + self.att_pub_keys.remove(&key); + self.att_duties.remove(&AttKey { + slot: key.slot, + committee_idx: key.committee_idx, + }); + } } } - } - DutyType::Aggregator => { - if let Some(keys) = state.agg_keys_by_slot.remove(&slot) { - for key in keys { - state.agg_duties.remove(&key); + DutyType::Aggregator => { + if let Some(keys) = self.agg_keys_by_slot.remove(&slot) { + for key in keys { + self.agg_duties.remove(&key); + } } } - } - DutyType::SyncContribution => { - if let Some(keys) = state.contrib_keys_by_slot.remove(&slot) { - for key in keys { - state.contrib_duties.remove(&key); + DutyType::SyncContribution => { + if let Some(keys) = self.contrib_keys_by_slot.remove(&slot) { + for key in keys { + self.contrib_duties.remove(&key); + } } } + _ => return Err(Error::UnknownDutyType), } - _ => return Err(Error::UnknownDutyType), + Ok(()) } - Ok(()) } #[cfg(test)] From 0dce2ad751435e0b6c0cc1859142a1d6877190b5 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Thu, 14 May 2026 14:59:53 +0200 Subject: [PATCH 07/47] improved test --- crates/core/src/dutydb/memory.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/core/src/dutydb/memory.rs b/crates/core/src/dutydb/memory.rs index 7fef5938..2f1608c6 100644 --- a/crates/core/src/dutydb/memory.rs +++ b/crates/core/src/dutydb/memory.rs @@ -858,8 +858,9 @@ pub(crate) mod tests { .unwrap(); } - for handle in handles { - handle.await.unwrap().unwrap(); + for (handle, &slot) in handles.into_iter().zip(slots.iter()) { + let proposal = handle.await.unwrap().unwrap(); + assert_eq!(proposal.slot(), slot); } } From fd6b97d9c870b077bf4b7b4e54c73f1f9e13129e Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Thu, 14 May 2026 15:44:38 +0200 Subject: [PATCH 08/47] Locking state after adding duty to the deadliner in store function --- crates/core/src/dutydb/memory.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/core/src/dutydb/memory.rs b/crates/core/src/dutydb/memory.rs index 2f1608c6..25fba6f9 100644 --- a/crates/core/src/dutydb/memory.rs +++ b/crates/core/src/dutydb/memory.rs @@ -217,12 +217,12 @@ impl MemDB { /// Stores unsigned duty data for the given duty, waking any pending /// waiters. pub async fn store(&self, duty: Duty, unsigned_set: UnsignedDataSet) -> Result<()> { - let mut state = self.state.write().await; - if !self.deadliner.add(duty.clone()).await { return Err(Error::ExpiredDuty); } + let mut state = self.state.write().await; + match duty.duty_type { DutyType::Proposer => { if unsigned_set.len() > 1 { From d62c106eeb277e932f82649119bd7ce5117be3fa Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Thu, 14 May 2026 16:53:45 +0200 Subject: [PATCH 09/47] fixed Dead clash check in store_agg_attestation --- crates/core/src/dutydb/memory.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/core/src/dutydb/memory.rs b/crates/core/src/dutydb/memory.rs index 25fba6f9..7638b59b 100644 --- a/crates/core/src/dutydb/memory.rs +++ b/crates/core/src/dutydb/memory.rs @@ -513,9 +513,10 @@ impl State { let slot = att_data.slot; let key = AggKey { slot, root }; - if let Some(existing) = self.agg_duties.get(&key) { - let existing_data = existing.data().ok_or(Error::InvalidAggregatedAttestation)?; - if existing_data.tree_hash_root().0 != root { + // Unlike Go (memory.go:458-460) which keys by {slot,root} making ClashingDataRoot + // unreachable, we detect a real clash by checking for a different root at the same slot. + if let Some(existing_keys) = self.agg_keys_by_slot.get(&slot) { + if existing_keys.iter().any(|k| k.root != root) { return Err(Error::ClashingDataRoot); } } else { @@ -528,7 +529,6 @@ impl State { fn store_sync_contribution(&mut self, contrib: &SyncContribution) -> Result<()> { let inner = &contrib.0; - let contrib_root = inner.tree_hash_root().0; let key = ContribKey { slot: inner.slot, @@ -537,7 +537,7 @@ impl State { }; if let Some(existing) = self.contrib_duties.get(&key) { - if existing.tree_hash_root().0 != contrib_root { + if existing.tree_hash_root().0 != inner.tree_hash_root().0 { return Err(Error::ClashingSyncContributions); } } else { From 84e8fe34ac9c546329dd9a98416b9f02118acbeb Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Thu, 14 May 2026 16:55:37 +0200 Subject: [PATCH 10/47] store_agg_attestation inserting only when not slot present --- crates/core/src/dutydb/memory.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/core/src/dutydb/memory.rs b/crates/core/src/dutydb/memory.rs index 7638b59b..78cb1e0c 100644 --- a/crates/core/src/dutydb/memory.rs +++ b/crates/core/src/dutydb/memory.rs @@ -520,9 +520,9 @@ impl State { return Err(Error::ClashingDataRoot); } } else { + self.agg_duties.insert(key, agg.clone()); self.agg_keys_by_slot.entry(slot).or_default().push(key); } - self.agg_duties.insert(key, agg.clone()); Ok(()) } From 90bcb60b6156c43da9098ded1aa3b90bf51f15b1 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Thu, 14 May 2026 16:57:14 +0200 Subject: [PATCH 11/47] fmt --- crates/core/src/dutydb/memory.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/core/src/dutydb/memory.rs b/crates/core/src/dutydb/memory.rs index 78cb1e0c..f5cb4890 100644 --- a/crates/core/src/dutydb/memory.rs +++ b/crates/core/src/dutydb/memory.rs @@ -513,8 +513,9 @@ impl State { let slot = att_data.slot; let key = AggKey { slot, root }; - // Unlike Go (memory.go:458-460) which keys by {slot,root} making ClashingDataRoot - // unreachable, we detect a real clash by checking for a different root at the same slot. + // Unlike Go (memory.go:458-460) which keys by {slot,root} making + // ClashingDataRoot unreachable, we detect a real clash by checking for + // a different root at the same slot. if let Some(existing_keys) = self.agg_keys_by_slot.get(&slot) { if existing_keys.iter().any(|k| k.root != root) { return Err(Error::ClashingDataRoot); From cb8a5a6f87135e432ab0778f3980d01fd57bfadf Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Thu, 14 May 2026 17:26:23 +0200 Subject: [PATCH 12/47] return early on DeprecatedDutyBuilderProposer --- crates/core/src/dutydb/memory.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/core/src/dutydb/memory.rs b/crates/core/src/dutydb/memory.rs index f5cb4890..d60feae2 100644 --- a/crates/core/src/dutydb/memory.rs +++ b/crates/core/src/dutydb/memory.rs @@ -217,6 +217,10 @@ impl MemDB { /// Stores unsigned duty data for the given duty, waking any pending /// waiters. pub async fn store(&self, duty: Duty, unsigned_set: UnsignedDataSet) -> Result<()> { + if duty.duty_type == DutyType::BuilderProposer { + return Err(Error::DeprecatedDutyBuilderProposer); + } + if !self.deadliner.add(duty.clone()).await { return Err(Error::ExpiredDuty); } @@ -235,7 +239,6 @@ impl MemDB { } self.pro_notify.notify_waiters(); } - DutyType::BuilderProposer => return Err(Error::DeprecatedDutyBuilderProposer), DutyType::Attester => { for (pubkey, data) in &unsigned_set { let att = match data { From 5719029e7a9b85265de98eaabdc38ade5fefdff2 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Fri, 15 May 2026 12:03:27 +0200 Subject: [PATCH 13/47] redundant enable --- crates/core/src/dutydb/memory.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/core/src/dutydb/memory.rs b/crates/core/src/dutydb/memory.rs index d60feae2..d56b9124 100644 --- a/crates/core/src/dutydb/memory.rs +++ b/crates/core/src/dutydb/memory.rs @@ -352,7 +352,6 @@ impl MemDB { loop { let notified = notify.notified(); tokio::pin!(notified); - notified.as_mut().enable(); { let state = self.state.read().await; From 923d9cb70cdfc6479f8a111227ee147dba2ef80a Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Fri, 15 May 2026 12:15:45 +0200 Subject: [PATCH 14/47] removed Option from the State, deadliner field. --- crates/core/src/dutydb/memory.rs | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/crates/core/src/dutydb/memory.rs b/crates/core/src/dutydb/memory.rs index d56b9124..c7af8ecd 100644 --- a/crates/core/src/dutydb/memory.rs +++ b/crates/core/src/dutydb/memory.rs @@ -166,7 +166,7 @@ struct State { contrib_duties: HashMap, contrib_keys_by_slot: HashMap>, - deadliner_rx: Option>, + deadliner_rx: tokio::sync::mpsc::Receiver, } /// In-memory DutyDB. @@ -186,7 +186,9 @@ pub struct MemDB { impl MemDB { /// Creates a new in-memory DutyDB. pub fn new(deadliner: Arc, cancel: CancellationToken) -> Self { - let deadliner_rx = deadliner.c(); + let deadliner_rx = deadliner + .c() + .expect("Deadliner::c() must be called only once"); Self { state: RwLock::new(State { att_duties: HashMap::new(), @@ -273,14 +275,7 @@ impl MemDB { } // Drain all expired duties that the deadliner has sent. - loop { - let expired = match state.deadliner_rx { - Some(ref mut rx) => match rx.try_recv() { - Ok(d) => d, - Err(_) => break, - }, - None => break, - }; + while let Ok(expired) = state.deadliner_rx.try_recv() { state.delete_duty(expired)?; } From aab5845ace56553923eed3461b2042ec71877067 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Fri, 15 May 2026 12:37:03 +0200 Subject: [PATCH 15/47] comment ported from charon --- crates/core/src/dutydb/memory.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/crates/core/src/dutydb/memory.rs b/crates/core/src/dutydb/memory.rs index c7af8ecd..882d5a1b 100644 --- a/crates/core/src/dutydb/memory.rs +++ b/crates/core/src/dutydb/memory.rs @@ -460,8 +460,18 @@ impl State { Ok(()) } - // Store pubkey and attestation data with commIdx=0 for post-Electra VC - // compatibility. See: https://ethereum.github.io/beacon-APIs/#/Validator/produceAttestationData + // Post-Electra, committee index 0 is the protocol default and well-behaved + // VCs request attestation data with commIdx=0. However, some VCs still + // request with the actual committee index, so Charon stores both: the real + // index (handled by the caller) and a hardcoded-0 entry here. Once all VCs + // are fixed this function can be removed. + // + // The clash check compares only source and target (not the full data) + // because a slow beacon node may return stale heads mid-loop, causing + // different validators to see different beacon block roots. Source/target + // mismatches are still a real error. + // + // See: https://ethereum.github.io/beacon-APIs/#/Validator/produceAttestationData fn store_att_compat_commidx0( &mut self, slot: u64, From 0864a164fc733db33599374b65d6ada2389f897c Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Fri, 15 May 2026 13:02:59 +0200 Subject: [PATCH 16/47] additional, missing tests --- crates/core/src/dutydb/memory.rs | 223 ++++++++++++++++++++++++++++++- 1 file changed, 220 insertions(+), 3 deletions(-) diff --git a/crates/core/src/dutydb/memory.rs b/crates/core/src/dutydb/memory.rs index 882d5a1b..3e5f83ee 100644 --- a/crates/core/src/dutydb/memory.rs +++ b/crates/core/src/dutydb/memory.rs @@ -621,7 +621,8 @@ pub(crate) mod tests { } fn c(&self) -> Option> { - None + let (_, rx) = tokio::sync::mpsc::channel(1); + Some(rx) } } @@ -918,6 +919,222 @@ pub(crate) mod tests { assert!(err.to_string().contains("shutdown")); } + fn agg_attestation_fixture( + slot: u64, + comm_idx: u64, + val_idx: u64, + ) -> VersionedAggregatedAttestation { + let data = phase0::AttestationData { + slot, + index: comm_idx, + beacon_block_root: [0u8; 32], + source: phase0::Checkpoint { + epoch: 0, + root: [0u8; 32], + }, + target: phase0::Checkpoint { + epoch: 0, + root: [0u8; 32], + }, + }; + let att = phase0::Attestation { + aggregation_bits: phase0::BitList::<2048>::default(), + data, + signature: [0u8; 96], + }; + VersionedAggregatedAttestation(versioned::VersionedAttestation { + version: versioned::DataVersion::Phase0, + validator_index: Some(val_idx), + attestation: Some(versioned::AttestationPayload::Phase0(att)), + }) + } + + #[tokio::test] + async fn mem_db_aggregator() { + let db = Arc::new(make_db()); + + const SLOT: u64 = 200; + const COMM_IDX: u64 = 3; + const V_IDX: u64 = 7; + + let agg = agg_attestation_fixture(SLOT, COMM_IDX, V_IDX); + let att_data = agg.data().unwrap().clone(); + let root = att_data.tree_hash_root().0; + + let db_clone = Arc::clone(&db); + let waiter = tokio::spawn(async move { db_clone.await_agg_attestation(SLOT, root).await }); + + let mut set = UnsignedDataSet::new(); + set.insert(random_core_pub_key(), UnsignedDutyData::AggAttestation(agg)); + db.store(Duty::new(SlotNumber::new(SLOT), DutyType::Aggregator), set) + .await + .unwrap(); + + let versioned_att = waiter.await.unwrap().unwrap(); + let resolved_data = versioned_att.attestation.unwrap(); + assert_eq!(resolved_data.data().slot, SLOT); + assert_eq!(resolved_data.data().index, COMM_IDX); + + // Idempotent re-store. + let agg2 = agg_attestation_fixture(SLOT, COMM_IDX, V_IDX); + let mut set2 = UnsignedDataSet::new(); + set2.insert( + random_core_pub_key(), + UnsignedDutyData::AggAttestation(agg2), + ); + db.store(Duty::new(SlotNumber::new(SLOT), DutyType::Aggregator), set2) + .await + .unwrap(); + } + + #[tokio::test] + async fn clashing_public_key() { + const SLOT: u64 = 50; + const COMM_IDX: u64 = 1; + const V_IDX: u64 = 5; + + let db = make_db(); + let pk_a = random_core_pub_key(); + let pk_b = random_core_pub_key(); + let duty = Duty::new(SlotNumber::new(SLOT), DutyType::Attester); + + let mut set1 = UnsignedDataSet::new(); + set1.insert( + pk_a, + UnsignedDutyData::Attestation(att_data(SLOT, COMM_IDX, V_IDX)), + ); + db.store(duty.clone(), set1).await.unwrap(); + + let mut set2 = UnsignedDataSet::new(); + set2.insert( + pk_b, + UnsignedDutyData::Attestation(att_data(SLOT, COMM_IDX, V_IDX)), + ); + let err = db.store(duty, set2).await.unwrap_err(); + assert!( + matches!(err, Error::ClashingPublicKey), + "expected ClashingPublicKey, got: {err}" + ); + } + + #[tokio::test] + async fn clashing_attestation_data() { + const SLOT: u64 = 51; + const COMM_IDX: u64 = 2; + + let db = make_db(); + let duty = Duty::new(SlotNumber::new(SLOT), DutyType::Attester); + + let mut att_a = att_data(SLOT, COMM_IDX, 1); + att_a.data.beacon_block_root = [0xaa; 32]; + + let mut att_b = att_data(SLOT, COMM_IDX, 2); + att_b.data.beacon_block_root = [0xbb; 32]; + + let mut set1 = UnsignedDataSet::new(); + set1.insert(random_core_pub_key(), UnsignedDutyData::Attestation(att_a)); + db.store(duty.clone(), set1).await.unwrap(); + + let mut set2 = UnsignedDataSet::new(); + set2.insert(random_core_pub_key(), UnsignedDutyData::Attestation(att_b)); + let err = db.store(duty, set2).await.unwrap_err(); + assert!( + matches!(err, Error::ClashingAttestationData), + "expected ClashingAttestationData, got: {err}" + ); + } + + #[tokio::test] + async fn clashing_attestation_data_commidx0_source() { + const SLOT: u64 = 52; + + let db = make_db(); + let duty = Duty::new(SlotNumber::new(SLOT), DutyType::Attester); + + let mut att_a = att_data(SLOT, 1, 10); + att_a.data.source = phase0::Checkpoint { + epoch: 1, + root: [0xaa; 32], + }; + + let mut att_b = att_data(SLOT, 2, 11); + att_b.data.source = phase0::Checkpoint { + epoch: 2, + root: [0xbb; 32], + }; + + let mut set1 = UnsignedDataSet::new(); + set1.insert(random_core_pub_key(), UnsignedDutyData::Attestation(att_a)); + db.store(duty.clone(), set1).await.unwrap(); + + let mut set2 = UnsignedDataSet::new(); + set2.insert(random_core_pub_key(), UnsignedDutyData::Attestation(att_b)); + let err = db.store(duty, set2).await.unwrap_err(); + assert!( + matches!(err, Error::ClashingAttestationDataCommIdx0Source), + "expected ClashingAttestationDataCommIdx0Source, got: {err}" + ); + } + + #[tokio::test] + async fn clashing_attestation_data_commidx0_target() { + const SLOT: u64 = 53; + + let db = make_db(); + let duty = Duty::new(SlotNumber::new(SLOT), DutyType::Attester); + + let source = phase0::Checkpoint { + epoch: 1, + root: [0x11; 32], + }; + + let mut att_a = att_data(SLOT, 1, 10); + att_a.data.source = source.clone(); + att_a.data.target = phase0::Checkpoint { + epoch: 2, + root: [0xaa; 32], + }; + + let mut att_b = att_data(SLOT, 2, 11); + att_b.data.source = source; + att_b.data.target = phase0::Checkpoint { + epoch: 3, + root: [0xbb; 32], + }; + + let mut set1 = UnsignedDataSet::new(); + set1.insert(random_core_pub_key(), UnsignedDutyData::Attestation(att_a)); + db.store(duty.clone(), set1).await.unwrap(); + + let mut set2 = UnsignedDataSet::new(); + set2.insert(random_core_pub_key(), UnsignedDutyData::Attestation(att_b)); + let err = db.store(duty, set2).await.unwrap_err(); + assert!( + matches!(err, Error::ClashingAttestationDataCommIdx0Target), + "expected ClashingAttestationDataCommIdx0Target, got: {err}" + ); + } + + #[tokio::test] + async fn shutdown_wakes_waiting_await() { + let db = Arc::new(make_db()); + + let db_clone = Arc::clone(&db); + let waiter = tokio::spawn(async move { db_clone.await_proposal(9999).await }); + + // Yield to give the waiter task a chance to park in select!. + tokio::task::yield_now().await; + + db.shutdown(); + + let err = waiter.await.unwrap().unwrap_err(); + println!("{err}"); + assert!( + err.to_string().contains("shutdown"), + "expected shutdown error, got: {err}" + ); + } + #[tokio::test] async fn clashing_sync_contributions() { const SLOT: u64 = 123; @@ -929,9 +1146,9 @@ pub(crate) mod tests { let duty = Duty::new(SlotNumber::new(SLOT), DutyType::SyncContribution); let contrib1 = sync_contribution_fixture(SLOT, SUBCOMM_IDX, root); + // Differ by aggregation_bits, which affects the SSZ tree-hash root. let mut contrib2 = sync_contribution_fixture(SLOT, SUBCOMM_IDX, root); - // Make them differ by changing the signature. - contrib2.0.signature = [1u8; 96]; + contrib2.0.aggregation_bits = pluto_ssz::BitVector::with_bits(&[0]); let mut set1 = UnsignedDataSet::new(); set1.insert(pubkey, UnsignedDutyData::SyncContribution(contrib1)); From 8f21930f38cb1a4e662bf7411b8eff55b4d2bbf4 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Fri, 15 May 2026 13:11:08 +0200 Subject: [PATCH 17/47] rephrased error messages --- crates/core/src/dutydb/memory.rs | 83 ++++++++++++++++++-------------- 1 file changed, 46 insertions(+), 37 deletions(-) diff --git a/crates/core/src/dutydb/memory.rs b/crates/core/src/dutydb/memory.rs index 3e5f83ee..02a2e8d2 100644 --- a/crates/core/src/dutydb/memory.rs +++ b/crates/core/src/dutydb/memory.rs @@ -23,81 +23,90 @@ use crate::{ /// Error type for DutyDB operations. #[derive(Debug, thiserror::Error)] pub enum Error { - /// Duty has already expired; data not stored. - #[error("not storing unsigned data for expired duty")] + /// Duty has already expired; unsigned data will not be stored. + #[error("duty expired: unsigned data will not be stored")] ExpiredDuty, /// Proposer data set must contain at most one entry. - #[error("unexpected proposer data set length")] + #[error("proposer data set must contain at most one entry")] UnexpectedProposerSetLength, - /// DutyBuilderProposer is no longer supported. - #[error("deprecated duty DutyBuilderProposer")] + /// `DutyBuilderProposer` is deprecated and no longer supported. + #[error("DutyBuilderProposer is deprecated and no longer supported")] DeprecatedDutyBuilderProposer, - /// Duty type is not stored by DutyDB. - #[error("unsupported duty type")] + /// Duty type is not stored by `DutyDB`. + #[error("unsupported duty type: not stored by DutyDB")] UnsupportedDutyType, /// DB was shut down before the query could be answered. - #[error("dutydb shutdown")] + #[error("dutydb shutdown: query could not be answered")] Shutdown, - /// Two validators mapped to the same (slot, commIdx, valIdx) with different + /// Two validators share the same `(slot, commIdx, valIdx)` with different /// public keys. - #[error("clashing public key")] + #[error( + "clashing public key: two validators share the same (slot, commIdx, valIdx) with different keys" + )] ClashingPublicKey, - /// Two different attestation data objects for the same (slot, commIdx). - #[error("clashing attestation data")] + /// Two different attestation data objects for the same `(slot, commIdx)`. + #[error("clashing attestation data: two different data objects for the same (slot, commIdx)")] ClashingAttestationData, - /// Mismatched source checkpoint when storing commIdx=0 compatibility entry. - #[error("clashing attestation data with hardcoded commidx=0 source")] + /// Mismatched source checkpoint in the hardcoded `commIdx=0` compatibility + /// entry. + #[error( + "clashing attestation data with hardcoded commidx=0 source: mismatched source checkpoint" + )] ClashingAttestationDataCommIdx0Source, - /// Mismatched target checkpoint when storing commIdx=0 compatibility entry. - #[error("clashing attestation data with hardcoded commidx=0 target")] + /// Mismatched target checkpoint in the hardcoded `commIdx=0` compatibility + /// entry. + #[error( + "clashing attestation data with hardcoded commidx=0 target: mismatched target checkpoint" + )] ClashingAttestationDataCommIdx0Target, - /// Two different aggregated attestations for the same slot+root key. - #[error("clashing data root")] + /// Two different aggregated attestations for the same slot and attestation + /// data root. + #[error("clashing data root: two different aggregated attestations for the same slot and root")] ClashingDataRoot, - /// Two different sync contributions for the same (slot, subcommIdx, root). - #[error("clashing sync contributions")] + /// Two different sync contributions for the same `(slot, subcommIdx, + /// root)`. + #[error( + "clashing sync contributions: two different contributions for the same (slot, subcommIdx, root)" + )] ClashingSyncContributions, /// Two different blocks for the same slot. - #[error("clashing blocks")] + #[error("clashing blocks: two different blocks for the same slot")] ClashingBlocks, - /// No public key found for the given (slot, commIdx, valIdx). - #[error("pubkey not found")] + /// No public key found for the given `(slot, commIdx, valIdx)`. + #[error("pubkey not found for the given (slot, commIdx, valIdx)")] PubKeyNotFound, - /// Duty type is not handled by deleteDutyUnsafe. - #[error("unknown duty type")] + /// Duty type is not handled by the delete path. + #[error("unknown duty type: not handled by delete")] UnknownDutyType, - /// The unsigned data provided does not match the expected type for - /// DutyProposer. - #[error("invalid versioned proposal")] + /// Unsigned data does not match the expected type for `DutyProposer`. + #[error("invalid versioned proposal: unsigned data does not match DutyProposer")] InvalidVersionedProposal, - /// The unsigned data provided does not match the expected type for - /// DutyAttester. - #[error("invalid unsigned attestation data")] + /// Unsigned data does not match the expected type for `DutyAttester`. + #[error("invalid unsigned attestation data: does not match DutyAttester")] InvalidAttestationData, - /// The unsigned data provided does not match the expected type for - /// DutyAggregator. - #[error("invalid unsigned aggregated attestation")] + /// Unsigned data does not match the expected type for `DutyAggregator`. + #[error("invalid unsigned aggregated attestation: does not match DutyAggregator")] InvalidAggregatedAttestation, - /// The unsigned data provided does not match the expected type for - /// DutySyncContribution. - #[error("invalid unsigned sync committee contribution")] + /// Unsigned data does not match the expected type for + /// `DutySyncContribution`. + #[error("invalid unsigned sync committee contribution: does not match DutySyncContribution")] InvalidSyncContribution, } From c3b084442b35dac318e51fa2df485c083641dab3 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Fri, 15 May 2026 16:07:30 +0200 Subject: [PATCH 18/47] Fixed bug with second aggregations on the same slot --- crates/core/src/dutydb/memory.rs | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/crates/core/src/dutydb/memory.rs b/crates/core/src/dutydb/memory.rs index 02a2e8d2..60728e6a 100644 --- a/crates/core/src/dutydb/memory.rs +++ b/crates/core/src/dutydb/memory.rs @@ -149,7 +149,6 @@ struct PkKey { /// Lookup key for aggregated attestations: (slot, attestation data root). #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] struct AggKey { - slot: u64, root: phase0::Root, } @@ -317,11 +316,9 @@ impl MemDB { /// attestation root is available. pub async fn await_agg_attestation( &self, - slot: u64, attestation_root: phase0::Root, ) -> Result { let key = AggKey { - slot, root: attestation_root, }; self.await_data(&self.agg_notify, |s| s.agg_duties.get(&key).map(|a| &a.0)) @@ -528,14 +525,12 @@ impl State { let root = att_data.tree_hash_root().0; let slot = att_data.slot; - let key = AggKey { slot, root }; - // Unlike Go (memory.go:458-460) which keys by {slot,root} making - // ClashingDataRoot unreachable, we detect a real clash by checking for - // a different root at the same slot. - if let Some(existing_keys) = self.agg_keys_by_slot.get(&slot) { - if existing_keys.iter().any(|k| k.root != root) { - return Err(Error::ClashingDataRoot); - } + // Unlike Go implementation, we key by root only, slot field is redundant. + let key = AggKey { root }; + if self.agg_duties.contains_key(&key) { + // we don't check existingDataRoot != providedDataRoot because these values + // comes from the same source and the error was unreachable + self.agg_duties.insert(key, agg.clone()); } else { self.agg_duties.insert(key, agg.clone()); self.agg_keys_by_slot.entry(slot).or_default().push(key); @@ -971,7 +966,7 @@ pub(crate) mod tests { let root = att_data.tree_hash_root().0; let db_clone = Arc::clone(&db); - let waiter = tokio::spawn(async move { db_clone.await_agg_attestation(SLOT, root).await }); + let waiter = tokio::spawn(async move { db_clone.await_agg_attestation(root).await }); let mut set = UnsignedDataSet::new(); set.insert(random_core_pub_key(), UnsignedDutyData::AggAttestation(agg)); From bbd4be3416f8e9eb6830ec7cb6fe0053944e7591 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Fri, 15 May 2026 16:15:49 +0200 Subject: [PATCH 19/47] clippy --- crates/core/src/dutydb/memory.rs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/crates/core/src/dutydb/memory.rs b/crates/core/src/dutydb/memory.rs index 60728e6a..204d7264 100644 --- a/crates/core/src/dutydb/memory.rs +++ b/crates/core/src/dutydb/memory.rs @@ -527,13 +527,16 @@ impl State { // Unlike Go implementation, we key by root only, slot field is redundant. let key = AggKey { root }; - if self.agg_duties.contains_key(&key) { - // we don't check existingDataRoot != providedDataRoot because these values - // comes from the same source and the error was unreachable - self.agg_duties.insert(key, agg.clone()); - } else { - self.agg_duties.insert(key, agg.clone()); - self.agg_keys_by_slot.entry(slot).or_default().push(key); + match self.agg_duties.entry(key) { + std::collections::hash_map::Entry::Occupied(mut e) => { + // we don't check existingDataRoot != providedDataRoot because these values + // comes from the same source and the error was unreachable + e.insert(agg.clone()); + } + std::collections::hash_map::Entry::Vacant(e) => { + e.insert(agg.clone()); + self.agg_keys_by_slot.entry(slot).or_default().push(key); + } } Ok(()) From 32486cac44dbf8aabd7313f0ef185211d9cdc9e9 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Fri, 15 May 2026 16:45:27 +0200 Subject: [PATCH 20/47] fixed #7 --- crates/core/src/dutydb/memory.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/core/src/dutydb/memory.rs b/crates/core/src/dutydb/memory.rs index 204d7264..9855956b 100644 --- a/crates/core/src/dutydb/memory.rs +++ b/crates/core/src/dutydb/memory.rs @@ -231,12 +231,12 @@ impl MemDB { return Err(Error::DeprecatedDutyBuilderProposer); } + let mut state = self.state.write().await; + if !self.deadliner.add(duty.clone()).await { return Err(Error::ExpiredDuty); } - let mut state = self.state.write().await; - match duty.duty_type { DutyType::Proposer => { if unsigned_set.len() > 1 { From a7d8a16950037d73c0b052d3a0ac18d8cb122aa3 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Fri, 15 May 2026 16:49:25 +0200 Subject: [PATCH 21/47] better implementation of the #1 --- crates/core/src/dutydb/memory.rs | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/crates/core/src/dutydb/memory.rs b/crates/core/src/dutydb/memory.rs index 9855956b..5bf839c6 100644 --- a/crates/core/src/dutydb/memory.rs +++ b/crates/core/src/dutydb/memory.rs @@ -527,17 +527,12 @@ impl State { // Unlike Go implementation, we key by root only, slot field is redundant. let key = AggKey { root }; - match self.agg_duties.entry(key) { - std::collections::hash_map::Entry::Occupied(mut e) => { - // we don't check existingDataRoot != providedDataRoot because these values - // comes from the same source and the error was unreachable - e.insert(agg.clone()); - } - std::collections::hash_map::Entry::Vacant(e) => { - e.insert(agg.clone()); - self.agg_keys_by_slot.entry(slot).or_default().push(key); - } + if !self.agg_duties.contains_key(&key) { + self.agg_keys_by_slot.entry(slot).or_default().push(key); } + // we don't check existingDataRoot != providedDataRoot because these values + // comes from the same source and the error was unreachable + self.agg_duties.insert(key, agg.clone()); // unconditional overwrite Ok(()) } From 6836a4c4ec3ec7e1ff218cde3ed5ef8d6e825981 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Fri, 15 May 2026 17:16:15 +0200 Subject: [PATCH 22/47] Impl Drop for MemDB --- crates/core/src/dutydb/memory.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/core/src/dutydb/memory.rs b/crates/core/src/dutydb/memory.rs index 5bf839c6..1fa4bb3d 100644 --- a/crates/core/src/dutydb/memory.rs +++ b/crates/core/src/dutydb/memory.rs @@ -218,8 +218,8 @@ impl MemDB { } } - /// Shuts down the DB, causing all pending `await_*` calls to return an - /// error. + /// Shuts down the DB, signalling all current and future `await_*` calls to + /// return `Error::Shutdown` on their next poll. pub fn shutdown(&self) { self.cancel.cancel(); } @@ -390,6 +390,12 @@ impl MemDB { } } +impl Drop for MemDB { + fn drop(&mut self) { + self.cancel.cancel(); + } +} + impl State { fn store_proposal(&mut self, proposal: &VersionedProposal) -> Result<()> { let slot = proposal.slot(); From 9933d7e6a72cc2466021ef99109ff2c2d6e3ddea Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Fri, 15 May 2026 17:17:36 +0200 Subject: [PATCH 23/47] fixed comment 2 --- crates/core/src/dutydb/memory.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/core/src/dutydb/memory.rs b/crates/core/src/dutydb/memory.rs index 1fa4bb3d..ad612905 100644 --- a/crates/core/src/dutydb/memory.rs +++ b/crates/core/src/dutydb/memory.rs @@ -194,9 +194,9 @@ pub struct MemDB { impl MemDB { /// Creates a new in-memory DutyDB. pub fn new(deadliner: Arc, cancel: CancellationToken) -> Self { - let deadliner_rx = deadliner - .c() - .expect("Deadliner::c() must be called only once"); + let deadliner_rx = deadliner.c().expect( + "Deadliner::c() returned None — the receiver was already consumed. Each MemDB must use a fresh Deadliner.", + ); Self { state: RwLock::new(State { att_duties: HashMap::new(), From fbd66ec6afbb8afe3c03b5f5a2988b2ea3bdddab Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Fri, 15 May 2026 17:22:51 +0200 Subject: [PATCH 24/47] More detailed errors --- crates/core/src/dutydb/memory.rs | 115 ++++++++++++++++++++++--------- 1 file changed, 84 insertions(+), 31 deletions(-) diff --git a/crates/core/src/dutydb/memory.rs b/crates/core/src/dutydb/memory.rs index ad612905..fdb00605 100644 --- a/crates/core/src/dutydb/memory.rs +++ b/crates/core/src/dutydb/memory.rs @@ -10,6 +10,7 @@ use pluto_eth2api::{ }; use tokio::sync::{Notify, RwLock}; use tokio_util::sync::CancellationToken; +use tracing::{info, warn}; use tree_hash::TreeHash; use crate::{ @@ -45,28 +46,44 @@ pub enum Error { /// Two validators share the same `(slot, commIdx, valIdx)` with different /// public keys. - #[error( - "clashing public key: two validators share the same (slot, commIdx, valIdx) with different keys" - )] - ClashingPublicKey, + #[error("clashing public key: slot={slot} comm_idx={comm_idx} val_idx={val_idx}")] + ClashingPublicKey { + /// Slot of the attestation duty. + slot: u64, + /// Committee index. + comm_idx: u64, + /// Validator index. + val_idx: u64, + }, /// Two different attestation data objects for the same `(slot, commIdx)`. - #[error("clashing attestation data: two different data objects for the same (slot, commIdx)")] - ClashingAttestationData, + #[error("clashing attestation data: slot={slot} comm_idx={comm_idx}")] + ClashingAttestationData { + /// Slot of the attestation duty. + slot: u64, + /// Committee index. + comm_idx: u64, + }, /// Mismatched source checkpoint in the hardcoded `commIdx=0` compatibility /// entry. - #[error( - "clashing attestation data with hardcoded commidx=0 source: mismatched source checkpoint" - )] - ClashingAttestationDataCommIdx0Source, + #[error("clashing attestation data commidx=0 source: slot={slot} val_idx={val_idx}")] + ClashingAttestationDataCommIdx0Source { + /// Slot of the attestation duty. + slot: u64, + /// Validator index. + val_idx: u64, + }, /// Mismatched target checkpoint in the hardcoded `commIdx=0` compatibility /// entry. - #[error( - "clashing attestation data with hardcoded commidx=0 target: mismatched target checkpoint" - )] - ClashingAttestationDataCommIdx0Target, + #[error("clashing attestation data commidx=0 target: slot={slot} val_idx={val_idx}")] + ClashingAttestationDataCommIdx0Target { + /// Slot of the attestation duty. + slot: u64, + /// Validator index. + val_idx: u64, + }, /// Two different aggregated attestations for the same slot and attestation /// data root. @@ -75,14 +92,20 @@ pub enum Error { /// Two different sync contributions for the same `(slot, subcommIdx, /// root)`. - #[error( - "clashing sync contributions: two different contributions for the same (slot, subcommIdx, root)" - )] - ClashingSyncContributions, + #[error("clashing sync contributions: slot={slot} subcomm_idx={subcomm_idx}")] + ClashingSyncContributions { + /// Slot of the sync contribution duty. + slot: u64, + /// Subcommittee index. + subcomm_idx: u64, + }, /// Two different blocks for the same slot. - #[error("clashing blocks: two different blocks for the same slot")] - ClashingBlocks, + #[error("clashing blocks: slot={slot}")] + ClashingBlocks { + /// Slot of the proposer duty. + slot: u64, + }, /// No public key found for the given `(slot, commIdx, valIdx)`. #[error("pubkey not found for the given (slot, commIdx, valIdx)")] @@ -221,6 +244,7 @@ impl MemDB { /// Shuts down the DB, signalling all current and future `await_*` calls to /// return `Error::Shutdown` on their next poll. pub fn shutdown(&self) { + info!("dutydb: shutting down"); self.cancel.cancel(); } @@ -401,7 +425,8 @@ impl State { let slot = proposal.slot(); if let Some(existing) = self.pro_duties.get(&slot) { if existing.root() != proposal.root() { - return Err(Error::ClashingBlocks); + warn!(slot, "dutydb: clashing blocks"); + return Err(Error::ClashingBlocks { slot }); } } else { self.pro_duties.insert(slot, proposal.clone()); @@ -437,7 +462,12 @@ impl State { }; if let Some(&existing) = self.att_pub_keys.get(&pk_key) { if existing != pubkey { - return Err(Error::ClashingPublicKey); + warn!(slot, comm_idx, val_idx, "dutydb: clashing public key"); + return Err(Error::ClashingPublicKey { + slot, + comm_idx, + val_idx, + }); } } else { self.att_pub_keys.insert(pk_key, pubkey); @@ -464,7 +494,8 @@ impl State { || existing.target != data.target || existing.beacon_block_root != data.beacon_block_root { - return Err(Error::ClashingAttestationData); + warn!(slot, comm_idx, "dutydb: clashing attestation data"); + return Err(Error::ClashingAttestationData { slot, comm_idx }); } } else { self.att_duties.insert(att_key, data.clone()); @@ -499,7 +530,12 @@ impl State { }; if let Some(&existing) = self.att_pub_keys.get(&pk_key0) { if existing != pubkey { - return Err(Error::ClashingPublicKey); + warn!(slot, val_idx, "dutydb: clashing public key at commidx=0"); + return Err(Error::ClashingPublicKey { + slot, + comm_idx: 0, + val_idx, + }); } } else { self.att_pub_keys.insert(pk_key0, pubkey); @@ -515,10 +551,18 @@ impl State { }; if let Some(existing) = self.att_duties.get(&att_key0) { if existing.source != data.source { - return Err(Error::ClashingAttestationDataCommIdx0Source); + warn!( + slot, + val_idx, "dutydb: clashing attestation data commidx=0 source" + ); + return Err(Error::ClashingAttestationDataCommIdx0Source { slot, val_idx }); } if existing.target != data.target { - return Err(Error::ClashingAttestationDataCommIdx0Target); + warn!( + slot, + val_idx, "dutydb: clashing attestation data commidx=0 target" + ); + return Err(Error::ClashingAttestationDataCommIdx0Target { slot, val_idx }); } } else { self.att_duties.insert(att_key0, data.clone()); @@ -554,7 +598,15 @@ impl State { if let Some(existing) = self.contrib_duties.get(&key) { if existing.tree_hash_root().0 != inner.tree_hash_root().0 { - return Err(Error::ClashingSyncContributions); + warn!( + slot = inner.slot, + subcomm_idx = inner.subcommittee_index, + "dutydb: clashing sync contributions" + ); + return Err(Error::ClashingSyncContributions { + slot: inner.slot, + subcomm_idx: inner.subcommittee_index, + }); } } else { self.contrib_duties.insert(key, inner.clone()); @@ -569,6 +621,7 @@ impl State { fn delete_duty(&mut self, duty: Duty) -> Result<()> { let slot = duty.slot.inner(); + info!(slot, duty_type = %duty.duty_type, "dutydb: deleting expired duty"); match duty.duty_type { DutyType::Proposer => { self.pro_duties.remove(&slot); @@ -1020,7 +1073,7 @@ pub(crate) mod tests { ); let err = db.store(duty, set2).await.unwrap_err(); assert!( - matches!(err, Error::ClashingPublicKey), + matches!(err, Error::ClashingPublicKey { .. }), "expected ClashingPublicKey, got: {err}" ); } @@ -1047,7 +1100,7 @@ pub(crate) mod tests { set2.insert(random_core_pub_key(), UnsignedDutyData::Attestation(att_b)); let err = db.store(duty, set2).await.unwrap_err(); assert!( - matches!(err, Error::ClashingAttestationData), + matches!(err, Error::ClashingAttestationData { .. }), "expected ClashingAttestationData, got: {err}" ); } @@ -1079,7 +1132,7 @@ pub(crate) mod tests { set2.insert(random_core_pub_key(), UnsignedDutyData::Attestation(att_b)); let err = db.store(duty, set2).await.unwrap_err(); assert!( - matches!(err, Error::ClashingAttestationDataCommIdx0Source), + matches!(err, Error::ClashingAttestationDataCommIdx0Source { .. }), "expected ClashingAttestationDataCommIdx0Source, got: {err}" ); } @@ -1118,7 +1171,7 @@ pub(crate) mod tests { set2.insert(random_core_pub_key(), UnsignedDutyData::Attestation(att_b)); let err = db.store(duty, set2).await.unwrap_err(); assert!( - matches!(err, Error::ClashingAttestationDataCommIdx0Target), + matches!(err, Error::ClashingAttestationDataCommIdx0Target { .. }), "expected ClashingAttestationDataCommIdx0Target, got: {err}" ); } From 63dc96837c54e6f75431ab0ee7c595586e711b84 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Fri, 15 May 2026 17:35:33 +0200 Subject: [PATCH 25/47] fix comment 11 --- crates/core/src/dutydb/memory.rs | 2 -- crates/core/src/signeddata.rs | 40 ++++++++++++++++++++++++++++---- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/crates/core/src/dutydb/memory.rs b/crates/core/src/dutydb/memory.rs index fdb00605..62f2f87e 100644 --- a/crates/core/src/dutydb/memory.rs +++ b/crates/core/src/dutydb/memory.rs @@ -787,8 +787,6 @@ pub(crate) mod tests { }, }; VersionedProposal { - version: versioned::DataVersion::Phase0, - blinded: false, block: ProposalBlock::Phase0(block), } } diff --git a/crates/core/src/signeddata.rs b/crates/core/src/signeddata.rs index ce7cf374..f08b2145 100644 --- a/crates/core/src/signeddata.rs +++ b/crates/core/src/signeddata.rs @@ -1210,6 +1210,31 @@ pub enum ProposalBlock { } impl ProposalBlock { + /// Returns the fork version of this block. + pub fn version(&self) -> versioned::DataVersion { + match self { + Self::Phase0(_) => versioned::DataVersion::Phase0, + Self::Altair(_) => versioned::DataVersion::Altair, + Self::Bellatrix(_) | Self::BellatrixBlinded(_) => versioned::DataVersion::Bellatrix, + Self::Capella(_) | Self::CapellaBlinded(_) => versioned::DataVersion::Capella, + Self::Deneb { .. } | Self::DenebBlinded(_) => versioned::DataVersion::Deneb, + Self::Electra { .. } | Self::ElectraBlinded(_) => versioned::DataVersion::Electra, + Self::Fulu { .. } | Self::FuluBlinded(_) => versioned::DataVersion::Fulu, + } + } + + /// Returns true if this is a blinded block. + pub fn is_blinded(&self) -> bool { + matches!( + self, + Self::BellatrixBlinded(_) + | Self::CapellaBlinded(_) + | Self::DenebBlinded(_) + | Self::ElectraBlinded(_) + | Self::FuluBlinded(_) + ) + } + /// Returns the slot of this block. pub fn slot(&self) -> phase0::Slot { match self { @@ -1250,15 +1275,22 @@ impl ProposalBlock { /// Unsigned versioned proposal across all supported forks. #[derive(Debug, Clone, PartialEq, Eq)] pub struct VersionedProposal { - /// Fork version. - pub version: versioned::DataVersion, - /// True if this is a blinded proposal. - pub blinded: bool, /// Unsigned block payload. pub block: ProposalBlock, } impl VersionedProposal { + /// Returns the fork version, derived from the block variant. + pub fn version(&self) -> versioned::DataVersion { + self.block.version() + } + + /// Returns true if this is a blinded proposal, derived from the block + /// variant. + pub fn is_blinded(&self) -> bool { + self.block.is_blinded() + } + /// Returns the slot of the proposal block. pub fn slot(&self) -> phase0::Slot { self.block.slot() From 9f5284f54d1e90b3fdbad678cd6633c6c7dcc340 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Fri, 15 May 2026 17:39:06 +0200 Subject: [PATCH 26/47] fix unneeded pub(crate) --- crates/core/src/dutydb/memory.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/core/src/dutydb/memory.rs b/crates/core/src/dutydb/memory.rs index 62f2f87e..31397828 100644 --- a/crates/core/src/dutydb/memory.rs +++ b/crates/core/src/dutydb/memory.rs @@ -659,7 +659,7 @@ impl State { } #[cfg(test)] -pub(crate) mod tests { +mod tests { use std::sync::Arc; use async_trait::async_trait; From 5f62b2326198d8aa4a1c21abdc441ac1d3134640 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Fri, 15 May 2026 17:47:19 +0200 Subject: [PATCH 27/47] comment --- crates/core/src/dutydb/memory.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/core/src/dutydb/memory.rs b/crates/core/src/dutydb/memory.rs index 31397828..4c4e94d2 100644 --- a/crates/core/src/dutydb/memory.rs +++ b/crates/core/src/dutydb/memory.rs @@ -366,6 +366,11 @@ impl MemDB { .await } + // A single Notify per duty type wakes all waiters on every store, not only + // those whose key matches. The number of concurrent waiters per duty type + // is small (one per validator), so the extra wakeups are cheap. A keyed + // notify (HashMap) would avoid them but adds complexity that + // isn't worth it here. async fn await_data( &self, notify: &Notify, From b7946d59f5196044bbc68539ca3e8665daab720e Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Fri, 15 May 2026 17:54:42 +0200 Subject: [PATCH 28/47] additional tests --- crates/core/src/dutydb/memory.rs | 199 +++++++++++++++++++++++++++---- 1 file changed, 179 insertions(+), 20 deletions(-) diff --git a/crates/core/src/dutydb/memory.rs b/crates/core/src/dutydb/memory.rs index 4c4e94d2..b97beb16 100644 --- a/crates/core/src/dutydb/memory.rs +++ b/crates/core/src/dutydb/memory.rs @@ -1227,26 +1227,6 @@ mod tests { ); } - #[tokio::test] - async fn mem_db_clashing_blocks() { - const SLOT: u64 = 123; - let db = make_db(); - let pubkey = random_core_pub_key(); - let duty = Duty::new(SlotNumber::new(SLOT), DutyType::Proposer); - - let block1 = phase0_proposal(SLOT, 1); - let block2 = phase0_proposal(SLOT, 2); - - let mut set1 = UnsignedDataSet::new(); - set1.insert(pubkey, UnsignedDutyData::Proposal(Box::new(block1))); - db.store(duty.clone(), set1).await.unwrap(); - - let mut set2 = UnsignedDataSet::new(); - set2.insert(pubkey, UnsignedDutyData::Proposal(Box::new(block2))); - let err = db.store(duty, set2).await.unwrap_err(); - assert!(err.to_string().contains("clashing blocks"), "got: {err}"); - } - #[tokio::test] async fn mem_db_clash_proposer() { const SLOT: u64 = 123; @@ -1311,4 +1291,183 @@ mod tests { // Should no longer be findable. assert!(db.pub_key_by_attestation(SLOT, 0, 0).await.is_err()); } + + #[tokio::test] + async fn agg_attestation_two_roots_same_slot() { + const SLOT: u64 = 300; + let db = make_db(); + + // Two aggregations at the same slot but different committee indices + // produce different tree-hash roots and must coexist. + let agg_a = agg_attestation_fixture(SLOT, 1, 0); + let agg_b = agg_attestation_fixture(SLOT, 2, 0); + let root_a = agg_a.data().unwrap().tree_hash_root().0; + let root_b = agg_b.data().unwrap().tree_hash_root().0; + assert_ne!(root_a, root_b); + + let duty = Duty::new(SlotNumber::new(SLOT), DutyType::Aggregator); + let mut set = UnsignedDataSet::new(); + set.insert( + random_core_pub_key(), + UnsignedDutyData::AggAttestation(agg_a), + ); + set.insert( + random_core_pub_key(), + UnsignedDutyData::AggAttestation(agg_b), + ); + db.store(duty, set).await.unwrap(); + + let att_a = db.await_agg_attestation(root_a).await.unwrap(); + assert_eq!(att_a.attestation.unwrap().data().slot, SLOT); + + let att_b = db.await_agg_attestation(root_b).await.unwrap(); + assert_eq!(att_b.attestation.unwrap().data().slot, SLOT); + } + + #[tokio::test] + async fn concurrent_attestation_waiters() { + const SLOT: u64 = 400; + const COMM_IDX: u64 = 5; + const N: usize = 100; + + let db = Arc::new(make_db()); + let handles: Vec<_> = (0..N) + .map(|_| { + let db = Arc::clone(&db); + tokio::spawn(async move { db.await_attestation(SLOT, COMM_IDX).await }) + }) + .collect(); + + tokio::task::yield_now().await; + + let att = att_data(SLOT, COMM_IDX, 0); + let mut set = UnsignedDataSet::new(); + set.insert(random_core_pub_key(), UnsignedDutyData::Attestation(att)); + db.store(Duty::new(SlotNumber::new(SLOT), DutyType::Attester), set) + .await + .unwrap(); + + for handle in handles { + let data = handle.await.unwrap().unwrap(); + assert_eq!(data.slot, SLOT); + assert_eq!(data.index, COMM_IDX); + } + } + + #[tokio::test] + async fn await_attestation_before_store() { + const SLOT: u64 = 500; + const COMM_IDX: u64 = 2; + + let db = Arc::new(make_db()); + let handles: Vec<_> = (0..3) + .map(|_| { + let db = Arc::clone(&db); + tokio::spawn(async move { db.await_attestation(SLOT, COMM_IDX).await }) + }) + .collect(); + + tokio::task::yield_now().await; + + let att = att_data(SLOT, COMM_IDX, 0); + let mut set = UnsignedDataSet::new(); + set.insert(random_core_pub_key(), UnsignedDutyData::Attestation(att)); + db.store(Duty::new(SlotNumber::new(SLOT), DutyType::Attester), set) + .await + .unwrap(); + + for handle in handles { + handle.await.unwrap().unwrap(); + } + } + + #[tokio::test] + async fn await_before_shutdown() { + let db = Arc::new(make_db()); + + let db_clone = Arc::clone(&db); + let waiter = tokio::spawn(async move { db_clone.await_attestation(9999, 0).await }); + + tokio::task::yield_now().await; + db.shutdown(); + + let err = waiter.await.unwrap().unwrap_err(); + assert!( + err.to_string().contains("shutdown"), + "expected shutdown error, got: {err}" + ); + } + + #[tokio::test] + async fn shutdown_wakes_await_attestation() { + let db = make_db(); + db.shutdown(); + + let err = db.await_attestation(0, 0).await.unwrap_err(); + assert!(err.to_string().contains("shutdown"), "got: {err}"); + } + + #[tokio::test] + async fn shutdown_wakes_await_agg_attestation() { + let db = make_db(); + db.shutdown(); + + let err = db.await_agg_attestation([0u8; 32]).await.unwrap_err(); + assert!(err.to_string().contains("shutdown"), "got: {err}"); + } + + #[tokio::test] + async fn invalid_unsigned_type_proposer() { + let db = make_db(); + let duty = Duty::new(SlotNumber::new(1), DutyType::Proposer); + let mut set = UnsignedDataSet::new(); + set.insert( + random_core_pub_key(), + UnsignedDutyData::Attestation(att_data(1, 0, 0)), + ); + let err = db.store(duty, set).await.unwrap_err(); + assert!(matches!(err, Error::InvalidVersionedProposal), "got: {err}"); + } + + #[tokio::test] + async fn invalid_unsigned_type_attester() { + let db = make_db(); + let duty = Duty::new(SlotNumber::new(1), DutyType::Attester); + let mut set = UnsignedDataSet::new(); + set.insert( + random_core_pub_key(), + UnsignedDutyData::Proposal(Box::new(phase0_proposal(1, 0))), + ); + let err = db.store(duty, set).await.unwrap_err(); + assert!(matches!(err, Error::InvalidAttestationData), "got: {err}"); + } + + #[tokio::test] + async fn invalid_unsigned_type_aggregator() { + let db = make_db(); + let duty = Duty::new(SlotNumber::new(1), DutyType::Aggregator); + let mut set = UnsignedDataSet::new(); + set.insert( + random_core_pub_key(), + UnsignedDutyData::Attestation(att_data(1, 0, 0)), + ); + let err = db.store(duty, set).await.unwrap_err(); + assert!( + matches!(err, Error::InvalidAggregatedAttestation), + "got: {err}" + ); + } + + #[tokio::test] + async fn invalid_unsigned_type_sync_contribution() { + let db = make_db(); + let duty = Duty::new(SlotNumber::new(1), DutyType::SyncContribution); + let mut set = UnsignedDataSet::new(); + set.insert( + random_core_pub_key(), + UnsignedDutyData::Attestation(att_data(1, 0, 0)), + ); + let err = db.store(duty, set).await.unwrap_err(); + assert!(matches!(err, Error::InvalidSyncContribution), "got: {err}"); + } } From a17b5e92bb526e0a483037252f31ceb8e09558b8 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Tue, 19 May 2026 09:23:25 +0200 Subject: [PATCH 29/47] removed unused, renamed variables --- crates/core/src/dutydb/memory.rs | 183 ++++++++++++++++++------------- 1 file changed, 106 insertions(+), 77 deletions(-) diff --git a/crates/core/src/dutydb/memory.rs b/crates/core/src/dutydb/memory.rs index b97beb16..0f2be5fd 100644 --- a/crates/core/src/dutydb/memory.rs +++ b/crates/core/src/dutydb/memory.rs @@ -46,58 +46,59 @@ pub enum Error { /// Two validators share the same `(slot, commIdx, valIdx)` with different /// public keys. - #[error("clashing public key: slot={slot} comm_idx={comm_idx} val_idx={val_idx}")] + #[error( + "clashing public key: slot={slot} committee_index={committee_index} validator_index={validator_index}" + )] ClashingPublicKey { /// Slot of the attestation duty. slot: u64, /// Committee index. - comm_idx: u64, + committee_index: u64, /// Validator index. - val_idx: u64, + validator_index: u64, }, /// Two different attestation data objects for the same `(slot, commIdx)`. - #[error("clashing attestation data: slot={slot} comm_idx={comm_idx}")] + #[error("clashing attestation data: slot={slot} committee_index={committee_index}")] ClashingAttestationData { /// Slot of the attestation duty. slot: u64, /// Committee index. - comm_idx: u64, + committee_index: u64, }, /// Mismatched source checkpoint in the hardcoded `commIdx=0` compatibility /// entry. - #[error("clashing attestation data commidx=0 source: slot={slot} val_idx={val_idx}")] + #[error( + "clashing attestation data commidx=0 source: slot={slot} validator_index={validator_index}" + )] ClashingAttestationDataCommIdx0Source { /// Slot of the attestation duty. slot: u64, /// Validator index. - val_idx: u64, + validator_index: u64, }, /// Mismatched target checkpoint in the hardcoded `commIdx=0` compatibility /// entry. - #[error("clashing attestation data commidx=0 target: slot={slot} val_idx={val_idx}")] + #[error( + "clashing attestation data commidx=0 target: slot={slot} validator_index={validator_index}" + )] ClashingAttestationDataCommIdx0Target { /// Slot of the attestation duty. slot: u64, /// Validator index. - val_idx: u64, + validator_index: u64, }, - /// Two different aggregated attestations for the same slot and attestation - /// data root. - #[error("clashing data root: two different aggregated attestations for the same slot and root")] - ClashingDataRoot, - /// Two different sync contributions for the same `(slot, subcommIdx, /// root)`. - #[error("clashing sync contributions: slot={slot} subcomm_idx={subcomm_idx}")] + #[error("clashing sync contributions: slot={slot} subcommittee_index={subcommittee_index}")] ClashingSyncContributions { /// Slot of the sync contribution duty. slot: u64, /// Subcommittee index. - subcomm_idx: u64, + subcommittee_index: u64, }, /// Two different blocks for the same slot. @@ -108,8 +109,17 @@ pub enum Error { }, /// No public key found for the given `(slot, commIdx, valIdx)`. - #[error("pubkey not found for the given (slot, commIdx, valIdx)")] - PubKeyNotFound, + #[error( + "pubkey not found for the given (slot={slot}, commIdx={committee_index}, valIdx={validator_index})" + )] + PubKeyNotFound { + /// Slot of the attestation duty. + slot: u64, + /// Committee index of the attestation duty. + committee_index: u64, + /// Validator index of the attestation duty. + validator_index: u64, + }, /// Duty type is not handled by the delete path. #[error("unknown duty type: not handled by delete")] @@ -157,7 +167,7 @@ pub type UnsignedDataSet = HashMap; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] struct AttKey { slot: u64, - committee_idx: u64, + committee_index: u64, } /// Lookup key for public-key-by-attestation: (slot, committee index, validator @@ -165,8 +175,8 @@ struct AttKey { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] struct PkKey { slot: u64, - committee_idx: u64, - validator_idx: u64, + committee_index: u64, + validator_index: u64, } /// Lookup key for aggregated attestations: (slot, attestation data root). @@ -180,7 +190,7 @@ struct AggKey { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] struct ContribKey { slot: u64, - subcomm_idx: u64, + subcommittee_index: u64, root: phase0::Root, } @@ -326,11 +336,11 @@ impl MemDB { pub async fn await_attestation( &self, slot: u64, - comm_idx: u64, + committee_index: u64, ) -> Result { let key = AttKey { slot, - committee_idx: comm_idx, + committee_index, }; self.await_data(&self.att_notify, |s| s.att_duties.get(&key)) .await @@ -354,12 +364,12 @@ impl MemDB { pub async fn await_sync_contribution( &self, slot: u64, - subcomm_idx: u64, + subcommittee_index: u64, beacon_block_root: phase0::Root, ) -> Result { let key = ContribKey { slot, - subcomm_idx, + subcommittee_index, root: beacon_block_root, }; self.await_data(&self.contrib_notify, |s| s.contrib_duties.get(&key)) @@ -403,19 +413,23 @@ impl MemDB { pub async fn pub_key_by_attestation( &self, slot: u64, - comm_idx: u64, - val_idx: u64, + committee_index: u64, + validator_index: u64, ) -> Result { let state = self.state.read().await; state .att_pub_keys .get(&PkKey { slot, - committee_idx: comm_idx, - validator_idx: val_idx, + committee_index, + validator_index, }) .copied() - .ok_or(Error::PubKeyNotFound) + .ok_or(Error::PubKeyNotFound { + slot, + committee_index, + validator_index, + }) } } @@ -442,12 +456,12 @@ impl State { fn store_attestation(&mut self, pubkey: PubKey, att: &AttestationData) -> Result<()> { let slot = att.data.slot; let duty_slot = att.duty.slot; - let comm_idx = att.duty.committee_index; - let val_idx = att.duty.validator_index; + let committee_index = att.duty.committee_index; + let validator_index = att.duty.validator_index; - self.store_att_pubkey(slot, duty_slot, comm_idx, val_idx, pubkey)?; - self.store_att_data(slot, comm_idx, &att.data)?; - self.store_att_compat_commidx0(slot, duty_slot, val_idx, pubkey, &att.data)?; + self.store_att_pubkey(slot, duty_slot, committee_index, validator_index, pubkey)?; + self.store_att_data(slot, committee_index, &att.data)?; + self.store_att_compat_commidx0(slot, duty_slot, validator_index, pubkey, &att.data)?; Ok(()) } @@ -456,22 +470,25 @@ impl State { &mut self, slot: u64, duty_slot: u64, - comm_idx: u64, - val_idx: u64, + committee_index: u64, + validator_index: u64, pubkey: PubKey, ) -> Result<()> { let pk_key = PkKey { slot, - committee_idx: comm_idx, - validator_idx: val_idx, + committee_index, + validator_index, }; if let Some(&existing) = self.att_pub_keys.get(&pk_key) { if existing != pubkey { - warn!(slot, comm_idx, val_idx, "dutydb: clashing public key"); + warn!( + slot, + committee_index, validator_index, "dutydb: clashing public key" + ); return Err(Error::ClashingPublicKey { slot, - comm_idx, - val_idx, + committee_index, + validator_index, }); } } else { @@ -487,20 +504,23 @@ impl State { fn store_att_data( &mut self, slot: u64, - comm_idx: u64, + committee_index: u64, data: &phase0::AttestationData, ) -> Result<()> { let att_key = AttKey { slot, - committee_idx: comm_idx, + committee_index, }; if let Some(existing) = self.att_duties.get(&att_key) { if existing.source != data.source || existing.target != data.target || existing.beacon_block_root != data.beacon_block_root { - warn!(slot, comm_idx, "dutydb: clashing attestation data"); - return Err(Error::ClashingAttestationData { slot, comm_idx }); + warn!(slot, committee_index, "dutydb: clashing attestation data"); + return Err(Error::ClashingAttestationData { + slot, + committee_index, + }); } } else { self.att_duties.insert(att_key, data.clone()); @@ -524,22 +544,25 @@ impl State { &mut self, slot: u64, duty_slot: u64, - val_idx: u64, + validator_index: u64, pubkey: PubKey, data: &phase0::AttestationData, ) -> Result<()> { let pk_key0 = PkKey { slot, - committee_idx: 0, - validator_idx: val_idx, + committee_index: 0, + validator_index, }; if let Some(&existing) = self.att_pub_keys.get(&pk_key0) { if existing != pubkey { - warn!(slot, val_idx, "dutydb: clashing public key at commidx=0"); + warn!( + slot, + validator_index, "dutydb: clashing public key at commidx=0" + ); return Err(Error::ClashingPublicKey { slot, - comm_idx: 0, - val_idx, + committee_index: 0, + validator_index, }); } } else { @@ -552,22 +575,28 @@ impl State { let att_key0 = AttKey { slot, - committee_idx: 0, + committee_index: 0, }; if let Some(existing) = self.att_duties.get(&att_key0) { if existing.source != data.source { warn!( slot, - val_idx, "dutydb: clashing attestation data commidx=0 source" + validator_index, "dutydb: clashing attestation data commidx=0 source" ); - return Err(Error::ClashingAttestationDataCommIdx0Source { slot, val_idx }); + return Err(Error::ClashingAttestationDataCommIdx0Source { + slot, + validator_index, + }); } if existing.target != data.target { warn!( slot, - val_idx, "dutydb: clashing attestation data commidx=0 target" + validator_index, "dutydb: clashing attestation data commidx=0 target" ); - return Err(Error::ClashingAttestationDataCommIdx0Target { slot, val_idx }); + return Err(Error::ClashingAttestationDataCommIdx0Target { + slot, + validator_index, + }); } } else { self.att_duties.insert(att_key0, data.clone()); @@ -597,7 +626,7 @@ impl State { let key = ContribKey { slot: inner.slot, - subcomm_idx: inner.subcommittee_index, + subcommittee_index: inner.subcommittee_index, root: inner.beacon_block_root, }; @@ -605,12 +634,12 @@ impl State { if existing.tree_hash_root().0 != inner.tree_hash_root().0 { warn!( slot = inner.slot, - subcomm_idx = inner.subcommittee_index, + subcommittee_index = inner.subcommittee_index, "dutydb: clashing sync contributions" ); return Err(Error::ClashingSyncContributions { slot: inner.slot, - subcomm_idx: inner.subcommittee_index, + subcommittee_index: inner.subcommittee_index, }); } } else { @@ -638,7 +667,7 @@ impl State { self.att_pub_keys.remove(&key); self.att_duties.remove(&AttKey { slot: key.slot, - committee_idx: key.committee_idx, + committee_index: key.committee_index, }); } } @@ -742,11 +771,11 @@ mod tests { MemDB::new(deadliner, CancellationToken::new()) } - fn att_data(slot: u64, comm_idx: u64, val_idx: u64) -> AttestationData { + fn att_data(slot: u64, committee_index: u64, validator_index: u64) -> AttestationData { AttestationData { data: phase0::AttestationData { slot, - index: comm_idx, + index: committee_index, beacon_block_root: [0u8; 32], source: phase0::Checkpoint { epoch: 0, @@ -759,11 +788,11 @@ mod tests { }, duty: AttesterDuty { slot, - validator_index: val_idx, - committee_index: comm_idx, + validator_index, + committee_index, committee_length: 8, committees_at_slot: 1, - validator_committee_index: val_idx, + validator_committee_index: validator_index, }, } } @@ -798,13 +827,13 @@ mod tests { fn sync_contribution_fixture( slot: u64, - subcomm_idx: u64, + subcommittee_index: u64, root: phase0::Root, ) -> SyncContribution { SyncContribution(altair::SyncCommitteeContribution { slot, beacon_block_root: root, - subcommittee_index: subcomm_idx, + subcommittee_index, aggregation_bits: pluto_ssz::BitVector::default(), signature: [0u8; 96], }) @@ -943,10 +972,10 @@ mod tests { for i in 0..3u8 { let slot = u64::from(i).saturating_add(100); - let subcomm_idx = u64::from(i); + let subcommittee_index = u64::from(i); let root = random_root(i); - let contrib = sync_contribution_fixture(slot, subcomm_idx, root); + let contrib = sync_contribution_fixture(slot, subcommittee_index, root); let mut set = UnsignedDataSet::new(); set.insert( @@ -962,11 +991,11 @@ mod tests { .unwrap(); let resp = db - .await_sync_contribution(slot, subcomm_idx, root) + .await_sync_contribution(slot, subcommittee_index, root) .await .unwrap(); assert_eq!(resp.slot, slot); - assert_eq!(resp.subcommittee_index, subcomm_idx); + assert_eq!(resp.subcommittee_index, subcommittee_index); assert_eq!(resp.beacon_block_root, root); } } @@ -985,12 +1014,12 @@ mod tests { fn agg_attestation_fixture( slot: u64, - comm_idx: u64, - val_idx: u64, + committee_index: u64, + validator_index: u64, ) -> VersionedAggregatedAttestation { let data = phase0::AttestationData { slot, - index: comm_idx, + index: committee_index, beacon_block_root: [0u8; 32], source: phase0::Checkpoint { epoch: 0, @@ -1008,7 +1037,7 @@ mod tests { }; VersionedAggregatedAttestation(versioned::VersionedAttestation { version: versioned::DataVersion::Phase0, - validator_index: Some(val_idx), + validator_index: Some(validator_index), attestation: Some(versioned::AttestationPayload::Phase0(att)), }) } From 0628163b4315e8a14e3a45f2afd61138a7470e95 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Tue, 19 May 2026 10:14:14 +0200 Subject: [PATCH 30/47] comment fixed --- crates/core/src/dutydb/memory.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/core/src/dutydb/memory.rs b/crates/core/src/dutydb/memory.rs index 0f2be5fd..b4d7cbd4 100644 --- a/crates/core/src/dutydb/memory.rs +++ b/crates/core/src/dutydb/memory.rs @@ -179,7 +179,7 @@ struct PkKey { validator_index: u64, } -/// Lookup key for aggregated attestations: (slot, attestation data root). +/// Lookup key for aggregated attestations: attestation data root. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] struct AggKey { root: phase0::Root, From 2db5faf62483212e3845c6aa2298f897b9df2cef Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Tue, 19 May 2026 10:32:40 +0200 Subject: [PATCH 31/47] more comments fixed --- crates/core/src/dutydb/memory.rs | 38 +++++++++++++++++--------------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/crates/core/src/dutydb/memory.rs b/crates/core/src/dutydb/memory.rs index b4d7cbd4..3d9f73b4 100644 --- a/crates/core/src/dutydb/memory.rs +++ b/crates/core/src/dutydb/memory.rs @@ -44,8 +44,8 @@ pub enum Error { #[error("dutydb shutdown: query could not be answered")] Shutdown, - /// Two validators share the same `(slot, commIdx, valIdx)` with different - /// public keys. + /// Two validators share the same `(slot, committee_index, valIdx)` with + /// different public keys. #[error( "clashing public key: slot={slot} committee_index={committee_index} validator_index={validator_index}" )] @@ -58,7 +58,8 @@ pub enum Error { validator_index: u64, }, - /// Two different attestation data objects for the same `(slot, commIdx)`. + /// Two different attestation data objects for the same `(slot, + /// committee_index)`. #[error("clashing attestation data: slot={slot} committee_index={committee_index}")] ClashingAttestationData { /// Slot of the attestation duty. @@ -67,10 +68,10 @@ pub enum Error { committee_index: u64, }, - /// Mismatched source checkpoint in the hardcoded `commIdx=0` compatibility - /// entry. + /// Mismatched source checkpoint in the hardcoded `committee_index=0` + /// compatibility entry. #[error( - "clashing attestation data commidx=0 source: slot={slot} validator_index={validator_index}" + "clashing attestation data committee_index=0 source: slot={slot} validator_index={validator_index}" )] ClashingAttestationDataCommIdx0Source { /// Slot of the attestation duty. @@ -79,10 +80,10 @@ pub enum Error { validator_index: u64, }, - /// Mismatched target checkpoint in the hardcoded `commIdx=0` compatibility - /// entry. + /// Mismatched target checkpoint in the hardcoded `committee_index=0` + /// compatibility entry. #[error( - "clashing attestation data commidx=0 target: slot={slot} validator_index={validator_index}" + "clashing attestation data committee_index=0 target: slot={slot} validator_index={validator_index}" )] ClashingAttestationDataCommIdx0Target { /// Slot of the attestation duty. @@ -91,8 +92,8 @@ pub enum Error { validator_index: u64, }, - /// Two different sync contributions for the same `(slot, subcommIdx, - /// root)`. + /// Two different sync contributions for the same `(slot, + /// subcommittee_index, root)`. #[error("clashing sync contributions: slot={slot} subcommittee_index={subcommittee_index}")] ClashingSyncContributions { /// Slot of the sync contribution duty. @@ -108,9 +109,10 @@ pub enum Error { slot: u64, }, - /// No public key found for the given `(slot, commIdx, valIdx)`. + /// No public key found for the given `(slot, committee_index, + /// validator_index)`. #[error( - "pubkey not found for the given (slot={slot}, commIdx={committee_index}, valIdx={validator_index})" + "pubkey not found for the given (slot={slot}, committee_index={committee_index}, validator_index={validator_index})" )] PubKeyNotFound { /// Slot of the attestation duty. @@ -529,7 +531,7 @@ impl State { } // Post-Electra, committee index 0 is the protocol default and well-behaved - // VCs request attestation data with commIdx=0. However, some VCs still + // VCs request attestation data with committee_index=0. However, some VCs still // request with the actual committee index, so Charon stores both: the real // index (handled by the caller) and a hardcoded-0 entry here. Once all VCs // are fixed this function can be removed. @@ -557,7 +559,7 @@ impl State { if existing != pubkey { warn!( slot, - validator_index, "dutydb: clashing public key at commidx=0" + validator_index, "dutydb: clashing public key at committee_index=0" ); return Err(Error::ClashingPublicKey { slot, @@ -581,7 +583,7 @@ impl State { if existing.source != data.source { warn!( slot, - validator_index, "dutydb: clashing attestation data commidx=0 source" + validator_index, "dutydb: clashing attestation data committee_index=0 source" ); return Err(Error::ClashingAttestationDataCommIdx0Source { slot, @@ -591,7 +593,7 @@ impl State { if existing.target != data.target { warn!( slot, - validator_index, "dutydb: clashing attestation data commidx=0 target" + validator_index, "dutydb: clashing attestation data committee_index=0 target" ); return Err(Error::ClashingAttestationDataCommIdx0Target { slot, @@ -615,7 +617,7 @@ impl State { self.agg_keys_by_slot.entry(slot).or_default().push(key); } // we don't check existingDataRoot != providedDataRoot because these values - // comes from the same source and the error was unreachable + // come from the same source and the error was unreachable self.agg_duties.insert(key, agg.clone()); // unconditional overwrite Ok(()) From eefe1c9749e9963a001eb295b50ebe193d08e9a9 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Tue, 19 May 2026 12:00:01 +0200 Subject: [PATCH 32/47] comment updated --- crates/core/src/dutydb/memory.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/core/src/dutydb/memory.rs b/crates/core/src/dutydb/memory.rs index 3d9f73b4..1134a00d 100644 --- a/crates/core/src/dutydb/memory.rs +++ b/crates/core/src/dutydb/memory.rs @@ -228,6 +228,9 @@ pub struct MemDB { impl MemDB { /// Creates a new in-memory DutyDB. + /// cancel: cancellation token that shuts down the DB, it should be a child + /// token of the caller token to prevent further cancellation + /// propagation pub fn new(deadliner: Arc, cancel: CancellationToken) -> Self { let deadliner_rx = deadliner.c().expect( "Deadliner::c() returned None — the receiver was already consumed. Each MemDB must use a fresh Deadliner.", From dd3a5133ad0a467929c3bc379f6ed41ab915fcd8 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Tue, 19 May 2026 13:24:21 +0200 Subject: [PATCH 33/47] more uses, removed export o Result --- crates/core/src/dutydb/memory.rs | 21 +++++++++++---------- crates/core/src/dutydb/mod.rs | 2 +- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/crates/core/src/dutydb/memory.rs b/crates/core/src/dutydb/memory.rs index 1134a00d..bf294da4 100644 --- a/crates/core/src/dutydb/memory.rs +++ b/crates/core/src/dutydb/memory.rs @@ -699,9 +699,10 @@ impl State { #[cfg(test)] mod tests { - use std::sync::Arc; + use std::sync::{Arc, Mutex}; use async_trait::async_trait; + use tokio::sync::mpsc::{Receiver, Sender, channel}; use tokio_util::sync::CancellationToken; use super::*; @@ -720,8 +721,8 @@ mod tests { true } - fn c(&self) -> Option> { - let (_, rx) = tokio::sync::mpsc::channel(1); + fn c(&self) -> Option> { + let (_, rx) = channel(1); Some(rx) } } @@ -729,18 +730,18 @@ mod tests { /// Deadliner that collects duties and can flush them to a channel on /// demand. pub(crate) struct TestDeadliner { - added: std::sync::Mutex>, - tx: tokio::sync::mpsc::Sender, - rx: std::sync::Mutex>>, + added: Mutex>, + tx: Sender, + rx: Mutex>>, } impl TestDeadliner { pub(crate) fn new() -> Arc { - let (tx, rx) = tokio::sync::mpsc::channel(64); + let (tx, rx) = channel(64); Arc::new(Self { - added: std::sync::Mutex::new(Vec::new()), + added: Mutex::new(Vec::new()), tx, - rx: std::sync::Mutex::new(Some(rx)), + rx: Mutex::new(Some(rx)), }) } @@ -763,7 +764,7 @@ mod tests { true } - fn c(&self) -> Option> { + fn c(&self) -> Option> { self.rx.lock().unwrap().take() } } diff --git a/crates/core/src/dutydb/mod.rs b/crates/core/src/dutydb/mod.rs index 6cdb9aa3..c96e9ddf 100644 --- a/crates/core/src/dutydb/mod.rs +++ b/crates/core/src/dutydb/mod.rs @@ -2,4 +2,4 @@ pub mod memory; -pub use memory::{Error, MemDB, Result, UnsignedDataSet, UnsignedDutyData}; +pub use memory::{Error, MemDB, UnsignedDataSet, UnsignedDutyData}; From f113de9e10dd74fb566222d50c3d3f43c399d175 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Tue, 19 May 2026 13:29:09 +0200 Subject: [PATCH 34/47] Checkpoint::default() --- crates/core/src/dutydb/memory.rs | 20 ++++---------------- crates/eth2api/src/spec/phase0.rs | 4 +++- 2 files changed, 7 insertions(+), 17 deletions(-) diff --git a/crates/core/src/dutydb/memory.rs b/crates/core/src/dutydb/memory.rs index bf294da4..8ae9880a 100644 --- a/crates/core/src/dutydb/memory.rs +++ b/crates/core/src/dutydb/memory.rs @@ -783,14 +783,8 @@ mod tests { slot, index: committee_index, beacon_block_root: [0u8; 32], - source: phase0::Checkpoint { - epoch: 0, - root: [0u8; 32], - }, - target: phase0::Checkpoint { - epoch: 0, - root: [0u8; 32], - }, + source: phase0::Checkpoint::default(), + target: phase0::Checkpoint::default(), }, duty: AttesterDuty { slot, @@ -1027,14 +1021,8 @@ mod tests { slot, index: committee_index, beacon_block_root: [0u8; 32], - source: phase0::Checkpoint { - epoch: 0, - root: [0u8; 32], - }, - target: phase0::Checkpoint { - epoch: 0, - root: [0u8; 32], - }, + source: phase0::Checkpoint::default(), + target: phase0::Checkpoint::default(), }; let att = phase0::Attestation { aggregation_bits: phase0::BitList::<2048>::default(), diff --git a/crates/eth2api/src/spec/phase0.rs b/crates/eth2api/src/spec/phase0.rs index d072c205..4b67a76f 100644 --- a/crates/eth2api/src/spec/phase0.rs +++ b/crates/eth2api/src/spec/phase0.rs @@ -324,7 +324,9 @@ pub struct SignedBeaconBlock { /// /// Spec: #[serde_as] -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, TreeHash, Serialize, Deserialize)] +#[derive( + Debug, Clone, PartialEq, Eq, Default, Encode, Decode, TreeHash, Serialize, Deserialize, +)] pub struct Checkpoint { /// Epoch associated with the checkpoint. #[serde_as(as = "serde_with::DisplayFromStr")] From 2b2fe1f94b261b21de3bd843cf73081623d996ae Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Tue, 19 May 2026 17:00:54 +0200 Subject: [PATCH 35/47] renamed variables --- crates/core/src/dutydb/memory.rs | 102 ++++++++++++++++--------------- 1 file changed, 53 insertions(+), 49 deletions(-) diff --git a/crates/core/src/dutydb/memory.rs b/crates/core/src/dutydb/memory.rs index 8ae9880a..bca6806d 100644 --- a/crates/core/src/dutydb/memory.rs +++ b/crates/core/src/dutydb/memory.rs @@ -197,14 +197,14 @@ struct ContribKey { } struct State { - att_duties: HashMap, - att_pub_keys: HashMap, - att_keys_by_slot: HashMap>, + attestation_duties: HashMap, + attestation_pub_keys: HashMap, + attestation_keys_by_slot: HashMap>, - pro_duties: HashMap, + proposer_duties: HashMap, - agg_duties: HashMap, - agg_keys_by_slot: HashMap>, + aggregation_duties: HashMap, + aggregation_keys_by_slot: HashMap>, contrib_duties: HashMap, contrib_keys_by_slot: HashMap>, @@ -218,9 +218,9 @@ struct State { /// blocking `await_*` queries when the relevant data becomes available. pub struct MemDB { state: RwLock, - att_notify: Notify, - pro_notify: Notify, - agg_notify: Notify, + attestation_notify: Notify, + proposer_notify: Notify, + aggregation_notify: Notify, contrib_notify: Notify, cancel: CancellationToken, deadliner: Arc, @@ -237,19 +237,19 @@ impl MemDB { ); Self { state: RwLock::new(State { - att_duties: HashMap::new(), - att_pub_keys: HashMap::new(), - att_keys_by_slot: HashMap::new(), - pro_duties: HashMap::new(), - agg_duties: HashMap::new(), - agg_keys_by_slot: HashMap::new(), + attestation_duties: HashMap::new(), + attestation_pub_keys: HashMap::new(), + attestation_keys_by_slot: HashMap::new(), + proposer_duties: HashMap::new(), + aggregation_duties: HashMap::new(), + aggregation_keys_by_slot: HashMap::new(), contrib_duties: HashMap::new(), contrib_keys_by_slot: HashMap::new(), deadliner_rx, }), - att_notify: Notify::new(), - pro_notify: Notify::new(), - agg_notify: Notify::new(), + attestation_notify: Notify::new(), + proposer_notify: Notify::new(), + aggregation_notify: Notify::new(), contrib_notify: Notify::new(), cancel, deadliner, @@ -286,7 +286,7 @@ impl MemDB { Some(UnsignedDutyData::Proposal(p)) => state.store_proposal(p)?, Some(_) => return Err(Error::InvalidVersionedProposal), } - self.pro_notify.notify_waiters(); + self.proposer_notify.notify_waiters(); } DutyType::Attester => { for (pubkey, data) in &unsigned_set { @@ -296,7 +296,7 @@ impl MemDB { }; state.store_attestation(*pubkey, att)?; } - self.att_notify.notify_waiters(); + self.attestation_notify.notify_waiters(); } DutyType::Aggregator => { for data in unsigned_set.values() { @@ -306,7 +306,7 @@ impl MemDB { }; state.store_agg_attestation(agg)?; } - self.agg_notify.notify_waiters(); + self.aggregation_notify.notify_waiters(); } DutyType::SyncContribution => { for data in unsigned_set.values() { @@ -332,7 +332,7 @@ impl MemDB { /// Blocks until a proposal for the given slot is available, then returns /// it. pub async fn await_proposal(&self, slot: u64) -> Result { - self.await_data(&self.pro_notify, |s| s.pro_duties.get(&slot)) + self.await_data(&self.proposer_notify, |s| s.proposer_duties.get(&slot)) .await } @@ -347,7 +347,7 @@ impl MemDB { slot, committee_index, }; - self.await_data(&self.att_notify, |s| s.att_duties.get(&key)) + self.await_data(&self.attestation_notify, |s| s.attestation_duties.get(&key)) .await } @@ -360,8 +360,10 @@ impl MemDB { let key = AggKey { root: attestation_root, }; - self.await_data(&self.agg_notify, |s| s.agg_duties.get(&key).map(|a| &a.0)) - .await + self.await_data(&self.aggregation_notify, |s| { + s.aggregation_duties.get(&key).map(|a| &a.0) + }) + .await } /// Blocks until a sync contribution for the given slot, subcommittee index, @@ -423,7 +425,7 @@ impl MemDB { ) -> Result { let state = self.state.read().await; state - .att_pub_keys + .attestation_pub_keys .get(&PkKey { slot, committee_index, @@ -447,13 +449,13 @@ impl Drop for MemDB { impl State { fn store_proposal(&mut self, proposal: &VersionedProposal) -> Result<()> { let slot = proposal.slot(); - if let Some(existing) = self.pro_duties.get(&slot) { + if let Some(existing) = self.proposer_duties.get(&slot) { if existing.root() != proposal.root() { warn!(slot, "dutydb: clashing blocks"); return Err(Error::ClashingBlocks { slot }); } } else { - self.pro_duties.insert(slot, proposal.clone()); + self.proposer_duties.insert(slot, proposal.clone()); } Ok(()) } @@ -484,7 +486,7 @@ impl State { committee_index, validator_index, }; - if let Some(&existing) = self.att_pub_keys.get(&pk_key) { + if let Some(&existing) = self.attestation_pub_keys.get(&pk_key) { if existing != pubkey { warn!( slot, @@ -497,8 +499,8 @@ impl State { }); } } else { - self.att_pub_keys.insert(pk_key, pubkey); - self.att_keys_by_slot + self.attestation_pub_keys.insert(pk_key, pubkey); + self.attestation_keys_by_slot .entry(duty_slot) .or_default() .push(pk_key); @@ -516,7 +518,7 @@ impl State { slot, committee_index, }; - if let Some(existing) = self.att_duties.get(&att_key) { + if let Some(existing) = self.attestation_duties.get(&att_key) { if existing.source != data.source || existing.target != data.target || existing.beacon_block_root != data.beacon_block_root @@ -528,7 +530,7 @@ impl State { }); } } else { - self.att_duties.insert(att_key, data.clone()); + self.attestation_duties.insert(att_key, data.clone()); } Ok(()) } @@ -558,7 +560,7 @@ impl State { committee_index: 0, validator_index, }; - if let Some(&existing) = self.att_pub_keys.get(&pk_key0) { + if let Some(&existing) = self.attestation_pub_keys.get(&pk_key0) { if existing != pubkey { warn!( slot, @@ -571,8 +573,8 @@ impl State { }); } } else { - self.att_pub_keys.insert(pk_key0, pubkey); - self.att_keys_by_slot + self.attestation_pub_keys.insert(pk_key0, pubkey); + self.attestation_keys_by_slot .entry(duty_slot) .or_default() .push(pk_key0); @@ -582,7 +584,7 @@ impl State { slot, committee_index: 0, }; - if let Some(existing) = self.att_duties.get(&att_key0) { + if let Some(existing) = self.attestation_duties.get(&att_key0) { if existing.source != data.source { warn!( slot, @@ -604,7 +606,7 @@ impl State { }); } } else { - self.att_duties.insert(att_key0, data.clone()); + self.attestation_duties.insert(att_key0, data.clone()); } Ok(()) } @@ -616,12 +618,15 @@ impl State { // Unlike Go implementation, we key by root only, slot field is redundant. let key = AggKey { root }; - if !self.agg_duties.contains_key(&key) { - self.agg_keys_by_slot.entry(slot).or_default().push(key); + if !self.aggregation_duties.contains_key(&key) { + self.aggregation_keys_by_slot + .entry(slot) + .or_default() + .push(key); } // we don't check existingDataRoot != providedDataRoot because these values // come from the same source and the error was unreachable - self.agg_duties.insert(key, agg.clone()); // unconditional overwrite + self.aggregation_duties.insert(key, agg.clone()); // unconditional overwrite Ok(()) } @@ -663,14 +668,14 @@ impl State { info!(slot, duty_type = %duty.duty_type, "dutydb: deleting expired duty"); match duty.duty_type { DutyType::Proposer => { - self.pro_duties.remove(&slot); + self.proposer_duties.remove(&slot); } DutyType::BuilderProposer => return Err(Error::DeprecatedDutyBuilderProposer), DutyType::Attester => { - if let Some(keys) = self.att_keys_by_slot.remove(&slot) { + if let Some(keys) = self.attestation_keys_by_slot.remove(&slot) { for key in keys { - self.att_pub_keys.remove(&key); - self.att_duties.remove(&AttKey { + self.attestation_pub_keys.remove(&key); + self.attestation_duties.remove(&AttKey { slot: key.slot, committee_index: key.committee_index, }); @@ -678,9 +683,9 @@ impl State { } } DutyType::Aggregator => { - if let Some(keys) = self.agg_keys_by_slot.remove(&slot) { + if let Some(keys) = self.aggregation_keys_by_slot.remove(&slot) { for key in keys { - self.agg_duties.remove(&key); + self.aggregation_duties.remove(&key); } } } @@ -1045,8 +1050,7 @@ mod tests { const V_IDX: u64 = 7; let agg = agg_attestation_fixture(SLOT, COMM_IDX, V_IDX); - let att_data = agg.data().unwrap().clone(); - let root = att_data.tree_hash_root().0; + let root = agg.data().unwrap().tree_hash_root().0; let db_clone = Arc::clone(&db); let waiter = tokio::spawn(async move { db_clone.await_agg_attestation(root).await }); From 447530339991ea29fd0b929f29e619382a0df0c8 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Wed, 20 May 2026 11:41:48 +0200 Subject: [PATCH 36/47] sigagg first attempt --- Cargo.lock | 1 + crates/core/Cargo.toml | 2 + crates/core/src/lib.rs | 3 + crates/core/src/sigagg.rs | 314 ++++++++++++++++++++++++++++++++++ crates/core/src/signeddata.rs | 98 +++++++++++ crates/core/src/types.rs | 13 ++ 6 files changed, 431 insertions(+) create mode 100644 crates/core/src/sigagg.rs diff --git a/Cargo.lock b/Cargo.lock index 934c8932..f872601a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5581,6 +5581,7 @@ dependencies = [ "libp2p", "pluto-build-proto", "pluto-cluster", + "pluto-crypto", "pluto-eth2api", "pluto-eth2util", "pluto-p2p", diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 594b2d18..7e0d7539 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -27,6 +27,7 @@ thiserror.workspace = true tokio.workspace = true tokio-util.workspace = true tracing.workspace = true +pluto-crypto.workspace = true pluto-eth2util.workspace = true pluto-ssz.workspace = true ssz.workspace = true @@ -44,6 +45,7 @@ prost-types.workspace = true hex.workspace = true chrono.workspace = true test-case.workspace = true +pluto-crypto.workspace = true pluto-eth2util.workspace = true pluto-cluster.workspace = true pluto-p2p.workspace = true diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 5b44a216..5e69855f 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -29,6 +29,9 @@ pub mod parsigdb; /// DutyDB — in-memory store for unsigned duty data. pub mod dutydb; +/// SigAgg — threshold BLS signature aggregation. +pub mod sigagg; + 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 diff --git a/crates/core/src/sigagg.rs b/crates/core/src/sigagg.rs new file mode 100644 index 00000000..20430ff2 --- /dev/null +++ b/crates/core/src/sigagg.rs @@ -0,0 +1,314 @@ +/// SigAgg aggregates threshold partial BLS signatures into a single aggregated +/// signature ready to be broadcast to the beacon chain. +use std::{collections::HashMap, future::Future, pin::Pin, sync::Arc}; + +use pluto_crypto::{blst_impl::BlstImpl, tbls::Tbls}; + +use crate::{ + signeddata::{SignedDataError, VersionedAttestation}, + types::{Duty, ParSignedData, PubKey, Signature, SignedData}, +}; + +/// Error type for sigagg. +#[derive(Debug, thiserror::Error)] +pub enum SigAggError { + /// Threshold must be a positive integer. + #[error("invalid threshold")] + InvalidThreshold, + + /// Aggregate was called with an empty per-validator map. + #[error("empty partial signed data set")] + EmptySet, + + /// A validator entry has fewer partial signatures than the threshold. + #[error("require threshold signatures")] + RequireThresholdSignatures, + + /// After deduplicating by share index, fewer distinct signatures remain + /// than the threshold. + #[error("number of partial signatures less than threshold")] + InsufficientDistinctSignatures, + + /// Failed to extract the BLS signature bytes from a partial signed data. + #[error("signature from core: {0}")] + SignatureFromCore(#[from] SignedDataError), + + /// BLS threshold aggregation failed. + #[error("threshold aggregate: {0}")] + ThresholdAggregate(pluto_crypto::types::Error), +} + +type Result = std::result::Result; + +/// Per-duty output: one aggregated [`SignedData`] per validator public key. +pub type AggSignedDataSet = HashMap>; + +/// Callback invoked after a successful threshold aggregation for a duty. +pub type AggSub = Arc< + dyn Fn(&Duty, &AggSignedDataSet) -> Pin> + Send + Sync>> + + Send + + Sync + + 'static, +>; + +/// Verify callback — checks the aggregated signature against the beacon chain. +pub type VerifyFn = Arc< + dyn Fn(&PubKey, &dyn SignedData) -> Pin> + Send + Sync>> + + Send + + Sync + + 'static, +>; + +/// Aggregates threshold partial BLS signatures into a single aggregated +/// signature per validator. +pub struct Aggregator { + threshold: u64, + verify_fn: VerifyFn, + subs: Vec, +} + +impl Aggregator { + /// Creates a new `Aggregator`. + /// + /// Returns an error if `threshold` is zero. + pub fn new(threshold: u64, verify_fn: VerifyFn) -> Result { + if threshold == 0 { + return Err(SigAggError::InvalidThreshold); + } + + Ok(Self { + threshold, + verify_fn, + subs: Vec::new(), + }) + } + + /// Registers a callback for aggregated signed duty data. + pub fn subscribe(&mut self, sub: AggSub) { + self.subs.push(sub); + } + + /// Aggregates the partially signed duty data for the set of DVs and + /// notifies all subscribers. + pub async fn aggregate( + &self, + duty: &Duty, + set: HashMap>, + ) -> Result<()> { + if set.is_empty() { + return Err(SigAggError::EmptySet); + } + + let mut output = AggSignedDataSet::new(); + + for (pubkey, par_sigs) in &set { + let signed = self.aggregate_one(pubkey, par_sigs).await?; + output.insert(*pubkey, signed); + } + + for sub in &self.subs { + sub(duty, &output).await?; + } + + Ok(()) + } + + async fn aggregate_one( + &self, + pubkey: &PubKey, + par_sigs: &[ParSignedData], + ) -> Result> { + if (par_sigs.len() as u64) < self.threshold { + return Err(SigAggError::RequireThresholdSignatures); + } + + // Deduplicate by share index; last writer wins (matches Go behaviour). + let mut bls_sigs: HashMap = HashMap::new(); + for par_sig in par_sigs { + let sig = par_sig.signed_data.signature()?; + bls_sigs.insert(par_sig.share_idx, sig); + } + + if (bls_sigs.len() as u64) < self.threshold { + return Err(SigAggError::InsufficientDistinctSignatures); + } + + // Convert to u8-indexed map required by the crypto crate. + #[allow(clippy::cast_possible_truncation)] + let bls_map: HashMap = bls_sigs + .iter() + .map(|(idx, sig)| (*idx as u8, *sig.as_ref())) + .collect(); + + let agg_bytes = BlstImpl + .threshold_aggregate(&bls_map) + .map_err(SigAggError::ThresholdAggregate)?; + + // Prefer a VersionedAttestation that has validator_index set — the local VC + // includes it, peers don't. Falling back to parSigs[0] is fine for all other + // duty types, and for attestations where no parSig carries a validator_index. + // All parSigs share the same unsigned payload (guaranteed by consensus), so + // any one works as a template. + let template = par_sigs + .iter() + .find_map(|ps| { + let att = ps + .signed_data + .as_any() + .downcast_ref::()?; + att.0.validator_index?; + Some(ps.signed_data.as_ref()) + }) + .unwrap_or_else(|| par_sigs[0].signed_data.as_ref()); + + let agg_signed = template.set_signature_boxed(Signature::new(agg_bytes))?; + + (self.verify_fn)(pubkey, agg_signed.as_ref()).await?; + + Ok(agg_signed) + } +} + +#[cfg(test)] +mod tests { + use std::sync::Mutex; + + use pluto_crypto::{blst_impl::BlstImpl, tbls::Tbls}; + + use super::*; + use crate::{signeddata::SignedDataError, types::Signature as CoreSig}; + + fn noop_verify() -> VerifyFn { + Arc::new(|_, _| Box::pin(async { Ok(()) })) + } + + #[derive(Debug, Clone, PartialEq, Eq)] + struct MockSignedData { + sig: [u8; 96], + } + + impl SignedData for MockSignedData { + fn signature(&self) -> std::result::Result { + Ok(CoreSig::new(self.sig)) + } + + fn set_signature(&self, sig: CoreSig) -> std::result::Result + where + Self: Sized, + { + Ok(Self { sig: *sig.as_ref() }) + } + + fn set_signature_boxed( + &self, + signature: CoreSig, + ) -> std::result::Result, SignedDataError> { + Ok(Box::new(self.set_signature(signature)?)) + } + + fn message_root(&self) -> std::result::Result<[u8; 32], SignedDataError> { + Ok([0u8; 32]) + } + } + + fn mock_par_sigs(count: usize, share_idx: u64) -> Vec { + (0..count) + .map(|_| ParSignedData::new(MockSignedData { sig: [0u8; 96] }, share_idx)) + .collect() + } + + #[test] + fn invalid_threshold() { + let result = Aggregator::new(0, noop_verify()); + let Err(err) = result else { + panic!("expected error") + }; + assert!(matches!(err, SigAggError::InvalidThreshold)); + assert_eq!(err.to_string(), "invalid threshold"); + } + + #[tokio::test] + async fn require_threshold_signatures() { + let agg = Aggregator::new(3, noop_verify()).unwrap(); + let mut set = HashMap::new(); + set.insert(PubKey::new([0u8; 48]), vec![]); + let err = agg + .aggregate(&Duty::new_attester_duty(1.into()), set) + .await + .unwrap_err(); + assert!(matches!(err, SigAggError::RequireThresholdSignatures)); + assert_eq!(err.to_string(), "require threshold signatures"); + } + + #[tokio::test] + async fn aggregate_attester() { + const THRESHOLD: u64 = 3; + const PEERS: u8 = 4; + + let tbls = BlstImpl; + let mut rng = rand::thread_rng(); + + let secret = tbls.generate_secret_key(&mut rng).unwrap(); + let pubkey = tbls.secret_to_public_key(&secret).unwrap(); + let shares = tbls + .threshold_split(&secret, PEERS, u8::try_from(THRESHOLD).unwrap()) + .unwrap(); + + let msg = [42u8; 32]; + + let mut par_sigs = Vec::new(); + let mut expected_bls: HashMap = HashMap::new(); + + for (share_idx, share) in &shares { + let sig = tbls.sign(share, &msg).unwrap(); + expected_bls.insert(*share_idx, sig); + par_sigs.push(ParSignedData::new( + MockSignedData { sig }, + u64::from(*share_idx), + )); + } + + let expected_agg = tbls.threshold_aggregate(&expected_bls).unwrap(); + + let mut agg = Aggregator::new(THRESHOLD, noop_verify()).unwrap(); + + let received: Arc>> = Arc::new(Mutex::new(None)); + let received_clone = received.clone(); + + agg.subscribe(Arc::new(move |_, set: &AggSignedDataSet| { + let received_clone = received_clone.clone(); + let sig = set.values().next().unwrap().signature().unwrap(); + Box::pin(async move { + *received_clone.lock().unwrap() = Some(sig); + Ok(()) + }) + })); + + let mut set = HashMap::new(); + set.insert(PubKey::new(pubkey), par_sigs); + + agg.aggregate(&Duty::new_attester_duty(1.into()), set) + .await + .unwrap(); + + let received_sig = received.lock().unwrap().take().unwrap(); + assert_eq!(*received_sig.as_ref(), expected_agg); + } + + #[tokio::test] + async fn insufficient_distinct_signatures() { + // 4 parSigs all with the same share_idx → deduplicates to 1, below threshold 3. + let agg = Aggregator::new(3, noop_verify()).unwrap(); + let mut set = HashMap::new(); + set.insert(PubKey::new([0u8; 48]), mock_par_sigs(4, 0)); + let err = agg + .aggregate(&Duty::new_attester_duty(1.into()), set) + .await + .unwrap_err(); + assert!(matches!(err, SigAggError::InsufficientDistinctSignatures)); + assert_eq!( + err.to_string(), + "number of partial signatures less than threshold" + ); + } +} diff --git a/crates/core/src/signeddata.rs b/crates/core/src/signeddata.rs index f08b2145..288ebca3 100644 --- a/crates/core/src/signeddata.rs +++ b/crates/core/src/signeddata.rs @@ -151,6 +151,13 @@ impl SignedData for Signature { Ok(signature) } + fn set_signature_boxed( + &self, + signature: Signature, + ) -> Result, SignedDataError> { + Ok(Box::new(self.set_signature(signature)?)) + } + fn message_root(&self) -> Result<[u8; 32], SignedDataError> { Err(SignedDataError::UnsupportedSignatureMessageRoot) } @@ -259,6 +266,13 @@ impl SignedData for VersionedSignedProposal { Ok(out) } + fn set_signature_boxed( + &self, + signature: Signature, + ) -> Result, SignedDataError> { + Ok(Box::new(self.set_signature(signature)?)) + } + fn message_root(&self) -> Result<[u8; 32], SignedDataError> { let proposal = &self.0; if proposal.version == versioned::DataVersion::Unknown { @@ -397,6 +411,13 @@ impl SignedData for Attestation { Ok(out) } + fn set_signature_boxed( + &self, + signature: Signature, + ) -> Result, SignedDataError> { + Ok(Box::new(self.set_signature(signature)?)) + } + fn message_root(&self) -> Result<[u8; 32], SignedDataError> { Ok(hash_root(&self.0.data)) } @@ -475,6 +496,13 @@ impl SignedData for VersionedAttestation { Ok(out) } + fn set_signature_boxed( + &self, + signature: Signature, + ) -> Result, SignedDataError> { + Ok(Box::new(self.set_signature(signature)?)) + } + fn message_root(&self) -> Result<[u8; 32], SignedDataError> { let version = self.0.version; if version == versioned::DataVersion::Unknown { @@ -590,6 +618,13 @@ impl SignedData for SignedVoluntaryExit { Ok(out) } + fn set_signature_boxed( + &self, + signature: Signature, + ) -> Result, SignedDataError> { + Ok(Box::new(self.set_signature(signature)?)) + } + fn message_root(&self) -> Result<[u8; 32], SignedDataError> { Ok(self.0.message_root()) } @@ -672,6 +707,13 @@ impl SignedData for VersionedSignedValidatorRegistration { Ok(out) } + fn set_signature_boxed( + &self, + signature: Signature, + ) -> Result, SignedDataError> { + Ok(Box::new(self.set_signature(signature)?)) + } + fn message_root(&self) -> Result<[u8; 32], SignedDataError> { match self.0.version { versioned::BuilderVersion::V1 => self @@ -748,6 +790,13 @@ impl SignedData for SignedRandao { Ok(out) } + fn set_signature_boxed( + &self, + signature: Signature, + ) -> Result, SignedDataError> { + Ok(Box::new(self.set_signature(signature)?)) + } + fn message_root(&self) -> Result<[u8; 32], SignedDataError> { Ok(self.0.message_root()) } @@ -791,6 +840,13 @@ impl SignedData for BeaconCommitteeSelection { Ok(out) } + fn set_signature_boxed( + &self, + signature: Signature, + ) -> Result, SignedDataError> { + Ok(Box::new(self.set_signature(signature)?)) + } + fn message_root(&self) -> Result<[u8; 32], SignedDataError> { Ok(self.0.message_root()) } @@ -827,6 +883,13 @@ impl SignedData for SyncCommitteeSelection { Ok(out) } + fn set_signature_boxed( + &self, + signature: Signature, + ) -> Result, SignedDataError> { + Ok(Box::new(self.set_signature(signature)?)) + } + fn message_root(&self) -> Result<[u8; 32], SignedDataError> { Ok(self.0.message_root()) } @@ -863,6 +926,13 @@ impl SignedData for SignedAggregateAndProof { Ok(out) } + fn set_signature_boxed( + &self, + signature: Signature, + ) -> Result, SignedDataError> { + Ok(Box::new(self.set_signature(signature)?)) + } + fn message_root(&self) -> Result<[u8; 32], SignedDataError> { Ok(hash_root(&self.0.message)) } @@ -943,6 +1013,13 @@ impl SignedData for VersionedSignedAggregateAndProof { Ok(out) } + fn set_signature_boxed( + &self, + signature: Signature, + ) -> Result, SignedDataError> { + Ok(Box::new(self.set_signature(signature)?)) + } + fn message_root(&self) -> Result<[u8; 32], SignedDataError> { let version = self.0.version; if version == versioned::DataVersion::Unknown { @@ -1029,6 +1106,13 @@ impl SignedData for SignedSyncMessage { Ok(out) } + fn set_signature_boxed( + &self, + signature: Signature, + ) -> Result, SignedDataError> { + Ok(Box::new(self.set_signature(signature)?)) + } + fn message_root(&self) -> Result<[u8; 32], SignedDataError> { Ok(self.0.message_root()) } @@ -1065,6 +1149,13 @@ impl SignedData for SyncContributionAndProof { Ok(out) } + fn set_signature_boxed( + &self, + signature: Signature, + ) -> Result, SignedDataError> { + Ok(Box::new(self.set_signature(signature)?)) + } + fn message_root(&self) -> Result<[u8; 32], SignedDataError> { Ok(self.0.selection_proof_message_root()) } @@ -1101,6 +1192,13 @@ impl SignedData for SignedSyncContributionAndProof { Ok(out) } + fn set_signature_boxed( + &self, + signature: Signature, + ) -> Result, SignedDataError> { + Ok(Box::new(self.set_signature(signature)?)) + } + fn message_root(&self) -> Result<[u8; 32], SignedDataError> { Ok(self.0.message_root()) } diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index 8d971f7d..02ab1919 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -546,6 +546,12 @@ pub trait SignedData: Any + DynClone + DynEq + StdDebug + Send + Sync { where Self: Sized; + /// Object-safe equivalent of [`SignedData::set_signature`]. + fn set_signature_boxed( + &self, + signature: Signature, + ) -> Result, SignedDataError>; + /// message_root returns the message root for the unsigned data. fn message_root(&self) -> Result<[u8; 32], SignedDataError>; } @@ -1028,6 +1034,13 @@ mod tests { Ok(self.clone()) } + fn set_signature_boxed( + &self, + signature: Signature, + ) -> Result, SignedDataError> { + Ok(Box::new(self.set_signature(signature)?)) + } + fn message_root(&self) -> Result<[u8; 32], SignedDataError> { Ok([42u8; 32]) } From 5465002083ebfa6b5e955420896f67ee6cc9b4e6 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Wed, 20 May 2026 13:38:04 +0200 Subject: [PATCH 37/47] More error cases, additional tests --- crates/core/src/sigagg.rs | 347 +++++++++++++++++++++++++++++++++----- 1 file changed, 309 insertions(+), 38 deletions(-) diff --git a/crates/core/src/sigagg.rs b/crates/core/src/sigagg.rs index 20430ff2..94ca5bfe 100644 --- a/crates/core/src/sigagg.rs +++ b/crates/core/src/sigagg.rs @@ -3,6 +3,7 @@ use std::{collections::HashMap, future::Future, pin::Pin, sync::Arc}; use pluto_crypto::{blst_impl::BlstImpl, tbls::Tbls}; +use tracing::debug; use crate::{ signeddata::{SignedDataError, VersionedAttestation}, @@ -31,7 +32,11 @@ pub enum SigAggError { /// Failed to extract the BLS signature bytes from a partial signed data. #[error("signature from core: {0}")] - SignatureFromCore(#[from] SignedDataError), + SignatureFromCore(SignedDataError), + + /// Failed to inject the aggregated signature into the output signed data. + #[error("set signature: {0}")] + SetSignature(SignedDataError), /// BLS threshold aggregation failed. #[error("threshold aggregate: {0}")] @@ -106,6 +111,8 @@ impl Aggregator { output.insert(*pubkey, signed); } + debug!("Threshold aggregated partial signatures"); + for sub in &self.subs { sub(duty, &output).await?; } @@ -125,7 +132,10 @@ impl Aggregator { // Deduplicate by share index; last writer wins (matches Go behaviour). let mut bls_sigs: HashMap = HashMap::new(); for par_sig in par_sigs { - let sig = par_sig.signed_data.signature()?; + let sig = par_sig + .signed_data + .signature() + .map_err(SigAggError::SignatureFromCore)?; bls_sigs.insert(par_sig.share_idx, sig); } @@ -161,7 +171,9 @@ impl Aggregator { }) .unwrap_or_else(|| par_sigs[0].signed_data.as_ref()); - let agg_signed = template.set_signature_boxed(Signature::new(agg_bytes))?; + let agg_signed = template + .set_signature_boxed(Signature::new(agg_bytes)) + .map_err(SigAggError::SetSignature)?; (self.verify_fn)(pubkey, agg_signed.as_ref()).await?; @@ -169,14 +181,30 @@ impl Aggregator { } } +/// Returns a [`VerifyFn`] that verifies the aggregated signature against the +/// beacon chain. +/// +/// TODO: implement once `Eth2SignedData` and beacon-client verification are +/// ported (`core::types` has a placeholder — see types.rs TODO for +/// `Eth2SignedData`). For now callers can use a no-op or BLS-only verifier. +pub fn new_verifier() -> VerifyFn { + Arc::new(|_, _| Box::pin(async { Ok(()) })) +} + #[cfg(test)] mod tests { - use std::sync::Mutex; + use std::{fs, sync::Mutex}; use pluto_crypto::{blst_impl::BlstImpl, tbls::Tbls}; use super::*; - use crate::{signeddata::SignedDataError, types::Signature as CoreSig}; + use crate::{ + signeddata::{ + SignedDataError, SignedRandao, SignedVoluntaryExit, VersionedSignedProposal, + VersionedSignedValidatorRegistration, + }, + types::Signature as CoreSig, + }; fn noop_verify() -> VerifyFn { Arc::new(|_, _| Box::pin(async { Ok(()) })) @@ -217,6 +245,80 @@ mod tests { .collect() } + struct BLSContext { + pubkey: [u8; 48], + sigs: Vec<(u8, [u8; 96])>, + expected_agg: [u8; 96], + } + + fn make_bls_context() -> BLSContext { + const THRESHOLD: u64 = 3; + const PEERS: u8 = 4; + const MSG: [u8; 32] = [42u8; 32]; + + let tbls = BlstImpl; + let mut rng = rand::thread_rng(); + let secret = tbls.generate_secret_key(&mut rng).unwrap(); + let pubkey = tbls.secret_to_public_key(&secret).unwrap(); + let shares = tbls + .threshold_split(&secret, PEERS, u8::try_from(THRESHOLD).unwrap()) + .unwrap(); + + let mut bls_map: HashMap = HashMap::new(); + let mut sigs = Vec::new(); + for (share_idx, share) in &shares { + let sig = tbls.sign(share, &MSG).unwrap(); + bls_map.insert(*share_idx, sig); + sigs.push((*share_idx, sig)); + } + + BLSContext { + pubkey, + sigs, + expected_agg: tbls.threshold_aggregate(&bls_map).unwrap(), + } + } + + async fn assert_aggregates( + pubkey: [u8; 48], + par_sigs: Vec, + expected_agg: [u8; 96], + duty: &Duty, + ) { + let received: Arc>> = Arc::new(Mutex::new(None)); + let received_clone = received.clone(); + + let mut agg = Aggregator::new(3, noop_verify()).unwrap(); + agg.subscribe(Arc::new(move |_, set: &AggSignedDataSet| { + let received_clone = received_clone.clone(); + let sig = set.values().next().unwrap().signature().unwrap(); + Box::pin(async move { + *received_clone.lock().unwrap() = Some(sig); + Ok(()) + }) + })); + + let mut set = HashMap::new(); + set.insert(PubKey::new(pubkey), par_sigs); + agg.aggregate(duty, set).await.unwrap(); + + let received_sig = received.lock().unwrap().take().unwrap(); + assert_eq!(*received_sig.as_ref(), expected_agg); + } + + async fn run_aggregation_test(template: &dyn SignedData, duty: &Duty) { + let ctx = make_bls_context(); + let par_sigs = ctx + .sigs + .iter() + .map(|(idx, sig)| { + let signed = template.set_signature_boxed(CoreSig::new(*sig)).unwrap(); + ParSignedData::new_boxed(signed, u64::from(*idx)) + }) + .collect(); + assert_aggregates(ctx.pubkey, par_sigs, ctx.expected_agg, duty).await; + } + #[test] fn invalid_threshold() { let result = Aggregator::new(0, noop_verify()); @@ -242,6 +344,51 @@ mod tests { #[tokio::test] async fn aggregate_attester() { + let ctx = make_bls_context(); + let par_sigs = ctx + .sigs + .iter() + .map(|(idx, sig)| ParSignedData::new(MockSignedData { sig: *sig }, u64::from(*idx))) + .collect(); + assert_aggregates( + ctx.pubkey, + par_sigs, + ctx.expected_agg, + &Duty::new_attester_duty(1.into()), + ) + .await; + } + + #[tokio::test] + async fn insufficient_distinct_signatures() { + // 4 parSigs all with the same share_idx → deduplicates to 1, below threshold 3. + let agg = Aggregator::new(3, noop_verify()).unwrap(); + let mut set = HashMap::new(); + set.insert(PubKey::new([0u8; 48]), mock_par_sigs(4, 0)); + let err = agg + .aggregate(&Duty::new_attester_duty(1.into()), set) + .await + .unwrap_err(); + assert!(matches!(err, SigAggError::InsufficientDistinctSignatures)); + assert_eq!( + err.to_string(), + "number of partial signatures less than threshold" + ); + } + + #[tokio::test] + async fn empty_set() { + let agg = Aggregator::new(3, noop_verify()).unwrap(); + let err = agg + .aggregate(&Duty::new_attester_duty(1.into()), HashMap::new()) + .await + .unwrap_err(); + assert!(matches!(err, SigAggError::EmptySet)); + assert_eq!(err.to_string(), "empty partial signed data set"); + } + + #[tokio::test] + async fn multiple_subscribers_all_notified() { const THRESHOLD: u64 = 3; const PEERS: u8 = 4; @@ -254,61 +401,185 @@ mod tests { .threshold_split(&secret, PEERS, u8::try_from(THRESHOLD).unwrap()) .unwrap(); - let msg = [42u8; 32]; - + let msg = [7u8; 32]; let mut par_sigs = Vec::new(); - let mut expected_bls: HashMap = HashMap::new(); - for (share_idx, share) in &shares { let sig = tbls.sign(share, &msg).unwrap(); - expected_bls.insert(*share_idx, sig); par_sigs.push(ParSignedData::new( MockSignedData { sig }, u64::from(*share_idx), )); } - let expected_agg = tbls.threshold_aggregate(&expected_bls).unwrap(); - let mut agg = Aggregator::new(THRESHOLD, noop_verify()).unwrap(); - let received: Arc>> = Arc::new(Mutex::new(None)); + let count: Arc> = Arc::new(Mutex::new(0)); + + for _ in 0..3 { + let count = count.clone(); + agg.subscribe(Arc::new(move |_, _| { + let count = count.clone(); + Box::pin(async move { + *count.lock().unwrap() += 1; + Ok(()) + }) + })); + } + + let mut set = HashMap::new(); + set.insert(PubKey::new(pubkey), par_sigs); + agg.aggregate(&Duty::new_attester_duty(1.into()), set) + .await + .unwrap(); + + assert_eq!(*count.lock().unwrap(), 3); + } + + #[tokio::test] + async fn deduplication_succeeds() { + // 5 parSigs with 4 distinct share indices (one duplicate) at threshold 3 → + // success. + let ctx = make_bls_context(); + let mut par_sigs: Vec = ctx + .sigs + .iter() + .map(|(idx, sig)| ParSignedData::new(MockSignedData { sig: *sig }, u64::from(*idx))) + .collect(); + + // Add a duplicate of the first share — last writer wins, same sig so result + // identical. + let (first_idx, first_sig) = ctx.sigs[0]; + par_sigs.push(ParSignedData::new( + MockSignedData { sig: first_sig }, + u64::from(first_idx), + )); + + assert_aggregates( + ctx.pubkey, + par_sigs, + ctx.expected_agg, + &Duty::new_attester_duty(1.into()), + ) + .await; + } + + fn fixture_path(name: &str) -> std::path::PathBuf { + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("testdata") + .join("signeddata") + .join(name) + } + + #[tokio::test] + async fn aggregate_randao() { + let json = fs::read_to_string(fixture_path( + "TestJSONSerialisation_SignedRandao.json.golden", + )) + .unwrap(); + let template: SignedRandao = serde_json::from_str(&json).unwrap(); + run_aggregation_test(&template, &Duty::new_randao_duty(1.into())).await; + } + + #[tokio::test] + async fn aggregate_exit() { + let json = fs::read_to_string(fixture_path( + "TestJSONSerialisation_SignedVoluntaryExit.json.golden", + )) + .unwrap(); + let template: SignedVoluntaryExit = serde_json::from_str(&json).unwrap(); + run_aggregation_test(&template, &Duty::new_voluntary_exit_duty(1.into())).await; + } + + #[tokio::test] + async fn aggregate_proposer() { + let json = fs::read_to_string(fixture_path( + "TestJSONSerialisation_VersionedSignedProposal.json.golden", + )) + .unwrap(); + let template: VersionedSignedProposal = serde_json::from_str(&json).unwrap(); + run_aggregation_test(&template, &Duty::new_proposer_duty(1.into())).await; + } + + #[tokio::test] + async fn aggregate_builder_proposer() { + let json = fs::read_to_string(fixture_path( + "TestJSONSerialisation_VersionedSignedProposal.json#01.golden", + )) + .unwrap(); + let template: VersionedSignedProposal = serde_json::from_str(&json).unwrap(); + run_aggregation_test(&template, &Duty::new_builder_proposer_duty(1.into())).await; + } + + #[tokio::test] + async fn aggregate_builder_registration() { + let json = fs::read_to_string(fixture_path("VersionedSignedValidatorRegistration.v1.json")) + .unwrap(); + let template: VersionedSignedValidatorRegistration = serde_json::from_str(&json).unwrap(); + run_aggregation_test(&template, &Duty::new_builder_registration_duty(1.into())).await; + } + + #[tokio::test] + async fn multiple_validators() { + // Two independent validators aggregated in a single aggregate() call. + const THRESHOLD: u64 = 3; + const PEERS: u8 = 4; + + let tbls = BlstImpl; + let mut rng = rand::thread_rng(); + let msg = [55u8; 32]; + + let mut agg_set: HashMap> = HashMap::new(); + let mut expected: HashMap = HashMap::new(); + + for _ in 0..2 { + let secret = tbls.generate_secret_key(&mut rng).unwrap(); + let pubkey_bytes = tbls.secret_to_public_key(&secret).unwrap(); + let shares = tbls + .threshold_split(&secret, PEERS, u8::try_from(THRESHOLD).unwrap()) + .unwrap(); + + let mut par_sigs = Vec::new(); + let mut bls_map: HashMap = HashMap::new(); + for (share_idx, share) in &shares { + let sig = tbls.sign(share, &msg).unwrap(); + bls_map.insert(*share_idx, sig); + par_sigs.push(ParSignedData::new( + MockSignedData { sig }, + u64::from(*share_idx), + )); + } + + let agg_sig = tbls.threshold_aggregate(&bls_map).unwrap(); + let pubkey = PubKey::new(pubkey_bytes); + expected.insert(pubkey, agg_sig); + agg_set.insert(pubkey, par_sigs); + } + + let received: Arc>> = Arc::new(Mutex::new(HashMap::new())); let received_clone = received.clone(); + let mut agg = Aggregator::new(THRESHOLD, noop_verify()).unwrap(); agg.subscribe(Arc::new(move |_, set: &AggSignedDataSet| { let received_clone = received_clone.clone(); - let sig = set.values().next().unwrap().signature().unwrap(); + let sigs: HashMap = set + .iter() + .map(|(k, v)| (*k, v.signature().unwrap())) + .collect(); Box::pin(async move { - *received_clone.lock().unwrap() = Some(sig); + received_clone.lock().unwrap().extend(sigs); Ok(()) }) })); - let mut set = HashMap::new(); - set.insert(PubKey::new(pubkey), par_sigs); - - agg.aggregate(&Duty::new_attester_duty(1.into()), set) + agg.aggregate(&Duty::new_attester_duty(1.into()), agg_set) .await .unwrap(); - let received_sig = received.lock().unwrap().take().unwrap(); - assert_eq!(*received_sig.as_ref(), expected_agg); - } - - #[tokio::test] - async fn insufficient_distinct_signatures() { - // 4 parSigs all with the same share_idx → deduplicates to 1, below threshold 3. - let agg = Aggregator::new(3, noop_verify()).unwrap(); - let mut set = HashMap::new(); - set.insert(PubKey::new([0u8; 48]), mock_par_sigs(4, 0)); - let err = agg - .aggregate(&Duty::new_attester_duty(1.into()), set) - .await - .unwrap_err(); - assert!(matches!(err, SigAggError::InsufficientDistinctSignatures)); - assert_eq!( - err.to_string(), - "number of partial signatures less than threshold" - ); + let received = received.lock().unwrap(); + assert_eq!(received.len(), 2); + for (pubkey, exp_bytes) in &expected { + let got = &received[pubkey]; + assert_eq!(*got.as_ref(), *exp_bytes); + } } } From ad748952045201b4dc525355b030d381586033a8 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Thu, 21 May 2026 11:03:16 +0200 Subject: [PATCH 38/47] Safe conversion from u64 to u8 --- crates/core/src/sigagg.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/crates/core/src/sigagg.rs b/crates/core/src/sigagg.rs index 94ca5bfe..5bdb2798 100644 --- a/crates/core/src/sigagg.rs +++ b/crates/core/src/sigagg.rs @@ -41,6 +41,10 @@ pub enum SigAggError { /// BLS threshold aggregation failed. #[error("threshold aggregate: {0}")] ThresholdAggregate(pluto_crypto::types::Error), + + /// Share index does not fit in a u8. + #[error("invalid share index: {0}")] + InvalidShareIndex(u64), } type Result = std::result::Result; @@ -143,12 +147,14 @@ impl Aggregator { return Err(SigAggError::InsufficientDistinctSignatures); } - // Convert to u8-indexed map required by the crypto crate. - #[allow(clippy::cast_possible_truncation)] let bls_map: HashMap = bls_sigs .iter() - .map(|(idx, sig)| (*idx as u8, *sig.as_ref())) - .collect(); + .map(|(idx, sig)| { + let idx_u8 = + u8::try_from(*idx).map_err(|_| SigAggError::InvalidShareIndex(*idx))?; + Ok((idx_u8, *sig.as_ref())) + }) + .collect::>()?; let agg_bytes = BlstImpl .threshold_aggregate(&bls_map) From 9828f7bf821eeed04073f63d7d9a8b7c298b6f6e Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Thu, 21 May 2026 11:12:20 +0200 Subject: [PATCH 39/47] Better erros for aggregate_one --- crates/core/src/sigagg.rs | 112 ++++++++++++++++++++++++++++---------- 1 file changed, 84 insertions(+), 28 deletions(-) diff --git a/crates/core/src/sigagg.rs b/crates/core/src/sigagg.rs index 5bdb2798..a5ed51b0 100644 --- a/crates/core/src/sigagg.rs +++ b/crates/core/src/sigagg.rs @@ -22,29 +22,58 @@ pub enum SigAggError { EmptySet, /// A validator entry has fewer partial signatures than the threshold. - #[error("require threshold signatures")] - RequireThresholdSignatures, + #[error("validator {pubkey}: require threshold signatures")] + RequireThresholdSignatures { + /// The validator public key. + pubkey: PubKey, + }, /// After deduplicating by share index, fewer distinct signatures remain /// than the threshold. - #[error("number of partial signatures less than threshold")] - InsufficientDistinctSignatures, + #[error("validator {pubkey}: number of partial signatures less than threshold")] + InsufficientDistinctSignatures { + /// The validator public key. + pubkey: PubKey, + }, /// Failed to extract the BLS signature bytes from a partial signed data. - #[error("signature from core: {0}")] - SignatureFromCore(SignedDataError), + #[error("validator {pubkey}: signature from core: {source}")] + SignatureFromCore { + /// The validator public key. + pubkey: PubKey, + /// The underlying error. + #[source] + source: SignedDataError, + }, /// Failed to inject the aggregated signature into the output signed data. - #[error("set signature: {0}")] - SetSignature(SignedDataError), + #[error("validator {pubkey}: set signature: {source}")] + SetSignature { + /// The validator public key. + pubkey: PubKey, + /// The underlying error. + #[source] + source: SignedDataError, + }, /// BLS threshold aggregation failed. - #[error("threshold aggregate: {0}")] - ThresholdAggregate(pluto_crypto::types::Error), + #[error("validator {pubkey}: threshold aggregate: {source}")] + ThresholdAggregate { + /// The validator public key. + pubkey: PubKey, + /// The underlying error. + #[source] + source: pluto_crypto::types::Error, + }, /// Share index does not fit in a u8. - #[error("invalid share index: {0}")] - InvalidShareIndex(u64), + #[error("validator {pubkey}: invalid share index: {idx}")] + InvalidShareIndex { + /// The validator public key. + pubkey: PubKey, + /// The out-of-range index value. + idx: u64, + }, } type Result = std::result::Result; @@ -130,35 +159,44 @@ impl Aggregator { par_sigs: &[ParSignedData], ) -> Result> { if (par_sigs.len() as u64) < self.threshold { - return Err(SigAggError::RequireThresholdSignatures); + return Err(SigAggError::RequireThresholdSignatures { pubkey: *pubkey }); } // Deduplicate by share index; last writer wins (matches Go behaviour). let mut bls_sigs: HashMap = HashMap::new(); for par_sig in par_sigs { - let sig = par_sig - .signed_data - .signature() - .map_err(SigAggError::SignatureFromCore)?; + let sig = + par_sig + .signed_data + .signature() + .map_err(|e| SigAggError::SignatureFromCore { + pubkey: *pubkey, + source: e, + })?; bls_sigs.insert(par_sig.share_idx, sig); } if (bls_sigs.len() as u64) < self.threshold { - return Err(SigAggError::InsufficientDistinctSignatures); + return Err(SigAggError::InsufficientDistinctSignatures { pubkey: *pubkey }); } let bls_map: HashMap = bls_sigs .iter() .map(|(idx, sig)| { - let idx_u8 = - u8::try_from(*idx).map_err(|_| SigAggError::InvalidShareIndex(*idx))?; + let idx_u8 = u8::try_from(*idx).map_err(|_| SigAggError::InvalidShareIndex { + pubkey: *pubkey, + idx: *idx, + })?; Ok((idx_u8, *sig.as_ref())) }) .collect::>()?; - let agg_bytes = BlstImpl - .threshold_aggregate(&bls_map) - .map_err(SigAggError::ThresholdAggregate)?; + let agg_bytes = BlstImpl.threshold_aggregate(&bls_map).map_err(|e| { + SigAggError::ThresholdAggregate { + pubkey: *pubkey, + source: e, + } + })?; // Prefer a VersionedAttestation that has validator_index set — the local VC // includes it, peers don't. Falling back to parSigs[0] is fine for all other @@ -179,7 +217,10 @@ impl Aggregator { let agg_signed = template .set_signature_boxed(Signature::new(agg_bytes)) - .map_err(SigAggError::SetSignature)?; + .map_err(|e| SigAggError::SetSignature { + pubkey: *pubkey, + source: e, + })?; (self.verify_fn)(pubkey, agg_signed.as_ref()).await?; @@ -344,8 +385,17 @@ mod tests { .aggregate(&Duty::new_attester_duty(1.into()), set) .await .unwrap_err(); - assert!(matches!(err, SigAggError::RequireThresholdSignatures)); - assert_eq!(err.to_string(), "require threshold signatures"); + assert!(matches!( + err, + SigAggError::RequireThresholdSignatures { .. } + )); + assert_eq!( + err.to_string(), + format!( + "validator 0x{}: require threshold signatures", + "00".repeat(48) + ) + ); } #[tokio::test] @@ -375,10 +425,16 @@ mod tests { .aggregate(&Duty::new_attester_duty(1.into()), set) .await .unwrap_err(); - assert!(matches!(err, SigAggError::InsufficientDistinctSignatures)); + assert!(matches!( + err, + SigAggError::InsufficientDistinctSignatures { .. } + )); assert_eq!( err.to_string(), - "number of partial signatures less than threshold" + format!( + "validator 0x{}: number of partial signatures less than threshold", + "00".repeat(48) + ) ); } From a6884074787b7007344616b1e3003e525b739476 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Thu, 21 May 2026 12:13:11 +0200 Subject: [PATCH 40/47] comment for aggregate_one --- crates/core/src/sigagg.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/core/src/sigagg.rs b/crates/core/src/sigagg.rs index a5ed51b0..b60eff99 100644 --- a/crates/core/src/sigagg.rs +++ b/crates/core/src/sigagg.rs @@ -210,7 +210,7 @@ impl Aggregator { .signed_data .as_any() .downcast_ref::()?; - att.0.validator_index?; + att.0.validator_index?; // return an error if validator_index is not set Some(ps.signed_data.as_ref()) }) .unwrap_or_else(|| par_sigs[0].signed_data.as_ref()); From a546f80bec9a2a22b00254fa846645fd3abfeff9 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Thu, 21 May 2026 13:05:30 +0200 Subject: [PATCH 41/47] info_span as in charon --- crates/core/src/sigagg.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/core/src/sigagg.rs b/crates/core/src/sigagg.rs index b60eff99..c8cbff4c 100644 --- a/crates/core/src/sigagg.rs +++ b/crates/core/src/sigagg.rs @@ -3,7 +3,7 @@ use std::{collections::HashMap, future::Future, pin::Pin, sync::Arc}; use pluto_crypto::{blst_impl::BlstImpl, tbls::Tbls}; -use tracing::debug; +use tracing::{debug, info_span}; use crate::{ signeddata::{SignedDataError, VersionedAttestation}, @@ -191,12 +191,12 @@ impl Aggregator { }) .collect::>()?; - let agg_bytes = BlstImpl.threshold_aggregate(&bls_map).map_err(|e| { - SigAggError::ThresholdAggregate { + let agg_bytes = info_span!("tbls.ThresholdAggregate") + .in_scope(|| BlstImpl.threshold_aggregate(&bls_map)) + .map_err(|e| SigAggError::ThresholdAggregate { pubkey: *pubkey, source: e, - } - })?; + })?; // Prefer a VersionedAttestation that has validator_index set — the local VC // includes it, peers don't. Falling back to parSigs[0] is fine for all other From a5ad6cd22586b8120d7eefc6fdfc72f5b5223056 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Thu, 21 May 2026 13:09:31 +0200 Subject: [PATCH 42/47] unneeded Sync in functions results --- crates/core/src/sigagg.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/core/src/sigagg.rs b/crates/core/src/sigagg.rs index c8cbff4c..dbbd35e8 100644 --- a/crates/core/src/sigagg.rs +++ b/crates/core/src/sigagg.rs @@ -83,7 +83,7 @@ pub type AggSignedDataSet = HashMap>; /// Callback invoked after a successful threshold aggregation for a duty. pub type AggSub = Arc< - dyn Fn(&Duty, &AggSignedDataSet) -> Pin> + Send + Sync>> + dyn Fn(&Duty, &AggSignedDataSet) -> Pin> + Send>> + Send + Sync + 'static, @@ -91,7 +91,7 @@ pub type AggSub = Arc< /// Verify callback — checks the aggregated signature against the beacon chain. pub type VerifyFn = Arc< - dyn Fn(&PubKey, &dyn SignedData) -> Pin> + Send + Sync>> + dyn Fn(&PubKey, &dyn SignedData) -> Pin> + Send>> + Send + Sync + 'static, From 184b70f7ff27acea4369f0d1510e89c2930ba3ba Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Thu, 21 May 2026 13:13:35 +0200 Subject: [PATCH 43/47] set passed as ref to aggregate function --- crates/core/src/sigagg.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/core/src/sigagg.rs b/crates/core/src/sigagg.rs index dbbd35e8..a3a3e444 100644 --- a/crates/core/src/sigagg.rs +++ b/crates/core/src/sigagg.rs @@ -131,7 +131,7 @@ impl Aggregator { pub async fn aggregate( &self, duty: &Duty, - set: HashMap>, + set: &HashMap>, ) -> Result<()> { if set.is_empty() { return Err(SigAggError::EmptySet); @@ -139,7 +139,7 @@ impl Aggregator { let mut output = AggSignedDataSet::new(); - for (pubkey, par_sigs) in &set { + for (pubkey, par_sigs) in set { let signed = self.aggregate_one(pubkey, par_sigs).await?; output.insert(*pubkey, signed); } @@ -347,7 +347,7 @@ mod tests { let mut set = HashMap::new(); set.insert(PubKey::new(pubkey), par_sigs); - agg.aggregate(duty, set).await.unwrap(); + agg.aggregate(duty, &set).await.unwrap(); let received_sig = received.lock().unwrap().take().unwrap(); assert_eq!(*received_sig.as_ref(), expected_agg); @@ -382,7 +382,7 @@ mod tests { let mut set = HashMap::new(); set.insert(PubKey::new([0u8; 48]), vec![]); let err = agg - .aggregate(&Duty::new_attester_duty(1.into()), set) + .aggregate(&Duty::new_attester_duty(1.into()), &set) .await .unwrap_err(); assert!(matches!( @@ -422,7 +422,7 @@ mod tests { let mut set = HashMap::new(); set.insert(PubKey::new([0u8; 48]), mock_par_sigs(4, 0)); let err = agg - .aggregate(&Duty::new_attester_duty(1.into()), set) + .aggregate(&Duty::new_attester_duty(1.into()), &set) .await .unwrap_err(); assert!(matches!( @@ -442,7 +442,7 @@ mod tests { async fn empty_set() { let agg = Aggregator::new(3, noop_verify()).unwrap(); let err = agg - .aggregate(&Duty::new_attester_duty(1.into()), HashMap::new()) + .aggregate(&Duty::new_attester_duty(1.into()), &HashMap::new()) .await .unwrap_err(); assert!(matches!(err, SigAggError::EmptySet)); @@ -490,7 +490,7 @@ mod tests { let mut set = HashMap::new(); set.insert(PubKey::new(pubkey), par_sigs); - agg.aggregate(&Duty::new_attester_duty(1.into()), set) + agg.aggregate(&Duty::new_attester_duty(1.into()), &set) .await .unwrap(); @@ -633,7 +633,7 @@ mod tests { }) })); - agg.aggregate(&Duty::new_attester_duty(1.into()), agg_set) + agg.aggregate(&Duty::new_attester_duty(1.into()), &agg_set) .await .unwrap(); From df6b0fa383a51d1297989ac95c8cd33c39ca9fde Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Thu, 21 May 2026 13:20:10 +0200 Subject: [PATCH 44/47] pub result --- crates/core/src/sigagg.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/core/src/sigagg.rs b/crates/core/src/sigagg.rs index a3a3e444..308ee975 100644 --- a/crates/core/src/sigagg.rs +++ b/crates/core/src/sigagg.rs @@ -76,7 +76,7 @@ pub enum SigAggError { }, } -type Result = std::result::Result; +pub type Result = std::result::Result; /// Per-duty output: one aggregated [`SignedData`] per validator public key. pub type AggSignedDataSet = HashMap>; From 05e25e4931e67481b82dd6130ad715fc7e998617 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Thu, 21 May 2026 13:22:53 +0200 Subject: [PATCH 45/47] fixed top comment --- crates/core/src/sigagg.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/core/src/sigagg.rs b/crates/core/src/sigagg.rs index 308ee975..ab1c561d 100644 --- a/crates/core/src/sigagg.rs +++ b/crates/core/src/sigagg.rs @@ -1,5 +1,6 @@ -/// SigAgg aggregates threshold partial BLS signatures into a single aggregated -/// signature ready to be broadcast to the beacon chain. +//! SigAgg aggregates threshold partial BLS signatures into a single aggregated +//! signature ready to be broadcast to the beacon chain. + use std::{collections::HashMap, future::Future, pin::Pin, sync::Arc}; use pluto_crypto::{blst_impl::BlstImpl, tbls::Tbls}; @@ -76,6 +77,7 @@ pub enum SigAggError { }, } +/// Convenience alias for [`std::result::Result`] with [`SigAggError`]. pub type Result = std::result::Result; /// Per-duty output: one aggregated [`SignedData`] per validator public key. From 92125d55510f056800b0934baecb60d5d5278e60 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Thu, 21 May 2026 15:02:11 +0200 Subject: [PATCH 46/47] Additional tests suggested by claude --- crates/core/src/sigagg.rs | 195 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 195 insertions(+) diff --git a/crates/core/src/sigagg.rs b/crates/core/src/sigagg.rs index ab1c561d..af33e588 100644 --- a/crates/core/src/sigagg.rs +++ b/crates/core/src/sigagg.rs @@ -288,6 +288,62 @@ mod tests { } } + #[derive(Debug, Clone, PartialEq, Eq)] + struct FailSignatureMock; + + impl SignedData for FailSignatureMock { + fn signature(&self) -> std::result::Result { + Err(SignedDataError::UnknownType) + } + + fn set_signature(&self, _: CoreSig) -> std::result::Result + where + Self: Sized, + { + Ok(Self) + } + + fn set_signature_boxed( + &self, + sig: CoreSig, + ) -> std::result::Result, SignedDataError> { + Ok(Box::new(self.set_signature(sig)?)) + } + + fn message_root(&self) -> std::result::Result<[u8; 32], SignedDataError> { + Ok([0u8; 32]) + } + } + + #[derive(Debug, Clone, PartialEq, Eq)] + struct FailSetSignatureMock { + sig: [u8; 96], + } + + impl SignedData for FailSetSignatureMock { + fn signature(&self) -> std::result::Result { + Ok(CoreSig::new(self.sig)) + } + + fn set_signature(&self, _: CoreSig) -> std::result::Result + where + Self: Sized, + { + Err(SignedDataError::UnknownType) + } + + fn set_signature_boxed( + &self, + _: CoreSig, + ) -> std::result::Result, SignedDataError> { + Err(SignedDataError::UnknownType) + } + + fn message_root(&self) -> std::result::Result<[u8; 32], SignedDataError> { + Ok([0u8; 32]) + } + } + fn mock_par_sigs(count: usize, share_idx: u64) -> Vec { (0..count) .map(|_| ParSignedData::new(MockSignedData { sig: [0u8; 96] }, share_idx)) @@ -646,4 +702,143 @@ mod tests { assert_eq!(*got.as_ref(), *exp_bytes); } } + + #[tokio::test] + async fn verify_fn_error() { + let ctx = make_bls_context(); + let par_sigs: Vec = ctx + .sigs + .iter() + .map(|(idx, sig)| ParSignedData::new(MockSignedData { sig: *sig }, u64::from(*idx))) + .collect(); + + let fail_verify: VerifyFn = + Arc::new(|_, _| Box::pin(async { Err(SigAggError::InvalidThreshold) })); + let agg = Aggregator::new(3, fail_verify).unwrap(); + let mut set = HashMap::new(); + set.insert(PubKey::new(ctx.pubkey), par_sigs); + let err = agg + .aggregate(&Duty::new_attester_duty(1.into()), &set) + .await + .unwrap_err(); + assert!(matches!(err, SigAggError::InvalidThreshold)); + } + + #[tokio::test] + async fn subscriber_error() { + let ctx = make_bls_context(); + let par_sigs: Vec = ctx + .sigs + .iter() + .map(|(idx, sig)| ParSignedData::new(MockSignedData { sig: *sig }, u64::from(*idx))) + .collect(); + + let mut agg = Aggregator::new(3, noop_verify()).unwrap(); + agg.subscribe(Arc::new(|_, _| { + Box::pin(async { Err(SigAggError::InvalidThreshold) }) + })); + let mut set = HashMap::new(); + set.insert(PubKey::new(ctx.pubkey), par_sigs); + let err = agg + .aggregate(&Duty::new_attester_duty(1.into()), &set) + .await + .unwrap_err(); + assert!(matches!(err, SigAggError::InvalidThreshold)); + } + + #[tokio::test] + async fn signature_from_core_error() { + let agg = Aggregator::new(3, noop_verify()).unwrap(); + let par_sigs: Vec = (0..3u64) + .map(|i| ParSignedData::new(FailSignatureMock, i)) + .collect(); + let mut set = HashMap::new(); + set.insert(PubKey::new([1u8; 48]), par_sigs); + let err = agg + .aggregate(&Duty::new_attester_duty(1.into()), &set) + .await + .unwrap_err(); + assert!(matches!(err, SigAggError::SignatureFromCore { .. })); + } + + #[tokio::test] + async fn set_signature_error() { + let ctx = make_bls_context(); + let par_sigs: Vec = ctx + .sigs + .iter() + .map(|(idx, sig)| { + ParSignedData::new(FailSetSignatureMock { sig: *sig }, u64::from(*idx)) + }) + .collect(); + + let agg = Aggregator::new(3, noop_verify()).unwrap(); + let mut set = HashMap::new(); + set.insert(PubKey::new(ctx.pubkey), par_sigs); + let err = agg + .aggregate(&Duty::new_attester_duty(1.into()), &set) + .await + .unwrap_err(); + assert!(matches!(err, SigAggError::SetSignature { .. })); + } + + #[tokio::test] + async fn versioned_attestation_validator_index_preference() { + let json = fs::read_to_string(fixture_path( + "TestJSONSerialisation_VersionedAttestation.json.golden", + )) + .unwrap(); + let with_idx: VersionedAttestation = serde_json::from_str(&json).unwrap(); + assert!( + with_idx.0.validator_index.is_some(), + "fixture must carry validator_index" + ); + + let mut inner_no_idx = with_idx.0.clone(); + inner_no_idx.validator_index = None; + let without_idx = VersionedAttestation::new(inner_no_idx).unwrap(); + + let ctx = make_bls_context(); + // First par_sig has no validator_index; second has it — template must prefer + // the latter. + let par_sigs: Vec = ctx + .sigs + .iter() + .enumerate() + .map(|(i, (idx, sig))| { + let template: &dyn SignedData = if i == 0 { &without_idx } else { &with_idx }; + let signed = template.set_signature_boxed(CoreSig::new(*sig)).unwrap(); + ParSignedData::new_boxed(signed, u64::from(*idx)) + }) + .collect(); + + let captured: Arc>>> = Arc::new(Mutex::new(None)); + let captured_clone = captured.clone(); + + let mut agg = Aggregator::new(3, noop_verify()).unwrap(); + agg.subscribe(Arc::new(move |_, set: &AggSignedDataSet| { + let captured_clone = captured_clone.clone(); + let output = set.values().next().unwrap().clone(); + Box::pin(async move { + *captured_clone.lock().unwrap() = Some(output); + Ok(()) + }) + })); + + let mut set = HashMap::new(); + set.insert(PubKey::new(ctx.pubkey), par_sigs); + agg.aggregate(&Duty::new_attester_duty(1.into()), &set) + .await + .unwrap(); + + let output = captured.lock().unwrap().take().unwrap(); + let att = output + .as_any() + .downcast_ref::() + .expect("output must be VersionedAttestation"); + assert!( + att.0.validator_index.is_some(), + "output must preserve validator_index from template" + ); + } } From ad3a12354633c1501560d3dc6822c8e7a19cffa0 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Thu, 21 May 2026 15:06:56 +0200 Subject: [PATCH 47/47] additional comment --- crates/core/src/sigagg.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/core/src/sigagg.rs b/crates/core/src/sigagg.rs index af33e588..53d5bd60 100644 --- a/crates/core/src/sigagg.rs +++ b/crates/core/src/sigagg.rs @@ -130,6 +130,9 @@ impl Aggregator { /// Aggregates the partially signed duty data for the set of DVs and /// notifies all subscribers. + /// + /// If aggregation fails for any validator the entire call returns that + /// error immediately — no partial results are emitted. pub async fn aggregate( &self, duty: &Duty,