Skip to main content

exo_proofs/
stark.rs

1// Copyright 2026 Exochain Foundation
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at:
6//
7//     https://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14//
15// SPDX-License-Identifier: Apache-2.0
16
17//! STARK proof system -- hash-based, post-quantum.
18//!
19//! Uses blake3 for all commitments (no elliptic curves).
20//! This is a pedagogical implementation demonstrating the STARK structure.
21
22use std::collections::BTreeSet;
23
24use exo_core::{
25    hash::{merkle_proof, merkle_root, verify_merkle_proof},
26    types::Hash256,
27};
28use serde::{Deserialize, Serialize};
29
30use crate::error::{ProofError, Result};
31
32// ---------------------------------------------------------------------------
33// Configuration
34// ---------------------------------------------------------------------------
35
36/// Configuration for STARK proof generation.
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct StarkConfig {
39    /// Size of the field (we use u64 arithmetic modulo this).
40    pub field_size: u64,
41    /// Expansion factor for the low-degree extension.
42    pub expansion_factor: usize,
43    /// Number of query rounds for soundness.
44    pub num_queries: usize,
45}
46
47impl StarkConfig {
48    /// Default configuration suitable for testing.
49    #[must_use]
50    pub fn default_config() -> Self {
51        Self {
52            field_size: (1u64 << 31) - 1, // Mersenne prime 2^31 - 1
53            expansion_factor: 4,
54            num_queries: 8,
55        }
56    }
57}
58
59// ---------------------------------------------------------------------------
60// Constraint
61// ---------------------------------------------------------------------------
62
63/// A STARK constraint over the execution trace.
64///
65/// Constraints are transition rules: given `row[i]` and `row[i+1]`,
66/// the constraint function must evaluate to 0.
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
68pub struct StarkConstraint {
69    /// Human-readable name.
70    pub name: String,
71    /// Indices of columns involved in this constraint.
72    pub column_indices: Vec<usize>,
73    /// Coefficients for a polynomial constraint.
74    /// Format: pairs of (current_row_coeff, next_row_coeff) per column.
75    /// The constraint is: `sum(current_coeff[i] * trace[row][col_i] + next_coeff[i] * trace[row+1][col_i]) == 0`
76    pub coefficients: Vec<(u64, u64)>,
77}
78
79// ---------------------------------------------------------------------------
80// FRI Proof
81// ---------------------------------------------------------------------------
82
83/// A FRI (Fast Reed-Solomon IOP of Proximity) proof component.
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85pub struct FriProof {
86    /// Commitment hashes at each folding round.
87    pub layer_commitments: Vec<Hash256>,
88    /// Query responses at each layer.
89    pub query_values: Vec<Vec<u64>>,
90}
91
92/// Merkle-authenticated trace row opened by a STARK query.
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94pub struct TraceQueryProof {
95    /// Query index in the committed trace.
96    pub index: usize,
97    /// Trace row value at `index`.
98    pub row: Vec<u64>,
99    /// Merkle authentication path from `hash_row(row)` to the trace root.
100    pub authentication_path: Vec<Hash256>,
101    /// Trace row value at `index + 1`, used for transition constraints.
102    pub next_row: Vec<u64>,
103    /// Merkle authentication path from `hash_row(next_row)` to the trace root.
104    pub next_authentication_path: Vec<Hash256>,
105}
106
107// ---------------------------------------------------------------------------
108// StarkProof
109// ---------------------------------------------------------------------------
110
111/// A complete STARK proof.
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113pub struct StarkProof {
114    /// Commitment to the execution trace.
115    pub trace_commitment: Hash256,
116    /// Commitment to the constraint polynomial.
117    pub constraint_commitment: Hash256,
118    /// Query indices (deterministic, derived from commitments).
119    pub query_indices: Vec<usize>,
120    /// FRI proof of low degree.
121    pub fri_proof: FriProof,
122    /// Configuration used.
123    pub config: StarkConfig,
124    /// Number of rows in the trace (needed for verification).
125    pub trace_length: usize,
126    /// Hash of the public inputs (first row of trace) for verification.
127    pub public_input_hash: Hash256,
128    /// Public transition constraints for the statement being proven.
129    pub constraints: Vec<StarkConstraint>,
130    /// Merkle authentication path from the public input row to trace root.
131    pub public_input_authentication_path: Vec<Hash256>,
132    /// Merkle-authenticated trace openings for every Fiat-Shamir query.
133    pub trace_query_proofs: Vec<TraceQueryProof>,
134    /// Complete committed trace rows.
135    ///
136    /// This keeps the unaudited pedagogical verifier fail-closed: it can
137    /// recompute the trace root, recompute the constraint commitment, and
138    /// evaluate every transition instead of trusting sampled openings as a
139    /// production STARK soundness argument.
140    pub trace_rows: Vec<Vec<u64>>,
141}
142
143impl PartialEq for StarkConfig {
144    fn eq(&self, other: &Self) -> bool {
145        self.field_size == other.field_size
146            && self.expansion_factor == other.expansion_factor
147            && self.num_queries == other.num_queries
148    }
149}
150
151impl Eq for StarkConfig {}
152
153// ---------------------------------------------------------------------------
154// Prove
155// ---------------------------------------------------------------------------
156
157/// Generate a STARK proof from an execution trace and constraints.
158///
159/// `trace` is a 2D array: trace[row][column].
160/// `constraints` define the transition rules between consecutive rows.
161///
162/// **Unaudited** — gated behind the `unaudited-pedagogical-proofs` feature.
163pub fn prove_stark(
164    trace: &[Vec<u64>],
165    constraints: &[StarkConstraint],
166    config: &StarkConfig,
167) -> Result<StarkProof> {
168    crate::guard_unaudited("stark::prove_stark")?;
169    if trace.is_empty() {
170        return Err(ProofError::ProofGenerationFailed("empty trace".to_string()));
171    }
172    if trace.len() < 2 {
173        return Err(ProofError::ProofGenerationFailed(
174            "trace must have at least 2 rows".to_string(),
175        ));
176    }
177    if constraints.is_empty() {
178        return Err(ProofError::ProofGenerationFailed(
179            "STARK proof requires at least one constraint".to_string(),
180        ));
181    }
182
183    let num_cols = trace[0].len();
184    for row in trace {
185        if row.len() != num_cols {
186            return Err(ProofError::ProofGenerationFailed(
187                "inconsistent column count".to_string(),
188            ));
189        }
190    }
191    for constraint in constraints {
192        validate_constraint_shape(constraint, num_cols)?;
193    }
194
195    // Step 1: Verify constraints are satisfied
196    for (row_idx, window) in trace.windows(2).enumerate() {
197        let current = &window[0];
198        let next = &window[1];
199
200        for constraint in constraints {
201            let val = evaluate_constraint(constraint, current, next, config.field_size)?;
202            if val != 0 {
203                return Err(ProofError::ProofGenerationFailed(format!(
204                    "constraint '{}' not satisfied at row {row_idx}",
205                    constraint.name
206                )));
207            }
208        }
209    }
210
211    let trace_leaves = trace_leaf_hashes(trace);
212
213    // Step 2: Commit to the trace
214    let trace_commitment = commit_trace_from_leaves(&trace_leaves);
215
216    // Step 3: Commit to the constraint polynomial
217    let constraint_commitment = commit_constraints(trace, constraints, config.field_size)?;
218
219    // Step 4: Derive query indices (Fiat-Shamir)
220    let query_indices = derive_queries(
221        &trace_commitment,
222        &constraint_commitment,
223        config.num_queries,
224        trace.len() - 1,
225    )?;
226
227    // Step 5: Build FRI proof
228    let fri_proof = build_fri_proof(
229        trace,
230        &query_indices,
231        config,
232        &trace_commitment,
233        &constraint_commitment,
234    );
235    let public_input_authentication_path = merkle_proof(&trace_leaves, 0).map_err(|_| {
236        ProofError::ProofGenerationFailed(
237            "failed to build public input authentication path".to_string(),
238        )
239    })?;
240    let trace_query_proofs = build_trace_query_proofs(trace, &trace_leaves, &query_indices)?;
241
242    Ok(StarkProof {
243        trace_commitment,
244        constraint_commitment,
245        query_indices,
246        fri_proof,
247        config: config.clone(),
248        trace_length: trace.len(),
249        public_input_hash: hash_row(&trace[0]),
250        constraints: constraints.to_vec(),
251        public_input_authentication_path,
252        trace_query_proofs,
253        trace_rows: trace.to_vec(),
254    })
255}
256
257// ---------------------------------------------------------------------------
258// Verify
259// ---------------------------------------------------------------------------
260
261/// Fail-closed compatibility verifier for a STARK proof.
262///
263/// Public inputs are the first row of the trace. The statement constraints are
264/// not derivable from the proof because proof-embedded constraints are
265/// prover-controlled. Use [`verify_stark_with_constraints`] when the verifier
266/// has trusted public constraints out of band.
267///
268/// **Unaudited** — gated behind the `unaudited-pedagogical-proofs` feature.
269/// Returns `Err(UnauditedImplementation)` when the feature is disabled.
270pub fn verify_stark(_proof: &StarkProof, _public_inputs: &[u64]) -> Result<bool> {
271    crate::guard_unaudited("stark::verify_stark")?;
272    Err(ProofError::InvalidProofFormat(
273        "trusted STARK constraints are required; use verify_stark_with_constraints".to_string(),
274    ))
275}
276
277/// Verify a STARK proof for caller-supplied public constraints.
278///
279/// Prefer this API when the verifier knows the statement constraints out of
280/// band. [`verify_stark`] remains available for serialized proof bundles that
281/// embed their public constraints.
282pub fn verify_stark_with_constraints(
283    proof: &StarkProof,
284    public_inputs: &[u64],
285    constraints: &[StarkConstraint],
286) -> Result<bool> {
287    crate::guard_unaudited("stark::verify_stark")?;
288    if proof.trace_length < 2
289        || constraints.is_empty()
290        || proof.constraints.is_empty()
291        || proof.constraints != constraints
292        || proof.trace_rows.len() != proof.trace_length
293    {
294        return Ok(false);
295    }
296
297    if validate_trace_shape(&proof.trace_rows).is_err() {
298        return Ok(false);
299    }
300
301    let trace_leaves = trace_leaf_hashes(&proof.trace_rows);
302    if commit_trace_from_leaves(&trace_leaves) != proof.trace_commitment {
303        return Ok(false);
304    }
305
306    let expected_constraint_commitment =
307        match commit_constraints(&proof.trace_rows, constraints, proof.config.field_size) {
308            Ok(commitment) => commitment,
309            Err(_) => return Ok(false),
310        };
311    if expected_constraint_commitment != proof.constraint_commitment {
312        return Ok(false);
313    }
314
315    for window in proof.trace_rows.windows(2) {
316        for constraint in constraints {
317            let constraint_value = match evaluate_constraint(
318                constraint,
319                &window[0],
320                &window[1],
321                proof.config.field_size,
322            ) {
323                Ok(value) => value,
324                Err(_) => return Ok(false),
325            };
326            if constraint_value != 0 {
327                return Ok(false);
328            }
329        }
330    }
331
332    // Step 1: Re-derive query indices from commitments (Fiat-Shamir)
333    let expected_queries = match derive_queries(
334        &proof.trace_commitment,
335        &proof.constraint_commitment,
336        proof.config.num_queries,
337        proof.trace_length - 1,
338    ) {
339        Ok(q) => q,
340        Err(_) => return Ok(false),
341    };
342
343    if expected_queries != proof.query_indices {
344        return Ok(false);
345    }
346
347    // Step 2: Verify FRI proof structure
348    if proof.fri_proof.layer_commitments.is_empty() {
349        return Ok(false);
350    }
351
352    // Step 3: Verify the public inputs match what was committed.
353    let Some(first_row) = proof.trace_rows.first() else {
354        return Ok(false);
355    };
356    if first_row.as_slice() != public_inputs {
357        return Ok(false);
358    }
359    let public_hash = hash_row(public_inputs);
360    if public_hash != proof.public_input_hash {
361        return Ok(false);
362    }
363    if !verify_merkle_proof(
364        &proof.trace_commitment,
365        &proof.public_input_hash,
366        &proof.public_input_authentication_path,
367        0,
368    ) {
369        return Ok(false);
370    }
371
372    // Step 4: Verify every Fiat-Shamir trace opening against the trace
373    // commitment and the query values carried by the FRI component.
374    if proof.trace_query_proofs.len() != proof.query_indices.len()
375        || proof.fri_proof.query_values.len() != proof.query_indices.len()
376    {
377        return Ok(false);
378    }
379
380    for ((expected_index, query_proof), query_values) in proof
381        .query_indices
382        .iter()
383        .zip(&proof.trace_query_proofs)
384        .zip(&proof.fri_proof.query_values)
385    {
386        if query_proof.index != *expected_index || query_values != &query_proof.row {
387            return Ok(false);
388        }
389
390        let Some(committed_row) = proof.trace_rows.get(query_proof.index) else {
391            return Ok(false);
392        };
393        if committed_row != &query_proof.row {
394            return Ok(false);
395        }
396
397        let Some(next_index) = query_proof.index.checked_add(1) else {
398            return Ok(false);
399        };
400        if next_index >= proof.trace_length {
401            return Ok(false);
402        }
403        let Some(committed_next_row) = proof.trace_rows.get(next_index) else {
404            return Ok(false);
405        };
406        if committed_next_row != &query_proof.next_row {
407            return Ok(false);
408        }
409
410        let leaf = hash_row(&query_proof.row);
411        if !verify_merkle_proof(
412            &proof.trace_commitment,
413            &leaf,
414            &query_proof.authentication_path,
415            query_proof.index,
416        ) {
417            return Ok(false);
418        }
419
420        let next_leaf = hash_row(&query_proof.next_row);
421        if !verify_merkle_proof(
422            &proof.trace_commitment,
423            &next_leaf,
424            &query_proof.next_authentication_path,
425            next_index,
426        ) {
427            return Ok(false);
428        }
429
430        for constraint in constraints {
431            let constraint_value = match evaluate_constraint(
432                constraint,
433                &query_proof.row,
434                &query_proof.next_row,
435                proof.config.field_size,
436            ) {
437                Ok(value) => value,
438                Err(_) => return Ok(false),
439            };
440            if constraint_value != 0 {
441                return Ok(false);
442            }
443        }
444    }
445
446    // Step 5: Verify consistency between trace commitment, constraint commitment,
447    // and FRI proof.
448    let expected_fri_base =
449        compute_fri_base_commitment(&proof.trace_commitment, &proof.constraint_commitment);
450
451    if proof.fri_proof.layer_commitments[0] != expected_fri_base {
452        return Ok(false);
453    }
454
455    // Step 6: Verify FRI layer transitions
456    for window in proof.fri_proof.layer_commitments.windows(2) {
457        let mut h = blake3::Hasher::new();
458        h.update(b"stark:fri:fold:");
459        h.update(window[0].as_bytes());
460        let expected_next = Hash256::from_bytes(*h.finalize().as_bytes());
461        if window[1] != expected_next {
462            return Ok(false);
463        }
464    }
465
466    Ok(true)
467}
468
469// ---------------------------------------------------------------------------
470// Internals
471// ---------------------------------------------------------------------------
472
473fn evaluate_constraint(
474    constraint: &StarkConstraint,
475    current: &[u64],
476    next: &[u64],
477    field_size: u64,
478) -> Result<u64> {
479    if field_size == 0 {
480        return Err(ProofError::ProofGenerationFailed(
481            "field_size must be non-zero".to_string(),
482        ));
483    }
484    if current.len() != next.len() {
485        return Err(ProofError::ConstraintError(format!(
486            "constraint '{}' cannot evaluate rows with mismatched widths: current {} != next {}",
487            constraint.name,
488            current.len(),
489            next.len()
490        )));
491    }
492    validate_constraint_shape(constraint, current.len())?;
493
494    let modulus = u128::from(field_size);
495    let mut sum: u128 = 0;
496    for (i, &col_idx) in constraint.column_indices.iter().enumerate() {
497        let (curr_coeff, next_coeff) = constraint.coefficients[i];
498        let curr_val = current[col_idx];
499        let next_val = next[col_idx];
500        let curr_term = (u128::from(curr_coeff) * u128::from(curr_val)) % modulus;
501        let next_term = (u128::from(next_coeff) * u128::from(next_val)) % modulus;
502        sum = (sum + curr_term + next_term) % modulus;
503    }
504    u64::try_from(sum).map_err(|_| {
505        ProofError::ProofGenerationFailed("constraint evaluation overflowed u64".to_string())
506    })
507}
508
509fn validate_constraint_shape(constraint: &StarkConstraint, trace_width: usize) -> Result<()> {
510    if constraint.column_indices.is_empty() {
511        return Err(ProofError::ConstraintError(format!(
512            "constraint '{}' must reference at least one trace column",
513            constraint.name
514        )));
515    }
516    if constraint.column_indices.len() != constraint.coefficients.len() {
517        return Err(ProofError::ConstraintError(format!(
518            "constraint '{}' column_indices length {} must match coefficients length {}",
519            constraint.name,
520            constraint.column_indices.len(),
521            constraint.coefficients.len()
522        )));
523    }
524    for &col_idx in &constraint.column_indices {
525        if col_idx >= trace_width {
526            return Err(ProofError::ConstraintError(format!(
527                "constraint '{}' column index {col_idx} is outside trace width {trace_width}",
528                constraint.name
529            )));
530        }
531    }
532    Ok(())
533}
534
535fn hash_row(row: &[u64]) -> Hash256 {
536    let mut hasher = blake3::Hasher::new();
537    hasher.update(b"stark:row:");
538    for &val in row {
539        hasher.update(&val.to_le_bytes());
540    }
541    Hash256::from_bytes(*hasher.finalize().as_bytes())
542}
543
544fn trace_leaf_hashes(trace: &[Vec<u64>]) -> Vec<Hash256> {
545    trace.iter().map(|row| hash_row(row)).collect()
546}
547
548fn commit_trace_from_leaves(leaves: &[Hash256]) -> Hash256 {
549    merkle_root(leaves)
550}
551
552fn validate_trace_shape(trace: &[Vec<u64>]) -> Result<()> {
553    let Some(first_row) = trace.first() else {
554        return Err(ProofError::ProofGenerationFailed("empty trace".to_string()));
555    };
556    let trace_width = first_row.len();
557    if trace_width == 0 {
558        return Err(ProofError::ProofGenerationFailed(
559            "trace rows must contain at least one column".to_string(),
560        ));
561    }
562    for (row_idx, row) in trace.iter().enumerate() {
563        if row.len() != trace_width {
564            return Err(ProofError::ProofGenerationFailed(format!(
565                "trace row {row_idx} has width {}, expected {trace_width}",
566                row.len()
567            )));
568        }
569    }
570    Ok(())
571}
572
573fn commit_constraints(
574    trace: &[Vec<u64>],
575    constraints: &[StarkConstraint],
576    field_size: u64,
577) -> Result<Hash256> {
578    let mut hasher = blake3::Hasher::new();
579    hasher.update(b"stark:constraints:");
580    for window in trace.windows(2) {
581        for constraint in constraints {
582            let val = evaluate_constraint(constraint, &window[0], &window[1], field_size)?;
583            hasher.update(&val.to_le_bytes());
584        }
585    }
586    Ok(Hash256::from_bytes(*hasher.finalize().as_bytes()))
587}
588
589fn derive_queries(
590    trace_commitment: &Hash256,
591    constraint_commitment: &Hash256,
592    num_queries: usize,
593    trace_len: usize,
594) -> Result<Vec<usize>> {
595    if num_queries == 0 {
596        return Ok(Vec::new());
597    }
598    if num_queries > trace_len {
599        return Err(ProofError::ProofGenerationFailed(format!(
600            "query budget {num_queries} exceeds available transition rows {trace_len}"
601        )));
602    }
603    let trace_len_u64 = u64::try_from(trace_len).map_err(|_| {
604        ProofError::ProofGenerationFailed(format!("trace length {trace_len} overflows u64"))
605    })?;
606    let mut seen = BTreeSet::new();
607    let mut indices = Vec::with_capacity(num_queries);
608
609    for i in 0..num_queries {
610        let query_round = u64::try_from(i).map_err(|_| {
611            ProofError::ProofGenerationFailed(format!("query round {i} overflows u64"))
612        })?;
613        let mut attempt = 0u64;
614
615        loop {
616            let mut hasher = blake3::Hasher::new();
617            hasher.update(b"stark:query_index:");
618            hasher.update(trace_commitment.as_bytes());
619            hasher.update(constraint_commitment.as_bytes());
620            hasher.update(&query_round.to_le_bytes());
621            hasher.update(&attempt.to_le_bytes());
622            let seed = hasher.finalize();
623            let mut idx_bytes = [0u8; 8];
624            idx_bytes.copy_from_slice(&seed.as_bytes()[..8]);
625
626            let idx_u64 = u64::from_le_bytes(idx_bytes) % trace_len_u64;
627            let idx = usize::try_from(idx_u64).map_err(|_| {
628                ProofError::ProofGenerationFailed(format!("query index {idx_u64} overflows usize"))
629            })?;
630
631            if seen.insert(idx) {
632                indices.push(idx);
633                break;
634            }
635
636            attempt = attempt.checked_add(1).ok_or_else(|| {
637                ProofError::ProofGenerationFailed(format!(
638                    "query index derivation exhausted attempts for round {i}"
639                ))
640            })?;
641        }
642    }
643
644    Ok(indices)
645}
646
647fn compute_fri_base_commitment(
648    trace_commitment: &Hash256,
649    constraint_commitment: &Hash256,
650) -> Hash256 {
651    let mut hasher = blake3::Hasher::new();
652    hasher.update(b"stark:fri:base:");
653    hasher.update(trace_commitment.as_bytes());
654    hasher.update(constraint_commitment.as_bytes());
655    Hash256::from_bytes(*hasher.finalize().as_bytes())
656}
657
658fn build_fri_proof(
659    trace: &[Vec<u64>],
660    query_indices: &[usize],
661    config: &StarkConfig,
662    trace_commitment: &Hash256,
663    constraint_commitment: &Hash256,
664) -> FriProof {
665    let base = compute_fri_base_commitment(trace_commitment, constraint_commitment);
666
667    let num_layers = config.expansion_factor;
668    let mut layer_commitments = Vec::with_capacity(num_layers);
669    let mut current = base;
670    layer_commitments.push(current);
671
672    for _ in 1..num_layers {
673        let mut h = blake3::Hasher::new();
674        h.update(b"stark:fri:fold:");
675        h.update(current.as_bytes());
676        current = Hash256::from_bytes(*h.finalize().as_bytes());
677        layer_commitments.push(current);
678    }
679
680    // Query values: for each query, return the trace row
681    let query_values: Vec<Vec<u64>> = query_indices
682        .iter()
683        .map(|&idx| {
684            if idx < trace.len() {
685                trace[idx].clone()
686            } else {
687                Vec::new()
688            }
689        })
690        .collect();
691
692    FriProof {
693        layer_commitments,
694        query_values,
695    }
696}
697
698fn build_trace_query_proofs(
699    trace: &[Vec<u64>],
700    trace_leaves: &[Hash256],
701    query_indices: &[usize],
702) -> Result<Vec<TraceQueryProof>> {
703    query_indices
704        .iter()
705        .map(|&idx| {
706            let Some(row) = trace.get(idx) else {
707                return Err(ProofError::ProofGenerationFailed(format!(
708                    "query index {idx} is outside trace length {}",
709                    trace.len()
710                )));
711            };
712            let Some(next_idx) = idx.checked_add(1) else {
713                return Err(ProofError::ProofGenerationFailed(format!(
714                    "query index {idx} cannot address a successor row"
715                )));
716            };
717            let Some(next_row) = trace.get(next_idx) else {
718                return Err(ProofError::ProofGenerationFailed(format!(
719                    "query index {idx} has no successor row in trace length {}",
720                    trace.len()
721                )));
722            };
723            let authentication_path = merkle_proof(trace_leaves, idx).map_err(|_| {
724                ProofError::ProofGenerationFailed(format!(
725                    "failed to build trace authentication path for query index {idx}"
726                ))
727            })?;
728            let next_authentication_path = merkle_proof(trace_leaves, next_idx).map_err(|_| {
729                ProofError::ProofGenerationFailed(format!(
730                    "failed to build trace authentication path for successor query index {next_idx}"
731                ))
732            })?;
733            Ok(TraceQueryProof {
734                index: idx,
735                row: row.clone(),
736                authentication_path,
737                next_row: next_row.clone(),
738                next_authentication_path,
739            })
740        })
741        .collect()
742}
743
744// ---------------------------------------------------------------------------
745// Tests
746// ---------------------------------------------------------------------------
747
748#[cfg(all(test, feature = "unaudited-pedagogical-proofs"))]
749mod tests {
750    use super::*;
751
752    fn make_fibonacci_trace(n: usize, field_size: u64) -> Vec<Vec<u64>> {
753        let mut trace = Vec::with_capacity(n);
754        trace.push(vec![0, 1]);
755        for i in 1..n {
756            let prev = &trace[i - 1];
757            let next_val = (prev[0] + prev[1]) % field_size;
758            trace.push(vec![prev[1], next_val]);
759        }
760        trace
761    }
762
763    fn fib_constraint() -> StarkConstraint {
764        // Constraint: current[1] + current[0] - next[1] == 0
765        // i.e., current[0]*1 + current[1]*1 + next[1]*(-1) == 0
766        // But we use positive arithmetic mod field_size.
767        // Rewrite: current[0] + current[1] = next[1]
768        // In our format: col0_curr=1, col0_next=0, col1_curr=1, col1_next=(field_size-1)
769        let field_size = (1u64 << 31) - 1;
770        StarkConstraint {
771            name: "fibonacci".to_string(),
772            column_indices: vec![0, 1],
773            coefficients: vec![(1, 0), (1, field_size - 1)],
774        }
775    }
776
777    fn equality_constraint() -> StarkConstraint {
778        let field_size = (1u64 << 31) - 1;
779        StarkConstraint {
780            name: "same-value".to_string(),
781            column_indices: vec![0],
782            coefficients: vec![(1, field_size - 1)],
783        }
784    }
785
786    fn vacuous_constraint() -> StarkConstraint {
787        StarkConstraint {
788            name: "vacuous".to_string(),
789            column_indices: vec![0],
790            coefficients: vec![(0, 0)],
791        }
792    }
793
794    #[test]
795    fn prove_and_verify_fibonacci() {
796        let config = StarkConfig::default_config();
797        let trace = make_fibonacci_trace(16, config.field_size);
798        let constraints = vec![fib_constraint()];
799
800        let proof = prove_stark(&trace, &constraints, &config).unwrap();
801        let public_inputs = &trace[0];
802        assert!(verify_stark_with_constraints(&proof, public_inputs, &constraints).unwrap());
803    }
804
805    #[test]
806    fn verify_stark_rejects_embedded_constraints_even_for_valid_proof() {
807        let config = StarkConfig::default_config();
808        let trace = make_fibonacci_trace(16, config.field_size);
809        let constraints = vec![fib_constraint()];
810
811        let proof = prove_stark(&trace, &constraints, &config).unwrap();
812        let err = verify_stark(&proof, &trace[0]).unwrap_err();
813
814        assert!(matches!(
815            err,
816            ProofError::InvalidProofFormat(message)
817                if message.contains("trusted STARK constraints are required")
818        ));
819    }
820
821    #[test]
822    fn verify_stark_source_does_not_trust_proof_embedded_constraints() {
823        let source = include_str!("stark.rs");
824        let verify_body = source
825            .split("pub fn verify_stark(")
826            .nth(1)
827            .expect("verify_stark source exists")
828            .split("/// Verify a STARK proof for caller-supplied public constraints.")
829            .next()
830            .expect("verify_stark source ends before trusted verifier");
831
832        assert!(
833            !verify_body.contains("&proof.constraints"),
834            "direct STARK verification must not promote prover-embedded constraints to trusted verifier constraints"
835        );
836        assert!(
837            verify_body.contains("trusted STARK constraints are required"),
838            "direct STARK verification must fail closed toward the trusted-constraints API"
839        );
840    }
841
842    #[test]
843    fn verify_stark_rejects_vacuous_prover_embedded_constraints() {
844        let config = StarkConfig::default_config();
845        let trace: Vec<Vec<u64>> = (0..16usize)
846            .map(|row| vec![u64::try_from(row).expect("test row index fits u64"), 999])
847            .collect();
848        let attacker_constraints = vec![vacuous_constraint()];
849        let proof = prove_stark(&trace, &attacker_constraints, &config).unwrap();
850
851        let err = verify_stark(&proof, &trace[0]).unwrap_err();
852
853        assert!(matches!(
854            err,
855            ProofError::InvalidProofFormat(message)
856                if message.contains("trusted STARK constraints are required")
857        ));
858    }
859
860    #[test]
861    fn empty_trace_rejected() {
862        let config = StarkConfig::default_config();
863        let err = prove_stark(&[], &[], &config).unwrap_err();
864        assert!(matches!(err, ProofError::ProofGenerationFailed(_)));
865    }
866
867    #[test]
868    fn single_row_trace_rejected() {
869        let config = StarkConfig::default_config();
870        let constraints = vec![equality_constraint()];
871        let err = prove_stark(&[vec![1, 2]], &constraints, &config).unwrap_err();
872        assert!(matches!(err, ProofError::ProofGenerationFailed(_)));
873    }
874
875    #[test]
876    fn inconsistent_columns_rejected() {
877        let config = StarkConfig::default_config();
878        let trace = vec![vec![1, 2], vec![3]];
879        let constraints = vec![equality_constraint()];
880        let err = prove_stark(&trace, &constraints, &config).unwrap_err();
881        assert!(matches!(err, ProofError::ProofGenerationFailed(_)));
882    }
883
884    #[test]
885    fn unsatisfied_constraint_rejected() {
886        let config = StarkConfig::default_config();
887        let trace = vec![vec![0, 1], vec![1, 999]]; // wrong fibonacci
888        let constraints = vec![fib_constraint()];
889        let err = prove_stark(&trace, &constraints, &config).unwrap_err();
890        assert!(matches!(err, ProofError::ProofGenerationFailed(_)));
891    }
892
893    #[test]
894    fn zero_field_size_rejected_without_panic() {
895        let config = StarkConfig {
896            field_size: 0,
897            expansion_factor: 4,
898            num_queries: 2,
899        };
900        let trace = vec![vec![1], vec![1]];
901        let constraints = vec![equality_constraint()];
902
903        let err = prove_stark(&trace, &constraints, &config).unwrap_err();
904
905        assert!(matches!(err, ProofError::ProofGenerationFailed(_)));
906    }
907
908    #[test]
909    fn large_constraint_terms_do_not_overflow_before_modular_reduction() {
910        let config = StarkConfig {
911            field_size: u64::MAX - 58,
912            expansion_factor: 4,
913            num_queries: 2,
914        };
915        let trace = vec![vec![1, 1], vec![1, 1]];
916        let constraint = StarkConstraint {
917            name: "large-terms".to_string(),
918            column_indices: vec![0, 1],
919            coefficients: vec![(u64::MAX, 0), (u64::MAX, 0)],
920        };
921
922        let result = prove_stark(&trace, &[constraint], &config);
923
924        assert!(matches!(result, Err(ProofError::ProofGenerationFailed(_))));
925    }
926
927    #[test]
928    fn proof_deterministic() {
929        let config = StarkConfig::default_config();
930        let trace = make_fibonacci_trace(16, config.field_size);
931        let constraints = vec![fib_constraint()];
932
933        let p1 = prove_stark(&trace, &constraints, &config).unwrap();
934        let p2 = prove_stark(&trace, &constraints, &config).unwrap();
935        assert_eq!(p1, p2);
936    }
937
938    #[test]
939    fn different_traces_different_proofs() {
940        let config = StarkConfig::default_config();
941        let t1 = make_fibonacci_trace(16, config.field_size);
942        let t2 = {
943            let mut t = vec![vec![1, 1]];
944            for i in 1..16 {
945                let prev = &t[i - 1];
946                let next_val = (prev[0] + prev[1]) % config.field_size;
947                t.push(vec![prev[1], next_val]);
948            }
949            t
950        };
951        let constraints = vec![fib_constraint()];
952
953        let p1 = prove_stark(&t1, &constraints, &config).unwrap();
954        let p2 = prove_stark(&t2, &constraints, &config).unwrap();
955        assert_ne!(p1.trace_commitment, p2.trace_commitment);
956    }
957
958    #[test]
959    fn verify_rejects_wrong_public_inputs() {
960        let config = StarkConfig::default_config();
961        let trace = make_fibonacci_trace(16, config.field_size);
962        let constraints = vec![fib_constraint()];
963
964        let proof = prove_stark(&trace, &constraints, &config).unwrap();
965        // Wrong public inputs
966        assert!(!verify_stark_with_constraints(&proof, &[99, 99], &constraints).unwrap());
967    }
968
969    #[test]
970    fn verify_rejects_public_inputs_not_bound_to_trace_commitment() {
971        let config = StarkConfig::default_config();
972        let trace = make_fibonacci_trace(16, config.field_size);
973        let constraints = vec![fib_constraint()];
974
975        let mut proof = prove_stark(&trace, &constraints, &config).unwrap();
976        let forged_public_inputs = vec![99, 99];
977        proof.public_input_hash = hash_row(&forged_public_inputs);
978
979        assert!(
980            !verify_stark_with_constraints(&proof, &forged_public_inputs, &constraints).unwrap()
981        );
982    }
983
984    #[test]
985    fn verify_rejects_tampered_query_values() {
986        let config = StarkConfig::default_config();
987        let trace = make_fibonacci_trace(16, config.field_size);
988        let constraints = vec![fib_constraint()];
989
990        let mut proof = prove_stark(&trace, &constraints, &config).unwrap();
991        proof.fri_proof.query_values = vec![vec![u64::MAX; 4]; config.num_queries];
992
993        assert!(!verify_stark_with_constraints(&proof, &trace[0], &constraints).unwrap());
994    }
995
996    #[test]
997    fn verify_with_constraints_rejects_authenticated_trace_that_violates_constraints() {
998        let config = StarkConfig {
999            num_queries: 5,
1000            ..StarkConfig::default_config()
1001        };
1002        let constraints = vec![equality_constraint()];
1003        let trace = vec![vec![0], vec![1], vec![2], vec![3], vec![4], vec![5]];
1004        let trace_leaves = trace_leaf_hashes(&trace);
1005        let trace_commitment = commit_trace_from_leaves(&trace_leaves);
1006        let constraint_commitment =
1007            commit_constraints(&trace, &constraints, config.field_size).unwrap();
1008        let query_indices = derive_queries(
1009            &trace_commitment,
1010            &constraint_commitment,
1011            config.num_queries,
1012            trace.len() - 1,
1013        )
1014        .unwrap();
1015        let fri_proof = build_fri_proof(
1016            &trace,
1017            &query_indices,
1018            &config,
1019            &trace_commitment,
1020            &constraint_commitment,
1021        );
1022        let public_input_authentication_path = merkle_proof(&trace_leaves, 0).unwrap();
1023        let trace_query_proofs =
1024            build_trace_query_proofs(&trace, &trace_leaves, &query_indices).unwrap();
1025        let proof = StarkProof {
1026            trace_commitment,
1027            constraint_commitment,
1028            query_indices,
1029            fri_proof,
1030            config,
1031            trace_length: trace.len(),
1032            public_input_hash: hash_row(&trace[0]),
1033            constraints: constraints.clone(),
1034            public_input_authentication_path,
1035            trace_query_proofs,
1036            trace_rows: trace.clone(),
1037        };
1038
1039        assert!(!verify_stark_with_constraints(&proof, &trace[0], &constraints).unwrap());
1040    }
1041
1042    #[test]
1043    fn verify_rejects_forged_trace_with_unqueried_constraint_violation() {
1044        let config = StarkConfig {
1045            num_queries: 4,
1046            ..StarkConfig::default_config()
1047        };
1048        let constraints = vec![equality_constraint()];
1049        let trace_len = 32usize;
1050
1051        let mut forged = None;
1052        for bad_row in 1..(trace_len - 1) {
1053            let mut trace = vec![vec![7u64]; trace_len];
1054            trace[bad_row] = vec![9u64];
1055            let trace_leaves = trace_leaf_hashes(&trace);
1056            let trace_commitment = commit_trace_from_leaves(&trace_leaves);
1057            let constraint_commitment =
1058                commit_constraints(&trace, &constraints, config.field_size).unwrap();
1059            let query_indices = derive_queries(
1060                &trace_commitment,
1061                &constraint_commitment,
1062                config.num_queries,
1063                trace.len() - 1,
1064            )
1065            .unwrap();
1066            let queried: BTreeSet<usize> = query_indices.iter().copied().collect();
1067            if !queried.contains(&(bad_row - 1)) && !queried.contains(&bad_row) {
1068                forged = Some((
1069                    trace,
1070                    trace_leaves,
1071                    trace_commitment,
1072                    constraint_commitment,
1073                    query_indices,
1074                ));
1075                break;
1076            }
1077        }
1078
1079        let Some((trace, trace_leaves, trace_commitment, constraint_commitment, query_indices)) =
1080            forged
1081        else {
1082            panic!("test fixture must find an unqueried bad transition");
1083        };
1084        let fri_proof = build_fri_proof(
1085            &trace,
1086            &query_indices,
1087            &config,
1088            &trace_commitment,
1089            &constraint_commitment,
1090        );
1091        let public_input_authentication_path = merkle_proof(&trace_leaves, 0).unwrap();
1092        let trace_query_proofs =
1093            build_trace_query_proofs(&trace, &trace_leaves, &query_indices).unwrap();
1094        let proof = StarkProof {
1095            trace_commitment,
1096            constraint_commitment,
1097            query_indices,
1098            fri_proof,
1099            config,
1100            trace_length: trace.len(),
1101            public_input_hash: hash_row(&trace[0]),
1102            constraints: constraints.clone(),
1103            public_input_authentication_path,
1104            trace_query_proofs,
1105            trace_rows: trace.clone(),
1106        };
1107
1108        assert!(!verify_stark_with_constraints(&proof, &trace[0], &constraints).unwrap());
1109    }
1110
1111    #[test]
1112    fn derive_queries_produces_unique_indices_when_domain_permits() {
1113        let trace_commitment = Hash256::from_bytes([1u8; 32]);
1114        let constraint_commitment = Hash256::from_bytes([2u8; 32]);
1115        let queries = derive_queries(&trace_commitment, &constraint_commitment, 16, 64).unwrap();
1116        let unique: std::collections::BTreeSet<usize> = queries.iter().copied().collect();
1117
1118        assert_eq!(queries.len(), 16);
1119        assert_eq!(unique.len(), queries.len());
1120    }
1121
1122    #[test]
1123    fn derive_queries_rejects_query_budget_exceeding_transition_domain() {
1124        let trace_commitment = Hash256::from_bytes([1u8; 32]);
1125        let constraint_commitment = Hash256::from_bytes([2u8; 32]);
1126        let err = derive_queries(&trace_commitment, &constraint_commitment, 8, 7).unwrap_err();
1127
1128        assert!(
1129            err.to_string()
1130                .contains("exceeds available transition rows"),
1131            "query derivation must not silently reuse duplicate indices: {err}"
1132        );
1133    }
1134
1135    #[test]
1136    fn prove_rejects_query_budget_exceeding_transition_domain() {
1137        let config = StarkConfig::default_config();
1138        let trace = make_fibonacci_trace(8, config.field_size);
1139        let constraints = vec![fib_constraint()];
1140
1141        let err = prove_stark(&trace, &constraints, &config).unwrap_err();
1142
1143        assert!(
1144            err.to_string()
1145                .contains("exceeds available transition rows"),
1146            "proof generation must fail closed instead of proving duplicate query indices: {err}"
1147        );
1148    }
1149
1150    #[test]
1151    fn verify_rejects_tampered_proof() {
1152        let config = StarkConfig::default_config();
1153        let trace = make_fibonacci_trace(16, config.field_size);
1154        let constraints = vec![fib_constraint()];
1155
1156        let mut proof = prove_stark(&trace, &constraints, &config).unwrap();
1157        proof.trace_commitment = Hash256::ZERO;
1158        assert!(!verify_stark_with_constraints(&proof, &trace[0], &constraints).unwrap());
1159    }
1160
1161    #[test]
1162    fn prove_stark_rejects_empty_constraints() {
1163        let config = StarkConfig {
1164            num_queries: 2,
1165            ..StarkConfig::default_config()
1166        };
1167        let trace = vec![vec![1, 2], vec![3, 4], vec![5, 6]];
1168
1169        let err = prove_stark(&trace, &[], &config).unwrap_err();
1170
1171        assert!(
1172            err.to_string().contains("at least one constraint"),
1173            "empty STARK constraints must fail closed: {err}"
1174        );
1175    }
1176
1177    #[test]
1178    fn verify_rejects_manually_constructed_empty_constraint_proof() {
1179        let config = StarkConfig {
1180            num_queries: 2,
1181            ..StarkConfig::default_config()
1182        };
1183        let trace = vec![vec![1, 2], vec![3, 4], vec![5, 6]];
1184        let constraints = Vec::new();
1185        let trace_leaves = trace_leaf_hashes(&trace);
1186        let trace_commitment = commit_trace_from_leaves(&trace_leaves);
1187        let constraint_commitment =
1188            commit_constraints(&trace, &constraints, config.field_size).unwrap();
1189        let query_indices = derive_queries(
1190            &trace_commitment,
1191            &constraint_commitment,
1192            config.num_queries,
1193            trace.len() - 1,
1194        )
1195        .unwrap();
1196        let fri_proof = build_fri_proof(
1197            &trace,
1198            &query_indices,
1199            &config,
1200            &trace_commitment,
1201            &constraint_commitment,
1202        );
1203        let public_input_authentication_path = merkle_proof(&trace_leaves, 0).unwrap();
1204        let trace_query_proofs =
1205            build_trace_query_proofs(&trace, &trace_leaves, &query_indices).unwrap();
1206        let proof = StarkProof {
1207            trace_commitment,
1208            constraint_commitment,
1209            query_indices,
1210            fri_proof,
1211            config,
1212            trace_length: trace.len(),
1213            public_input_hash: hash_row(&trace[0]),
1214            constraints: constraints.clone(),
1215            public_input_authentication_path,
1216            trace_query_proofs,
1217            trace_rows: trace.clone(),
1218        };
1219
1220        assert!(!verify_stark_with_constraints(&proof, &trace[0], &constraints).unwrap());
1221    }
1222
1223    #[test]
1224    fn stark_config_eq() {
1225        let c1 = StarkConfig::default_config();
1226        let c2 = StarkConfig::default_config();
1227        assert_eq!(c1, c2);
1228    }
1229
1230    #[test]
1231    fn fri_proof_structure() {
1232        let config = StarkConfig::default_config();
1233        let trace = make_fibonacci_trace(16, config.field_size);
1234        let constraints = vec![fib_constraint()];
1235        let proof = prove_stark(&trace, &constraints, &config).unwrap();
1236
1237        assert_eq!(
1238            proof.fri_proof.layer_commitments.len(),
1239            config.expansion_factor
1240        );
1241        assert_eq!(proof.fri_proof.query_values.len(), config.num_queries);
1242    }
1243
1244    #[test]
1245    fn malformed_constraint_oob_column_is_rejected() {
1246        let config = StarkConfig::default_config();
1247        let trace = vec![vec![7u64], vec![11u64]];
1248        let constraint = StarkConstraint {
1249            name: "oob".to_string(),
1250            column_indices: vec![5],
1251            coefficients: vec![(1, 1)],
1252        };
1253        let constraints = vec![constraint];
1254        let err = prove_stark(&trace, &constraints, &config).unwrap_err();
1255        assert!(
1256            err.to_string().contains("outside trace width"),
1257            "out-of-bounds constraint columns must fail closed: {err}"
1258        );
1259    }
1260
1261    #[test]
1262    fn malformed_constraint_missing_coefficients_is_rejected() {
1263        let config = StarkConfig::default_config();
1264        let trace = vec![vec![0u64, 5u64], vec![0u64, 7u64]];
1265        let constraint = StarkConstraint {
1266            name: "partial".to_string(),
1267            column_indices: vec![0, 1],
1268            coefficients: vec![(1, 1)],
1269        };
1270        let constraints = vec![constraint];
1271        let err = prove_stark(&trace, &constraints, &config).unwrap_err();
1272        assert!(
1273            err.to_string().contains("must match coefficients"),
1274            "constraint columns without matching coefficients must fail closed: {err}"
1275        );
1276    }
1277}