Skip to main content

zync_core/
verifier.rs

1//! ligerito proof verification with continuity checking
2//!
3//! wire format (combined proof):
4//!   [epoch_full_size: u32][epoch_full][tip_full]
5//! where each full proof is:
6//!   [public_outputs_len: u32][public_outputs (bincode)][log_size: u8][ligerito_proof (bincode)]
7
8use crate::{verifier_config_for_log_size, FIELDS_PER_HEADER, TIP_SENTINEL_SIZE};
9use anyhow::Result;
10use ligerito::{
11    transcript::{FiatShamir, Transcript},
12    verify_with_transcript, FinalizedLigeritoProof,
13};
14use ligerito_binary_fields::{BinaryElem128, BinaryElem32};
15use serde::{Deserialize, Serialize};
16
17#[cfg(not(target_arch = "wasm32"))]
18use std::thread;
19
20/// public outputs embedded in each proof
21#[derive(Clone, Debug, Serialize, Deserialize)]
22pub struct ProofPublicOutputs {
23    pub start_height: u32,
24    pub end_height: u32,
25    pub start_hash: [u8; 32],
26    pub start_prev_hash: [u8; 32],
27    pub tip_hash: [u8; 32],
28    pub tip_prev_hash: [u8; 32],
29    pub cumulative_difficulty: u64,
30    pub final_commitment: [u8; 32],
31    pub final_state_commitment: [u8; 32],
32    pub num_headers: u32,
33    /// Tree root (orchard commitment tree) at proof's end height
34    pub tip_tree_root: [u8; 32],
35    /// Nullifier root (nomt) at proof's end height
36    pub tip_nullifier_root: [u8; 32],
37    /// Final actions commitment (running chain, zeros until populated)
38    pub final_actions_commitment: [u8; 32],
39}
40
41/// result of proof verification
42#[derive(Clone, Debug)]
43pub struct VerifyResult {
44    pub epoch_proof_valid: bool,
45    pub tip_valid: bool,
46    pub continuous: bool,
47    pub epoch_outputs: ProofPublicOutputs,
48    pub tip_outputs: Option<ProofPublicOutputs>,
49}
50
51/// split a full proof into (public_outputs, raw_proof_bytes)
52fn split_full_proof(full: &[u8]) -> Result<(ProofPublicOutputs, Vec<u8>)> {
53    if full.len() < 4 {
54        anyhow::bail!("proof too short");
55    }
56    let public_len = u32::from_le_bytes([full[0], full[1], full[2], full[3]]) as usize;
57    if full.len() < 4 + public_len + 1 {
58        anyhow::bail!("proof truncated");
59    }
60    let outputs: ProofPublicOutputs = bincode::deserialize(&full[4..4 + public_len])
61        .map_err(|e| anyhow::anyhow!("deserialize public outputs: {}", e))?;
62    let raw = full[4 + public_len..].to_vec();
63    Ok((outputs, raw))
64}
65
66/// deserialize raw proof: [log_size: u8][proof_bytes...]
67fn deserialize_proof(
68    bytes: &[u8],
69) -> Result<(FinalizedLigeritoProof<BinaryElem32, BinaryElem128>, u8)> {
70    if bytes.is_empty() {
71        anyhow::bail!("empty proof bytes");
72    }
73    let log_size = bytes[0];
74    let proof = bincode::deserialize(&bytes[1..])
75        .map_err(|e| anyhow::anyhow!("failed to deserialize proof: {}", e))?;
76    Ok((proof, log_size))
77}
78
79/// verify a single raw proof (sha256 transcript to match prover)
80///
81/// public outputs are transcript-bound (anti-tampering) but NOT evaluation-proven.
82/// soundness against a malicious prover requires cross-verification.
83fn verify_single(proof_bytes: &[u8], public_outputs: &ProofPublicOutputs) -> Result<bool> {
84    let (proof, log_size) = deserialize_proof(proof_bytes)?;
85
86    // validate log_size against num_headers to prevent config downgrade attacks
87    // prover may use a larger fixed trace size (e.g. 2^20 for tip proofs), so allow >=
88    let expected_trace_elements =
89        (public_outputs.num_headers as usize) * FIELDS_PER_HEADER + TIP_SENTINEL_SIZE;
90    let expected_padded = expected_trace_elements.next_power_of_two();
91    let min_log_size = expected_padded.trailing_zeros() as u8;
92    if log_size < min_log_size {
93        anyhow::bail!(
94            "log_size too small: proof claims {} but num_headers={} requires at least {}",
95            log_size,
96            public_outputs.num_headers,
97            min_log_size,
98        );
99    }
100
101    let config = verifier_config_for_log_size(log_size as u32);
102    let mut transcript = FiatShamir::new_sha256(0);
103
104    // bind public outputs to transcript (must match prover)
105    let public_bytes = bincode::serialize(public_outputs)
106        .map_err(|e| anyhow::anyhow!("serialize public outputs: {}", e))?;
107    transcript.absorb_bytes(b"public_outputs", &public_bytes);
108
109    verify_with_transcript(&config, &proof, transcript)
110        .map_err(|e| anyhow::anyhow!("verification error: {}", e))
111}
112
113/// verify combined epoch proof + tip proof with continuity checking
114///
115/// format: [epoch_full_size: u32][epoch_full][tip_full]
116/// each full proof: [public_outputs_len: u32][public_outputs][log_size: u8][proof]
117///
118/// checks:
119/// 1. both proofs verify cryptographically
120/// 2. tip_proof.start_prev_hash == epoch proof.tip_hash (chain continuity)
121#[cfg(not(target_arch = "wasm32"))]
122pub fn verify_proofs(combined_proof: &[u8]) -> Result<(bool, bool)> {
123    let result = verify_proofs_full(combined_proof)?;
124    Ok((
125        result.epoch_proof_valid,
126        result.tip_valid && result.continuous,
127    ))
128}
129
130/// full verification with detailed result
131#[cfg(not(target_arch = "wasm32"))]
132pub fn verify_proofs_full(combined_proof: &[u8]) -> Result<VerifyResult> {
133    if combined_proof.len() < 4 {
134        anyhow::bail!("proof too small");
135    }
136
137    let epoch_full_size = u32::from_le_bytes([
138        combined_proof[0],
139        combined_proof[1],
140        combined_proof[2],
141        combined_proof[3],
142    ]) as usize;
143
144    if combined_proof.len() < 4 + epoch_full_size {
145        anyhow::bail!("invalid proof format");
146    }
147
148    let epoch_full = &combined_proof[4..4 + epoch_full_size];
149    let tip_full = &combined_proof[4 + epoch_full_size..];
150
151    // parse public outputs from both proofs
152    let (epoch_outputs, epoch_raw) = split_full_proof(epoch_full)?;
153    let (tip_outputs, tip_raw) = if !tip_full.is_empty() {
154        let (o, r) = split_full_proof(tip_full)?;
155        (Some(o), r)
156    } else {
157        (None, vec![])
158    };
159
160    // verify both proofs in parallel (public outputs bound to transcript)
161    let epoch_raw_clone = epoch_raw;
162    let epoch_outputs_clone = epoch_outputs.clone();
163    let tip_raw_clone = tip_raw;
164    let tip_outputs_clone = tip_outputs.clone();
165    let epoch_handle =
166        thread::spawn(move || verify_single(&epoch_raw_clone, &epoch_outputs_clone));
167    let tip_handle = if !tip_raw_clone.is_empty() {
168        let tip_out = tip_outputs_clone.unwrap();
169        Some(thread::spawn(move || {
170            verify_single(&tip_raw_clone, &tip_out)
171        }))
172    } else {
173        None
174    };
175
176    let epoch_proof_valid = epoch_handle
177        .join()
178        .map_err(|_| anyhow::anyhow!("epoch proof thread panicked"))??;
179    let tip_valid = match tip_handle {
180        Some(h) => h
181            .join()
182            .map_err(|_| anyhow::anyhow!("tip thread panicked"))??,
183        None => true,
184    };
185
186    // check continuity: tip starts where epoch proof ends
187    let continuous = match &tip_outputs {
188        Some(tip) => tip.start_prev_hash == epoch_outputs.tip_hash,
189        None => true, // no tip = epoch proof covers everything
190    };
191
192    Ok(VerifyResult {
193        epoch_proof_valid,
194        tip_valid,
195        continuous,
196        epoch_outputs,
197        tip_outputs,
198    })
199}
200
201/// wasm variant
202#[cfg(target_arch = "wasm32")]
203pub fn verify_proofs(combined_proof: &[u8]) -> Result<(bool, bool)> {
204    let result = verify_proofs_full(combined_proof)?;
205    Ok((
206        result.epoch_proof_valid,
207        result.tip_valid && result.continuous,
208    ))
209}
210
211#[cfg(target_arch = "wasm32")]
212pub fn verify_proofs_full(combined_proof: &[u8]) -> Result<VerifyResult> {
213    if combined_proof.len() < 4 {
214        anyhow::bail!("proof too small");
215    }
216
217    let epoch_full_size = u32::from_le_bytes([
218        combined_proof[0],
219        combined_proof[1],
220        combined_proof[2],
221        combined_proof[3],
222    ]) as usize;
223
224    if combined_proof.len() < 4 + epoch_full_size {
225        anyhow::bail!("invalid proof format");
226    }
227
228    let epoch_full = &combined_proof[4..4 + epoch_full_size];
229    let tip_full = &combined_proof[4 + epoch_full_size..];
230
231    let (epoch_outputs, epoch_raw) = split_full_proof(epoch_full)?;
232    let (tip_outputs, tip_raw) = if !tip_full.is_empty() {
233        let (o, r) = split_full_proof(tip_full)?;
234        (Some(o), r)
235    } else {
236        (None, vec![])
237    };
238
239    let epoch_proof_valid = verify_single(&epoch_raw, &epoch_outputs)?;
240    let tip_valid = if !tip_raw.is_empty() {
241        verify_single(&tip_raw, tip_outputs.as_ref().unwrap())?
242    } else {
243        true
244    };
245
246    let continuous = match &tip_outputs {
247        Some(tip) => tip.start_prev_hash == epoch_outputs.tip_hash,
248        None => true,
249    };
250
251    Ok(VerifyResult {
252        epoch_proof_valid,
253        tip_valid,
254        continuous,
255        epoch_outputs,
256        tip_outputs,
257    })
258}
259
260/// verify just tip proof (for incremental sync)
261/// tip_proof is a full proof: [public_outputs_len][public_outputs][log_size][proof]
262pub fn verify_tip(tip_proof: &[u8]) -> Result<bool> {
263    let (outputs, raw) = split_full_proof(tip_proof)?;
264    verify_single(&raw, &outputs)
265}
266
267/// result of chain verification across multiple proof segments
268#[derive(Clone, Debug)]
269pub struct ChainVerifyResult {
270    /// all individual proofs passed the ligerito proximity test
271    pub all_proofs_valid: bool,
272    /// adjacent proof public outputs satisfy continuity invariants
273    pub chain_continuous: bool,
274    /// public outputs of the first proof in the chain (chain start)
275    pub start_outputs: ProofPublicOutputs,
276    /// public outputs of the last proof in the chain (chain tip)
277    pub tip_outputs: ProofPublicOutputs,
278    /// number of segments verified
279    pub num_segments: usize,
280}
281
282/// verify a chain of proof segments with continuity checking.
283///
284/// each segment is a full proof: [public_outputs_len][public_outputs][log_size][proof]
285///
286/// # what this checks
287///
288/// 1. each proof individually passes the ligerito proximity test
289/// 2. adjacent segments satisfy: prev.tip_hash == next.start_prev_hash
290/// 3. adjacent segments satisfy: prev.end_height + 1 == next.start_height
291/// 4. running commitment chains: prev.final_commitment is consistent
292///    with next's trace (not enforced by proof — honest prover assumed)
293///
294/// # what this does NOT check
295///
296/// the ligerito proof is a polynomial commitment proximity test. it does
297/// NOT prove that the public outputs (start_hash, tip_hash, commitments)
298/// actually match the committed polynomial. a malicious prover can claim
299/// arbitrary public outputs for any valid polynomial commitment.
300///
301/// sound composition requires evaluation opening proofs binding public
302/// outputs to specific polynomial positions. until ligerito supports
303/// evaluation proofs, chain verification is sound ONLY under:
304///
305/// - honest prover assumption (zidecar extracts outputs correctly), OR
306/// - cross-verification against independent nodes confirms chain tip
307///
308/// callers MUST cross-verify the chain tip against independent sources.
309pub fn verify_chain(segments: &[&[u8]]) -> Result<ChainVerifyResult> {
310    if segments.is_empty() {
311        anyhow::bail!("empty chain");
312    }
313
314    let mut all_outputs: Vec<ProofPublicOutputs> = Vec::with_capacity(segments.len());
315
316    // verify each proof individually and collect public outputs
317    let mut all_valid = true;
318    for (i, segment) in segments.iter().enumerate() {
319        let (outputs, raw) = split_full_proof(segment)
320            .map_err(|e| anyhow::anyhow!("segment {}: {}", i, e))?;
321        let valid = verify_single(&raw, &outputs)
322            .map_err(|e| anyhow::anyhow!("segment {} verification: {}", i, e))?;
323        if !valid {
324            all_valid = false;
325        }
326        all_outputs.push(outputs);
327    }
328
329    // check continuity between adjacent segments
330    let mut continuous = true;
331    for i in 0..all_outputs.len() - 1 {
332        let prev = &all_outputs[i];
333        let next = &all_outputs[i + 1];
334
335        // hash linkage: next block's prev_hash must equal previous tip
336        if prev.tip_hash != next.start_prev_hash {
337            continuous = false;
338            break;
339        }
340
341        // height continuity
342        if prev.end_height + 1 != next.start_height {
343            continuous = false;
344            break;
345        }
346    }
347
348    Ok(ChainVerifyResult {
349        all_proofs_valid: all_valid,
350        chain_continuous: continuous,
351        start_outputs: all_outputs[0].clone(),
352        tip_outputs: all_outputs.last().unwrap().clone(),
353        num_segments: segments.len(),
354    })
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360
361    #[test]
362    fn test_empty_proof_fails() {
363        let result = verify_proofs(&[]);
364        assert!(result.is_err());
365    }
366
367    #[test]
368    fn test_too_small_proof_fails() {
369        let result = verify_proofs(&[1, 2, 3]);
370        assert!(result.is_err());
371    }
372}