From ee366e6cddadaa40bea970eb6314a2b07ebf5885 Mon Sep 17 00:00:00 2001 From: Darioush Jalali Date: Tue, 16 Jun 2026 12:51:59 -0700 Subject: [PATCH 1/2] refactor(spice): extract endorsement-threshold tally to shared helper --- chain/chain/src/spice/core.rs | 28 ++++++ chain/chain/src/spice/core_writer_actor.rs | 85 +++++-------------- .../validator_assignment.rs | 15 ++++ 3 files changed, 65 insertions(+), 63 deletions(-) diff --git a/chain/chain/src/spice/core.rs b/chain/chain/src/spice/core.rs index 26241a132a0..686c9170c02 100644 --- a/chain/chain/src/spice/core.rs +++ b/chain/chain/src/spice/core.rs @@ -13,6 +13,7 @@ use near_primitives::shard_layout::ShardUId; use near_primitives::spice::chunk_endorsement::{ SpiceEndorsementCoreStatement, SpiceStoredVerifiedEndorsement, }; +use near_primitives::stateless_validation::validator_assignment::ChunkValidatorAssignments; use near_primitives::types::chunk_extra::ChunkExtra; use near_primitives::types::validator_stake::ValidatorStake; use near_primitives::types::{ @@ -75,6 +76,33 @@ impl SpiceCoreReader { .get_ser(DBCol::endorsements(), &get_endorsements_key(block_hash, shard_id, account_id)) } + /// Whether the union of `endorsers` and the validators whose stored endorsement attests + /// `result_hash` certifies the chunk under `assignment`. `endorsers` seeds the set with the + /// endorsers already in hand (e.g. from the block); the rest are read from the store. + pub(crate) fn reaches_endorsement_threshold( + &self, + chunk_id: &SpiceChunkId, + result_hash: &ChunkExecutionResultHash, + assignment: &ChunkValidatorAssignments, + mut endorsers: HashSet, + ) -> bool { + for (account_id, _) in assignment.assignments() { + if endorsers.contains(account_id) { + continue; + } + let Some(stored) = + self.get_endorsement(&chunk_id.block_hash, chunk_id.shard_id, account_id) + else { + continue; + }; + if stored.execution_result_hash != *result_hash { + continue; + } + endorsers.insert(account_id.clone()); + } + assignment.is_endorsed(&endorsers) + } + fn get_execution_result( &self, block_header: &BlockHeader, diff --git a/chain/chain/src/spice/core_writer_actor.rs b/chain/chain/src/spice/core_writer_actor.rs index 324cca4452a..66c27e267da 100644 --- a/chain/chain/src/spice/core_writer_actor.rs +++ b/chain/chain/src/spice/core_writer_actor.rs @@ -3,7 +3,6 @@ use itertools::Itertools; use near_async::messaging::{Handler, Sender}; use near_cache::SyncLruCache; use near_chain_primitives::Error; -use near_crypto::Signature; use near_epoch_manager::EpochManagerAdapter; use near_network::client::SpiceChunkEndorsementMessage; use near_primitives::block::Block; @@ -146,9 +145,9 @@ impl SpiceCoreWriterActor { endorsements: Vec, ) -> Result { let mut store_update = self.chain_store.store().store_update(); - let mut endorsements_by_unique_result: HashMap< + let mut endorsers_by_unique_result: HashMap< (&SpiceChunkId, ChunkExecutionResultHash), - HashMap<&AccountId, Signature>, + HashSet, > = HashMap::new(); let mut execution_results: HashMap = HashMap::new(); @@ -164,41 +163,25 @@ impl SpiceCoreWriterActor { &endorsement.to_stored(), )); let execution_result_hash = endorsement.execution_result().compute_hash(); - endorsements_by_unique_result + endorsers_by_unique_result .entry((chunk_id, execution_result_hash.clone())) .or_default() - .insert(endorsement.account_id(), endorsement.signature().clone()); + .insert(endorsement.account_id().clone()); execution_results.insert(execution_result_hash, endorsement.execution_result()); } - for ((chunk_id, chunk_execution_result_hash), mut signatures) in - endorsements_by_unique_result - { + for ((chunk_id, chunk_execution_result_hash), endorsers) in endorsers_by_unique_result { let chunk_validator_assignments = self.epoch_manager.get_chunk_validator_assignments( &block.header().epoch_id(), chunk_id.shard_id, block.header().height(), )?; - - for (account_id, _) in chunk_validator_assignments.assignments() { - if signatures.contains_key(account_id) { - continue; - } - let Some(stored_endorsement) = - self.get_endorsement(&chunk_id.block_hash, chunk_id.shard_id, &account_id) - else { - continue; - }; - if stored_endorsement.execution_result_hash != chunk_execution_result_hash { - continue; - } - signatures.insert(account_id, stored_endorsement.signature); - } - - let endorsement_state = - chunk_validator_assignments.compute_endorsement_state(signatures); - - if !endorsement_state.is_endorsed { + if !self.core_reader.reaches_endorsement_threshold( + chunk_id, + &chunk_execution_result_hash, + &chunk_validator_assignments, + endorsers, + ) { continue; } @@ -218,17 +201,6 @@ impl SpiceCoreWriterActor { return Ok(store_update); } - fn get_endorsement( - &self, - block_hash: &CryptoHash, - shard_id: ShardId, - account_id: &AccountId, - ) -> Option { - self.chain_store - .store() - .get_ser(DBCol::endorsements(), &get_endorsements_key(block_hash, shard_id, account_id)) - } - fn get_execution_result_from_store( &self, block_hash: &CryptoHash, @@ -446,9 +418,9 @@ impl SpiceCoreWriterActor { fn record_block_core_statements(&self, block: &Block) -> Result { let mut store_update = self.chain_store.store().store_update(); - let mut endorsements_by_unique_result: HashMap< + let mut endorsers_by_unique_result: HashMap< (&SpiceChunkId, ChunkExecutionResultHash), - HashMap<&AccountId, Signature>, + HashSet, > = HashMap::new(); let mut in_block_execution_results: HashSet<&SpiceChunkId> = HashSet::new(); for core_statement in block.spice_core_statements() { @@ -464,10 +436,10 @@ impl SpiceCoreWriterActor { account_id, &stored_endorsement, )); - endorsements_by_unique_result + endorsers_by_unique_result .entry((chunk_id, stored_endorsement.execution_result_hash)) .or_default() - .insert(endorsement.account_id(), stored_endorsement.signature); + .insert(endorsement.account_id().clone()); } SpiceCoreStatement::ChunkExecutionResult { execution_result, chunk_id } => { store_update.merge(self.save_execution_result( @@ -479,9 +451,7 @@ impl SpiceCoreWriterActor { } }; } - for ((chunk_id, chunk_execution_result_hash), mut signatures) in - endorsements_by_unique_result - { + for ((chunk_id, chunk_execution_result_hash), endorsers) in endorsers_by_unique_result { if in_block_execution_results.contains(chunk_id) { continue; } @@ -501,23 +471,12 @@ impl SpiceCoreWriterActor { chunk_id.shard_id, endorsement_block.header().height(), )?; - for (account_id, _) in chunk_validator_assignments.assignments() { - if signatures.contains_key(account_id) { - continue; - } - let Some(stored_endorsement) = - self.get_endorsement(&chunk_id.block_hash, chunk_id.shard_id, &account_id) - else { - continue; - }; - if stored_endorsement.execution_result_hash != chunk_execution_result_hash { - continue; - } - signatures.insert(account_id, stored_endorsement.signature); - } - let endorsement_state = - chunk_validator_assignments.compute_endorsement_state(signatures); - if endorsement_state.is_endorsed { + if self.core_reader.reaches_endorsement_threshold( + chunk_id, + &chunk_execution_result_hash, + &chunk_validator_assignments, + endorsers, + ) { let execution_result = self.get_uncertified_execution_result(&chunk_execution_result_hash) .expect("for each endorsement we should save corresponding uncertified execution result"); store_update.merge(self.save_execution_result( diff --git a/core/primitives/src/stateless_validation/validator_assignment.rs b/core/primitives/src/stateless_validation/validator_assignment.rs index 4e606e9e8dd..ead36fd8ce0 100644 --- a/core/primitives/src/stateless_validation/validator_assignment.rs +++ b/core/primitives/src/stateless_validation/validator_assignment.rs @@ -90,4 +90,19 @@ impl ChunkValidatorAssignments { signatures, } } + + /// Whether `endorsers` hold at least 2/3 of the assignment's total stake. Like + /// `compute_endorsement_state(..).is_endorsed`, but without materializing the full state when + /// only the verdict is needed. + pub fn is_endorsed(&self, endorsers: &HashSet) -> bool { + let mut total_stake = Balance::ZERO; + let mut endorsed_stake = Balance::ZERO; + for (account_id, stake) in &self.assignments { + total_stake = total_stake.checked_add(*stake).unwrap(); + if endorsers.contains(account_id) { + endorsed_stake = endorsed_stake.checked_add(*stake).unwrap(); + } + } + has_enough_stake(total_stake, endorsed_stake) + } } From 7de80be09186098a2c519abb72716f03f18b7f82 Mon Sep 17 00:00:00 2001 From: Darioush Jalali Date: Wed, 17 Jun 2026 15:51:42 -0700 Subject: [PATCH 2/2] refactor(spice): collect block-validation endorsements via AncestryEndorsements --- .../chain/src/spice/ancestry_endorsements.rs | 56 +++++++++ chain/chain/src/spice/core.rs | 119 +++++++++--------- chain/chain/src/spice/mod.rs | 1 + chain/chain/src/spice/tests/core.rs | 1 + core/primitives/src/types.rs | 12 ++ 5 files changed, 129 insertions(+), 60 deletions(-) create mode 100644 chain/chain/src/spice/ancestry_endorsements.rs diff --git a/chain/chain/src/spice/ancestry_endorsements.rs b/chain/chain/src/spice/ancestry_endorsements.rs new file mode 100644 index 00000000000..ffd2176aae2 --- /dev/null +++ b/chain/chain/src/spice/ancestry_endorsements.rs @@ -0,0 +1,56 @@ +use near_primitives::spice::chunk_endorsement::SpiceStoredVerifiedEndorsement; +use near_primitives::types::{AccountId, SpiceChunkId, SpiceUncertifiedChunkInfo}; +use std::collections::{HashMap, HashSet}; + +/// Endorsement state derived from the uncertified chunks as of the previous block: the context +/// against which a block's core statements are validated. Borrows `prev_uncertified_chunks`. +pub(crate) struct AncestryEndorsements<'a> { + pending_designated: HashSet<(&'a SpiceChunkId, &'a AccountId)>, + on_chain: HashMap<&'a SpiceChunkId, HashMap<&'a AccountId, &'a SpiceStoredVerifiedEndorsement>>, +} + +impl<'a> AncestryEndorsements<'a> { + pub(crate) fn collect(prev_uncertified_chunks: &'a [SpiceUncertifiedChunkInfo]) -> Self { + let mut on_chain: HashMap<_, HashMap<_, _>> = HashMap::new(); + for info in prev_uncertified_chunks { + for (account_id, endorsement) in info.all_present_endorsements() { + on_chain.entry(&info.chunk_id).or_default().insert(account_id, endorsement); + } + } + Self { + pending_designated: prev_uncertified_chunks + .iter() + .flat_map(|info| { + info.missing_endorsements.iter().map(|account_id| (&info.chunk_id, account_id)) + }) + .collect(), + on_chain, + } + } + + /// Whether `(chunk_id, account_id)` is a designated endorsement still awaited on chain. + pub(crate) fn is_pending_designated( + &self, + chunk_id: &'a SpiceChunkId, + account_id: &'a AccountId, + ) -> bool { + self.pending_designated.contains(&(chunk_id, account_id)) + } + + /// Chunks awaiting a designated endorsement on chain (with repeats per missing validator). + pub(crate) fn pending_designated_chunks(&self) -> impl Iterator + '_ { + self.pending_designated.iter().map(|(chunk_id, _)| *chunk_id) + } + + /// Endorsements (designated and non-designated) already on chain for `chunk_id`. + pub(crate) fn on_chain_for( + &self, + chunk_id: &SpiceChunkId, + ) -> impl Iterator + '_ { + self.on_chain + .get(chunk_id) + .into_iter() + .flatten() + .map(|(account_id, endorsement)| (*account_id, *endorsement)) + } +} diff --git a/chain/chain/src/spice/core.rs b/chain/chain/src/spice/core.rs index 686c9170c02..51d319bebcc 100644 --- a/chain/chain/src/spice/core.rs +++ b/chain/chain/src/spice/core.rs @@ -1,3 +1,4 @@ +use crate::spice::ancestry_endorsements::AncestryEndorsements; use crate::{Chain, ChainStoreAccess, ChainStoreUpdate}; use near_chain_primitives::Error; use near_crypto::Signature; @@ -11,7 +12,7 @@ use near_primitives::hash::CryptoHash; use near_primitives::merkle::merklize; use near_primitives::shard_layout::ShardUId; use near_primitives::spice::chunk_endorsement::{ - SpiceEndorsementCoreStatement, SpiceStoredVerifiedEndorsement, + SpiceEndorsementCoreStatement, SpiceEndorsementSignedData, SpiceStoredVerifiedEndorsement, }; use near_primitives::stateless_validation::validator_assignment::ChunkValidatorAssignments; use near_primitives::types::chunk_extra::ChunkExtra; @@ -322,6 +323,24 @@ impl SpiceCoreReader { Ok(()) } + /// Pushes, as endorsement core statements, the stored endorsements the producer holds for + /// `accounts`. Accounts whose endorsement isn't in the store are simply skipped. + fn push_stored_endorsements<'b>( + &self, + chunk_id: &SpiceChunkId, + accounts: impl IntoIterator, + core_statements: &mut Vec, + ) { + for account_id in accounts { + if let Some(endorsement) = + self.get_endorsement(&chunk_id.block_hash, chunk_id.shard_id, account_id) + { + core_statements + .push(endorsement.into_core_statement(chunk_id.clone(), account_id.clone())); + } + } + } + pub fn core_statements_for_next_block( &self, block_header: &BlockHeader, @@ -335,17 +354,11 @@ impl SpiceCoreReader { let mut core_statements = Vec::new(); for chunk_info in uncertified_chunks { - for account_id in chunk_info.missing_endorsements { - if let Some(endorsement) = self.get_endorsement( - &chunk_info.chunk_id.block_hash, - chunk_info.chunk_id.shard_id, - &account_id, - ) { - core_statements.push( - endorsement.into_core_statement(chunk_info.chunk_id.clone(), account_id), - ); - } - } + self.push_stored_endorsements( + &chunk_info.chunk_id, + &chunk_info.missing_endorsements, + &mut core_statements, + ); let Some(execution_result) = get_execution_result_from_store( &self.chain_store, @@ -445,6 +458,20 @@ impl SpiceCoreReader { Ok(()) } + /// Verifies `endorsement`'s signature against its signer's key in `epoch_id`, returning the + /// signed data and signature. The error is a reason string for `InvalidCoreStatement`. + fn verify_endorsement_signature<'e>( + &self, + epoch_id: &EpochId, + endorsement: &'e SpiceEndorsementCoreStatement, + ) -> Result<(&'e SpiceEndorsementSignedData, &'e Signature), &'static str> { + let validator_info = self + .epoch_manager + .get_validator_by_account_id(epoch_id, endorsement.account_id()) + .map_err(|_| "endorsement from non-validator")?; + endorsement.verified_signed_data(validator_info.public_key()).ok_or("invalid signature") + } + pub fn validate_core_statements_in_block( &self, block: &Block, @@ -466,23 +493,7 @@ impl SpiceCoreReader { tracing::debug!(target: "spice_core", prev_hash=?block.header().prev_hash(), ?err, "failed getting uncertified_chunks"); NoPrevUncertifiedChunks })?; - let waiting_on_endorsements: HashSet<_> = prev_uncertified_chunks - .iter() - .flat_map(|info| { - info.missing_endorsements.iter().map(|account_id| (&info.chunk_id, account_id)) - }) - .collect(); - let known_endorsements: HashMap< - (&SpiceChunkId, &AccountId), - &SpiceStoredVerifiedEndorsement, - > = prev_uncertified_chunks - .iter() - .flat_map(|info| { - info.present_endorsements - .iter() - .map(|(account_id, endorsement)| ((&info.chunk_id, account_id), endorsement)) - }) - .collect(); + let ancestry_endorsements = AncestryEndorsements::collect(&prev_uncertified_chunks); let mut in_block_endorsements: HashMap< &SpiceChunkId, @@ -498,11 +509,9 @@ impl SpiceCoreReader { let account_id = endorsement.account_id(); // TODO(spice): reject more than one endorsement per (chunk, account), // regardless of result hash. The dup check below is per result hash and - // `waiting_on_endorsements` is not updated mid-loop, so a validator can + // `pending_designated` is not updated mid-loop, so a validator can // equivocate (endorse two results for one chunk) and count toward both. - // Checking contents of waiting_on_endorsements makes sure that - // chunk_id and account_id are valid. - if !waiting_on_endorsements.contains(&(chunk_id, account_id)) { + if !ancestry_endorsements.is_pending_designated(chunk_id, account_id) { return Err(InvalidCoreStatement { index, // It can either be already included in the ancestry or be for a block @@ -511,18 +520,14 @@ impl SpiceCoreReader { }); } - let block = get_block(self.chain_store.store_ref(), &chunk_id.block_hash)?; - - let validator_info = self - .epoch_manager - .get_validator_by_account_id(block.header().epoch_id(), account_id) - .expect("we are waiting on endorsement for this account so relevant validator has to exist"); - - let Some((signed_data, signature)) = - endorsement.verified_signed_data(validator_info.public_key()) - else { - return Err(InvalidCoreStatement { index, reason: "invalid signature" }); - }; + let endorsement_block = + get_block(self.chain_store.store_ref(), &chunk_id.block_hash)?; + let (signed_data, signature) = self + .verify_endorsement_signature( + endorsement_block.header().epoch_id(), + endorsement, + ) + .map_err(|reason| InvalidCoreStatement { index, reason })?; if in_block_endorsements .entry(chunk_id) @@ -559,7 +564,7 @@ impl SpiceCoreReader { // TODO(spice): Add validation that endorsements for blocks are included only when previous // block is fully endorsed (as part of block we are validating or it's ancestry). - for (chunk_id, _) in &waiting_on_endorsements { + for chunk_id in ancestry_endorsements.pending_designated_chunks() { if block_execution_results.contains_key(chunk_id) { continue; } @@ -574,7 +579,7 @@ impl SpiceCoreReader { // We cannot be waiting on an endorsement for chunk created at height that is less // than maximum endorsed height for the chunk as that would mean that child is // endorsed before parent. - return Err(SkippedExecutionResult { chunk_id: (*chunk_id).clone() }); + return Err(SkippedExecutionResult { chunk_id: chunk_id.clone() }); } } @@ -590,19 +595,12 @@ impl SpiceCoreReader { .expect( "since we are waiting for endorsement we should know it's validator assignments", ); - for (account_id, _) in chunk_validator_assignments.assignments() { - // We cannot look for endorsements in store since there they are written by a - // separate actor which isn't synchronized with block processing. - if !waiting_on_endorsements.contains(&(chunk_id, account_id)) { - let endorsement = known_endorsements.get(&(chunk_id, account_id)) - .expect( - "if we aren't waiting for endorsement in this block it should be in ancestry and known" - ); - on_chain_endorsements - .entry(endorsement.execution_result_hash.clone()) - .or_default() - .insert(account_id, endorsement.signature.clone()); - } + // Add the on-chain ancestry endorsements to the in-block ones, grouped by attested result. + for (account_id, endorsement) in ancestry_endorsements.on_chain_for(chunk_id) { + on_chain_endorsements + .entry(endorsement.execution_result_hash.clone()) + .or_default() + .insert(account_id, endorsement.signature.clone()); } for (execution_result_hash, validator_signatures) in on_chain_endorsements { let endorsement_state = @@ -833,6 +831,7 @@ pub fn record_uncertified_chunks_for_block( chunk_id: SpiceChunkId { block_hash: *block.hash(), shard_id }, missing_endorsements, present_endorsements: Vec::new(), + present_fallback_endorsements: Vec::new(), }); } diff --git a/chain/chain/src/spice/mod.rs b/chain/chain/src/spice/mod.rs index 520104f5210..9cd3bc2aaf3 100644 --- a/chain/chain/src/spice/mod.rs +++ b/chain/chain/src/spice/mod.rs @@ -1,3 +1,4 @@ +mod ancestry_endorsements; pub mod block_application; pub mod chain; pub mod chunk_application; diff --git a/chain/chain/src/spice/tests/core.rs b/chain/chain/src/spice/tests/core.rs index e471066a309..c05f878a932 100644 --- a/chain/chain/src/spice/tests/core.rs +++ b/chain/chain/src/spice/tests/core.rs @@ -1724,6 +1724,7 @@ fn uncertified_chunk_info(block_hash: CryptoHash, shard_id: ShardId) -> SpiceUnc chunk_id: SpiceChunkId { block_hash, shard_id }, missing_endorsements: vec![], present_endorsements: vec![], + present_fallback_endorsements: vec![], } } diff --git a/core/primitives/src/types.rs b/core/primitives/src/types.rs index 5a8b018fab6..76e0a10bd6a 100644 --- a/core/primitives/src/types.rs +++ b/core/primitives/src/types.rs @@ -1381,6 +1381,18 @@ pub struct SpiceUncertifiedChunkInfo { pub chunk_id: SpiceChunkId, pub missing_endorsements: Vec, pub present_endorsements: Vec<(AccountId, SpiceStoredVerifiedEndorsement)>, + /// Non-designated endorsements already on chain, accumulated for the all-stake fallback. + pub present_fallback_endorsements: Vec<(AccountId, SpiceStoredVerifiedEndorsement)>, +} + +impl SpiceUncertifiedChunkInfo { + /// All endorsements already on chain for this chunk: designated (`present_endorsements`) and + /// non-designated (`present_fallback_endorsements`). + pub fn all_present_endorsements( + &self, + ) -> impl Iterator { + self.present_endorsements.iter().chain(&self.present_fallback_endorsements) + } } /// Keeps the current status of a single yield/resume operation. Before yielding and after executing