From 2444e1ec730a267e44fc4c0759c9b2a55a9d4967 Mon Sep 17 00:00:00 2001 From: diegokingston Date: Fri, 22 May 2026 12:16:06 -0300 Subject: [PATCH 1/3] feat(merkle): add Merkle cap support to the binary tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Merkle cap commits the top 2^cap_height tree nodes instead of a single root, so every authentication path stops cap_height levels early — smaller proofs and cap_height fewer hash compressions per opening. The cap is just a slice of the existing heap-ordered node array (the nodes at tree depth cap_height), so no new tree storage is needed. Adds `cap`, `get_proof_by_pos_capped`, `get_batch_proof_capped`, and `Proof`/`BatchProof::verify_capped`; the existing root-based methods become cap_height=0 wrappers and are bit-for-bit unchanged in behavior. cap_height clamps to the tree depth, so small trees degrade gracefully to a single root. Stage 1 of the caps + arity-4 FRI plan; purely additive, no STARK-layer changes yet. --- crypto/crypto/src/merkle_tree/merkle.rs | 85 ++++++++--- crypto/crypto/src/merkle_tree/proof.rs | 61 +++++++- crypto/crypto/src/tests/merkle_cap_tests.rs | 153 ++++++++++++++++++++ crypto/crypto/src/tests/mod.rs | 1 + 4 files changed, 268 insertions(+), 32 deletions(-) create mode 100644 crypto/crypto/src/tests/merkle_cap_tests.rs diff --git a/crypto/crypto/src/merkle_tree/merkle.rs b/crypto/crypto/src/merkle_tree/merkle.rs index 4ea0e5411..0ff6a56a8 100644 --- a/crypto/crypto/src/merkle_tree/merkle.rs +++ b/crypto/crypto/src/merkle_tree/merkle.rs @@ -192,31 +192,53 @@ where &self.nodes } - /// Returns a Merkle proof for the element/s at position pos - /// For example, give me an inclusion proof for the 3rd element in the - /// Merkle tree - pub fn get_proof_by_pos(&self, pos: usize) -> Option> { - let pos = pos + self.node_count() / 2; - let Ok(merkle_path) = self.build_merkle_path(pos) else { - return None; - }; + /// Total tree depth (root-to-leaf): `log2(number of leaves)`. + fn depth(&self) -> usize { + let leaves_len = self.node_count().div_ceil(2); + leaves_len.trailing_zeros() as usize + } - self.create_proof(merkle_path) + /// Returns the Merkle cap at `cap_height`: the `2^cap_height` nodes at tree + /// depth `cap_height`, ordered left to right. `cap_height` is clamped to the + /// tree depth; `cap_height == 0` yields a single-node cap equal to the root. + pub fn cap(&self, cap_height: usize) -> Vec { + let c = cap_height.min(self.depth()); + let start = (1usize << c) - 1; + let end = (1usize << (c + 1)) - 1; + (start..end) + .map(|i| self.node_get(i).expect("cap node index in bounds").clone()) + .collect() } - /// Creates a proof from a Merkle pasth - fn create_proof(&self, merkle_path: Vec) -> Option> { + /// Returns a Merkle proof for the element at position `pos` (full path to + /// the root). + pub fn get_proof_by_pos(&self, pos: usize) -> Option> { + self.get_proof_by_pos_capped(pos, 0) + } + + /// Returns a Merkle proof for the element at position `pos` whose + /// authentication path stops at the cap level (`cap_height` levels below + /// the root). `cap_height == 0` is equivalent to [`get_proof_by_pos`]. + pub fn get_proof_by_pos_capped(&self, pos: usize, cap_height: usize) -> Option> { + let pos = pos + self.node_count() / 2; + let merkle_path = self.build_merkle_path_capped(pos, cap_height).ok()?; Some(Proof { merkle_path }) } - /// Returns the Merkle path for the element/s for the leaf at position pos - fn build_merkle_path(&self, pos: usize) -> Result, Error> { - // Pre-allocate based on tree depth (log2 of tree size) - let tree_depth = (self.node_count() + 1).ilog2() as usize; - let mut merkle_path = Vec::with_capacity(tree_depth); + /// Returns the authentication path from leaf node `pos` up to — but not + /// including — the cap level. + fn build_merkle_path_capped( + &self, + pos: usize, + cap_height: usize, + ) -> Result, Error> { + let c = cap_height.min(self.depth()); + let cap_start = (1usize << c) - 1; + let cap_end = (1usize << (c + 1)) - 1; + let mut merkle_path = Vec::with_capacity(self.depth() - c); let mut pos = pos; - while pos != ROOT { + while !(cap_start..cap_end).contains(&pos) { let Some(node) = self.node_get(sibling_index(pos)) else { // out of bounds, exit returning the current merkle_path return Err(Error::OutOfBounds); @@ -248,6 +270,16 @@ where /// - `Error::EmptyPositionList` if `pos_list` is empty /// - `Error::OutOfBounds` if any position in `pos_list` is >= number of leaves pub fn get_batch_proof(&self, pos_list: &[usize]) -> Result, Error> { + self.get_batch_proof_capped(pos_list, 0) + } + + /// Batch proof variant whose authentication paths stop at the cap level. + /// `cap_height == 0` is equivalent to [`get_batch_proof`]. + pub fn get_batch_proof_capped( + &self, + pos_list: &[usize], + cap_height: usize, + ) -> Result, Error> { if pos_list.is_empty() { return Err(Error::EmptyPositionList); } @@ -268,7 +300,8 @@ where .map(|pos| pos + self.node_count() / 2) .collect::>(); // We get the positions of the nodes for the batch proof. - let batch_auth_path_positions = self.get_batch_auth_path_positions(&leaf_positions); + let batch_auth_path_positions = + self.get_batch_auth_path_positions(&leaf_positions, cap_height); // We get the nodes for the batch proof. let batch_auth_path_nodes = batch_auth_path_positions @@ -297,16 +330,20 @@ where /// /// This ordering is critical because the verifier consumes proof nodes level-by-level /// starting from leaves, so it needs leaf-level siblings first. - fn get_batch_auth_path_positions(&self, leaf_positions: &[usize]) -> Vec { + fn get_batch_auth_path_positions( + &self, + leaf_positions: &[usize], + cap_height: usize, + ) -> Vec { // BTreeSet always maintains elements in ascending order (smaller indices first), regardless of insertion order. let mut auth_path_set = BTreeSet::::new(); let mut obtainable: BTreeSet = leaf_positions.iter().cloned().collect(); - // Number of levels in tree - let num_levels = (self.node_count() + 1).ilog2(); - - // Iter lefevel-by-level from leaves to root. - for _ in 0..num_levels - 1 { + // Climb level-by-level from the leaves up to the cap level. A full + // tree has `depth` levels above the leaves; a cap of height `c` stops + // `c` levels early. + let levels_to_climb = self.depth() - cap_height.min(self.depth()); + for _ in 0..levels_to_climb { let mut next_obtainable = BTreeSet::new(); for &pos in &obtainable { diff --git a/crypto/crypto/src/merkle_tree/proof.rs b/crypto/crypto/src/merkle_tree/proof.rs index 20d5452a2..2cd0cdecb 100644 --- a/crypto/crypto/src/merkle_tree/proof.rs +++ b/crypto/crypto/src/merkle_tree/proof.rs @@ -21,7 +21,18 @@ pub struct Proof { impl Proof { /// Verifies a Merkle inclusion proof for the value contained at leaf index. - pub fn verify(&self, root_hash: &B::Node, mut index: usize, value: &B::Data) -> bool + pub fn verify(&self, root_hash: &B::Node, index: usize, value: &B::Data) -> bool + where + B: IsMerkleTreeBackend, + { + self.verify_capped::(core::slice::from_ref(root_hash), index, value) + } + + /// Verifies a Merkle inclusion proof against a Merkle cap (`2^cap_height` + /// nodes). The path is folded up to the cap level and the result is checked + /// for membership in the cap. A single-node cap is equivalent to + /// [`verify`](Self::verify). + pub fn verify_capped(&self, cap: &[B::Node], mut index: usize, value: &B::Data) -> bool where B: IsMerkleTreeBackend, { @@ -37,7 +48,9 @@ impl Proof { index >>= 1; } - root_hash == &hashed_value + // After folding `merkle_path.len()` levels, `index` is the position of + // the reconstructed node within the cap. + cap.get(index) == Some(&hashed_value) } } @@ -102,12 +115,35 @@ impl BatchProof { values: &[B::Data], num_leaves: usize, ) -> bool + where + B: IsMerkleTreeBackend, + { + self.verify_capped::( + core::slice::from_ref(root_hash), + pos_list, + values, + num_leaves, + ) + } + + /// Batch verification against a Merkle cap (`2^cap_height` nodes). A + /// single-node cap is equivalent to [`verify`](Self::verify). + pub fn verify_capped( + &self, + cap: &[B::Node], + pos_list: &[usize], + values: &[B::Data], + num_leaves: usize, + ) -> bool where B: IsMerkleTreeBackend, { if pos_list.len() != values.len() || pos_list.is_empty() { return false; } + if !cap.len().is_power_of_two() { + return false; + } // Index of the first leaf as it is ordered in the tree struct (from top to bottom). let first_leaf_index = num_leaves - 1; @@ -136,9 +172,11 @@ impl BatchProof { let mut proof_path_iter = self.path.iter(); - let num_levels = (2 * num_leaves).ilog2(); - // Process level by level, from bottom to top, same as `get_batch_auth_path_positions`. - for _ in 0..num_levels - 1 { + let depth = num_leaves.trailing_zeros() as usize; + let cap_height = cap.len().trailing_zeros() as usize; + // Process level by level, from the leaves up to the cap level, same as + // `get_batch_auth_path_positions`. + for _ in 0..depth - cap_height { let mut next_level_known_nodes: BTreeMap = BTreeMap::new(); // Process each known node from right to left to match the order of the proof. @@ -178,9 +216,16 @@ impl BatchProof { current_level_known_nodes = next_level_known_nodes; } - // Verify: root computed correctly and all proof nodes consumed. + // Verify: every reconstructed cap-level node is in the cap, and all + // proof nodes were consumed. + let cap_start = cap.len() - 1; proof_path_iter.next().is_none() - && current_level_known_nodes.len() == 1 - && (current_level_known_nodes.get(&0) == Some(root_hash)) + && !current_level_known_nodes.is_empty() + && current_level_known_nodes.iter().all(|(tree_index, node)| { + tree_index + .checked_sub(cap_start) + .and_then(|cap_index| cap.get(cap_index)) + == Some(node) + }) } } diff --git a/crypto/crypto/src/tests/merkle_cap_tests.rs b/crypto/crypto/src/tests/merkle_cap_tests.rs new file mode 100644 index 000000000..ca1331483 --- /dev/null +++ b/crypto/crypto/src/tests/merkle_cap_tests.rs @@ -0,0 +1,153 @@ +//! Tests for Merkle caps: committing the top `2^cap_height` nodes instead of a +//! single root, so authentication paths stop `cap_height` levels early. + +use alloc::vec::Vec; +use math::field::{element::FieldElement, goldilocks::GoldilocksField}; + +use crate::merkle_tree::backends::types::Keccak256Backend; +use crate::merkle_tree::merkle::MerkleTree; + +type F = GoldilocksField; +type FE = FieldElement; +type Backend = Keccak256Backend; + +// A 16-leaf tree has depth 4, so cap heights 0..=4 are all valid. +const DEPTH: usize = 4; + +fn leaves() -> Vec { + (1..=16u64).map(FE::from).collect() +} + +fn tree() -> MerkleTree { + MerkleTree::::build(&leaves()).unwrap() +} + +#[test] +fn cap_height_zero_is_the_root() { + let t = tree(); + let cap = t.cap(0); + assert_eq!(cap.len(), 1); + assert_eq!(cap[0], t.root); +} + +#[test] +fn cap_has_two_pow_height_nodes() { + let t = tree(); + for c in 0..=DEPTH { + assert_eq!(t.cap(c).len(), 1 << c, "cap height {c}"); + } +} + +#[test] +fn cap_height_is_clamped_to_tree_depth() { + let t = tree(); + // A cap taller than the tree clamps to the leaf level. + assert_eq!(t.cap(99).len(), 16); +} + +#[test] +fn capped_path_is_shorter_by_cap_height() { + let t = tree(); + for c in 0..=DEPTH { + let proof = t.get_proof_by_pos_capped(0, c).unwrap(); + assert_eq!(proof.merkle_path.len(), DEPTH - c, "cap height {c}"); + } +} + +#[test] +fn capped_proof_roundtrips_for_every_leaf_and_cap_height() { + let values = leaves(); + let t = MerkleTree::::build(&values).unwrap(); + for c in 0..=DEPTH { + let cap = t.cap(c); + for (pos, value) in values.iter().enumerate() { + let proof = t.get_proof_by_pos_capped(pos, c).unwrap(); + assert!( + proof.verify_capped::(&cap, pos, value), + "leaf {pos}, cap height {c}", + ); + } + } +} + +#[test] +fn cap_height_zero_matches_uncapped() { + let values = leaves(); + let t = MerkleTree::::build(&values).unwrap(); + for (pos, value) in values.iter().enumerate() { + let capped = t.get_proof_by_pos_capped(pos, 0).unwrap(); + let plain = t.get_proof_by_pos(pos).unwrap(); + assert_eq!(capped.merkle_path, plain.merkle_path); + assert!(capped.verify_capped::(&t.cap(0), pos, value)); + assert!(plain.verify::(&t.root, pos, value)); + } +} + +#[test] +fn capped_proof_rejects_wrong_value() { + let t = tree(); + let cap = t.cap(2); + let proof = t.get_proof_by_pos_capped(5, 2).unwrap(); + assert!(!proof.verify_capped::(&cap, 5, &FE::from(999u64))); +} + +#[test] +fn capped_proof_rejects_wrong_position() { + let values = leaves(); + let t = MerkleTree::::build(&values).unwrap(); + let cap = t.cap(2); + let proof = t.get_proof_by_pos_capped(5, 2).unwrap(); + assert!(!proof.verify_capped::(&cap, 6, &values[5])); +} + +#[test] +fn capped_proof_rejects_tampered_cap() { + let values = leaves(); + let t = MerkleTree::::build(&values).unwrap(); + let mut cap = t.cap(2); + let proof = t.get_proof_by_pos_capped(5, 2).unwrap(); + // The proof for leaf 5 resolves to cap entry `5 >> (DEPTH - 2)`. + let cap_index = 5 >> (DEPTH - 2); + cap[cap_index] = [0u8; 32]; + assert!(!proof.verify_capped::(&cap, 5, &values[5])); +} + +#[test] +fn batch_capped_roundtrips() { + let values = leaves(); + let t = MerkleTree::::build(&values).unwrap(); + let positions = [1usize, 4, 5, 11, 15]; + let leaf_values: Vec = positions.iter().map(|&p| values[p]).collect(); + for c in 0..=DEPTH { + let cap = t.cap(c); + let batch = t.get_batch_proof_capped(&positions, c).unwrap(); + assert!( + batch.verify_capped::(&cap, &positions, &leaf_values, 16), + "cap height {c}", + ); + } +} + +#[test] +fn batch_capped_zero_matches_uncapped() { + let values = leaves(); + let t = MerkleTree::::build(&values).unwrap(); + let positions = [2usize, 7, 13]; + let leaf_values: Vec = positions.iter().map(|&p| values[p]).collect(); + let capped = t.get_batch_proof_capped(&positions, 0).unwrap(); + let plain = t.get_batch_proof(&positions).unwrap(); + assert_eq!(capped.path, plain.path); + assert!(capped.verify_capped::(&t.cap(0), &positions, &leaf_values, 16)); + assert!(plain.verify::(&t.root, &positions, &leaf_values, 16)); +} + +#[test] +fn batch_capped_rejects_tampered_value() { + let values = leaves(); + let t = MerkleTree::::build(&values).unwrap(); + let positions = [1usize, 4, 9]; + let mut leaf_values: Vec = positions.iter().map(|&p| values[p]).collect(); + leaf_values[1] = FE::from(12345u64); + let batch = t.get_batch_proof_capped(&positions, 2).unwrap(); + assert!(!batch.verify_capped::(&t.cap(2), &positions, &leaf_values, 16)); +} diff --git a/crypto/crypto/src/tests/mod.rs b/crypto/crypto/src/tests/mod.rs index 9b9952e7f..9c388b642 100644 --- a/crypto/crypto/src/tests/mod.rs +++ b/crypto/crypto/src/tests/mod.rs @@ -1,4 +1,5 @@ pub mod default_transcript_tests; +pub mod merkle_cap_tests; pub mod merkle_proof_tests; pub mod merkle_tests; pub mod merkle_utils_tests; From 376bc2ed87f998c8016d8ca2750ce0e7a71d71a5 Mon Sep 17 00:00:00 2001 From: diegokingston Date: Fri, 22 May 2026 13:17:36 -0300 Subject: [PATCH 2/3] feat(merkle): add quad-leaf backend for arity-4 FRI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `FieldElementQuadBackend` hashes a fixed group of four field elements into one leaf. With arity-4 FRI folding a single fold orbit is four evaluations that are always opened together, so they belong under one leaf hash — this is the FRI-layer leaf for the arity-4 commit phase. Mirrors the existing `FieldElementPairBackend`; exposed as `QuadKeccak256Backend`. Building block for stage 2b (arity-4 FRI). --- .../backends/field_element_vector.rs | 49 +++++++++++++++++++ .../crypto/src/merkle_tree/backends/types.rs | 7 ++- crypto/crypto/src/tests/merkle_tests.rs | 32 ++++++++++++ 3 files changed, 87 insertions(+), 1 deletion(-) diff --git a/crypto/crypto/src/merkle_tree/backends/field_element_vector.rs b/crypto/crypto/src/merkle_tree/backends/field_element_vector.rs index 1023b3a03..389780eac 100644 --- a/crypto/crypto/src/merkle_tree/backends/field_element_vector.rs +++ b/crypto/crypto/src/merkle_tree/backends/field_element_vector.rs @@ -56,6 +56,55 @@ where } } +/// A backend for Merkle trees whose leaves are fixed-size groups of four field +/// elements. This is the natural leaf for arity-4 FRI, where one fold orbit +/// (four evaluations) is always opened together, so all four belong under a +/// single leaf hash. +#[derive(Clone)] +pub struct FieldElementQuadBackend { + phantom1: PhantomData, + phantom2: PhantomData, +} + +impl Default for FieldElementQuadBackend { + fn default() -> Self { + Self { + phantom1: PhantomData, + phantom2: PhantomData, + } + } +} + +impl IsMerkleTreeBackend + for FieldElementQuadBackend +where + F: IsField, + FieldElement: AsBytes, + [u8; NUM_BYTES]: From>, +{ + type Node = [u8; NUM_BYTES]; + type Data = [FieldElement; 4]; + + fn hash_data(input: &[FieldElement; 4]) -> [u8; NUM_BYTES] { + let mut hasher = D::new(); + for element in input { + hasher.update(element.as_bytes()); + } + let mut result_hash = [0_u8; NUM_BYTES]; + result_hash.copy_from_slice(&hasher.finalize()); + result_hash + } + + fn hash_new_parent(left: &[u8; NUM_BYTES], right: &[u8; NUM_BYTES]) -> [u8; NUM_BYTES] { + let mut hasher = D::new(); + hasher.update(left); + hasher.update(right); + let mut result_hash = [0_u8; NUM_BYTES]; + result_hash.copy_from_slice(&hasher.finalize()); + result_hash + } +} + #[derive(Clone)] pub struct FieldElementVectorBackend { phantom1: PhantomData, diff --git a/crypto/crypto/src/merkle_tree/backends/types.rs b/crypto/crypto/src/merkle_tree/backends/types.rs index 0c2a30422..dd3e6bd3c 100644 --- a/crypto/crypto/src/merkle_tree/backends/types.rs +++ b/crypto/crypto/src/merkle_tree/backends/types.rs @@ -2,7 +2,9 @@ use sha3::Keccak256; use super::{ field_element::FieldElementBackend, - field_element_vector::{FieldElementPairBackend, FieldElementVectorBackend}, + field_element_vector::{ + FieldElementPairBackend, FieldElementQuadBackend, FieldElementVectorBackend, + }, }; // Field element backend definitions @@ -13,3 +15,6 @@ pub type BatchKeccak256Backend = FieldElementVectorBackend; // Fixed-size pair backends (more efficient for FRI layers) pub type PairKeccak256Backend = FieldElementPairBackend; + +// Fixed-size quad backend: the leaf for arity-4 FRI fold orbits. +pub type QuadKeccak256Backend = FieldElementQuadBackend; diff --git a/crypto/crypto/src/tests/merkle_tests.rs b/crypto/crypto/src/tests/merkle_tests.rs index 18f853083..d9ffc6c95 100644 --- a/crypto/crypto/src/tests/merkle_tests.rs +++ b/crypto/crypto/src/tests/merkle_tests.rs @@ -136,3 +136,35 @@ fn batch_proof_len_is_expected_for_long_pos_list() { let batch_proof = merkle_tree.get_batch_proof(&pos_list).unwrap(); assert_eq!(batch_proof.path.len(), 2); } + +#[test] +fn quad_leaf_backend_builds_and_verifies() { + use crate::merkle_tree::backends::types::QuadKeccak256Backend; + use math::field::goldilocks::GoldilocksField; + + type QF = GoldilocksField; + type Qfe = FieldElement; + type QuadBackend = QuadKeccak256Backend; + + // Eight 4-element fold orbits. + let leaves: [[Qfe; 4]; 8] = core::array::from_fn(|g| { + let g = g as u64; + [ + Qfe::from(4 * g), + Qfe::from(4 * g + 1), + Qfe::from(4 * g + 2), + Qfe::from(4 * g + 3), + ] + }); + let tree = MerkleTree::::build(&leaves).unwrap(); + + for (pos, leaf) in leaves.iter().enumerate() { + let proof = tree.get_proof_by_pos(pos).unwrap(); + assert!(proof.verify::(&tree.root, pos, leaf)); + } + + // A wrong orbit must not verify. + let proof = tree.get_proof_by_pos(2).unwrap(); + let wrong = [Qfe::from(0u64); 4]; + assert!(!proof.verify::(&tree.root, 2, &wrong)); +} From eb50cc2fb46c45b61f5d0dca75c7b82cae69ee02 Mon Sep 17 00:00:00 2001 From: diegokingston Date: Fri, 22 May 2026 14:57:02 -0300 Subject: [PATCH 3/3] feat(stark): wire Merkle caps and arity-4 FRI folding Commit the top 2^c Merkle nodes as a cap instead of a single root, so every opening path is c hashes shorter, and fold FRI by 4 per committed layer (two binary folds) to halve the number of FRI trees and paths. - StarkProof / prover / verifier carry MerkleCap commitments; preprocessed trees stay uncapped to preserve the AIR-hardcoded constants. - FRI layers commit quad-leaf trees (one leaf per arity-4 fold orbit); commit_phase does one uncommitted initial fold then number_layers/2 arity-4 layers, folding to a constant last value. - Verifier replays two challenges per committed layer and folds each 4-element orbit, with the (-1)^(index&1) twiddle parity handled. The CUDA pair-leaf FRI tree builder is now stale w.r.t. arity-4; its CPU-parity test is disabled with a TODO until the CUDA builder is updated. --- crypto/math-cuda/tests/keccak_leaves.rs | 8 + crypto/stark/src/config.rs | 16 +- crypto/stark/src/fri/fri_commitment.rs | 13 +- crypto/stark/src/fri/fri_decommit.rs | 5 +- crypto/stark/src/fri/mod.rs | 165 ++++++++------- crypto/stark/src/proof/stark.rs | 22 +- crypto/stark/src/prover.rs | 139 ++++++++----- crypto/stark/src/verifier.rs | 265 ++++++++++++------------ prover/src/lib.rs | 6 +- 9 files changed, 353 insertions(+), 286 deletions(-) diff --git a/crypto/math-cuda/tests/keccak_leaves.rs b/crypto/math-cuda/tests/keccak_leaves.rs index d614e233d..e8103cf11 100644 --- a/crypto/math-cuda/tests/keccak_leaves.rs +++ b/crypto/math-cuda/tests/keccak_leaves.rs @@ -5,12 +5,14 @@ //! FRI commit. These are the same helpers the prover itself calls so any //! change to the CPU leaf-hash contract surfaces here. +#[cfg(any())] use crypto::merkle_tree::traits::IsMerkleTreeBackend; use math::field::element::FieldElement; use math::field::extensions_goldilocks::Degree3GoldilocksExtensionField; use math::field::goldilocks::GoldilocksField; use rand::{Rng, SeedableRng}; use rand_chacha::ChaCha8Rng; +#[cfg(any())] use stark::config::FriLayerMerkleTreeBackend; use stark::prover::{keccak_leaves_bit_reversed, keccak_leaves_row_pair_bit_reversed}; @@ -151,6 +153,12 @@ fn keccak_comp_poly_leaves_matches_cpu() { } } +// DISABLED for arity-4 FRI: the CPU prover now commits each FRI layer with quad +// (4-element) leaves, one per arity-4 fold orbit, while the CUDA +// `build_fri_layer_tree_from_evals_ext3` still builds pair leaves. Re-enable +// this GPU/CPU parity test once the CUDA FRI-layer tree builder is updated to +// quad leaves to match `FriLayerMerkleTreeBackend` (`QuadKeccak256Backend`). +#[cfg(any())] #[test] fn keccak_fri_leaves_matches_cpu() { for log_lde in [2u32, 4, 6, 8, 10, 12] { diff --git a/crypto/stark/src/config.rs b/crypto/stark/src/config.rs index 50650e40a..aaffc07d3 100644 --- a/crypto/stark/src/config.rs +++ b/crypto/stark/src/config.rs @@ -1,5 +1,5 @@ use crypto::merkle_tree::{ - backends::types::{BatchKeccak256Backend, Keccak256Backend, PairKeccak256Backend}, + backends::types::{BatchKeccak256Backend, Keccak256Backend, QuadKeccak256Backend}, merkle::MerkleTree, }; @@ -16,9 +16,19 @@ pub type FriMerkleTree = MerkleTree>; pub const COMMITMENT_SIZE: usize = 32; pub type Commitment = [u8; COMMITMENT_SIZE]; +/// Height of the Merkle commitment cap. The commitment is the `2^MERKLE_CAP_HEIGHT` +/// nodes at this tree depth instead of a single root, so every opening path is +/// `MERKLE_CAP_HEIGHT` hashes shorter. Clamped to the tree depth for small trees. +pub const MERKLE_CAP_HEIGHT: usize = 4; + +/// A Merkle commitment represented as a cap: the `2^MERKLE_CAP_HEIGHT` nodes at +/// tree depth `MERKLE_CAP_HEIGHT`, ordered left to right. +pub type MerkleCap = Vec; + pub type BatchedMerkleTreeBackend = BatchKeccak256Backend; pub type BatchedMerkleTree = MerkleTree>; -// FRI layer uses fixed-size pairs for efficiency (avoids Vec allocation per pair) -pub type FriLayerMerkleTreeBackend = PairKeccak256Backend; +// FRI layer uses fixed-size quad leaves: one leaf per arity-4 fold orbit, so a +// single Keccak covers the four conjugate evaluations a query opens together. +pub type FriLayerMerkleTreeBackend = QuadKeccak256Backend; pub type FriLayerMerkleTree = MerkleTree>; diff --git a/crypto/stark/src/fri/fri_commitment.rs b/crypto/stark/src/fri/fri_commitment.rs index b0b3188b2..931f0cc27 100644 --- a/crypto/stark/src/fri/fri_commitment.rs +++ b/crypto/stark/src/fri/fri_commitment.rs @@ -4,6 +4,8 @@ use math::{ traits::AsBytes, }; +/// One committed FRI layer: the bit-reversed evaluation vector plus the Merkle +/// tree (quad leaves, one per arity-4 fold orbit) committing it. #[cfg_attr(not(feature = "disk-spill"), derive(Clone))] pub struct FriLayer where @@ -13,8 +15,6 @@ where { pub evaluation: Vec>, pub merkle_tree: MerkleTree, - pub coset_offset: FieldElement, - pub domain_size: usize, } impl FriLayer @@ -23,17 +23,10 @@ where FieldElement: AsBytes, B: IsMerkleTreeBackend, { - pub fn new( - evaluation: &[FieldElement], - merkle_tree: MerkleTree, - coset_offset: FieldElement, - domain_size: usize, - ) -> Self { + pub fn new(evaluation: &[FieldElement], merkle_tree: MerkleTree) -> Self { Self { evaluation: evaluation.to_vec(), merkle_tree, - coset_offset, - domain_size, } } } diff --git a/crypto/stark/src/fri/fri_decommit.rs b/crypto/stark/src/fri/fri_decommit.rs index f398096d5..27fa78081 100644 --- a/crypto/stark/src/fri/fri_decommit.rs +++ b/crypto/stark/src/fri/fri_decommit.rs @@ -4,9 +4,12 @@ use math::field::traits::IsField; use crate::config::Commitment; +/// Per-query FRI decommitment. Each committed layer folds by 4, so a query +/// reveals one 4-element fold orbit (the quad leaf) and one authentication path +/// for that leaf. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[serde(bound = "")] pub struct FriDecommitment { pub layers_auth_paths: Vec>, - pub layers_evaluations_sym: Vec>, + pub layers_evaluations: Vec<[FieldElement; 4]>, } diff --git a/crypto/stark/src/fri/mod.rs b/crypto/stark/src/fri/mod.rs index 87ab66a5b..f8dd75b5f 100644 --- a/crypto/stark/src/fri/mod.rs +++ b/crypto/stark/src/fri/mod.rs @@ -8,7 +8,7 @@ use math::field::traits::IsSubFieldOf; use math::field::traits::{IsFFTField, IsField}; use math::traits::AsBytes; -use crate::config::{FriLayerMerkleTree, FriLayerMerkleTreeBackend}; +use crate::config::{FriLayerMerkleTree, FriLayerMerkleTreeBackend, MERKLE_CAP_HEIGHT}; use self::fri_commitment::FriLayer; use self::fri_decommit::FriDecommitment; @@ -16,9 +16,16 @@ use self::fri_functions::{ compute_coset_twiddles_inv, fold_evaluations_in_place, update_twiddles_in_place, }; -/// FRI commit phase from pre-computed bit-reversed evaluations. -/// skipping the initial FFT. Use this when the caller already has the evaluation -/// vector (e.g. from a fused LDE pipeline). +/// FRI commit phase with arity-4 folding, from pre-computed bit-reversed +/// evaluations (skipping the initial FFT — use when the caller already has the +/// evaluation vector, e.g. from a fused LDE pipeline). +/// +/// `number_layers` is `log2(trace_length)`. The first fold (p₀ → p₁) is +/// uncommitted — its inputs come from the DEEP/trace openings. The remaining +/// folds are grouped into `number_layers / 2` committed arity-4 layers; each +/// commits its input evaluations with quad leaves and folds twice. The folding +/// continues until the layer collapses to a constant, returned as the last +/// value (one extra binary fold happens for even `number_layers`). pub fn commit_phase_from_evaluations, E: IsField>( number_layers: usize, mut evals: Vec>, @@ -33,100 +40,116 @@ where FieldElement: AsBytes + Sync + Send, FieldElement: AsBytes + Sync + Send, { - // Inverse twiddle factors for evaluation-form folding + // Inverse twiddle factors for evaluation-form folding. let mut inv_twiddles = compute_coset_twiddles_inv(coset_offset, domain_size); - let mut fri_layer_list = Vec::with_capacity(number_layers); - let mut current_coset_offset = coset_offset.clone(); - let mut current_domain_size = domain_size; + // Committed arity-4 layers. One uncommitted initial fold, then the rest are + // paired up; integer division also folds an extra binary step when + // `number_layers` is even, collapsing the final layer to a constant. + let num_committed = number_layers / 2; + let mut fri_layer_list = Vec::with_capacity(num_committed); - for _ in 1..number_layers { - // <<<< Receive challenge 𝜁ₖ₋₁ + // <<<< Receive challenge 𝜁₀ — uncommitted initial fold p₀ → p₁. The + // verifier always replays this fold, so the prover always performs it. + { let zeta = transcript.sample_field_element(); - current_coset_offset = current_coset_offset.square(); - current_domain_size /= 2; - - // Fold evaluations in-place (no FFT needed) fold_evaluations_in_place(&mut evals, &zeta, &inv_twiddles); + update_twiddles_in_place(&mut inv_twiddles); + } - // Build Merkle tree from consecutive pairs - let leaves: Vec<[FieldElement; 2]> = evals - .chunks_exact(2) - .map(|chunk| [chunk[0].clone(), chunk[1].clone()]) + for _ in 0..num_committed { + // Commit the current layer with quad leaves: one leaf per fold orbit. + let leaves: Vec<[FieldElement; 4]> = evals + .chunks_exact(4) + .map(|chunk| { + [ + chunk[0].clone(), + chunk[1].clone(), + chunk[2].clone(), + chunk[3].clone(), + ] + }) .collect(); let merkle_tree = FriLayerMerkleTree::build(&leaves) .expect("FRI commit: Merkle tree construction must succeed"); - let root = merkle_tree.root; - fri_layer_list.push(FriLayer::new( - &evals, - merkle_tree, - current_coset_offset.clone().to_extension(), - current_domain_size, - )); - - // >>>> Send commitment: [pₖ] - transcript.append_bytes(&root); - - // Update twiddles for next level - update_twiddles_in_place(&mut inv_twiddles); - } + let cap = merkle_tree.cap(MERKLE_CAP_HEIGHT); - // <<<< Receive challenge: 𝜁ₙ₋₁ - let zeta = transcript.sample_field_element(); + // >>>> Send commitment cap: [pₖ] (before sampling this layer's challenges). + for node in &cap { + transcript.append_bytes(node); + } + fri_layer_list.push(FriLayer::new(&evals, merkle_tree)); - // Final fold - fold_evaluations_in_place(&mut evals, &zeta, &inv_twiddles); + // Fold by 4 = two binary folds with independent challenges. + let zeta_a = transcript.sample_field_element(); + fold_evaluations_in_place(&mut evals, &zeta_a, &inv_twiddles); + update_twiddles_in_place(&mut inv_twiddles); - let last_value = evals.first().unwrap_or(&FieldElement::zero()).clone(); + let zeta_b = transcript.sample_field_element(); + fold_evaluations_in_place(&mut evals, &zeta_b, &inv_twiddles); + update_twiddles_in_place(&mut inv_twiddles); + } - // >>>> Send value: pₙ + // >>>> Send value: pₙ — the constant value of the final layer. + let last_value = evals.first().cloned().unwrap_or_else(FieldElement::zero); transcript.append_field_element(&last_value); (last_value, fri_layer_list) } +/// FRI query phase. For each query index, reveal the 4-element fold orbit and +/// one authentication path per committed arity-4 layer. pub fn query_phase( - fri_layers: &Vec>>, + fri_layers: &[FriLayer>], iotas: &[usize], ) -> Vec> where FieldElement: AsBytes + Sync + Send, { - if !fri_layers.is_empty() { - let num_layers = fri_layers.len(); - iotas - .iter() - .map(|iota_s| { - let mut layers_evaluations_sym = Vec::with_capacity(num_layers); - let mut layers_auth_paths_sym = Vec::with_capacity(num_layers); - - let mut index = *iota_s; - for layer in fri_layers { - // symmetric element - let evaluation_sym = layer.evaluation[index ^ 1].clone(); - let auth_path_sym = layer.merkle_tree.get_proof_by_pos(index >> 1).unwrap(); - layers_evaluations_sym.push(evaluation_sym); - layers_auth_paths_sym.push(auth_path_sym); - - index >>= 1; - } - - FriDecommitment { - layers_auth_paths: layers_auth_paths_sym, - layers_evaluations_sym, - } - }) - .collect() - } else { - // For 0 FRI layers (small traces), return empty decommitments for each query. - // The verifier still needs one decommitment entry per query, even if the - // FRI layer data is empty. - iotas + if fri_layers.is_empty() { + // No committed layers (tiny traces): the verifier still needs one + // decommitment entry per query, even if empty. + return iotas .iter() .map(|_| FriDecommitment { layers_auth_paths: vec![], - layers_evaluations_sym: vec![], + layers_evaluations: vec![], }) - .collect() + .collect(); } + + let num_layers = fri_layers.len(); + iotas + .iter() + .map(|iota_s| { + let mut layers_auth_paths = Vec::with_capacity(num_layers); + let mut layers_evaluations = Vec::with_capacity(num_layers); + + let mut index = *iota_s; + for layer in fri_layers { + // The fold orbit: four consecutive (bit-reversed) evaluations + // that fold together into one next-layer value. + let base = index & !3; + let orbit = [ + layer.evaluation[base].clone(), + layer.evaluation[base + 1].clone(), + layer.evaluation[base + 2].clone(), + layer.evaluation[base + 3].clone(), + ]; + let auth_path = layer + .merkle_tree + .get_proof_by_pos_capped(index >> 2, MERKLE_CAP_HEIGHT) + .expect("FRI query: layer orbit index in bounds"); + layers_evaluations.push(orbit); + layers_auth_paths.push(auth_path); + + index >>= 2; + } + + FriDecommitment { + layers_auth_paths, + layers_evaluations, + } + }) + .collect() } diff --git a/crypto/stark/src/proof/stark.rs b/crypto/stark/src/proof/stark.rs index 1751d60fe..9ce3bbd27 100644 --- a/crypto/stark/src/proof/stark.rs +++ b/crypto/stark/src/proof/stark.rs @@ -5,7 +5,10 @@ use math::field::{ }; use crate::{ - config::Commitment, fri::fri_decommit::FriDecommitment, lookup::BusPublicInputs, table::Table, + config::{Commitment, MerkleCap}, + fri::fri_decommit::FriDecommitment, + lookup::BusPublicInputs, + table::Table, }; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] @@ -35,24 +38,25 @@ pub type DeepPolynomialOpenings = Vec>; pub struct StarkProof, E: IsField, PI> { // Length of the execution trace pub trace_length: usize, - // Commitments of the trace columns + // Commitment cap of the trace columns // [tⱼ] - pub lde_trace_main_merkle_root: Commitment, - // Commitments of auxiliary trace columns + pub lde_trace_main_merkle_cap: MerkleCap, + // Commitment cap of auxiliary trace columns // [tⱼ] - pub lde_trace_aux_merkle_root: Option, + pub lde_trace_aux_merkle_cap: Option, // For preprocessed tables: commitment to precomputed columns only. // Verifier checks this matches the hardcoded commitment from AIR. + // Kept as a single root (uncapped) so the AIR-hardcoded constants stay valid. pub lde_trace_precomputed_merkle_root: Option, // tⱼ(zgᵏ) pub trace_ood_evaluations: Table, - // Commitments to Hᵢ - pub composition_poly_root: Commitment, + // Commitment cap to Hᵢ + pub composition_poly_cap: MerkleCap, // Hᵢ(z^N) pub composition_poly_parts_ood_evaluation: Vec>, // [pₖ] - pub fri_layers_merkle_roots: Vec, - // pₙ + pub fri_layers_merkle_caps: Vec, + // pₙ — the constant value of the final FRI layer pub fri_last_value: FieldElement, // Open(pₖ(Dₖ), −𝜐ₛ^(2ᵏ)) pub query_list: Vec>, diff --git a/crypto/stark/src/prover.rs b/crypto/stark/src/prover.rs index 620a809b2..e27858911 100644 --- a/crypto/stark/src/prover.rs +++ b/crypto/stark/src/prover.rs @@ -33,7 +33,9 @@ use crate::storage_mode::StorageMode; use crate::table::Table; use crate::trace::LDETraceTable; -use super::config::{BatchedMerkleTree, BatchedMerkleTreeBackend, Commitment}; +use super::config::{ + BatchedMerkleTree, BatchedMerkleTreeBackend, Commitment, MERKLE_CAP_HEIGHT, MerkleCap, +}; use super::constraints::evaluator::ConstraintEvaluator; use super::domain::{Domain, DomainConstants}; use super::fri::fri_decommit::FriDecommitment; @@ -91,11 +93,12 @@ where { /// Merkle tree over the trace columns (multiplicities only for preprocessed tables). pub(crate) tree: Arc>, - /// Root of `tree`. - pub(crate) root: Commitment, + /// Cap of `tree`. + pub(crate) cap: MerkleCap, /// Preprocessed tables only: Merkle tree over precomputed columns. pub(crate) precomputed_tree: Option>>, - /// Preprocessed tables only: root of `precomputed_tree`. + /// Preprocessed tables only: root of `precomputed_tree` (kept uncapped so the + /// AIR-hardcoded precomputed-commitment constants stay valid). pub(crate) precomputed_root: Option, /// Preprocessed tables only: number of precomputed columns. Zero otherwise. pub(crate) num_precomputed_cols: usize, @@ -106,10 +109,10 @@ where FieldElement: AsBytes, { /// Build a `TableCommit` for a plain (non-preprocessed) table. - fn plain(tree: BatchedMerkleTree, root: Commitment) -> Self { + fn plain(tree: BatchedMerkleTree, cap: MerkleCap) -> Self { Self { tree: Arc::new(tree), - root, + cap, precomputed_tree: None, precomputed_root: None, num_precomputed_cols: 0, @@ -119,25 +122,25 @@ where /// Build a `TableCommit` for a preprocessed table. fn preprocessed( tree: BatchedMerkleTree, - root: Commitment, + cap: MerkleCap, precomputed_tree: BatchedMerkleTree, precomputed_root: Commitment, num_precomputed_cols: usize, ) -> Self { Self { tree: Arc::new(tree), - root, + cap, precomputed_tree: Some(Arc::new(precomputed_tree)), precomputed_root: Some(precomputed_root), num_precomputed_cols, } } - /// Cheap clone. Only bumps Arc refcounts, no tree data is copied. + /// Cheap clone. Only bumps Arc refcounts and copies the small cap vec. fn share(&self) -> Self { Self { tree: Arc::clone(&self.tree), - root: self.root, + cap: self.cap.clone(), precomputed_tree: self.precomputed_tree.as_ref().map(Arc::clone), precomputed_root: self.precomputed_root, num_precomputed_cols: self.num_precomputed_cols, @@ -295,8 +298,8 @@ where pub(crate) lde_composition_poly_evaluations: Vec>>, /// The Merkle tree built to compute the commitment to the composition polynomial parts. pub(crate) composition_poly_merkle_tree: BatchedMerkleTree, - /// The commitment to the composition polynomial parts. - pub(crate) composition_poly_root: Commitment, + /// The commitment cap to the composition polynomial parts. + pub(crate) composition_poly_cap: MerkleCap, } /// A container for the results of the third round of the STARK Prove protocol. @@ -309,10 +312,10 @@ pub(crate) struct Round3 { /// A container for the results of the fourth round of the STARK Prove protocol. pub(crate) struct Round4, E: IsField> { - /// The final value resulting from folding the Deep composition polynomial all the way down to a constant value. + /// The constant value of the final FRI layer. fri_last_value: FieldElement, - /// The commitments to the fold polynomials of the inner layers of FRI. - fri_layers_merkle_roots: Vec, + /// The commitment caps to the fold polynomials of the inner layers of FRI. + fri_layers_merkle_caps: Vec, /// The values and proofs of validity of the evaluations of the trace polynomials and the composition polynomials /// parts at the domain values corresponding to the FRI query challenges and their symmetric counterparts. deep_poly_openings: DeepPolynomialOpenings, @@ -467,7 +470,7 @@ pub trait IsStarkProver< /// but avoids allocating the cloned and transposed matrices entirely. fn commit_columns_bit_reversed( columns: &[Vec>], - ) -> Option<(BatchedMerkleTree, Commitment)> + ) -> Option<(BatchedMerkleTree, MerkleCap)> where FieldElement: AsBytes + Sync + Send + math::traits::ByteConversion, E: IsField, @@ -477,8 +480,8 @@ pub trait IsStarkProver< } let hashed_leaves = keccak_leaves_bit_reversed(columns); let tree = BatchedMerkleTree::::build_from_hashed_leaves(hashed_leaves)?; - let root = tree.root; - Some((tree, root)) + let cap = tree.cap(MERKLE_CAP_HEIGHT); + Some((tree, cap)) } /// Compute the LDE commitment for a subset of columns from a trace (for testing). @@ -503,8 +506,8 @@ pub trait IsStarkProver< let twiddles = LdeTwiddles::new(&domain); let evals = Self::compute_lde_from_columns_cached::(&precomputed, &domain, &twiddles); - let (_, commitment) = Self::commit_columns_bit_reversed(&evals)?; - Some(commitment) + let (tree, _) = Self::commit_columns_bit_reversed(&evals)?; + Some(tree.root) } /// Compute LDE evaluations with pre-computed twiddle factors and coset weights. @@ -613,22 +616,25 @@ pub trait IsStarkProver< let commit = match precomputed { None => { #[allow(unused_mut)] - let (mut tree, root) = Self::commit_columns_bit_reversed(&columns) + let (mut tree, cap) = Self::commit_columns_bit_reversed(&columns) .ok_or(ProvingError::EmptyCommitment)?; #[cfg(feature = "disk-spill")] if storage_mode == StorageMode::Disk { tree.spill_nodes_to_disk() .map_err(|e| ProvingError::DiskSpill(format!("main Merkle tree: {e}")))?; } - TableCommit::plain(tree, root) + TableCommit::plain(tree, cap) } Some((expected_precomputed_root, num_cols)) => { #[allow(unused_mut)] - let (mut precomputed_tree, precomputed_root) = + let (mut precomputed_tree, _) = Self::commit_columns_bit_reversed(&columns[..num_cols]) .ok_or(ProvingError::EmptyCommitment)?; + // Preprocessed columns stay uncapped: their single root is checked + // against the AIR-hardcoded commitment. + let precomputed_root = precomputed_tree.root; #[allow(unused_mut)] - let (mut mult_tree, mult_root) = + let (mut mult_tree, mult_cap) = Self::commit_columns_bit_reversed(&columns[num_cols..]) .ok_or(ProvingError::EmptyCommitment)?; debug_assert_eq!( @@ -646,7 +652,7 @@ pub trait IsStarkProver< } TableCommit::preprocessed( mult_tree, - mult_root, + mult_cap, precomputed_tree, precomputed_root, num_cols, @@ -742,7 +748,7 @@ pub trait IsStarkProver< /// composition polynomial. fn commit_composition_polynomial( lde_composition_poly_parts_evaluations: &[Vec>], - ) -> Option<(BatchedMerkleTree, Commitment)> + ) -> Option<(BatchedMerkleTree, MerkleCap)> where FieldElement: AsBytes + Sync + Send, FieldElement: AsBytes + Sync + Send + math::traits::ByteConversion, @@ -758,8 +764,8 @@ pub trait IsStarkProver< let hashed_leaves = keccak_leaves_row_pair_bit_reversed(lde_composition_poly_parts_evaluations); let tree = BatchedMerkleTree::::build_from_hashed_leaves(hashed_leaves)?; - let root = tree.root; - Some((tree, root)) + let cap = tree.cap(MERKLE_CAP_HEIGHT); + Some((tree, cap)) } /// Algebraically decompose H(x) = H₀(x²) + x·H₁(x²) on the LDE coset, then @@ -913,7 +919,7 @@ pub trait IsStarkProver< #[cfg(feature = "instruments")] let t_sub = Instant::now(); - let Some((composition_poly_merkle_tree, composition_poly_root)) = + let Some((composition_poly_merkle_tree, composition_poly_cap)) = Self::commit_composition_polynomial(&lde_composition_poly_parts_evaluations) else { return Err(ProvingError::EmptyCommitment); @@ -927,7 +933,7 @@ pub trait IsStarkProver< Ok(Round2 { lde_composition_poly_evaluations: lde_composition_poly_parts_evaluations, composition_poly_merkle_tree, - composition_poly_root, + composition_poly_cap, }) } @@ -1087,9 +1093,9 @@ pub trait IsStarkProver< let query_list = fri::query_phase(&fri_layers, &iotas); - let fri_layers_merkle_roots: Vec<_> = fri_layers + let fri_layers_merkle_caps: Vec = fri_layers .iter() - .map(|layer| layer.merkle_tree.root) + .map(|layer| layer.merkle_tree.cap(MERKLE_CAP_HEIGHT)) .collect(); let deep_poly_openings = @@ -1103,7 +1109,7 @@ pub trait IsStarkProver< Round4 { fri_last_value, - fri_layers_merkle_roots, + fri_layers_merkle_caps, deep_poly_openings, query_list, nonce, @@ -1280,8 +1286,8 @@ pub trait IsStarkProver< FieldElement: AsBytes + Sync + Send, { let proof = composition_poly_merkle_tree - .get_proof_by_pos(index) - .unwrap(); + .get_proof_by_pos_capped(index, MERKLE_CAP_HEIGHT) + .expect("composition poly opening: leaf index in bounds"); let lde_composition_poly_parts_evaluation: Vec<_> = lde_composition_poly_evaluations .iter() @@ -1317,6 +1323,7 @@ pub trait IsStarkProver< domain: &Domain, tree: &BatchedMerkleTree, challenge: usize, + cap_height: usize, gather: G, ) -> PolynomialOpenings where @@ -1328,8 +1335,12 @@ pub trait IsStarkProver< let index = challenge * 2; let index_sym = challenge * 2 + 1; PolynomialOpenings { - proof: tree.get_proof_by_pos(index).unwrap(), - proof_sym: tree.get_proof_by_pos(index_sym).unwrap(), + proof: tree + .get_proof_by_pos_capped(index, cap_height) + .expect("trace opening: leaf index in bounds"), + proof_sym: tree + .get_proof_by_pos_capped(index_sym, cap_height) + .expect("trace opening: symmetric leaf index in bounds"), evaluations: gather(reverse_index(index, domain_size)), evaluations_sym: gather(reverse_index(index_sym, domain_size)), } @@ -1358,18 +1369,28 @@ pub trait IsStarkProver< // For preprocessed tables, open the main split (multiplicities only); // for normal tables, open all main columns. let main_trace_opening = if is_preprocessed { - Self::open_polys_with(domain, &main_commit.tree, *index, |row| { - lde_trace.gather_main_row_range(row, num_precomputed_cols, total_cols) - }) + Self::open_polys_with( + domain, + &main_commit.tree, + *index, + MERKLE_CAP_HEIGHT, + |row| lde_trace.gather_main_row_range(row, num_precomputed_cols, total_cols), + ) } else { - Self::open_polys_with(domain, &main_commit.tree, *index, |row| { - lde_trace.gather_main_row(row) - }) + Self::open_polys_with( + domain, + &main_commit.tree, + *index, + MERKLE_CAP_HEIGHT, + |row| lde_trace.gather_main_row(row), + ) }; // For preprocessed tables, also open the precomputed-columns tree. + // The precomputed tree stays uncapped (cap_height 0): its root is + // checked against the AIR-hardcoded commitment. let precomputed_trace_opening = main_commit.precomputed_tree.as_ref().map(|tree| { - Self::open_polys_with(domain, tree, *index, |row| { + Self::open_polys_with(domain, tree, *index, 0, |row| { lde_trace.gather_main_row_range(row, 0, num_precomputed_cols) }) }); @@ -1381,7 +1402,7 @@ pub trait IsStarkProver< ); let aux_trace_polys = round_1_result.aux.as_ref().map(|aux| { - Self::open_polys_with(domain, &aux.tree, *index, |row| { + Self::open_polys_with(domain, &aux.tree, *index, MERKLE_CAP_HEIGHT, |row| { lde_trace.gather_aux_row(row) }) }); @@ -1556,13 +1577,15 @@ pub trait IsStarkProver< }) .collect(); - // Sequential: append roots to shared transcript (Fiat-Shamir ordering) + // Sequential: append caps to shared transcript (Fiat-Shamir ordering) for result in chunk_results { let (commit, cached_main) = result?; if let Some(ref pre_root) = commit.precomputed_root { transcript.append_bytes(pre_root); } - transcript.append_bytes(&commit.root); + for node in &commit.cap { + transcript.append_bytes(node); + } main_commits.push(commit); main_ldes.push(cached_main); } @@ -1701,7 +1724,7 @@ pub trait IsStarkProver< #[cfg(feature = "instruments")] let t_sub = Instant::now(); #[allow(unused_mut)] - let (mut tree, root) = Self::commit_columns_bit_reversed(&columns) + let (mut tree, cap) = Self::commit_columns_bit_reversed(&columns) .ok_or(ProvingError::EmptyCommitment)?; #[cfg(feature = "instruments")] crate::instruments::accum_r1_aux(aux_lde_dur, t_sub.elapsed()); @@ -1712,18 +1735,20 @@ pub trait IsStarkProver< ProvingError::DiskSpill(format!("aux Merkle tree: {e}")) })?; } - Ok((Some(TableCommit::plain(tree, root)), columns)) + Ok((Some(TableCommit::plain(tree, cap)), columns)) } else { Ok((None, Vec::new())) } }) .collect(); - // Sequential: append aux roots to forked transcripts + // Sequential: append aux caps to forked transcripts for (j, result) in chunk_aux.into_iter().enumerate() { let (aux_commit, cached_aux) = result?; if let Some(ref c) = aux_commit { - table_transcripts[chunk_start + j].append_bytes(&c.root); + for node in &c.cap { + table_transcripts[chunk_start + j].append_bytes(node); + } } aux_results.push((aux_commit, cached_aux)); } @@ -1960,7 +1985,9 @@ pub trait IsStarkProver< )?; // >>>> Send commitments: [H₁], [H₂] - transcript.append_bytes(&round_2_result.composition_poly_root); + for node in &round_2_result.composition_poly_cap { + transcript.append_bytes(node); + } // =================================== // ==========| Round 3 |========== @@ -2037,20 +2064,20 @@ pub trait IsStarkProver< Ok(StarkProof { // [t] - lde_trace_main_merkle_root: round_1_result.main.root, + lde_trace_main_merkle_cap: round_1_result.main.cap.clone(), // [t] - lde_trace_aux_merkle_root: round_1_result.aux.as_ref().map(|x| x.root), + lde_trace_aux_merkle_cap: round_1_result.aux.as_ref().map(|x| x.cap.clone()), // For preprocessed tables: commitment to precomputed columns only lde_trace_precomputed_merkle_root: round_1_result.main.precomputed_root, // tⱼ(zgᵏ) trace_ood_evaluations: round_3_result.trace_ood_evaluations, // [H₁] and [H₂] - composition_poly_root: round_2_result.composition_poly_root, + composition_poly_cap: round_2_result.composition_poly_cap, // Hᵢ(z^N) composition_poly_parts_ood_evaluation: round_3_result .composition_poly_parts_ood_evaluation, // [pₖ] - fri_layers_merkle_roots: round_4_result.fri_layers_merkle_roots, + fri_layers_merkle_caps: round_4_result.fri_layers_merkle_caps, // pₙ fri_last_value: round_4_result.fri_last_value, // Open(p₀(D₀), 𝜐ₛ), Open(pₖ(Dₖ), −𝜐ₛ^(2ᵏ)) diff --git a/crypto/stark/src/verifier.rs b/crypto/stark/src/verifier.rs index 60befaca0..63f24c175 100644 --- a/crypto/stark/src/verifier.rs +++ b/crypto/stark/src/verifier.rs @@ -1,5 +1,5 @@ use super::{ - config::BatchedMerkleTreeBackend, + config::{BatchedMerkleTreeBackend, FriLayerMerkleTreeBackend}, domain::VerifierDomain, fri::fri_decommit::FriDecommitment, grinding, @@ -269,6 +269,18 @@ pub trait IsStarkVerifier< return false; } + // A primitive 4th root of unity of the FRI LDE domain. Inside an arity-4 + // fold orbit the two conjugate-pair twiddles differ by this root. + let lde_root_order = domain.lde_length.trailing_zeros(); + let zeta4 = if lde_root_order >= 2 { + domain.lde_primitive_root.pow(1u64 << (lde_root_order - 2)) + } else { + FieldElement::::one() + }; + let zeta4_inv = zeta4 + .inv() + .expect("primitive 4th root of unity is invertible"); + proof .query_list .iter() @@ -282,6 +294,8 @@ pub trait IsStarkVerifier< *iota_s, proof_s, eval, + zeta4.clone(), + zeta4_inv.clone(), &deep_poly_evaluations[i], &deep_poly_evaluations_sym[i], ) @@ -301,10 +315,10 @@ pub trait IsStarkVerifier< domain.lde_coset_element(reverse_index(raw, domain.lde_length as u64)) } - /// Verifies the validity of the opening proof. + /// Verifies the validity of the opening proof against a Merkle cap. fn verify_opening( proof: &Proof, - root: &Commitment, + cap: &[Commitment], index: usize, value: &[FieldElement], ) -> bool @@ -314,15 +328,15 @@ pub trait IsStarkVerifier< E: IsField, Field: IsSubFieldOf, { - proof.verify::>(root, index, &value.to_owned()) + proof.verify_capped::>(cap, index, &value.to_owned()) } /// Verify both (proof, evaluations) and (proof_sym, evaluations_sym) openings - /// of a `PolynomialOpenings` against the given `root` at iota positions - /// `iota*2` and `iota*2 + 1`. + /// of a `PolynomialOpenings` against the given Merkle `cap` at iota positions + /// `iota*2` and `iota*2 + 1`. A single-node `cap` slice checks against a root. fn verify_opening_pair( opening: &PolynomialOpenings, - root: &Commitment, + cap: &[Commitment], iota: usize, ) -> bool where @@ -331,10 +345,10 @@ pub trait IsStarkVerifier< E: IsField, Field: IsSubFieldOf, { - Self::verify_opening::(&opening.proof, root, iota * 2, &opening.evaluations) + Self::verify_opening::(&opening.proof, cap, iota * 2, &opening.evaluations) && Self::verify_opening::( &opening.proof_sym, - root, + cap, iota * 2 + 1, &opening.evaluations_sym, ) @@ -354,29 +368,32 @@ pub trait IsStarkVerifier< // Main trace (multiplicities for preprocessed, full trace for normal). let mut ok = Self::verify_opening_pair::( &deep_poly_openings.main_trace_polys, - &proof.lde_trace_main_merkle_root, + &proof.lde_trace_main_merkle_cap, iota, ); // Precomputed trace (preprocessed tables only). Mismatched presence is // unreachable in practice (multi_verify rejects such proofs upstream), // but a defensive check keeps this function self-contained. + // The precomputed tree is uncapped: its root is a single-node cap. ok &= match ( &proof.lde_trace_precomputed_merkle_root, &deep_poly_openings.precomputed_trace_polys, ) { - (Some(root), Some(opening)) => Self::verify_opening_pair::(opening, root, iota), + (Some(root), Some(opening)) => { + Self::verify_opening_pair::(opening, core::slice::from_ref(root), iota) + } (None, None) => true, _ => false, }; // Auxiliary trace. ok &= match ( - proof.lde_trace_aux_merkle_root, + &proof.lde_trace_aux_merkle_cap, &deep_poly_openings.aux_trace_polys, ) { - (Some(root), Some(opening)) => { - Self::verify_opening_pair::(opening, &root, iota) + (Some(cap), Some(opening)) => { + Self::verify_opening_pair::(opening, cap, iota) } (None, None) => true, _ => false, @@ -389,7 +406,7 @@ pub trait IsStarkVerifier< /// polynomial, where 𝜐 and -𝜐 are the elements corresponding to the index challenge `iota`. fn verify_composition_poly_opening( deep_poly_openings: &DeepPolynomialOpening, - composition_poly_merkle_root: &Commitment, + composition_poly_merkle_cap: &[Commitment], iota: &usize, ) -> bool where @@ -402,8 +419,8 @@ pub trait IsStarkVerifier< deep_poly_openings .composition_poly .proof - .verify::>( - composition_poly_merkle_root, + .verify_capped::>( + composition_poly_merkle_cap, *iota, &value, ) @@ -427,51 +444,25 @@ pub trait IsStarkVerifier< .all(|(iota_n, deep_poly_opening)| { Self::verify_composition_poly_opening( deep_poly_opening, - &proof.composition_poly_root, + &proof.composition_poly_cap, iota_n, ) && Self::verify_trace_openings(proof, deep_poly_opening, *iota_n) }) } - /// Verifies the openings of a fold polynomial of an inner layer of FRI. - fn verify_fri_layer_openings( - merkle_root: &Commitment, - auth_path_sym: &Proof, - evaluation: &FieldElement, - evaluation_sym: &FieldElement, - iota: usize, - ) -> bool - where - FieldElement: AsBytes + Sync + Send, - FieldElement: AsBytes + Sync + Send, - { - let evaluations = if iota % 2 == 1 { - vec![evaluation_sym.clone(), evaluation.clone()] - } else { - vec![evaluation.clone(), evaluation_sym.clone()] - }; - - auth_path_sym.verify::>( - merkle_root, - iota >> 1, - &evaluations, - ) - } - - /// Verify a single FRI query - /// `zetas`: the vector of all challenges sent by the verifier to the prover at the commit - /// phase to fold polynomials. - /// `iota`: the index challenge of this FRI query. This index uniquely determines two elements 𝜐 and -𝜐 - /// of the evaluation domain of FRI layer 0. - /// `evaluation_point_inv`: precomputed value of 𝜐⁻¹. - /// `deep_composition_evaluation`: precomputed value of p₀(𝜐), where p₀ is the deep composition polynomial. - /// `deep_composition_evaluation_sym`: precomputed value of p₀(-𝜐), where p₀ is the deep composition polynomial. + /// Verify a single FRI query under arity-4 folding. `zetas` holds 𝜁₀ for the + /// uncommitted initial fold and two challenges per committed arity-4 layer. + /// `zeta4` / `zeta4_inv` are a primitive 4th root of unity and its inverse, + /// relating the two conjugate-pair twiddles inside one fold orbit. + #[allow(clippy::too_many_arguments)] fn verify_query_and_sym_openings( proof: &StarkProof, zetas: &[FieldElement], iota: usize, fri_decommitment: &FriDecommitment, evaluation_point_inv: FieldElement, + zeta4: FieldElement, + zeta4_inv: FieldElement, deep_composition_evaluation: &FieldElement, deep_composition_evaluation_sym: &FieldElement, ) -> bool @@ -479,72 +470,70 @@ pub trait IsStarkVerifier< FieldElement: AsBytes + Sync + Send, FieldElement: AsBytes + Sync + Send, { - let fri_layers_merkle_roots = &proof.fri_layers_merkle_roots; - let evaluation_point_vec: Vec> = - core::iter::successors(Some(evaluation_point_inv.square()), |evaluation_point| { - Some(evaluation_point.square()) - }) - .take(fri_layers_merkle_roots.len()) - .collect(); + let caps = &proof.fri_layers_merkle_caps; + if caps.len() != fri_decommitment.layers_auth_paths.len() + || caps.len() != fri_decommitment.layers_evaluations.len() + { + return false; + } + // Each committed layer needs two challenges; zetas[0] is the initial fold. + if zetas.len() != 1 + 2 * caps.len() { + return false; + } - let p0_eval = deep_composition_evaluation; - let p0_eval_sym = deep_composition_evaluation_sym; + let p0 = deep_composition_evaluation; + let p0_sym = deep_composition_evaluation_sym; - // Reconstruct p₁(𝜐²) - let mut v = - (p0_eval + p0_eval_sym) + evaluation_point_inv * &zetas[0] * (p0_eval - p0_eval_sym); + // Uncommitted initial fold p₀ → p₁ at the query point, twiddle 𝜐⁻¹. + let mut v = (p0 + p0_sym) + evaluation_point_inv.clone() * &zetas[0] * (p0 - p0_sym); let mut index = iota; - // Handle case with 0 FRI layers (trace_length <= 2) - // In this case, the fold loop below doesn't iterate, so we need to verify - // the final value directly here. - if fri_layers_merkle_roots.is_empty() { - return v == proof.fri_last_value; + let mut ok = true; + for (j, cap) in caps.iter().enumerate() { + let orbit = &fri_decommitment.layers_evaluations[j]; + let auth_path = &fri_decommitment.layers_auth_paths[j]; + // tw_a = 𝜐⁻^(2^(1+2j)): twiddle of the tracked conjugate pair. + let tw_a = evaluation_point_inv.pow(1u64 << (1 + 2 * j)); + // The tracked value must be the orbit element at index&3. + if orbit[index & 3] != v { + ok = false; + } + // The orbit (quad leaf) must open against this layer's cap. + let leaf_ok = auth_path.verify_capped::>( + cap, + index >> 2, + orbit, + ); + if !leaf_ok { + ok = false; + } + // Fold the orbit by 4 = two binary folds, in storage order. The two + // storage pairs (o0,o1),(o2,o3) sit at coset points y0, y1 = ζ4·y0; + // their twiddles are 1/y0, 1/y1. `tw_a` (= 1/tracked-point) equals + // one of them up to the sign (-1)^(index&1), which is folded into + // the difference terms. + let (d01, d23) = if index & 1 == 0 { + (&orbit[0] - &orbit[1], &orbit[2] - &orbit[3]) + } else { + (&orbit[1] - &orbit[0], &orbit[3] - &orbit[2]) + }; + let (tw_p0, tw_p1) = if (index >> 1) & 1 == 0 { + (tw_a.clone(), &tw_a * &zeta4_inv) + } else { + (&tw_a * &zeta4, tw_a.clone()) + }; + let zeta_a = &zetas[1 + 2 * j]; + let zeta_b = &zetas[2 + 2 * j]; + let a = (&orbit[0] + &orbit[1]) + (tw_p0.clone() * zeta_a) * d01; + let b = (&orbit[2] + &orbit[3]) + (tw_p1 * zeta_a) * d23; + // Second fold of the pair (a,b); its twiddle is tw_p0². + let tw2 = tw_p0.square(); + v = (&a + &b) + (tw2 * zeta_b) * (&a - &b); + index >>= 2; } - - // For each FRI layer, starting from the layer 1: use the proof to verify the validity of values pᵢ(−𝜐^(2ⁱ)) (given by the prover) and - // pᵢ(𝜐^(2ⁱ)) (computed on the previous iteration by the verifier). Then use them to obtain pᵢ₊₁(𝜐^(2ⁱ⁺¹)). - // Finally, check that the final value coincides with the given by the prover. - fri_layers_merkle_roots - .iter() - .enumerate() - .zip(&fri_decommitment.layers_auth_paths) - .zip(&fri_decommitment.layers_evaluations_sym) - .zip(evaluation_point_vec) - .fold( - true, - |result, - ( - (((i, merkle_root), auth_path_sym), evaluation_sym), - evaluation_point_inv, - )| { - // Verify opening Open(pᵢ(Dₖ), −𝜐^(2ⁱ)) and Open(pᵢ(Dₖ), 𝜐^(2ⁱ)). - // `v` is pᵢ(𝜐^(2ⁱ)). - // `evaluation_sym` is pᵢ(−𝜐^(2ⁱ)). - let openings_ok = Self::verify_fri_layer_openings( - merkle_root, - auth_path_sym, - &v, - evaluation_sym, - index, - ); - - // Update `v` with next value pᵢ₊₁(𝜐^(2ⁱ⁺¹)). - v = (&v + evaluation_sym) + evaluation_point_inv * &zetas[i + 1] * (&v - evaluation_sym); - - // Update index for next iteration. The index of the squares in the next layer - // is obtained by halving the current index. This is due to the bit-reverse - // ordering of the elements in the Merkle tree. - index >>= 1; - - if i < fri_decommitment.layers_evaluations_sym.len() - 1 { - result & openings_ok - } else { - // Check that final value is the given by the prover - result & (v == proof.fri_last_value) & openings_ok - } - }, - ) + // After all committed layers the polynomial is a constant; the tracked + // value must equal the final value sent by the prover. + ok && v == proof.fri_last_value } fn reconstruct_deep_composition_poly_evaluations_for_all_queries( @@ -765,12 +754,16 @@ pub trait IsStarkVerifier< // Add BOTH commitments to transcript (Fiat-Shamir binding). // Precomputed commitment binds challenges to correct precomputed values. - // Multiplicities commitment binds challenges to actual lookups made. + // Multiplicities commitment cap binds challenges to actual lookups made. transcript.append_bytes(&expected_precomputed); - transcript.append_bytes(&proof.lde_trace_main_merkle_root); + for node in &proof.lde_trace_main_merkle_cap { + transcript.append_bytes(node); + } } else { - // Normal table: use commitment from proof - transcript.append_bytes(&proof.lde_trace_main_merkle_root); + // Normal table: use commitment cap from proof + for node in &proof.lde_trace_main_merkle_cap { + transcript.append_bytes(node); + } } } @@ -826,9 +819,11 @@ pub trait IsStarkVerifier< table_transcript.append_bytes(&(idx as u64).to_le_bytes()); } - // Phase C: replay aux commitment - if let Some(root) = proof.lde_trace_aux_merkle_root { - table_transcript.append_bytes(&root); + // Phase C: replay aux commitment cap + if let Some(cap) = &proof.lde_trace_aux_merkle_cap { + for node in cap { + table_transcript.append_bytes(node); + } } // Bind table_contribution (L) to transcript, matching prover. @@ -945,7 +940,9 @@ pub trait IsStarkVerifier< let boundary_coeffs = coefficients; // <<<< Receive commitments: [H₁], [H₂] - transcript.append_bytes(&proof.composition_poly_root); + for node in &proof.composition_poly_cap { + transcript.append_bytes(node); + } // =================================== // ==========| Round 3 |========== @@ -996,20 +993,22 @@ pub trait IsStarkVerifier< let gammas = deep_composition_coefficients; // FRI commit phase - let merkle_roots = &proof.fri_layers_merkle_roots; - let mut zetas = merkle_roots - .iter() - .map(|root| { - // >>>> Send challenge 𝜁ₖ - let element = transcript.sample_field_element(); - // <<<< Receive commitment: [pₖ] (the first one is [p₀]) - transcript.append_bytes(root); - element - }) - .collect::>>(); - - // >>>> Send challenge 𝜁ₙ₋₁ + // Arity-4 FRI commit phase: 𝜁₀ for the uncommitted initial fold, then + // two challenges per committed layer. Each layer's cap is absorbed + // before its two challenges are sampled. + let merkle_caps = &proof.fri_layers_merkle_caps; + let mut zetas = Vec::with_capacity(1 + 2 * merkle_caps.len()); + // >>>> Send challenge 𝜁₀ zetas.push(transcript.sample_field_element()); + for cap in merkle_caps { + // <<<< Receive commitment cap: [pₖ] + for node in cap { + transcript.append_bytes(node); + } + // >>>> Send the two fold challenges of this committed layer + zetas.push(transcript.sample_field_element()); + zetas.push(transcript.sample_field_element()); + } // <<<< Receive value: pₙ transcript.append_field_element(&proof.fri_last_value); diff --git a/prover/src/lib.rs b/prover/src/lib.rs index dbe13d20b..c239c3305 100644 --- a/prover/src/lib.rs +++ b/prover/src/lib.rs @@ -454,9 +454,9 @@ pub(crate) fn replay_transcript_phase_a( for (air, proof) in airs.iter().zip(&multi_proof.proofs) { if air.is_preprocessed() { transcript.append_bytes(&air.precomputed_commitment()); - transcript.append_bytes(&proof.lde_trace_main_merkle_root); - } else { - transcript.append_bytes(&proof.lde_trace_main_merkle_root); + } + for node in &proof.lde_trace_main_merkle_cap { + transcript.append_bytes(node); } } let z: FieldElement = transcript.sample_field_element();