Skip to main content

miden_verifier/recursive/
mod.rs

1//! Building the advice a MASM recursive verifier consumes to verify a Miden VM proof.
2//!
3//! `exec.vm::verify_vm_proof` reads a STARK proof from the advice provider in a fixed
4//! order. This module is the producer side of that ABI: it destructures an [`ExecutionProof`]
5//! against its [`ExecutionClaim`] into the advice-stack stream, Merkle store, and advice-map
6//! entries the verifier consumes. The consumption order is exercised end to end by the
7//! recursive verification tests, which drive the real MASM verifier over this output.
8//!
9//! Before calling `verify_vm_proof`, the consumer places this proof stream on top of the advice
10//! stack:
11//!
12//!   security params (nq, query_pow, deep_pow, folding_pow) ->
13//!   deferred root -> Miden AIR heights -> main commit -> aux commit ->
14//!   aux finals -> quotient commit -> deep alpha -> OOD evals ->
15//!   DEEP PoW witness -> FRI rounds -> FRI remainder -> query PoW witness
16//!
17//! [`RecursiveVerifierInputs::for_request`] stores this stream in the advice map under the verifier
18//! and claim commitments. The consumer fetches it before calling `verify_vm_proof`. The consumer
19//! also supplies the claim commitment; the advice map stores its 40-felt preimage under that
20//! commitment, and `verify_vm_proof` authenticates the preimage before using it. The advice map
21//! stores the flattened kernel procedure digests under the kernel commitment as well. Query rows,
22//! the Merkle store, and the ACE circuit are content-addressed too.
23
24use alloc::{
25    string::{String, ToString},
26    sync::Arc,
27    vec::Vec,
28};
29
30use miden_air::{
31    MIDEN_AIR_COUNT, MidenMultiAir, ProofOrder, PublicInputs, Statement,
32    ace::build_recursive_verifier_ace_circuit, config,
33};
34use miden_core::{
35    Felt, Word,
36    advice::AdviceInputs,
37    crypto::merkle::{MerklePath, MerkleStore, PartialMerkleTree},
38    deferred::{DEFAULT_MAX_DEFERRED_ELEMENTS, DeferredState, IntegrityError, TRUE_DIGEST},
39    field::QuadFelt,
40    program::{ExecutionClaim, proof_request_key},
41    proof::{DeferredProof, ExecutionProof, HashFunction},
42};
43use miden_crypto::{
44    field::BasedVectorSpace,
45    stark::{
46        StarkConfig, VerifierInstance,
47        lmcs::{Lmcs, proof::BatchProofView},
48        pcs::{PcsParams, PcsProof},
49        proof::{StarkProof, StarkProofData},
50        verifier::VerifierError as CryptoVerifierError,
51    },
52};
53use miden_serde_utils::deserialize_schema_exact;
54use serde_wincode::{SerdeCompat, wincode};
55
56use crate::MAX_STARK_PROOF_BYTES;
57
58// TYPES
59// ================================================================================================
60
61type Challenge = QuadFelt;
62type P2Config = config::Poseidon2Config;
63type P2Lmcs = <P2Config as StarkConfig<Felt, Challenge>>::Lmcs;
64type P2ProofData = StarkProofData<Felt, Challenge, P2Config>;
65
66/// Request-packaged inputs for MASM recursive verification.
67///
68/// Pass [`Self::claim_commitment`] on the operand stack. The consumer derives the request key,
69/// fetches the proof stream from the advice map, and then invokes `exec.vm::verify_vm_proof`.
70#[derive(Debug, Clone, Eq, PartialEq)]
71pub struct RecursiveVerifierInputs {
72    advice: AdviceInputs,
73    claim_commitment: Word,
74}
75
76impl RecursiveVerifierInputs {
77    /// Builds a proof package addressed by the verifier and claim commitments.
78    ///
79    /// The proof must be a Poseidon2 proof because the recursive verifier supports only
80    /// Poseidon2 STARKs. Wire-backed deferred state is hydrated with the standard precompile
81    /// registry and [`DEFAULT_MAX_DEFERRED_ELEMENTS`].
82    ///
83    /// # Errors
84    ///
85    /// Returns an error if the proof cannot be converted into verifier advice.
86    pub fn for_request(
87        verifier_root: Word,
88        proof: &ExecutionProof,
89        claim: &ExecutionClaim,
90    ) -> Result<Self, RecursiveVerifierInputsError> {
91        Ok(build_verifier_inputs(proof, claim)?.into_request_package(verifier_root))
92    }
93
94    /// Returns the VM advice inputs.
95    pub fn advice(&self) -> &AdviceInputs {
96        &self.advice
97    }
98
99    /// Returns the execution claim commitment.
100    pub fn claim_commitment(&self) -> Word {
101        self.claim_commitment
102    }
103
104    /// Consumes these inputs into their VM advice and claim commitment.
105    pub fn into_parts(self) -> (AdviceInputs, Word) {
106        (self.advice, self.claim_commitment)
107    }
108
109    /// Moves the proof stream into the advice map under
110    /// `proof_request_key(verifier_root, claim_commitment)`, leaving the advice stack empty.
111    fn into_request_package(mut self, verifier_root: Word) -> Self {
112        let key = proof_request_key(verifier_root, self.claim_commitment);
113        let (proof_stream, map, store) = self.advice.into_parts();
114        self.advice = AdviceInputs::default().with_merkle_store(store);
115        self.advice.map = map;
116        self.advice.map.insert(key, proof_stream.into_elements());
117        self
118    }
119}
120
121/// Builds the raw advice consumed by `verify_vm_proof` before request packaging.
122fn build_verifier_inputs(
123    proof: &ExecutionProof,
124    claim: &ExecutionClaim,
125) -> Result<RecursiveVerifierInputs, RecursiveVerifierInputsError> {
126    let stark = proof.miden_proof();
127    if stark.hash_fn() != HashFunction::Poseidon2 {
128        return Err(RecursiveVerifierInputsError::UnsupportedHashFunction(stark.hash_fn()));
129    }
130    let pub_inputs = PublicInputs::new(
131        claim.to_program_info(),
132        *claim.stack_inputs(),
133        *claim.stack_outputs(),
134        resolve_deferred_root(proof.deferred_proof())?,
135    );
136
137    let claim_commitment = claim.commitment();
138    let mut inputs = build_from_proof_bytes(stark.bytes(), &pub_inputs, claim_commitment)?;
139
140    // The MASM verifier authenticates this preimage against the caller-provided commitment.
141    inputs.advice.map.insert(claim_commitment, claim.to_elements().to_vec());
142
143    let kernel = claim.kernel();
144    // The MASM verifier derives the procedure count from the value length.
145    let kernel_witness = Word::words_as_elements(kernel.proc_hashes()).to_vec();
146    inputs.advice.map.insert(kernel.commitment(), kernel_witness);
147
148    Ok(inputs)
149}
150
151/// Errors returned while building the advice for recursive verification.
152#[derive(Debug, thiserror::Error)]
153pub enum RecursiveVerifierInputsError {
154    #[error("proof deserialization error: {0}")]
155    ProofDeserialization(String),
156    #[error("STARK proof is too large: {size} bytes exceeds the {max} byte limit")]
157    ProofTooLarge { size: usize, max: usize },
158    #[error("invalid proof shape: {0}")]
159    InvalidProofShape(&'static str),
160    #[error("statement assembly error: {0}")]
161    StatementAssembly(String),
162    #[error("deferred wire hydration failed: {0}")]
163    DeferredIntegrity(#[from] IntegrityError),
164    #[error("recursive verification supports only Poseidon2 proofs, got {0:?}")]
165    UnsupportedHashFunction(HashFunction),
166    #[error("transcript error: {0}")]
167    Transcript(#[from] CryptoVerifierError),
168}
169
170/// Merkle store + advice map pair returned by Merkle data construction.
171type MerkleAdvice = (MerkleStore, Vec<(Word, Vec<Felt>)>);
172
173/// The per-AIR log trace heights, in both arrangements the advice needs: the fixed instance
174/// order (streamed to the verifier) and the sorted proof order (ACE circuit selection).
175struct MidenTraceHeights {
176    instance_log_heights: [usize; MIDEN_AIR_COUNT],
177    proof_order: ProofOrder,
178}
179
180/// Resolves the deferred root the outer VM statement binds, from the proof's deferred material:
181/// the canonical TRUE digest when no precompile claims were produced, the nested proof's public
182/// root when STARK-backed, and the hydrated wire's root for partial proofs (standard precompile
183/// registry, default deferred-element budget).
184fn resolve_deferred_root(deferred: &DeferredProof) -> Result<Word, RecursiveVerifierInputsError> {
185    match deferred {
186        DeferredProof::Empty => Ok(TRUE_DIGEST),
187        DeferredProof::Stark { public_root, .. } => Ok(*public_root),
188        DeferredProof::Wire(wire) => Ok(DeferredState::from_wire(
189            Arc::new(miden_precompiles::registry()),
190            wire,
191            DEFAULT_MAX_DEFERRED_ELEMENTS,
192        )?
193        .root()),
194    }
195}
196
197// ADVICE CONSTRUCTION
198// ================================================================================================
199
200fn build_from_proof_bytes(
201    proof_bytes: &[u8],
202    pub_inputs: &PublicInputs,
203    claim_commitment: Word,
204) -> Result<RecursiveVerifierInputs, RecursiveVerifierInputsError> {
205    let config = config::poseidon2_config(config::pcs_params(), config::RELATION_DIGEST);
206
207    let proof = deserialize_proof(proof_bytes)?;
208
209    let (public_values, aux_inputs) = pub_inputs.to_air_inputs();
210    let mut challenger = config.challenger();
211    config::observe_protocol_params(config.pcs(), &mut challenger);
212
213    let statement =
214        Statement::<Felt, Challenge, _>::new(MidenMultiAir::new(), public_values, aux_inputs)
215            .map_err(|e| RecursiveVerifierInputsError::StatementAssembly(e.to_string()))?;
216    let verifier_instance = VerifierInstance::new(&config, &statement, None)
217        .expect("Miden AIRs declare no preprocessed columns");
218
219    let (stark, _digest) = StarkProof::from_data(&verifier_instance, &proof, challenger)?;
220
221    let heights = miden_trace_heights(&stark)?;
222
223    build_advice(&config, &stark, heights, pub_inputs, claim_commitment)
224}
225
226/// Deserializes a wincode-encoded Poseidon2 STARK proof, enforcing the total byte limit,
227/// bounding preallocation, and rejecting trailing bytes.
228fn deserialize_proof(proof_bytes: &[u8]) -> Result<P2ProofData, RecursiveVerifierInputsError> {
229    if proof_bytes.len() > MAX_STARK_PROOF_BYTES {
230        return Err(RecursiveVerifierInputsError::ProofTooLarge {
231            size: proof_bytes.len(),
232            max: MAX_STARK_PROOF_BYTES,
233        });
234    }
235
236    let encoding_config = wincode::config::Configuration::default()
237        .with_preallocation_size_limit::<MAX_STARK_PROOF_BYTES>();
238    deserialize_schema_exact::<SerdeCompat<P2ProofData>, _>(proof_bytes, encoding_config)
239        .map_err(|e| RecursiveVerifierInputsError::ProofDeserialization(e.to_string()))
240}
241
242fn miden_trace_heights(
243    stark: &StarkProof<Challenge, P2Lmcs>,
244) -> Result<MidenTraceHeights, RecursiveVerifierInputsError> {
245    let log_heights = stark.log_trace_heights();
246    let Ok(log_heights): Result<[u8; MIDEN_AIR_COUNT], _> = log_heights.try_into() else {
247        return Err(RecursiveVerifierInputsError::InvalidProofShape(
248            "unexpected number of AIR log heights",
249        ));
250    };
251    Ok(MidenTraceHeights {
252        instance_log_heights: log_heights.map(usize::from),
253        proof_order: ProofOrder::from_instance_log_heights(&log_heights),
254    })
255}
256
257/// Packs the parsed STARK transcript into the advice-stack stream, Merkle store, and advice map.
258fn build_advice(
259    config: &P2Config,
260    stark: &StarkProof<Challenge, P2Lmcs>,
261    heights: MidenTraceHeights,
262    pub_inputs: &PublicInputs,
263    claim_commitment: Word,
264) -> Result<RecursiveVerifierInputs, RecursiveVerifierInputsError> {
265    let pcs = &stark.pcs_proof;
266    if stark.all_aux_values.len() != MIDEN_AIR_COUNT {
267        return Err(RecursiveVerifierInputsError::InvalidProofShape(
268            "unexpected number of aux-final groups",
269        ));
270    }
271
272    // This stream contains the verifier inputs derived from the proof. The claim preimage and
273    // kernel witness live in the advice map and are authenticated by the verifier against
274    // commitments supplied by the consumer.
275    //
276    // The section order below mirrors the consumption-order list in the module doc; both are
277    // pinned against the MASM verifier by the stark e2e differential tests.
278
279    let mut advice_stack = security_parameter_words(config.pcs()).to_vec();
280
281    // Final deferred root, loaded by `public_inputs::stage_boundary_inputs`.
282    advice_stack.extend_from_slice(pub_inputs.deferred_root().as_ref());
283
284    for height in heights.instance_log_heights {
285        advice_stack.push(Felt::new_unchecked(height as u64));
286    }
287
288    advice_stack.extend_from_slice(&commitment_felts(stark.main_commit));
289    advice_stack.extend_from_slice(&commitment_felts(stark.aux_commit));
290
291    for aux_values in &stark.all_aux_values {
292        advice_stack.extend_from_slice(&challenge_felts(aux_values));
293    }
294
295    advice_stack.extend_from_slice(&commitment_felts(stark.quotient_commit));
296
297    // The verifier consumes the DEEP alpha's two extension coordinates high-first.
298    let deep_alpha = pcs.deep_proof.challenge_columns;
299    let deep_coeffs: &[Felt] = deep_alpha.as_basis_coefficients_slice();
300    advice_stack.extend_from_slice(&[deep_coeffs[1], deep_coeffs[0]]);
301
302    append_ood_evaluations(&mut advice_stack, pcs)?;
303
304    advice_stack.push(pcs.deep_proof.pow_witness);
305
306    for round in &pcs.fri_proof.rounds {
307        advice_stack.extend_from_slice(&commitment_felts(round.commitment));
308        advice_stack.push(round.pow_witness);
309    }
310
311    let final_poly = &pcs.fri_proof.final_poly;
312    advice_stack.extend_from_slice(&QuadFelt::flatten_to_base(final_poly.to_vec()));
313
314    advice_stack.push(pcs.query_pow_witness);
315
316    let (store, advice_map) = build_merkle_data(config, stark, &heights.proof_order)?;
317
318    let advice = AdviceInputs::default()
319        .with_advice_stack(advice_stack.into())
320        .with_map(advice_map)
321        .with_merkle_store(store);
322
323    Ok(RecursiveVerifierInputs { advice, claim_commitment })
324}
325
326/// Returns the proof-package header in the order consumed by the recursive MASM verifier.
327fn security_parameter_words(params: &PcsParams) -> [Felt; 4] {
328    [
329        Felt::new_unchecked(params.num_queries() as u64),
330        Felt::new_unchecked(params.query_pow_bits() as u64),
331        Felt::new_unchecked(params.deep_pow_bits() as u64),
332        Felt::new_unchecked(params.folding_pow_bits() as u64),
333    ]
334}
335
336// OOD EVALUATIONS
337// ================================================================================================
338
339/// Flatten OOD evaluations into the advice stack.
340///
341/// The DEEP transcript contains evaluations at two points (z and z*g) for each committed matrix
342/// (main, aux, quotient), split into local (at z) and next (at z*g) rows, appended local-first.
343fn append_ood_evaluations<L>(
344    advice_stack: &mut Vec<Felt>,
345    pcs: &PcsProof<Challenge, L>,
346) -> Result<(), RecursiveVerifierInputsError>
347where
348    L: Lmcs<F = Felt>,
349{
350    let evals = &pcs.deep_proof.evals;
351    let mut local_values = Vec::new();
352    let mut next_values = Vec::new();
353
354    for group in evals {
355        for matrix in group {
356            let width = matrix.width;
357            let values = matrix.values.as_slice();
358            // A matrix carries its local row and, for two-point openings, its next row.
359            if values.len() != width && values.len() != 2 * width {
360                return Err(RecursiveVerifierInputsError::InvalidProofShape(
361                    "OOD matrix must hold exactly one or two rows",
362                ));
363            }
364            local_values.extend_from_slice(&values[..width]);
365            if values.len() == 2 * width {
366                next_values.extend_from_slice(&values[width..]);
367            }
368        }
369    }
370
371    advice_stack.extend_from_slice(&challenge_felts(&local_values));
372    advice_stack.extend_from_slice(&challenge_felts(&next_values));
373    Ok(())
374}
375
376// MERKLE DATA
377// ================================================================================================
378
379/// Build the Merkle store and advice map from the DEEP and FRI opening proofs.
380///
381/// Each opening proof becomes a `PartialMerkleTree` (for the store) and `leaf_hash -> leaf_data`
382/// entries (for the advice map). The verifier fetches authentication paths with `mtree_get` and
383/// leaf data with `adv.push_mapval`.
384fn build_merkle_data(
385    config: &P2Config,
386    stark: &StarkProof<Challenge, P2Lmcs>,
387    proof_order: &ProofOrder,
388) -> Result<MerkleAdvice, RecursiveVerifierInputsError> {
389    let pcs = &stark.pcs_proof;
390    let lmcs = config.lmcs();
391
392    let mut store = MerkleStore::new();
393    let mut advice_map = Vec::new();
394
395    // DEEP openings (one BatchProof per commitment: main, aux, quotient), then FRI openings
396    // (one per FRI round).
397    for batch_proof in pcs.deep_witnesses.iter().chain(pcs.fri_witnesses.iter()) {
398        let (tree, entries) = batch_proof_to_merkle(lmcs, batch_proof)?;
399        store.extend(tree.inner_nodes());
400        advice_map.extend(entries);
401    }
402
403    let registry_tree = config::ace_circuit_registry_tree();
404    store.extend(registry_tree.inner_nodes());
405
406    let circuit = build_recursive_verifier_ace_circuit(proof_order).map_err(|_| {
407        RecursiveVerifierInputsError::InvalidProofShape("failed to build recursive ACE circuit")
408    })?;
409    advice_map.push((circuit.commitment, circuit.instructions));
410
411    Ok((store, advice_map))
412}
413
414/// Converts a `BatchProof` into a `PartialMerkleTree` (for the store) and its
415/// `leaf_hash -> leaf_data` advice-map entries.
416fn batch_proof_to_merkle<L>(
417    lmcs: &L,
418    batch_proof: &L::BatchProof,
419) -> Result<(PartialMerkleTree, Vec<(Word, Vec<Felt>)>), RecursiveVerifierInputsError>
420where
421    L: Lmcs<F = Felt>,
422    L::Commitment: Copy + PartialEq + Into<[Felt; 4]>,
423    L::BatchProof: BatchProofView<Felt, L::Commitment>,
424{
425    let mut paths = Vec::new();
426    let mut advice_entries = Vec::new();
427
428    for index in batch_proof.indices() {
429        let rows =
430            batch_proof
431                .opening(index)
432                .ok_or(RecursiveVerifierInputsError::InvalidProofShape(
433                    "missing opening for query index",
434                ))?;
435        let siblings = batch_proof.path(index).ok_or(
436            RecursiveVerifierInputsError::InvalidProofShape("missing Merkle path for query index"),
437        )?;
438
439        let leaf_data: Vec<Felt> = rows.as_slice().to_vec();
440        let leaf_word: Word = Word::new(lmcs.hash(rows.iter_rows()).into());
441        let merkle_path =
442            MerklePath::new(siblings.into_iter().map(|c| Word::new(c.into())).collect());
443
444        paths.push((index as u64, leaf_word, merkle_path));
445        advice_entries.push((leaf_word, leaf_data));
446    }
447
448    let tree = PartialMerkleTree::with_paths(paths)
449        .map_err(|_| RecursiveVerifierInputsError::InvalidProofShape("invalid merkle paths"))?;
450
451    Ok((tree, advice_entries))
452}
453
454fn commitment_felts<C: Copy + Into<[Felt; 4]>>(commitment: C) -> [Felt; 4] {
455    commitment.into()
456}
457
458fn challenge_felts(challenges: &[Challenge]) -> Vec<Felt> {
459    QuadFelt::flatten_to_base(challenges.to_vec())
460}
461
462// TESTS
463// ================================================================================================
464
465#[cfg(test)]
466mod tests {
467    use alloc::vec;
468
469    use miden_core::{
470        crypto::merkle::InnerNodeInfo,
471        program::{KernelDescriptor, ProgramInfo, StackInputs, StackOutputs},
472    };
473
474    use super::*;
475
476    /// The top-level entry rejects non-Poseidon2 proofs up front, before touching the proof
477    /// bytes — the recursive verifier verifies only Poseidon2 STARKs.
478    #[test]
479    fn recursive_verifier_inputs_reject_non_poseidon2_proofs() {
480        let proof = ExecutionProof::from_parts(
481            Vec::new(),
482            HashFunction::Blake3_256,
483            DeferredProof::empty(),
484        );
485        let claim = ExecutionClaim::from_program_info(
486            ProgramInfo::new(Word::default(), KernelDescriptor::default()),
487            StackInputs::default(),
488            StackOutputs::default(),
489        );
490
491        let err = RecursiveVerifierInputs::for_request(Word::default(), &proof, &claim)
492            .expect_err("a Blake3 proof must be rejected");
493        assert!(matches!(
494            err,
495            RecursiveVerifierInputsError::UnsupportedHashFunction(HashFunction::Blake3_256)
496        ));
497    }
498
499    /// The proof-package header must describe the supplied PCS parameters rather than the Miden
500    /// VM's current defaults; otherwise its transcript and MASM security checks can disagree.
501    #[test]
502    fn security_parameter_header_uses_the_supplied_pcs_params() {
503        let params = PcsParams::new(4, 3, 6, 5, 11, 19, 13).expect("valid distinct PCS params");
504
505        assert_eq!(security_parameter_words(&params), [19, 13, 11, 5].map(Felt::new_unchecked),);
506    }
507
508    #[test]
509    fn proof_deserialization_rejects_oversized_input() {
510        let proof_bytes = vec![0; MAX_STARK_PROOF_BYTES + 1];
511
512        let err = deserialize_proof(&proof_bytes).expect_err("oversized proof must be rejected");
513        assert!(matches!(
514            err,
515            RecursiveVerifierInputsError::ProofTooLarge {
516                size,
517                max: MAX_STARK_PROOF_BYTES,
518            } if size == proof_bytes.len()
519        ));
520    }
521
522    /// Request packaging is a pure repackaging: the proof stream moves — unchanged and in
523    /// order — into the advice map under `proof_request_key(verifier_root, claim_commitment)`, and
524    /// everything else is untouched.
525    #[test]
526    fn request_package_uses_proof_request_key() {
527        let proof_stream: Vec<Felt> = (1..=8u64).map(Felt::new_unchecked).collect();
528        let claim_commitment = Word::from([11u64, 12, 13, 14].map(Felt::new_unchecked));
529        let verifier_root = Word::from([21u64, 22, 23, 24].map(Felt::new_unchecked));
530        let query_entry = (
531            Word::from([31u64, 32, 33, 34].map(Felt::new_unchecked)),
532            vec![Felt::new_unchecked(7)],
533        );
534        let merkle_node = InnerNodeInfo {
535            value: Word::from([41u64, 42, 43, 44].map(Felt::new_unchecked)),
536            left: Word::from([51u64, 52, 53, 54].map(Felt::new_unchecked)),
537            right: Word::from([61u64, 62, 63, 64].map(Felt::new_unchecked)),
538        };
539        let store: MerkleStore = [merkle_node].into_iter().collect();
540
541        let advice = AdviceInputs::default()
542            .with_advice_stack(proof_stream.clone().into())
543            .with_map([query_entry.clone()])
544            .with_merkle_store(store.clone());
545        let inputs = RecursiveVerifierInputs { advice, claim_commitment };
546
547        let package = inputs.into_request_package(verifier_root);
548
549        assert!(
550            package.advice().advice_stack().is_empty(),
551            "the proof must leave the advice stack"
552        );
553        assert_eq!(package.claim_commitment(), claim_commitment);
554        assert_eq!(&package.advice().store, &store);
555        assert_eq!(package.advice().map.len(), 2, "existing entries stay, proof entry added");
556        assert_eq!(package.advice().map.get(&query_entry.0).unwrap().as_ref(), query_entry.1);
557        assert_eq!(
558            package
559                .advice()
560                .map
561                .get(&proof_request_key(verifier_root, claim_commitment))
562                .unwrap()
563                .as_ref(),
564            proof_stream
565        );
566    }
567}