Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 30 additions & 7 deletions crypto/stark/src/fri/fri_functions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,37 @@ pub fn fold_evaluations_in_place<F: IsSubFieldOf<E>, E: IsField>(
inv_twiddles: &[FieldElement<F>],
) {
let half = evals.len() / 2;
for j in 0..half {
let lo = &evals[2 * j];
let hi = &evals[2 * j + 1];
let sum = lo + hi;
let diff = lo - hi;
evals[j] = &sum + &(&inv_twiddles[j] * &(zeta * &diff));

#[cfg(feature = "parallel")]
{
use rayon::prelude::*;
// Evaluations are stored interleaved: pairs (evals[2j], evals[2j+1]).
// Fold each pair in parallel, collecting into a new Vec of half length.
let folded: Vec<FieldElement<E>> = evals
.par_chunks(2)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low – Silent truncation vs. panic on length mismatch

The sequential path accesses inv_twiddles[j] for j in 0..half, which panics with a clear index-out-of-bounds if inv_twiddles.len() < half.

The parallel path uses par_chunks(2).zip(inv_twiddles.par_iter()), which silently truncates to the shorter of the two iterators. If inv_twiddles is shorter than expected, fewer elements are folded, folded has the wrong length, and the resulting proof is silently incorrect — no panic, no error.

Since FRI always operates on power-of-2-aligned data the lengths always match in practice. But for a cryptographic primitive, silent wrong-output is strictly worse than a loud panic. Consider adding a debug assertion:

debug_assert_eq!(evals.len(), inv_twiddles.len() * 2,
    "fold: evals length must be twice inv_twiddles length");

.zip(inv_twiddles.par_iter())
.map(|(pair, tw)| {
let lo = &pair[0];
let hi = &pair[1];
let sum = lo + hi;
let diff = lo - hi;
&sum + &(tw * &(zeta * &diff))
})
.collect();
*evals = folded;
}

#[cfg(not(feature = "parallel"))]
{
for j in 0..half {
let lo = &evals[2 * j];
let hi = &evals[2 * j + 1];
let sum = lo + hi;
let diff = lo - hi;
evals[j] = &sum + &(&inv_twiddles[j] * &(zeta * &diff));
}
evals.truncate(half);
}
evals.truncate(half);
}

/// Compute inverse twiddle factors for evaluation-form FRI folding.
Expand Down
154 changes: 154 additions & 0 deletions crypto/stark/src/fri/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,18 @@ use self::fri_functions::{
compute_coset_twiddles_inv, fold_evaluations_in_place, update_twiddles_in_place,
};

/// An insertion of new table DEEP evaluations into the FRI cascade at a specific layer.
/// Used by folding insertion FRI (S-two Protocol 3) to merge tables with different
/// domain sizes into a single shared FRI.
pub struct FriInsertion<E: IsField> {
/// Which FRI layer to insert after folding (0-indexed from the first fold).
/// Layer k means the insertion happens after the k-th fold, when the cascade
/// domain has been halved k times from the initial size.
pub layer: usize,
/// Alpha-batched DEEP evaluations for tables at this domain size (bit-reversed).
pub evals: Vec<FieldElement<E>>,
}

/// 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).
Expand Down Expand Up @@ -85,6 +97,148 @@ where
(last_value, fri_layer_list)
}

/// FRI commit phase with folding insertion for multi-table proving.
///
/// Like `commit_phase_from_evaluations`, but supports inserting additional tables'
/// DEEP evaluations at specific folding rounds. This implements the S-two Protocol 3
/// (Section 4.2) adapted to standard FRI over Goldilocks.
///
/// Tables with the largest domain start the cascade. When folding reduces the domain
/// to match a smaller table's size, that table's alpha-batched DEEP evaluations are
/// added (with lambda^2 weighting) to the folded vector before continuing.
///
/// `insertions` must be sorted by layer (ascending). Each insertion's `evals` must
/// have length equal to the cascade domain size at that layer (after folding).
pub fn commit_phase_with_insertion<F: IsFFTField + IsSubFieldOf<E>, E: IsField>(
number_layers: usize,
mut evals: Vec<FieldElement<E>>,
insertions: &[FriInsertion<E>],
lambda_sq: &FieldElement<E>,
transcript: &mut impl IsStarkTranscript<E, F>,
coset_offset: &FieldElement<F>,
domain_size: usize,
) -> (
FieldElement<E>,
Vec<FriLayer<E, FriLayerMerkleTreeBackend<E>>>,
)
where
FieldElement<F>: AsBytes + Sync + Send,
FieldElement<E>: AsBytes + Sync + Send,
{
// 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;
let mut insertion_idx = 0;

for layer_num in 1..number_layers {
// <<<< Receive challenge zeta_k
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);

// Check for insertions at this layer (layer_num corresponds to the fold number)
// After fold number `layer_num`, the domain has been halved `layer_num` times.
while insertion_idx < insertions.len() && insertions[insertion_idx].layer == layer_num {
let insertion = &insertions[insertion_idx];
debug_assert_eq!(
insertion.evals.len(),
evals.len(),
"Insertion evals length ({}) must match folded cascade length ({}) at layer {}",
insertion.evals.len(),
evals.len(),
layer_num,
);

// Add lambda^2 * h_new[i] to each folded evaluation
#[cfg(feature = "parallel")]
{
use rayon::prelude::*;
evals
.par_iter_mut()
.zip(insertion.evals.par_iter())
.for_each(|(e, h)| {
*e = &*e + &(lambda_sq * h);
});
}
#[cfg(not(feature = "parallel"))]
{
for (e, h) in evals.iter_mut().zip(insertion.evals.iter()) {
*e = &*e + &(lambda_sq * h);
}
}

insertion_idx += 1;
}

// Build Merkle tree from consecutive pairs
let leaves: Vec<[FieldElement<E>; 2]> = evals
.chunks_exact(2)
.map(|chunk| [chunk[0].clone(), chunk[1].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_k]
transcript.append_bytes(&root);

// Update twiddles for next level
update_twiddles_in_place(&mut inv_twiddles);
}

// <<<< Receive challenge: zeta_{n-1}
let zeta = transcript.sample_field_element();

// Final fold
fold_evaluations_in_place(&mut evals, &zeta, &inv_twiddles);

// Check for insertions at the final layer
while insertion_idx < insertions.len() && insertions[insertion_idx].layer == number_layers {
let insertion = &insertions[insertion_idx];
debug_assert_eq!(
insertion.evals.len(),
evals.len(),
"Insertion evals length must match folded cascade length at final layer"
);
#[cfg(feature = "parallel")]
{
use rayon::prelude::*;
evals
.par_iter_mut()
.zip(insertion.evals.par_iter())
.for_each(|(e, h)| {
*e = &*e + &(lambda_sq * h);
});
}
#[cfg(not(feature = "parallel"))]
{
for (e, h) in evals.iter_mut().zip(insertion.evals.iter()) {
*e = &*e + &(lambda_sq * h);
}
}
insertion_idx += 1;
}

let last_value = evals.first().unwrap_or(&FieldElement::zero()).clone();

// >>>> Send value: p_n
transcript.append_field_element(&last_value);

(last_value, fri_layer_list)
}

pub fn query_phase<F: IsField>(
fri_layers: &Vec<FriLayer<F, FriLayerMerkleTreeBackend<F>>>,
iotas: &[usize],
Expand Down
31 changes: 31 additions & 0 deletions crypto/stark/src/proof/stark.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,42 @@ pub struct StarkProof<F: IsSubFieldOf<E>, E: IsField, PI> {
pub public_inputs: PI,
}

/// Shared FRI data for multi-table proving with folding insertion.
///
/// When multiple tables have different domain sizes, a single FRI cascade
/// is used. Tables enter the cascade at the folding round matching their
/// domain size. This struct holds the shared FRI commitments, decommitments,
/// and query indices that replace per-table FRI data.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(bound = "")]
pub struct SharedFri<E: IsField> {
/// Merkle roots of the shared FRI layers.
pub fri_layers_merkle_roots: Vec<Commitment>,
/// The final constant value after all FRI folds.
pub fri_last_value: FieldElement<E>,
/// FRI decommitments for each query.
pub query_list: Vec<FriDecommitment<E>>,
/// Proof-of-work nonce for grinding.
pub nonce: Option<u64>,
/// Query indices on the largest LDE domain (shared across all tables).
pub query_indices: Vec<usize>,
/// The domain size of the largest table's LDE domain (for index mapping).
pub max_lde_domain_size: usize,
}

/// A collection of STARK proofs for multiple AIRs.
/// Used for multi-table proving where tables are linked via bus (LogUp).
/// Returned by `Prover::multi_prove` and verified by `Verifier::multi_verify`.
///
/// When `shared_fri` is `Some`, per-table FRI data in `StarkProof` is empty
/// (fri_layers_merkle_roots, fri_last_value, query_list, nonce are all defaults)
/// and the shared FRI cascade is used instead.
#[derive(Debug, serde::Serialize, serde::Deserialize)]
#[serde(bound = "PI: serde::Serialize + serde::de::DeserializeOwned")]
pub struct MultiProof<F: IsSubFieldOf<E>, E: IsField, PI> {
pub proofs: Vec<StarkProof<F, E, PI>>,
/// Shared FRI data when using folding insertion (multi-table).
/// None for single-table proofs (backward compatible).
#[serde(default)]
pub shared_fri: Option<SharedFri<E>>,
}
Loading
Loading