diff --git a/chain/chain/src/chain.rs b/chain/chain/src/chain.rs index 8eca86e356c..94a1d69d3df 100644 --- a/chain/chain/src/chain.rs +++ b/chain/chain/src/chain.rs @@ -6,7 +6,9 @@ use crate::block_processing_utils::{ use crate::blocks_delay_tracker::BlocksDelayTracker; use crate::chain_update::ChainUpdate; use crate::crypto_hash_timer::CryptoHashTimer; -use crate::lightclient::get_epoch_block_producers_view; +use crate::lightclient::{ + SpiceCertifiedBatchProof, get_epoch_block_producers_view, reconstruct_certified_lite_view, +}; use crate::missing_chunks::{MissingChunksPool, OptimisticBlockChunksPool}; use crate::orphan::{Orphan, OrphanBlockPool}; use crate::pending::PendingBlocksPool; @@ -89,7 +91,7 @@ use near_primitives::version::{PROTOCOL_VERSION, ProtocolFeature}; use near_primitives::views::{ BlockStatusView, DroppedReason, ExecutionOutcomeWithIdView, ExecutionStatusView, FinalExecutionOutcomeView, FinalExecutionOutcomeWithReceiptView, FinalExecutionStatus, - LightClientBlockView, SignedTransactionView, + LightClientBlockLiteView, LightClientBlockView, SignedTransactionView, }; use near_store::adapter::StoreAdapter; use near_store::adapter::chain_store::ChainStoreAdapter; @@ -704,6 +706,48 @@ impl Chain { create_light_client_block_view(&final_block_header, chain_store, Some(next_block_producers)) } + /// Spice: the certified-execution proof for `block_hash`, anchored to `head_block_hash`. + /// Two hops: `block_hash`'s leaf into its certifying block `C`'s batch root, then `C` into + /// `head`'s `block_merkle_root` (the existing consensus block proof). + pub fn spice_certified_batch_proof( + &self, + block_hash: &CryptoHash, + head_block_hash: &CryptoHash, + ) -> Result { + let certifying_block_hash = self.chain_store.get_certified_by_block(block_hash)?; + let certifying_block = self.chain_store.get_block(&certifying_block_hash)?; + let batch = self.spice_core_reader.certified_batch( + certifying_block.header().prev_hash(), + certifying_block.spice_core_statements(), + )?; + let batch_proof = batch.path_for(block_hash).ok_or_else(|| { + Error::Other(format!( + "block {block_hash} not certified by its indexed block {certifying_block_hash}" + )) + })?; + + let block_header = self.get_block_header(block_hash)?; + let (state_root, outcome_root) = self + .spice_core_reader + .certified_block_roots_for_certifying_block(&certifying_block, &block_header)? + .ok_or_else(|| Error::Other(format!("block {block_hash} is not certified")))?; + let block_header_lite = + reconstruct_certified_lite_view(&block_header, state_root, outcome_root); + + let certifying_block_header_lite = + LightClientBlockLiteView::from(BlockHeader::clone(certifying_block.header())); + let block_proof = self.compute_past_block_proof_in_merkle_tree_of_later_block( + &certifying_block_hash, + head_block_hash, + )?; + Ok(SpiceCertifiedBatchProof { + block_header_lite, + batch_proof, + certifying_block_header_lite, + block_proof, + }) + } + pub fn save_block(&mut self, block: MaybeValidated>) -> Result<(), Error> { if self.chain_store.get_block(block.hash()).is_ok() { return Ok(()); @@ -2569,6 +2613,7 @@ impl Chain { self.spice_core_reader.validate_core_statements_in_block(&block).map_err(Box::new)?; self.spice_core_reader.validate_prev_last_certified_block_epoch_id(header)?; self.spice_core_reader.validate_spice_chunk_endorsement_stats(header)?; + self.spice_core_reader.validate_certified_batch_root(&block)?; } else { if block.is_spice_block() { return Err(Error::Other( diff --git a/chain/chain/src/chain_update.rs b/chain/chain/src/chain_update.rs index ea9541aa6ca..071f4823faa 100644 --- a/chain/chain/src/chain_update.rs +++ b/chain/chain/src/chain_update.rs @@ -314,6 +314,17 @@ impl<'a> ChainUpdate<'a> { self.epoch_manager.as_ref(), &block, )?; + // Index the blocks this block certifies onto itself, but only while it is canonical + // (the header head can be ahead from header sync); `update_height` re-points on reorg. + let is_canonical = + match self.chain_store_update.get_block_hash_by_height(block.header().height()) { + Ok(hash) => hash == *block.hash(), + Err(Error::DBNotFoundErr(_)) => false, + Err(err) => return Err(err), + }; + if is_canonical { + self.chain_store_update.index_certified_by_block(&block)?; + } } // Update the chain head if it's the new tip diff --git a/chain/chain/src/lightclient.rs b/chain/chain/src/lightclient.rs index 6373f34e91c..628f31cc5ef 100644 --- a/chain/chain/src/lightclient.rs +++ b/chain/chain/src/lightclient.rs @@ -2,10 +2,23 @@ use crate::ChainStoreAccess; use near_chain_primitives::Error; use near_epoch_manager::EpochManagerAdapter; use near_primitives::block::BlockHeader; -use near_primitives::hash::hash; +use near_primitives::hash::{CryptoHash, hash}; +use near_primitives::merkle::MerklePath; use near_primitives::types::EpochId; use near_primitives::views::validator_stake_view::ValidatorStakeView; -use near_primitives::views::{BlockHeaderInnerLiteView, LightClientBlockView}; +use near_primitives::views::{ + BlockHeaderInnerLiteView, LightClientBlockLiteView, LightClientBlockView, +}; + +/// Spice: the two-hop certified-execution proof for a block `H`. `H`'s leaf proves into the +/// batch root of its certifying block `C` (carried in `certifying_block_header_lite`), and +/// `C` proves into the trusted head's `block_merkle_root` via `block_proof`. +pub struct SpiceCertifiedBatchProof { + pub block_header_lite: LightClientBlockLiteView, + pub batch_proof: MerklePath, + pub certifying_block_header_lite: LightClientBlockLiteView, + pub block_proof: MerklePath, +} pub fn get_epoch_block_producers_view( epoch_id: &EpochId, @@ -18,6 +31,33 @@ pub fn get_epoch_block_producers_view( .collect::>()) } +/// Light-client lite view of a certified spice block, with the certified roots in the +/// classic `prev_state_root` / `outcome_root` slots. Its `hash()` is the certified leaf. +pub fn reconstruct_certified_lite_view( + block_header: &BlockHeader, + certified_state_root: CryptoHash, + certified_outcome_root: CryptoHash, +) -> LightClientBlockLiteView { + let inner_lite = BlockHeaderInnerLiteView { + height: block_header.height(), + epoch_id: block_header.epoch_id().0, + next_epoch_id: block_header.next_epoch_id().0, + prev_state_root: certified_state_root, + outcome_root: certified_outcome_root, + timestamp: block_header.raw_timestamp(), + timestamp_nanosec: block_header.raw_timestamp(), + next_bp_hash: *block_header.next_bp_hash(), + block_merkle_root: *block_header.block_merkle_root(), + certified_block_merkle_root: block_header.certified_block_merkle_root().copied(), + last_certified_block: block_header.last_certified_block().copied(), + }; + LightClientBlockLiteView { + prev_block_hash: *block_header.prev_hash(), + inner_rest_hash: hash(&block_header.inner_rest_bytes()), + inner_lite, + } +} + /// Creates the `LightClientBlock` from the information in the chain store for a given block. /// /// # Arguments @@ -46,6 +86,8 @@ pub fn create_light_client_block_view( timestamp_nanosec: block_header.raw_timestamp(), next_bp_hash: *block_header.next_bp_hash(), block_merkle_root: *block_header.block_merkle_root(), + certified_block_merkle_root: block_header.certified_block_merkle_root().copied(), + last_certified_block: block_header.last_certified_block().copied(), }; let inner_rest_hash = hash(&block_header.inner_rest_bytes()); diff --git a/chain/chain/src/spice/core.rs b/chain/chain/src/spice/core.rs index 2c38551d6e1..20b8bedc3ff 100644 --- a/chain/chain/src/spice/core.rs +++ b/chain/chain/src/spice/core.rs @@ -1,3 +1,4 @@ +use crate::lightclient::reconstruct_certified_lite_view; use crate::{Chain, ChainStoreAccess, ChainStoreUpdate}; use near_chain_primitives::Error; use near_crypto::Signature; @@ -8,7 +9,7 @@ use near_primitives::epoch_info::EpochInfo; use near_primitives::errors::InvalidSpiceCoreStatementsError; use near_primitives::gas::Gas; use near_primitives::hash::CryptoHash; -use near_primitives::merkle::merklize; +use near_primitives::merkle::{MerklePath, merklize}; use near_primitives::shard_layout::ShardUId; use near_primitives::spice::chunk_endorsement::{ SpiceEndorsementCoreStatement, SpiceStoredVerifiedEndorsement, @@ -34,6 +35,25 @@ pub struct SpiceCoreReader { genesis_gas_limit: Gas, } +/// The certified batch a block commits: the merkle root over the lite-view leaves of the +/// blocks it newly certifies (in certified-block-height order), with each leaf's hash, +/// certified block, and inclusion path into `root`. Empty when nothing is newly certified. +pub struct CertifiedBatch { + pub root: CryptoHash, + pub certified_block_hashes: Vec, + pub leaf_hashes: Vec, + pub paths: Vec, +} + +impl CertifiedBatch { + /// Inclusion path of `certified_block_hash`'s leaf into `root`, or `None` if this batch + /// does not certify it. + pub fn path_for(&self, certified_block_hash: &CryptoHash) -> Option { + let index = self.certified_block_hashes.iter().position(|h| h == certified_block_hash)?; + Some(self.paths[index].clone()) + } +} + impl SpiceCoreReader { pub fn new( chain_store: ChainStoreAdapter, @@ -223,51 +243,196 @@ impl SpiceCoreReader { Ok(Some(BlockExecutionResults(results))) } - /// State root certified as of `block_hash`: the merkle root over per-shard - /// state roots of the last fully certified block. Mirrors the non-spice - /// `Chunks::compute_state_root`. Returns `None` when the certified block's - /// execution results are not all available yet. - pub fn last_certified_state_root( + /// Per-shard certified execution results for `block_header`: the committed + /// `execution_results` column, the ancestry under `context_hash` for shards it + /// lacks, and `certifying_block`'s own in-flight statements (when set, winning). + fn gather_certified_results( &self, - block_hash: &CryptoHash, - ) -> Result, Error> { - let last_certified = get_last_certified_block_header(&self.chain_store, block_hash)?; - let shard_layout = self.epoch_manager.get_shard_layout(last_certified.epoch_id())?; - - // Fast path: `DBCol::execution_results`, written asynchronously by - // `SpiceCoreWriterActor`. By the time a block is fully certified the writer has - // almost always recorded its results, so this usually returns everything. - let mut results = self.get_execution_results_by_shard_id(&last_certified)?; - - // Slow path: when the writer has not caught up yet some shards are missing. Recover - // them from the ancestry's block bodies, which is also the only source for shards - // this node does not track. Genesis carries no certifying statements, so its results - // only ever come from the fast path above. + context_hash: &CryptoHash, + block_header: &BlockHeader, + overlay_statements: Option<&SpiceCoreStatements>, + ) -> Result>, Error> { + let shard_layout = self.epoch_manager.get_shard_layout(block_header.epoch_id())?; + let mut results = self.get_execution_results_by_shard_id(block_header)?; + if let Some(overlay_statements) = overlay_statements { + for (chunk_id, result) in overlay_statements.iter_execution_results() { + if chunk_id.block_hash == *block_header.hash() { + results.insert(chunk_id.shard_id, Arc::new(result.clone())); + } + } + } let all_present = shard_layout.shard_ids().all(|shard_id| results.contains_key(&shard_id)); - if !all_present && !last_certified.is_genesis() { - let relevant_blocks = HashSet::from([*last_certified.hash()]); + if !all_present && !block_header.is_genesis() { + let relevant_blocks = HashSet::from([*block_header.hash()]); let mut results_by_block = HashMap::new(); self.collect_certified_execution_results_from_ancestry( - block_hash, - &last_certified, + context_hash, + block_header, &relevant_blocks, &mut results_by_block, )?; for (shard_id, result) in - results_by_block.remove(last_certified.hash()).unwrap_or_default() + results_by_block.remove(block_header.hash()).unwrap_or_default() { results.entry(shard_id).or_insert(result); } } + Ok(results) + } + + fn certified_block_roots_impl( + &self, + context_hash: &CryptoHash, + block_header: &BlockHeader, + overlay_statements: Option<&SpiceCoreStatements>, + ) -> Result, Error> { + let results = + self.gather_certified_results(context_hash, block_header, overlay_statements)?; + self.certified_roots_from_results(block_header, &results) + } + /// Merkle roots over the per-shard state and outcome roots. `None` if any shard is missing. + fn certified_roots_from_results( + &self, + block_header: &BlockHeader, + results: &HashMap>, + ) -> Result, Error> { + let shard_layout = self.epoch_manager.get_shard_layout(block_header.epoch_id())?; let mut state_roots = Vec::with_capacity(shard_layout.num_shards() as usize); + let mut outcome_roots = Vec::with_capacity(shard_layout.num_shards() as usize); for shard_id in shard_layout.shard_ids() { let Some(result) = results.get(&shard_id) else { return Ok(None); }; state_roots.push(*result.chunk_extra.state_root()); + outcome_roots.push(*result.chunk_extra.outcome_root()); } - Ok(Some(merklize(&state_roots).0)) + Ok(Some((merklize(&state_roots).0, merklize(&outcome_roots).0))) + } + + /// Certified state and outcome roots for a fully certified `block_header`, + /// from the committed store. `None` when any shard's result is unavailable. + pub fn certified_block_roots( + &self, + context_hash: &CryptoHash, + block_header: &BlockHeader, + ) -> Result, Error> { + self.certified_block_roots_impl(context_hash, block_header, None) + } + + /// Like `certified_block_roots`, but overlays `certifying_block`'s own not-yet- + /// committed results. Used while building the certified-block tree during application. + pub fn certified_block_roots_for_certifying_block( + &self, + certifying_block: &Block, + block_header: &BlockHeader, + ) -> Result, Error> { + self.certified_block_roots_impl( + certifying_block.header().prev_hash(), + block_header, + Some(certifying_block.spice_core_statements()), + ) + } + + /// The certified batch committed by a block whose prev is `prev_hash` and whose own + /// statements are `core_statements`: leaves of the blocks it newly certifies, ordered + /// by certified-block height, plus the merkle root and per-leaf paths. Self-contained, + /// the same call serves production, validation, and proof generation. + pub fn certified_batch( + &self, + prev_hash: &CryptoHash, + core_statements: &SpiceCoreStatements, + ) -> Result { + let prev_uncertified = self.get_uncertified_chunks(prev_hash)?; + let newly_certified = find_newly_certified_block_hashes(&prev_uncertified, core_statements); + let mut headers = Vec::with_capacity(newly_certified.len()); + for block_hash in &newly_certified { + headers.push(self.chain_store.get_block_header(block_hash)?); + } + // Unique heights give the total order all nodes must reproduce. + headers.sort_by_key(|header| header.height()); + + let mut certified_block_hashes = Vec::with_capacity(headers.len()); + let mut leaf_hashes = Vec::with_capacity(headers.len()); + for header in &headers { + let (state_root, outcome_root) = self + .certified_block_roots_impl(prev_hash, header, Some(core_statements))? + .ok_or_else(|| { + Error::Other(format!( + "certified block {} missing execution results", + header.hash() + )) + })?; + leaf_hashes + .push(reconstruct_certified_lite_view(header, state_root, outcome_root).hash()); + certified_block_hashes.push(*header.hash()); + } + let (root, paths) = merklize(&leaf_hashes); + Ok(CertifiedBatch { root, certified_block_hashes, leaf_hashes, paths }) + } + + /// Hash of the last fully certified block as of `prev_hash` (the certified frontier tip). + pub fn last_certified_block_hash(&self, prev_hash: &CryptoHash) -> Result { + Ok(*get_last_certified_block_header(&self.chain_store, prev_hash)?.hash()) + } + + /// Recomputes a block's certified batch root and last-certified-block from its own + /// statements and rejects a header that committed different values. Self-contained, + /// no saved tree to read. + pub fn validate_certified_batch_root(&self, block: &Block) -> Result<(), Error> { + let header = block.header(); + let prev_hash = header.prev_hash(); + let expected_root = self.certified_batch(prev_hash, block.spice_core_statements())?.root; + let actual_root = header.certified_block_merkle_root().ok_or_else(|| { + Error::Other("spice block missing certified_block_merkle_root".to_string()) + })?; + if *actual_root != expected_root { + return Err(Error::Other(format!( + "invalid certified_block_merkle_root: header {actual_root}, computed {expected_root}" + ))); + } + let expected_last = self.last_certified_block_hash(prev_hash)?; + let actual_last = header + .last_certified_block() + .ok_or_else(|| Error::Other("spice block missing last_certified_block".to_string()))?; + if *actual_last != expected_last { + return Err(Error::Other(format!( + "invalid last_certified_block: header {actual_last}, computed {expected_last}" + ))); + } + Ok(()) + } + + /// Per-shard certified outcome roots for `block_header`, in shard order; `merklize`d + /// they give the block's certified `outcome_root`. `None` if any shard is missing. + pub fn certified_block_shard_outcome_roots( + &self, + context_hash: &CryptoHash, + block_header: &BlockHeader, + ) -> Result>, Error> { + let results = self.gather_certified_results(context_hash, block_header, None)?; + let shard_layout = self.epoch_manager.get_shard_layout(block_header.epoch_id())?; + let mut outcome_roots = Vec::with_capacity(shard_layout.num_shards() as usize); + for shard_id in shard_layout.shard_ids() { + let Some(result) = results.get(&shard_id) else { + return Ok(None); + }; + outcome_roots.push(*result.chunk_extra.outcome_root()); + } + Ok(Some(outcome_roots)) + } + + /// State root certified as of `block_hash`: the merkle root over per-shard + /// state roots of the last fully certified block. Returns `None` when the + /// certified block's execution results are not all available yet. + pub fn last_certified_state_root( + &self, + block_hash: &CryptoHash, + ) -> Result, Error> { + let last_certified = get_last_certified_block_header(&self.chain_store, block_hash)?; + Ok(self + .certified_block_roots(block_hash, &last_certified)? + .map(|(state_root, _)| state_root)) } /// Walks the canonical ancestry backwards from `from_hash` down to (but excluding) @@ -1038,6 +1203,25 @@ pub fn find_newly_certified_block_hashes( blocks.into_iter().filter(|(_, all_certified)| *all_certified).map(|(hash, _)| hash).collect() } +/// Block hashes that `block` newly certifies: the blocks all of whose chunks first appear +/// in `block`'s execution-result statements. Drives the `CertifiedByBlock` index. +pub(crate) fn newly_certified_block_hashes_for_block( + chain_store: &ChainStoreAdapter, + block: &Block, +) -> Result, Error> { + if block.header().is_genesis() { + return Ok(vec![]); + } + let prev_hash = block.header().prev_hash(); + // A header can be synced ahead of its body, and old uncertified_chunks are garbage + // collected; in both cases there is nothing (left) to index for this block. + if !chain_store.store_ref().exists(DBCol::uncertified_chunks(), prev_hash.as_ref()) { + return Ok(vec![]); + } + let prev_uncertified = get_uncertified_chunks(chain_store, prev_hash)?; + Ok(find_newly_certified_block_hashes(&prev_uncertified, block.spice_core_statements())) +} + /// Returns the header of the last fully certified block relative to the given block. /// All chunks in blocks at or below this height have been certified. pub fn get_last_certified_block_header( diff --git a/chain/chain/src/spice/tests/core.rs b/chain/chain/src/spice/tests/core.rs index e471066a309..29a0f0055eb 100644 --- a/chain/chain/src/spice/tests/core.rs +++ b/chain/chain/src/spice/tests/core.rs @@ -21,7 +21,7 @@ use near_primitives::congestion_info::CongestionInfo; use near_primitives::errors::InvalidSpiceCoreStatementsError; use near_primitives::gas::Gas; use near_primitives::hash::CryptoHash; -use near_primitives::merkle::merklize; +use near_primitives::merkle::{merklize, verify_path}; use near_primitives::shard_layout::ShardLayout; use near_primitives::shard_layout::ShardUId; use near_primitives::sharding::ShardChunkHeader; @@ -1503,6 +1503,157 @@ fn test_get_newly_certified_block_execution_results_multi_block_incremental() { assert_eq!(block_execution_results(&b2), execution_results[0]); } +#[test] +#[cfg_attr(not(feature = "protocol_feature_spice"), ignore)] +fn test_certified_batch_proof_unaffected_by_side_fork() { + let (mut chain, _core_reader) = setup(); + let genesis = chain.genesis_block(); + + // Canonical: b2 certifies b1 (index[b1] = b2); b3 is the head. + let b1 = build_block(&chain, &genesis, vec![]); + process_block(&mut chain, b1.clone()); + let b2 = build_block(&chain, &b1, block_certification_core_statements(&b1)); + process_block(&mut chain, b2.clone()); + let b3 = build_block(&chain, &b2, vec![]); + process_block(&mut chain, b3.clone()); + + assert_eq!(chain.chain_store().get_certified_by_block(b1.hash()).unwrap(), *b2.hash()); + assert_eq!(certifying_block_of_proof(&chain, b1.hash(), b3.hash()), *b2.hash()); + + // A losing fork re-certifies b1 with different roots: a sibling of b2 (same prev b1) + // processed after the canonical head, so it never becomes canonical. + let fork_state_root = *b2.hash(); + let c2 = build_block( + &chain, + &b1, + block_certification_core_statements_with_state_root(&b1, fork_state_root), + ); + process_block(&mut chain, c2); + + // The losing fork must not re-point index[b1]; the proof still anchors on b2. + assert_eq!(chain.chain_store().get_certified_by_block(b1.hash()).unwrap(), *b2.hash()); + assert_eq!(certifying_block_of_proof(&chain, b1.hash(), b3.hash()), *b2.hash()); +} + +#[test] +#[cfg_attr(not(feature = "protocol_feature_spice"), ignore)] +fn test_certified_batch_proof_follows_reorg() { + let (mut chain, _core_reader) = setup(); + let genesis = chain.genesis_block(); + + // Canonical: b2 certifies b1 (index[b1] = b2); b3 is the head. + let b1 = build_block(&chain, &genesis, vec![]); + process_block(&mut chain, b1.clone()); + let b2 = build_block(&chain, &b1, block_certification_core_statements(&b1)); + process_block(&mut chain, b2.clone()); + let b3 = build_block(&chain, &b2, vec![]); + process_block(&mut chain, b3.clone()); + assert_eq!(chain.head().unwrap().last_block_hash, *b3.hash()); + + assert_eq!(chain.chain_store().get_certified_by_block(b1.hash()).unwrap(), *b2.hash()); + let b_proof = chain.spice_certified_batch_proof(b1.hash(), b3.hash()).unwrap(); + + // A heavier fork off b1 re-certifies b1 with a different root, then overtakes. + let fork_state_root = *b2.hash(); + let c2 = build_block( + &chain, + &b1, + block_certification_core_statements_with_state_root(&b1, fork_state_root), + ); + process_block(&mut chain, c2.clone()); + let c3 = build_block(&chain, &c2, vec![]); + process_block(&mut chain, c3.clone()); + let c4 = build_block(&chain, &c3, vec![]); + process_block(&mut chain, c4.clone()); + assert_eq!(chain.head().unwrap().last_block_hash, *c4.hash()); + + // The reorg re-points index[b1] from b2 to c2; the proof now anchors on c2 with a + // differing leaf and batch root. + assert_eq!(chain.chain_store().get_certified_by_block(b1.hash()).unwrap(), *c2.hash()); + assert_eq!(certifying_block_of_proof(&chain, b1.hash(), c4.hash()), *c2.hash()); + let c_proof = chain.spice_certified_batch_proof(b1.hash(), c4.hash()).unwrap(); + assert_ne!(c_proof.block_header_lite.hash(), b_proof.block_header_lite.hash()); + assert_ne!( + c_proof.certifying_block_header_lite.inner_lite.certified_block_merkle_root, + b_proof.certifying_block_header_lite.inner_lite.certified_block_merkle_root, + ); +} + +/// Verifies the leaf-into-batch-root hop of `certified_block_hash`'s proof and returns the +/// certifying block it anchors on. (The certifying-block-into-`block_merkle_root` hop is the +/// standard block proof, covered end-to-end where `block_merkle_root` is real.) +fn certifying_block_of_proof( + chain: &Chain, + certified_block_hash: &CryptoHash, + head: &CryptoHash, +) -> CryptoHash { + let proof = chain.spice_certified_batch_proof(certified_block_hash, head).unwrap(); + let batch_root = + proof.certifying_block_header_lite.inner_lite.certified_block_merkle_root.unwrap(); + assert!(verify_path(batch_root, &proof.batch_proof, proof.block_header_lite.hash())); + proof.certifying_block_header_lite.hash() +} + +#[test] +#[cfg_attr(not(feature = "protocol_feature_spice"), ignore)] +fn test_validate_certified_batch_root_rejects_wrong_fields() { + let (mut chain, core_reader) = setup(); + let genesis = chain.genesis_block(); + let b1 = build_block(&chain, &genesis, vec![]); + process_block(&mut chain, b1.clone()); + let b2 = build_block(&chain, &b1, block_certification_core_statements(&b1)); + process_block(&mut chain, b2.clone()); + + // A block on b2 that certifies b2 with the correct batch root validates. + let good = build_block(&chain, &b2, block_certification_core_statements(&b2)); + core_reader.validate_certified_batch_root(&good).unwrap(); + + let bad_root = block_builder(&chain, &b2) + .certified_block_merkle_root(*b2.hash()) + .last_certified_block(core_reader.last_certified_block_hash(b2.hash()).unwrap()) + .spice_core_statements(block_certification_core_statements(&b2)) + .build(); + let err = core_reader.validate_certified_batch_root(&bad_root).unwrap_err(); + assert!(format!("{err:?}").contains("invalid certified_block_merkle_root"), "{err:?}"); + + let good_root = build_block(&chain, &b2, block_certification_core_statements(&b2)); + let bad_last = block_builder(&chain, &b2) + .certified_block_merkle_root(*good_root.header().certified_block_merkle_root().unwrap()) + .last_certified_block(*b2.hash()) + .spice_core_statements(block_certification_core_statements(&b2)) + .build(); + let err = core_reader.validate_certified_batch_root(&bad_last).unwrap_err(); + assert!(format!("{err:?}").contains("invalid last_certified_block"), "{err:?}"); +} + +fn block_certification_core_statements_with_state_root( + block: &Block, + state_root: CryptoHash, +) -> Vec { + let validators = test_validators(); + let mut core_statements = Vec::new(); + for chunk in block.chunks().iter_raw() { + let execution_result = ChunkExecutionResult { + chunk_extra: ChunkExtra::new_with_only_state_root(&state_root), + outgoing_receipts_root: CryptoHash::default(), + }; + for validator in &validators { + let signer = create_test_signer(validator); + let endorsement = SpiceChunkEndorsement::new( + SpiceChunkId { block_hash: *block.hash(), shard_id: chunk.shard_id() }, + execution_result.clone(), + &signer, + ); + core_statements.push(endorsement_into_core_statement(endorsement)); + } + core_statements.push(SpiceCoreStatement::ChunkExecutionResult { + chunk_id: SpiceChunkId { block_hash: *block.hash(), shard_id: chunk.shard_id() }, + execution_result, + }); + } + core_statements +} + fn block_execution_results(block: &Block) -> BlockExecutionResults { let mut results = HashMap::new(); for chunk in block.chunks().iter_raw() { @@ -1573,7 +1724,20 @@ fn build_block( prev_block: &Block, spice_core_statements: Vec, ) -> Arc { - block_builder(chain, prev_block).spice_core_statements(spice_core_statements).build() + let prev_hash = prev_block.header().hash(); + let statements = SpiceCoreStatements::new(spice_core_statements.clone()); + let certified_block_merkle_root = chain + .spice_core_reader + .certified_batch(prev_hash, &statements) + .map(|batch| batch.root) + .unwrap_or_default(); + let last_certified_block = + chain.spice_core_reader.last_certified_block_hash(prev_hash).unwrap_or_default(); + block_builder(chain, prev_block) + .certified_block_merkle_root(certified_block_merkle_root) + .last_certified_block(last_certified_block) + .spice_core_statements(spice_core_statements) + .build() } fn process_block(chain: &mut Chain, block: Arc) { diff --git a/chain/chain/src/spice/tests/core_writer_actor.rs b/chain/chain/src/spice/tests/core_writer_actor.rs index f3593bd87b4..8b72192cfa1 100644 --- a/chain/chain/src/spice/tests/core_writer_actor.rs +++ b/chain/chain/src/spice/tests/core_writer_actor.rs @@ -15,7 +15,7 @@ use near_chain_configs::test_genesis::{TestGenesisBuilder, ValidatorsSpec}; use near_crypto::Signature; use near_o11y::testonly::init_test_logger; use near_primitives::block::Block; -use near_primitives::block_body::SpiceCoreStatement; +use near_primitives::block_body::{SpiceCoreStatement, SpiceCoreStatements}; use near_primitives::gas::Gas; use near_primitives::hash::CryptoHash; use near_primitives::shard_layout::ShardLayout; @@ -705,7 +705,20 @@ fn build_block( prev_block: &Block, spice_core_statements: Vec, ) -> Arc { - block_builder(chain, prev_block).spice_core_statements(spice_core_statements).build() + let prev_hash = prev_block.header().hash(); + let statements = SpiceCoreStatements::new(spice_core_statements.clone()); + let certified_block_merkle_root = chain + .spice_core_reader + .certified_batch(prev_hash, &statements) + .map(|batch| batch.root) + .unwrap_or_default(); + let last_certified_block = + chain.spice_core_reader.last_certified_block_hash(prev_hash).unwrap_or_default(); + block_builder(chain, prev_block) + .certified_block_merkle_root(certified_block_merkle_root) + .last_certified_block(last_certified_block) + .spice_core_statements(spice_core_statements) + .build() } #[track_caller] diff --git a/chain/chain/src/store/mod.rs b/chain/chain/src/store/mod.rs index c5b85e43775..e2693f5b44c 100644 --- a/chain/chain/src/store/mod.rs +++ b/chain/chain/src/store/mod.rs @@ -1,4 +1,5 @@ use crate::spice::chunk_application::ChunkPersistenceConfig; +use crate::spice::core::newly_certified_block_hashes_for_block; use crate::types::{Block, BlockHeader, LatestKnown}; use borsh::{BorshDeserialize, BorshSerialize}; use chrono::Utc; @@ -1050,6 +1051,7 @@ pub(crate) struct ChainStoreCacheUpdate { receipts: HashMap>, block_refcounts: HashMap, block_merkle_tree: HashMap>, + certified_by_block: HashMap, block_ordinal_to_hash: HashMap, processed_block_heights: HashSet, receipt_to_tx: Vec<(CryptoHash, ReceiptToTxInfo)>, @@ -1478,6 +1480,16 @@ impl<'a> ChainStoreUpdate<'a> { // At this point block_merkle_tree for header is already saved. let block_ordinal = self.get_block_merkle_tree(&header_hash)?.size(); self.chain_store_cache_update.block_ordinal_to_hash.insert(block_ordinal, header_hash); + // Re-point the certified-by-block index onto the canonical chain. A stale fork + // entry then isn't reachable from the canonical block_merkle_root and fails closed. + if header.is_spice() { + // The header can be ahead of its body during sync; skip until the body lands. + match self.get_block(&header_hash) { + Ok(block) => self.index_certified_by_block(&block)?, + Err(Error::DBNotFoundErr(_)) => {} + Err(err) => return Err(err), + } + } match self.get_block_hash_by_height(header_height) { Ok(cur_hash) if cur_hash == header_hash => { // Found common ancestor. @@ -1600,6 +1612,28 @@ impl<'a> ChainStoreUpdate<'a> { .insert(block_hash, Arc::new(block_merkle_tree)); } + pub fn save_certified_by_block( + &mut self, + certified_block_hash: CryptoHash, + certifying_block_hash: CryptoHash, + ) { + self.chain_store_cache_update + .certified_by_block + .insert(certified_block_hash, certifying_block_hash); + } + + /// Points each block `block` newly certifies at `block`, so a light-client proof for it + /// anchors on `block`'s batch root. Called only for canonical blocks (initial processing + /// and reorg re-point), keeping the index on the canonical chain. + pub(crate) fn index_certified_by_block(&mut self, block: &Block) -> Result<(), Error> { + let certified = newly_certified_block_hashes_for_block(self.chain_store(), block)?; + let certifying_block_hash = *block.header().hash(); + for certified_block_hash in certified { + self.save_certified_by_block(certified_block_hash, certifying_block_hash); + } + Ok(()) + } + fn update_and_save_block_merkle_tree(&mut self, header: &BlockHeader) -> Result<(), Error> { if header.is_genesis() { self.save_block_merkle_tree(*header.hash(), PartialMerkleTree::default()); @@ -2147,6 +2181,15 @@ impl<'a> ChainStoreUpdate<'a> { for (block_hash, block_merkle_tree) in &self.chain_store_cache_update.block_merkle_tree { store_update.set_ser(DBCol::BlockMerkleTree, block_hash.as_ref(), block_merkle_tree); } + for (certified_block_hash, certifying_block_hash) in + &self.chain_store_cache_update.certified_by_block + { + store_update.set_ser( + DBCol::certified_by_block(), + certified_block_hash.as_ref(), + certifying_block_hash, + ); + } for (block_ordinal, block_hash) in &self.chain_store_cache_update.block_ordinal_to_hash { store_update.set_ser(DBCol::BlockOrdinal, &index_to_bytes(*block_ordinal), block_hash); } diff --git a/chain/client-primitives/src/types.rs b/chain/client-primitives/src/types.rs index a2a37f6240e..6180e4d239c 100644 --- a/chain/client-primitives/src/types.rs +++ b/chain/client-primitives/src/types.rs @@ -861,6 +861,11 @@ pub struct GetBlockProof { pub struct GetBlockProofResponse { pub block_header_lite: LightClientBlockLiteView, pub proof: MerklePath, + /// Spice: `block_header_lite`'s leaf into the certifying block's batch root. `proof` then + /// anchors that certifying block (`certifying_block_header_lite`) into `block_merkle_root`. + /// `None` for non-spice blocks, where `proof` anchors `block_header_lite` directly. + pub batch_proof: Option, + pub certifying_block_header_lite: Option, } #[derive(thiserror::Error, Debug)] diff --git a/chain/client/src/client.rs b/chain/client/src/client.rs index a3e410b5d50..f10af26389b 100644 --- a/chain/client/src/client.rs +++ b/chain/client/src/client.rs @@ -1159,11 +1159,20 @@ impl Client { .chain .spice_core_reader .spice_chunk_endorsement_stats_for_next_block(prev_header, height)?; + let certified_block_merkle_root = self + .chain + .spice_core_reader + .certified_batch(prev_header.hash(), &core_statements)? + .root; + let last_certified_block = + self.chain.spice_core_reader.last_certified_block_hash(prev_header.hash())?; Some(SpiceNewBlockProductionInfo { core_statements, newly_certified_block_execution_results, prev_last_certified_block_epoch_id, spice_chunk_endorsement_stats, + certified_block_merkle_root, + last_certified_block, }) } else { None diff --git a/chain/client/src/test_utils.rs b/chain/client/src/test_utils.rs index 96a25d1a627..79d13f3270b 100644 --- a/chain/client/src/test_utils.rs +++ b/chain/client/src/test_utils.rs @@ -264,6 +264,14 @@ pub fn create_chunk( }; let epoch_sync_data_hash = client.epoch_manager.compute_epoch_sync_data_hash(last_block.hash()).unwrap(); + // The chain may have certified blocks by now. The builder's block carries no core + // statements, so its batch root stays the empty default, but last_certified_block must + // be the live certified frontier rather than the builder's stale carry-forward. + let last_certified_block = if ProtocolFeature::Spice.enabled(PROTOCOL_VERSION) { + client.chain.spice_core_reader.last_certified_block_hash(last_block.hash()).unwrap() + } else { + CryptoHash::default() + }; let block = TestBlockBuilder::from_prev_block(client.clock.clone(), &last_block, signer) .height(next_height) .chunks(vec![encoded_chunk.cloned_header()]) @@ -272,6 +280,7 @@ pub fn create_chunk( .block_merkle_tree(&mut block_merkle_tree) .spice_chunk_endorsement_stats(spice_chunk_endorsement_stats) .epoch_sync_data_hash(epoch_sync_data_hash) + .last_certified_block(last_certified_block) .build(); let chunk = ShardChunkWithEncoding::from_encoded_shard_chunk(encoded_chunk) .map_err(|(err, _)| err) diff --git a/chain/client/src/view_client_actor.rs b/chain/client/src/view_client_actor.rs index ccad4978558..4dc0bde79d4 100644 --- a/chain/client/src/view_client_actor.rs +++ b/chain/client/src/view_client_actor.rs @@ -50,9 +50,10 @@ use near_primitives::receipt::{ProcessedReceiptMetadata, Receipt, ReceiptOrigin, use near_primitives::shard_layout::{ShardLayout, ShardLayoutError}; use near_primitives::sharding::ShardChunk; use near_primitives::stateless_validation::ChunkProductionKey; +use near_primitives::transaction::ExecutionOutcomeWithIdAndProof; use near_primitives::types::{ AccountId, BlockHeight, BlockId, BlockReference, EpochHeight, EpochId, EpochReference, - Finality, MaybeBlockId, ShardId, SyncCheckpoint, TransactionOrReceiptId, + Finality, MaybeBlockId, ShardId, ShardIndex, SyncCheckpoint, TransactionOrReceiptId, ValidatorInfoIdentifier, }; use near_primitives::version::{PROTOCOL_VERSION, ProtocolFeature}; @@ -1107,7 +1108,30 @@ impl if ret.inner_lite.height <= last_height { Ok(None) } else { Ok(Some(Arc::new(ret))) } } else { match self.chain.chain_store().get_epoch_light_client_block(&last_next_epoch_id.0) { - Ok(light_block) => Ok(Some(light_block)), + Ok(light_block) => { + let epoch_id = EpochId(light_block.inner_lite.epoch_id); + let protocol_version = self + .epoch_manager + .get_epoch_protocol_version(&epoch_id) + .into_chain_error()?; + if !ProtocolFeature::Spice.enabled(protocol_version) { + return Ok(Some(light_block)); + } + // The persisted view borsh-skips the certified fields; read them from + // the block's own header. It is retained as long as the light client's + // last block is, so it is present whenever this branch can serve. + let block_hash = self + .chain + .chain_store() + .get_block_hash_by_height(light_block.inner_lite.height)?; + let header = self.chain.get_block_header(&block_hash)?; + let mut light_block = LightClientBlockView::clone(&light_block); + light_block.inner_lite.certified_block_merkle_root = + header.certified_block_merkle_root().copied(); + light_block.inner_lite.last_certified_block = + header.last_certified_block().copied(); + Ok(Some(Arc::new(light_block))) + } Err(e) => { if let near_chain::Error::DBNotFoundErr(_) = e { Ok(None) @@ -1120,6 +1144,34 @@ impl } } +/// `GetExecutionOutcome` for a spice block: the certified outcome root lives in the +/// outcome's own block, not the next block (classic `prev_outcome_root`), so resolve +/// against it without advancing. +fn spice_execution_outcome_response( + chain: &Chain, + outcome_proof: ExecutionOutcomeWithIdAndProof, + outcome_block_header: &BlockHeader, + target_shard_id: ShardId, + target_shard_index: ShardIndex, + id: CryptoHash, +) -> Result { + let context = chain.head()?.last_block_hash; + let outcome_roots = chain + .spice_core_reader + .certified_block_shard_outcome_roots(&context, outcome_block_header)? + .ok_or(GetExecutionOutcomeError::NotConfirmed { transaction_or_receipt_id: id })?; + if target_shard_index >= outcome_roots.len() { + return Err(GetExecutionOutcomeError::InconsistentState { + number_or_shards: outcome_roots.len(), + execution_outcome_shard_id: target_shard_id, + }); + } + Ok(GetExecutionOutcomeResponse { + outcome_proof: outcome_proof.into(), + outcome_root_proof: merklize(&outcome_roots).1[target_shard_index].clone(), + }) +} + impl Handler> for ViewClientActor { @@ -1153,6 +1205,18 @@ impl Handler> f let head_block_header = self.chain.get_block_header(&msg.head_block_hash)?; let head_block_header = BlockHeader::clone(&head_block_header); self.chain.check_blocks_final_and_canonical(&[block_header.clone(), head_block_header])?; + if block_header.is_spice() { + // Spice: anchor on the certifying block's batch root, then that block into + // `block_merkle_root`. `proof` carries the second hop. + let batch = + self.chain.spice_certified_batch_proof(&msg.block_hash, &msg.head_block_hash)?; + return Ok(GetBlockProofResponse { + block_header_lite: batch.block_header_lite, + proof: batch.block_proof, + batch_proof: Some(batch.batch_proof), + certifying_block_header_lite: Some(batch.certifying_block_header_lite), + }); + } let block_header_lite = block_header.into(); let proof = self.chain.compute_past_block_proof_in_merkle_tree_of_later_block( &msg.block_hash, &msg.head_block_hash, )?; - Ok(GetBlockProofResponse { block_header_lite, proof }) + Ok(GetBlockProofResponse { + block_header_lite, + proof, + batch_proof: None, + certifying_block_header_lite: None, + }) } } diff --git a/chain/jsonrpc-primitives/src/types/light_client.rs b/chain/jsonrpc-primitives/src/types/light_client.rs index 60612099c07..6ebe3671f17 100644 --- a/chain/jsonrpc-primitives/src/types/light_client.rs +++ b/chain/jsonrpc-primitives/src/types/light_client.rs @@ -29,6 +29,13 @@ pub struct RpcLightClientExecutionProofResponse { pub outcome_root_proof: near_primitives::merkle::MerklePath, pub block_header_lite: near_primitives::views::LightClientBlockLiteView, pub block_proof: near_primitives::merkle::MerklePath, + /// Spice: `block_header_lite`'s leaf into the certifying block's batch root. `block_proof` + /// then anchors `certifying_block_header_lite` into the head's `block_merkle_root`. Absent + /// for non-spice blocks, where `block_proof` anchors `block_header_lite` directly. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub batch_proof: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub certifying_block_header_lite: Option, } #[derive(Debug, serde::Serialize)] @@ -43,6 +50,10 @@ pub struct RpcLightClientNextBlockResponse { pub struct RpcLightClientBlockProofResponse { pub block_header_lite: near_primitives::views::LightClientBlockLiteView, pub block_proof: near_primitives::merkle::MerklePath, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub batch_proof: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub certifying_block_header_lite: Option, } #[derive(thiserror::Error, Debug, Clone, serde::Serialize, serde::Deserialize)] diff --git a/chain/jsonrpc/src/lib.rs b/chain/jsonrpc/src/lib.rs index 53f70128137..a2cd903e6dc 100644 --- a/chain/jsonrpc/src/lib.rs +++ b/chain/jsonrpc/src/lib.rs @@ -2372,6 +2372,8 @@ impl JsonRpcHandler { outcome_root_proof: execution_outcome_proof.outcome_root_proof, block_header_lite: block_proof.block_header_lite, block_proof: block_proof.proof, + batch_proof: block_proof.batch_proof, + certifying_block_header_lite: block_proof.certifying_block_header_lite, }) } @@ -2394,6 +2396,8 @@ impl JsonRpcHandler { Ok(near_jsonrpc_primitives::types::light_client::RpcLightClientBlockProofResponse { block_header_lite: block_proof.block_header_lite, block_proof: block_proof.proof, + batch_proof: block_proof.batch_proof, + certifying_block_header_lite: block_proof.certifying_block_header_lite, }) } diff --git a/core/primitives/src/block.rs b/core/primitives/src/block.rs index 0adf8357417..c2ba778779e 100644 --- a/core/primitives/src/block.rs +++ b/core/primitives/src/block.rs @@ -265,6 +265,9 @@ impl Block { spice_info.as_ref().map(|info| info.prev_last_certified_block_epoch_id); let spice_chunk_endorsement_stats = spice_info.as_ref().map(|info| info.spice_chunk_endorsement_stats.clone()); + let certified_block_merkle_root = + spice_info.as_ref().map(|info| info.certified_block_merkle_root); + let last_certified_block = spice_info.as_ref().map(|info| info.last_certified_block); let body = if let Some(spice_info) = spice_info { BlockBody::new_for_spice(chunks, vrf_value, vrf_proof, spice_info.core_statements) } else { @@ -303,6 +306,8 @@ impl Block { shard_split, prev_last_certified_block_epoch_id, spice_chunk_endorsement_stats, + certified_block_merkle_root, + last_certified_block, ); Self::new_block(header, body) @@ -652,6 +657,10 @@ pub struct SpiceNewBlockProductionInfo { /// Accumulated per-validator endorsement stats for the epoch; non-empty /// only on the last block of an epoch. pub spice_chunk_endorsement_stats: Vec, + /// Certified-block merkle root committed in the header (as of prev block). + pub certified_block_merkle_root: CryptoHash, + /// Most recently certified block (as of prev block). + pub last_certified_block: CryptoHash, } /// Distinguishes between new and old chunks. diff --git a/core/primitives/src/block_header.rs b/core/primitives/src/block_header.rs index b10110f4375..b118ceeb882 100644 --- a/core/primitives/src/block_header.rs +++ b/core/primitives/src/block_header.rs @@ -46,6 +46,34 @@ pub struct BlockHeaderInnerLite { pub block_merkle_root: CryptoHash, } +/// Spice commits certified execution roots so light clients can verify them +/// from the headers alone. +#[derive( + BorshSerialize, + BorshDeserialize, + serde::Serialize, + Debug, + Clone, + Eq, + PartialEq, + Default, + ProtocolSchema, +)] +pub struct BlockHeaderInnerLiteV2 { + pub height: BlockHeight, + pub epoch_id: EpochId, + pub next_epoch_id: EpochId, + pub prev_state_root: MerkleHash, + pub prev_outcome_root: MerkleHash, + pub timestamp: u64, + pub next_bp_hash: CryptoHash, + pub block_merkle_root: CryptoHash, + /// Merkle root over the reconstructed light-client lite views of every + /// block whose spice execution results are certified. + pub certified_block_merkle_root: CryptoHash, + pub last_certified_block: CryptoHash, +} + #[derive( BorshSerialize, BorshDeserialize, serde::Serialize, Debug, Clone, Eq, PartialEq, ProtocolSchema, )] @@ -680,7 +708,7 @@ pub struct BlockHeaderV7 { /// Inner part of the block header that gets hashed. /// It's split into two parts: one that is sent to light clients, /// and the other which contains the rest of information. - pub inner_lite: BlockHeaderInnerLite, + pub inner_lite: BlockHeaderInnerLiteV2, pub inner_rest: BlockHeaderInnerRestV7, /// Signature of the block producer. @@ -823,6 +851,8 @@ impl BlockHeader { shard_split: Option<(ShardId, AccountId)>, prev_last_certified_block_epoch_id: Option, spice_chunk_endorsement_stats: Option>, + certified_block_merkle_root: Option, + last_certified_block: Option, ) -> Self { Self::new_impl( current_protocol_version, @@ -856,6 +886,8 @@ impl BlockHeader { shard_split, prev_last_certified_block_epoch_id, spice_chunk_endorsement_stats, + certified_block_merkle_root, + last_certified_block, ) } @@ -892,6 +924,8 @@ impl BlockHeader { shard_split: Option<(ShardId, AccountId)>, prev_last_certified_block_epoch_id: Option, spice_chunk_endorsement_stats: Option>, + certified_block_merkle_root: Option, + last_certified_block: Option, ) -> Self { let header = Self::new_impl( epoch_protocol_version, @@ -925,6 +959,8 @@ impl BlockHeader { shard_split, prev_last_certified_block_epoch_id, spice_chunk_endorsement_stats, + certified_block_merkle_root, + last_certified_block, ); // Note: We do not panic but only log if the hash of the created header does not match the expected hash (From the view) // because there are tests that check if we can downgrade a BlockHeader's view a previous version, in which case the hash @@ -968,6 +1004,8 @@ impl BlockHeader { shard_split: Option<(ShardId, AccountId)>, prev_last_certified_block_epoch_id: Option, spice_chunk_endorsement_stats: Option>, + certified_block_merkle_root: Option, + last_certified_block: Option, ) -> Self { let inner_lite = BlockHeaderInnerLite { height, @@ -993,6 +1031,22 @@ impl BlockHeader { let spice_chunk_endorsement_stats = spice_chunk_endorsement_stats.expect( "BlockHeaderV7 requires spice_chunk_endorsement_stats when Spice is enabled", ); + let certified_block_merkle_root = certified_block_merkle_root + .expect("BlockHeaderV7 requires certified_block_merkle_root when Spice is enabled"); + let last_certified_block = last_certified_block + .expect("BlockHeaderV7 requires last_certified_block when Spice is enabled"); + let inner_lite = BlockHeaderInnerLiteV2 { + height, + epoch_id, + next_epoch_id, + prev_state_root, + prev_outcome_root: outcome_root, + timestamp, + next_bp_hash, + block_merkle_root, + certified_block_merkle_root, + last_certified_block, + }; let inner_rest = BlockHeaderInnerRestV7 { block_body_hash, prev_chunk_outgoing_receipts_root, @@ -1093,13 +1147,14 @@ impl BlockHeader { /// Exactly one of the `signer` and `signature` must be provided. /// If `signer` is given signs the header with given `prev_hash`, `inner_lite`, and `inner_rest` and returns the hash and signature of the header. /// If `signature` is given, uses the signature as is and only computes the hash. - fn compute_hash_and_sign( + fn compute_hash_and_sign( signature_source: SignatureSource, prev_hash: CryptoHash, - inner_lite: &BlockHeaderInnerLite, + inner_lite: &L, inner_rest: &T, ) -> (CryptoHash, Signature) where + L: BorshSerialize + ?Sized, T: BorshSerialize + ?Sized, { let hash = BlockHeader::compute_hash( @@ -1140,6 +1195,10 @@ impl BlockHeader { } else { None }; + let genesis_certified_block_merkle_root = + ProtocolFeature::Spice.enabled(genesis_protocol_version).then(CryptoHash::default); + let genesis_last_certified_block = + ProtocolFeature::Spice.enabled(genesis_protocol_version).then(CryptoHash::default); Self::new_impl( genesis_protocol_version, genesis_protocol_version, @@ -1172,6 +1231,8 @@ impl BlockHeader { None, // shard_split genesis_prev_last_certified_block_epoch_id, genesis_spice_chunk_endorsement_stats, + genesis_certified_block_merkle_root, + genesis_last_certified_block, ) } @@ -1677,15 +1738,20 @@ impl BlockHeader { } #[inline] - pub fn inner_lite(&self) -> &BlockHeaderInnerLite { + pub fn certified_block_merkle_root(&self) -> Option<&CryptoHash> { + match self { + BlockHeader::BlockHeaderV7(header) => { + Some(&header.inner_lite.certified_block_merkle_root) + } + _ => None, + } + } + + #[inline] + pub fn last_certified_block(&self) -> Option<&CryptoHash> { match self { - BlockHeader::BlockHeaderV1(header) => &header.inner_lite, - BlockHeader::BlockHeaderV2(header) => &header.inner_lite, - BlockHeader::BlockHeaderV3(header) => &header.inner_lite, - BlockHeader::BlockHeaderV4(header) => &header.inner_lite, - BlockHeader::BlockHeaderV5(header) => &header.inner_lite, - BlockHeader::BlockHeaderV6(header) => &header.inner_lite, - BlockHeader::BlockHeaderV7(header) => &header.inner_lite, + BlockHeader::BlockHeaderV7(header) => Some(&header.inner_lite.last_certified_block), + _ => None, } } diff --git a/core/primitives/src/test_utils.rs b/core/primitives/src/test_utils.rs index 74a4a5562f8..9cc8d7416ff 100644 --- a/core/primitives/src/test_utils.rs +++ b/core/primitives/src/test_utils.rs @@ -939,6 +939,8 @@ pub struct TestBlockBuilder { newly_certified_block_execution_results: Vec, prev_last_certified_block_epoch_id: Option, spice_chunk_endorsement_stats: Vec, + certified_block_merkle_root: CryptoHash, + last_certified_block: CryptoHash, } #[cfg(feature = "clock")] @@ -957,6 +959,13 @@ impl TestBlockBuilder { *prev_header.next_epoch_id() }; let chunks_len = prev_chunks.len(); + // Default the last certified block to genesis (no certification in most tests), + // carried forward from prev. Tests that certify set it explicitly. + let last_certified_block = if prev_header.is_genesis() { + *prev_header.hash() + } else { + prev_header.last_certified_block().copied().unwrap_or_else(|| *prev_header.hash()) + }; Self { clock, signer, @@ -984,6 +993,8 @@ impl TestBlockBuilder { None }, spice_chunk_endorsement_stats: Vec::new(), + certified_block_merkle_root: CryptoHash::default(), + last_certified_block, prev_header, } } @@ -1089,6 +1100,16 @@ impl TestBlockBuilder { self } + pub fn certified_block_merkle_root(mut self, root: CryptoHash) -> Self { + self.certified_block_merkle_root = root; + self + } + + pub fn last_certified_block(mut self, block_hash: CryptoHash) -> Self { + self.last_certified_block = block_hash; + self + } + pub fn spice_chunk_endorsement_stats(mut self, stats: Vec) -> Self { self.spice_chunk_endorsement_stats = stats; self @@ -1146,6 +1167,8 @@ impl TestBlockBuilder { .prev_last_certified_block_epoch_id .expect("prev_last_certified_block_epoch_id not set for spice block"), spice_chunk_endorsement_stats: self.spice_chunk_endorsement_stats, + certified_block_merkle_root: self.certified_block_merkle_root, + last_certified_block: self.last_certified_block, } }), ); diff --git a/core/primitives/src/views.rs b/core/primitives/src/views.rs index a66080fa575..b577fd7f2d8 100644 --- a/core/primitives/src/views.rs +++ b/core/primitives/src/views.rs @@ -15,7 +15,7 @@ use crate::action::{ }; use crate::bandwidth_scheduler::BandwidthRequests; use crate::block::{Block, BlockHeader, Tip}; -use crate::block_header::BlockHeaderInnerLite; +use crate::block_header::{BlockHeaderInnerLite, BlockHeaderInnerLiteV2}; use crate::challenge::SlashedValidator; use crate::congestion_info::{CongestionInfo, CongestionInfoV1}; use crate::errors::TxExecutionError; @@ -931,6 +931,10 @@ pub struct BlockHeaderView { pub prev_last_certified_block_epoch_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub spice_chunk_endorsement_stats: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub certified_block_merkle_root: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_certified_block: Option, } impl From<&BlockHeader> for BlockHeaderView { @@ -981,6 +985,8 @@ impl From<&BlockHeader> for BlockHeaderView { spice_chunk_endorsement_stats: header .spice_chunk_endorsement_stats() .map(<[SpiceChunkEndorsementStats]>::to_vec), + certified_block_merkle_root: header.certified_block_merkle_root().copied(), + last_certified_block: header.last_certified_block().copied(), } } } @@ -1019,6 +1025,8 @@ impl From for BlockHeader { view.shard_split, view.prev_last_certified_block_epoch_id, view.spice_chunk_endorsement_stats, + view.certified_block_merkle_root, + view.last_certified_block, ) } } @@ -1052,21 +1060,31 @@ pub struct BlockHeaderInnerLiteView { pub next_bp_hash: CryptoHash, /// The merkle root of all the block hashes pub block_merkle_root: CryptoHash, + /// Spice: merkle root anchoring certified execution roots (`None` on older headers). + /// Borsh-skipped to keep the persisted `EpochLightClientBlocks` layout intact. + #[borsh(skip)] + #[serde(default, skip_serializing_if = "Option::is_none")] + pub certified_block_merkle_root: Option, + /// Spice: most recently certified block (`None` on older headers). + #[borsh(skip)] + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_certified_block: Option, } impl From for BlockHeaderInnerLiteView { fn from(header: BlockHeader) -> Self { - let inner_lite = header.inner_lite(); BlockHeaderInnerLiteView { - height: inner_lite.height, - epoch_id: inner_lite.epoch_id.0, - next_epoch_id: inner_lite.next_epoch_id.0, - prev_state_root: inner_lite.prev_state_root, - outcome_root: inner_lite.prev_outcome_root, - timestamp: inner_lite.timestamp, - timestamp_nanosec: inner_lite.timestamp, - next_bp_hash: inner_lite.next_bp_hash, - block_merkle_root: inner_lite.block_merkle_root, + height: header.height(), + epoch_id: header.epoch_id().0, + next_epoch_id: header.next_epoch_id().0, + prev_state_root: *header.prev_state_root(), + outcome_root: *header.outcome_root(), + timestamp: header.raw_timestamp(), + timestamp_nanosec: header.raw_timestamp(), + next_bp_hash: *header.next_bp_hash(), + block_merkle_root: *header.block_merkle_root(), + certified_block_merkle_root: header.certified_block_merkle_root().copied(), + last_certified_block: header.last_certified_block().copied(), } } } @@ -2713,14 +2731,28 @@ impl From for LightClientBlockLiteView { } impl LightClientBlockLiteView { pub fn hash(&self) -> CryptoHash { - let block_header_inner_lite: BlockHeaderInnerLite = self.inner_lite.clone().into(); - combine_hash( - &combine_hash( - &hash(&borsh::to_vec(&block_header_inner_lite).unwrap()), - &self.inner_rest_hash, - ), - &self.prev_block_hash, - ) + let inner_lite_hash = match self.inner_lite.certified_block_merkle_root { + Some(certified_block_merkle_root) => { + let inner_lite = BlockHeaderInnerLiteV2 { + height: self.inner_lite.height, + epoch_id: EpochId(self.inner_lite.epoch_id), + next_epoch_id: EpochId(self.inner_lite.next_epoch_id), + prev_state_root: self.inner_lite.prev_state_root, + prev_outcome_root: self.inner_lite.outcome_root, + timestamp: self.inner_lite.timestamp_nanosec, + next_bp_hash: self.inner_lite.next_bp_hash, + block_merkle_root: self.inner_lite.block_merkle_root, + certified_block_merkle_root, + last_certified_block: self.inner_lite.last_certified_block.unwrap_or_default(), + }; + hash(&borsh::to_vec(&inner_lite).unwrap()) + } + None => { + let inner_lite: BlockHeaderInnerLite = self.inner_lite.clone().into(); + hash(&borsh::to_vec(&inner_lite).unwrap()) + } + }; + combine_hash(&combine_hash(&inner_lite_hash, &self.inner_rest_hash), &self.prev_block_hash) } } diff --git a/core/store/src/adapter/chain_store.rs b/core/store/src/adapter/chain_store.rs index b7aee14a3d0..ac94fecd2ef 100644 --- a/core/store/src/adapter/chain_store.rs +++ b/core/store/src/adapter/chain_store.rs @@ -316,6 +316,17 @@ impl ChainStoreAdapter { ) } + /// Spice: the canonical block `C` that certified `certified_block_hash`. + pub fn get_certified_by_block( + &self, + certified_block_hash: &CryptoHash, + ) -> Result { + option_to_not_found( + self.store.get_ser(DBCol::certified_by_block(), certified_block_hash.as_ref()), + format_args!("CERTIFIED BY BLOCK: {}", certified_block_hash), + ) + } + pub fn get_block_merkle_tree_from_ordinal( &self, block_ordinal: NumBlocks, diff --git a/core/store/src/archive/cloud_storage/mod.rs b/core/store/src/archive/cloud_storage/mod.rs index 712da73603d..79720219663 100644 --- a/core/store/src/archive/cloud_storage/mod.rs +++ b/core/store/src/archive/cloud_storage/mod.rs @@ -187,7 +187,7 @@ pub fn is_cloud_archive_reader_bootstrapped(col: DBCol) -> bool { fn is_cloud_archive_reader_skipped(col: DBCol) -> bool { // TODO(spice): decide how the reader handles spice columns. #[cfg(feature = "protocol_feature_spice")] - if col == DBCol::ReceiptProofs { + if matches!(col, DBCol::ReceiptProofs | DBCol::CertifiedByBlock) { return true; } matches!( diff --git a/core/store/src/columns.rs b/core/store/src/columns.rs index 7c254a2cff5..c9c234af0e2 100644 --- a/core/store/src/columns.rs +++ b/core/store/src/columns.rs @@ -392,6 +392,13 @@ pub enum DBCol { // spice state sync exists, otherwise the first post-sync block can't compute it. #[cfg(feature = "protocol_feature_spice")] SpiceEndorsementStats, + /// Spice: the canonical block `C` that certified block `H`, anchoring `H`'s + /// light-client proof on `C`'s `certified_block_merkle_root` (the batch root). Re-pointed + /// to follow the canonical chain, so a stale fork entry fails the proof closed. + /// - *Rows*: BlockHash (the certified block `H`) + /// - *Column type*: CryptoHash (the certifying block `C`) + #[cfg(feature = "protocol_feature_spice")] + CertifiedByBlock, /// Stores contract accesses (code hashes) per SPICE chunk. /// Used to validate the contract code requests and accompany the witness in the catch-up /// dataflow. Written atomically together with the witness. @@ -667,6 +674,8 @@ impl DBCol { | DBCol::StateSyncNewChunks // TODO(early-kickout): Make ChunkProducers a cold column when GC is implemented. => false, + #[cfg(feature = "protocol_feature_spice")] + DBCol::CertifiedByBlock => false, #[cfg(feature = "nightly")] DBCol::ChunkProducers => false, } @@ -729,6 +738,8 @@ impl DBCol { | DBCol::EpochStart | DBCol::EpochSyncProof | DBCol::EpochValidatorInfo => GcPolicy::Permanent, + #[cfg(feature = "protocol_feature_spice")] + DBCol::CertifiedByBlock => GcPolicy::Permanent, DBCol::AccountAnnouncements | DBCol::_BlockExtra @@ -865,6 +876,8 @@ impl DBCol { #[cfg(feature = "protocol_feature_spice")] DBCol::SpiceEndorsementStats => &[DBKeyType::BlockHash], #[cfg(feature = "protocol_feature_spice")] + DBCol::CertifiedByBlock => &[DBKeyType::BlockHash], + #[cfg(feature = "protocol_feature_spice")] DBCol::ContractAccesses => &[DBKeyType::BlockHash, DBKeyType::ShardId], #[cfg(feature = "nightly")] DBCol::ChunkProducers => &[DBKeyType::BlockHash, DBKeyType::ShardId], @@ -927,6 +940,13 @@ impl DBCol { panic!("Expected protocol_feature_spice to be enabled") } + pub fn certified_by_block() -> DBCol { + #[cfg(feature = "protocol_feature_spice")] + return DBCol::CertifiedByBlock; + #[cfg(not(feature = "protocol_feature_spice"))] + panic!("Expected protocol_feature_spice to be enabled") + } + pub fn contract_accesses() -> DBCol { #[cfg(feature = "protocol_feature_spice")] return DBCol::ContractAccesses; diff --git a/core/store/src/merkle_proof.rs b/core/store/src/merkle_proof.rs index e5adb01a1ef..41edbb83d98 100644 --- a/core/store/src/merkle_proof.rs +++ b/core/store/src/merkle_proof.rs @@ -42,71 +42,12 @@ pub trait MerkleProofAccess { block_hash, head_block_hash ))); } - - let mut path = vec![]; - let mut level: u64 = 0; - let mut index = leaf_index; - let mut remaining_size = tree_size; - - while remaining_size > 1 { - // Walk left. - { - let cur_index = index; - let cur_level = level; - // Go up as long as we're the right child. This finds us a largest subtree for - // which our current node is the rightmost descendant of at the current level. - while remaining_size > 1 && index % 2 == 1 { - index /= 2; - remaining_size = (remaining_size + 1) / 2; - level += 1; - } - if level > cur_level { - // To prove this subtree, get the partial tree for the rightmost leaf of the - // subtree. It's OK if we can only go as far as the tree size; we'll aggregate - // whatever we can. Once we have the partial tree, we push in whatever is in - // between the levels we just traversed. - let ordinal = ((cur_index + 1) * (1 << cur_level) - 1).min(tree_size - 1); - let partial_tree_for_node = get_block_merkle_tree_from_ordinal(self, ordinal)?; - partial_tree_for_node.iter_path_from_bottom(|hash, l| { - if l >= cur_level && l < level { - path.push(MerklePathItem { hash, direction: Direction::Left }); - } - }); - } - } - // Walk right. - if remaining_size > 1 { - let right_sibling_index = index + 1; - let ordinal = ((right_sibling_index + 1) * (1 << level) - 1).min(tree_size - 1); - // It's possible the right sibling is actually zero, in which case we don't push - // anything to the path. - if ordinal >= right_sibling_index * (1 << level) { - // To prove a right sibling, get the partial tree for the rightmost leaf of the - // subtree, and also get the block hash of the rightmost leaf; combining these - // two will give us the root of the subtree, i.e. the right sibling. - let leaf_hash = self.get_block_hash_from_ordinal(ordinal)?; - let mut subtree_root_hash = leaf_hash; - if level > 0 { - let partial_tree_for_sibling = - get_block_merkle_tree_from_ordinal(self, ordinal)?; - partial_tree_for_sibling.iter_path_from_bottom(|hash, l| { - if l < level { - subtree_root_hash = combine_hash(&hash, &subtree_root_hash); - } - }); - } - path.push(MerklePathItem { - hash: subtree_root_hash, - direction: Direction::Right, - }); - } - - index = (index + 1) / 2; - remaining_size = (remaining_size + 1) / 2; - level += 1; - } - } - Ok(path) + compute_merkle_path_by_ordinal( + leaf_index, + tree_size, + |ordinal| get_block_merkle_tree_from_ordinal(self, ordinal), + |ordinal| self.get_block_hash_from_ordinal(ordinal), + ) } } @@ -118,6 +59,83 @@ fn get_block_merkle_tree_from_ordinal( this.get_block_merkle_tree(&block_hash) } +/// Build the inclusion path for the leaf at `leaf_index` within a merkle tree of +/// `tree_size` leaves, given ordinal-indexed access to each leaf hash and to the +/// frontier (partial) tree as of each ordinal. Accesses no leaf below `leaf_index` +/// nor any frontier beyond `tree_size`. Errors if `leaf_index >= tree_size`. +pub fn compute_merkle_path_by_ordinal( + leaf_index: u64, + tree_size: u64, + partial_tree_at_ordinal: impl Fn(u64) -> Result, Error>, + leaf_hash_at_ordinal: impl Fn(u64) -> Result, +) -> Result { + if leaf_index >= tree_size { + return Err(Error::Other(format!( + "leaf index {leaf_index} is ahead of tree size {tree_size}" + ))); + } + let mut path = vec![]; + let mut level: u64 = 0; + let mut index = leaf_index; + let mut remaining_size = tree_size; + + while remaining_size > 1 { + // Walk left. + { + let cur_index = index; + let cur_level = level; + // Go up as long as we're the right child. This finds us a largest subtree for + // which our current node is the rightmost descendant of at the current level. + while remaining_size > 1 && index % 2 == 1 { + index /= 2; + remaining_size = (remaining_size + 1) / 2; + level += 1; + } + if level > cur_level { + // To prove this subtree, get the partial tree for the rightmost leaf of the + // subtree. It's OK if we can only go as far as the tree size; we'll aggregate + // whatever we can. Once we have the partial tree, we push in whatever is in + // between the levels we just traversed. + let ordinal = ((cur_index + 1) * (1 << cur_level) - 1).min(tree_size - 1); + let partial_tree_for_node = partial_tree_at_ordinal(ordinal)?; + partial_tree_for_node.iter_path_from_bottom(|hash, l| { + if l >= cur_level && l < level { + path.push(MerklePathItem { hash, direction: Direction::Left }); + } + }); + } + } + // Walk right. + if remaining_size > 1 { + let right_sibling_index = index + 1; + let ordinal = ((right_sibling_index + 1) * (1 << level) - 1).min(tree_size - 1); + // It's possible the right sibling is actually zero, in which case we don't push + // anything to the path. + if ordinal >= right_sibling_index * (1 << level) { + // To prove a right sibling, get the partial tree for the rightmost leaf of the + // subtree, and also get the leaf hash of the rightmost leaf; combining these + // two will give us the root of the subtree, i.e. the right sibling. + let leaf_hash = leaf_hash_at_ordinal(ordinal)?; + let mut subtree_root_hash = leaf_hash; + if level > 0 { + let partial_tree_for_sibling = partial_tree_at_ordinal(ordinal)?; + partial_tree_for_sibling.iter_path_from_bottom(|hash, l| { + if l < level { + subtree_root_hash = combine_hash(&hash, &subtree_root_hash); + } + }); + } + path.push(MerklePathItem { hash: subtree_root_hash, direction: Direction::Right }); + } + + index = (index + 1) / 2; + remaining_size = (remaining_size + 1) / 2; + level += 1; + } + } + Ok(path) +} + impl MerkleProofAccess for Store { fn get_block_merkle_tree( &self, diff --git a/integration-tests/src/tests/features/shard_split_validation.rs b/integration-tests/src/tests/features/shard_split_validation.rs index d8187d0a519..d4e1fd9e7f4 100644 --- a/integration-tests/src/tests/features/shard_split_validation.rs +++ b/integration-tests/src/tests/features/shard_split_validation.rs @@ -220,6 +220,8 @@ fn block_header_shard_split_validation() { forged_shard_split.clone(), // FORGED shard_split header.prev_last_certified_block_epoch_id().cloned(), header.spice_chunk_endorsement_stats().map(<[_]>::to_vec), + header.certified_block_merkle_root().cloned(), + header.last_certified_block().cloned(), ); // Sanity: the forged header is V6 and carries the forged shard_split. diff --git a/test-loop-tests/src/tests/spice/light_client.rs b/test-loop-tests/src/tests/spice/light_client.rs new file mode 100644 index 00000000000..417e94ac50d --- /dev/null +++ b/test-loop-tests/src/tests/spice/light_client.rs @@ -0,0 +1,178 @@ +use crate::setup::builder::TestLoopBuilder; +use crate::utils::account::{create_account_id, create_validators_spec}; +use near_async::messaging::Handler as _; +use near_async::time::Duration; +use near_client::GetBlock; +use near_client_primitives::types::{GetBlockProof, GetExecutionOutcome, GetNextLightClientBlock}; +use near_o11y::testonly::init_test_logger; +use near_primitives::hash::CryptoHash; +use near_primitives::merkle::{compute_root_from_path, verify_path}; +use near_primitives::test_utils::create_user_test_signer; +use near_primitives::transaction::SignedTransaction; +use near_primitives::types::{Balance, BlockReference, Finality, TransactionOrReceiptId}; +use near_primitives::views::LightClientBlockLiteView; + +#[test] +#[cfg_attr(not(feature = "protocol_feature_spice"), ignore)] +fn test_spice_light_client_proof() { + init_test_logger(); + + let sender = create_account_id("sender"); + let receiver = create_account_id("receiver"); + + let mut env = TestLoopBuilder::new() + .validators_spec(create_validators_spec(2, 1)) + .enable_rpc() + .add_user_account(&sender, Balance::from_near(10)) + .add_user_account(&receiver, Balance::from_near(0)) + .build(); + + let tx = SignedTransaction::send_money( + 1, + sender.clone(), + receiver, + &create_user_test_signer(&sender), + Balance::from_near(1), + env.rpc_node().head().last_block_hash, + ); + let tx_hash = tx.get_hash(); + let outcome = env.rpc_runner().execute_tx(tx, Duration::seconds(20)).unwrap(); + let tx_block_hash = outcome.transaction_outcome.block_hash; + let tx_height = + env.rpc_node().client().chain.get_block_header(&tx_block_hash).unwrap().height(); + // The block proof anchors the certifying block into the head's block_merkle_root, so run + // one block past the consensus head that certified the tx -- making it the final head's prev. + env.rpc_runner().run_until_certified(tx_height); + let certified_head_height = env.rpc_node().head().height; + env.rpc_runner().run_until_final_head_height(certified_head_height + 1); + + // The trusted head, from next_light_client_block (which returns a final block). Pass the + // head's prev as the client's last tracked head. + let final_head = env + .rpc_node_mut() + .view_client_actor() + .handle(GetBlock(BlockReference::Finality(Finality::Final))) + .unwrap(); + let light_client_block = env + .rpc_node_mut() + .view_client_actor() + .handle(GetNextLightClientBlock { last_block_hash: final_head.header.prev_hash }) + .unwrap() + .expect("next_light_client_block must return the certified head"); + let head_block_merkle_root = light_client_block.inner_lite.block_merkle_root; + let light_client_head = LightClientBlockLiteView { + prev_block_hash: light_client_block.prev_block_hash, + inner_rest_hash: light_client_block.inner_rest_hash, + inner_lite: light_client_block.inner_lite.clone(), + } + .hash(); + + let outcome_response = env + .rpc_node_mut() + .view_client_actor() + .handle(GetExecutionOutcome { + id: TransactionOrReceiptId::Transaction { + transaction_hash: tx_hash, + sender_id: sender, + }, + }) + .unwrap(); + let block_proof_response = env + .rpc_node_mut() + .view_client_actor() + .handle(GetBlockProof { + block_hash: outcome_response.outcome_proof.block_hash, + head_block_hash: light_client_head, + }) + .unwrap(); + let outcome_proof = outcome_response.outcome_proof; + let outcome_root_proof = outcome_response.outcome_root_proof; + let block_header_lite = block_proof_response.block_header_lite; + let block_proof = block_proof_response.proof; + let batch_proof = + block_proof_response.batch_proof.expect("spice block proof must carry batch_proof"); + let certifying_block_header_lite = block_proof_response + .certifying_block_header_lite + .expect("spice block proof must carry certifying_block_header_lite"); + + // Verify like a light client, three merkle proofs chained: + // + // tx outcome --outcome_proof---> B's certified outcome_root (in B's lite view) + // B's leaf --batch_proof-----> certifying block C's batch root (in C's lite view) + // C --block_proof-----> head's block_merkle_root (trusted via the head) + + // 1. The tx's outcome is committed in B's certified outcome_root. + let outcome_hash = CryptoHash::hash_borsh(&outcome_proof.to_hashes()); + let shard_outcome_root = compute_root_from_path(&outcome_proof.proof, outcome_hash); + let b_outcome_root = + compute_root_from_path(&outcome_root_proof, CryptoHash::hash_borsh(shard_outcome_root)); + assert_eq!(b_outcome_root, block_header_lite.inner_lite.outcome_root); + + // 2. B's leaf is in the certifying block C's batch root. (B's whole reconstructed lite view + // is hashed into the leaf, so it is verified too.) + let b_leaf = block_header_lite.hash(); + let batch_root = certifying_block_header_lite + .inner_lite + .certified_block_merkle_root + .expect("certifying block must commit a batch root"); + assert!(verify_path(batch_root, &batch_proof, b_leaf)); + + // 3. C is included in the head's block_merkle_root (today's consensus block proof). + assert_eq!( + compute_root_from_path(&block_proof, certifying_block_header_lite.hash()), + head_block_merkle_root, + ); +} + +#[test] +#[cfg_attr(not(feature = "protocol_feature_spice"), ignore)] +fn test_spice_light_client_cross_epoch() { + init_test_logger(); + + let mut env = TestLoopBuilder::new() + .validators_spec(create_validators_spec(2, 1)) + .enable_rpc() + .epoch_length(5) + .build(); + + // The light client's last known block lives in this epoch. + env.rpc_runner().run_until_new_epoch(); + let last_block_hash = env.rpc_node().head().last_block_hash; + let last_epoch_id = env.rpc_node().head().epoch_id; + + // Move the head two epochs ahead. next_light_client_block must then return the stored + // epoch block for the in-between epoch, not a freshly computed head block. + env.rpc_runner().run_until_new_epoch(); + let in_between_epoch_id = env.rpc_node().head().epoch_id; + env.rpc_runner().run_until_new_epoch(); + assert_ne!(env.rpc_node().head().epoch_id, last_epoch_id); + assert_ne!(env.rpc_node().head().epoch_id, in_between_epoch_id); + + let light_client_block = env + .rpc_node_mut() + .view_client_actor() + .handle(GetNextLightClientBlock { last_block_hash }) + .unwrap() + .expect("cross-epoch next_light_client_block must return the stored epoch block"); + // The stored block is the in-between epoch's, confirming the cross-epoch branch ran. + assert_eq!(light_client_block.inner_lite.epoch_id, in_between_epoch_id.0); + + // A cross-epoch light-client block must carry the certified fields, equal to what the + // corresponding on-chain block header committed. + let certified_block_merkle_root = light_client_block + .inner_lite + .certified_block_merkle_root + .expect("cross-epoch light-client block must carry certified_block_merkle_root"); + let last_certified_block = light_client_block + .inner_lite + .last_certified_block + .expect("cross-epoch light-client block must carry last_certified_block"); + let header = env + .rpc_node() + .client() + .chain + .get_block_header_by_height(light_client_block.inner_lite.height) + .unwrap(); + assert_eq!(Some(&certified_block_merkle_root), header.certified_block_merkle_root()); + assert_eq!(Some(&last_certified_block), header.last_certified_block()); +} diff --git a/test-loop-tests/src/tests/spice/mod.rs b/test-loop-tests/src/tests/spice/mod.rs index 4476a5bcc0d..2f6fbfb231b 100644 --- a/test-loop-tests/src/tests/spice/mod.rs +++ b/test-loop-tests/src/tests/spice/mod.rs @@ -1,4 +1,5 @@ mod basic; +mod light_client; mod malicious_chunk_producer; mod resharding; mod utils;