Skip to main content

zync_core/
sync.rs

1//! Sync verification primitives for Zcash light clients.
2//!
3//! Pure verification logic with no IO, no wallet state, and no network calls.
4//! Every function takes data in and returns a verdict. This is the core of the
5//! trust model: the server provides claims, these functions verify them against
6//! cryptographic proofs anchored to the hardcoded activation block hash.
7//!
8//! ## Verification flow
9//!
10//! ```text
11//! header proof bytes ─→ verify_header_proof() ─→ ProvenRoots
12//!                                                    │
13//!                  ┌─────────────────────────────────┼──────────────────────┐
14//!                  │                                 │                      │
15//!                  ▼                                 ▼                      ▼
16//!   verify_commitment_proofs()        verify_nullifier_proofs()   verify_actions_commitment()
17//!   (received notes exist)            (spent/unspent status)      (block action integrity)
18//! ```
19//!
20//! All verification functions return `Result<T, ZyncError>`. An `Err` means the
21//! server is lying or compromised. The caller MUST abort the sync and not persist
22//! any data from this session.
23//!
24//! ## Memo extraction
25//!
26//! [`extract_enc_ciphertext`] parses raw V5 transaction bytes to find the 580-byte
27//! encrypted ciphertext for a specific action. Memo decryption itself requires
28//! orchard key types (version-sensitive), so callers handle `try_note_decryption`
29//! directly using their own orchard dependency.
30
31use crate::error::ZyncError;
32use crate::verifier;
33use crate::{actions, ACTIVATION_HASH_MAINNET, EPOCH_SIZE};
34
35use zcash_note_encryption::ENC_CIPHERTEXT_SIZE;
36
37/// Proven NOMT roots extracted from the ligerito header proof.
38/// These are the roots that NOMT merkle proofs must verify against.
39#[derive(Clone, Debug, Default)]
40pub struct ProvenRoots {
41    pub tree_root: [u8; 32],
42    pub nullifier_root: [u8; 32],
43    pub actions_commitment: [u8; 32],
44}
45
46/// Result of cross-verifying a block hash against multiple endpoints.
47#[derive(Debug)]
48pub struct CrossVerifyTally {
49    pub agree: u32,
50    pub disagree: u32,
51}
52
53impl CrossVerifyTally {
54    /// Check BFT majority (>2/3 of responding nodes agree).
55    pub fn has_majority(&self) -> bool {
56        let total = self.agree + self.disagree;
57        if total == 0 {
58            return false;
59        }
60        let threshold = (total * 2).div_ceil(3);
61        self.agree >= threshold
62    }
63
64    pub fn total(&self) -> u32 {
65        self.agree + self.disagree
66    }
67}
68
69/// Compare two block hashes, accounting for LE/BE byte order differences
70/// between native gRPC lightwalletd (BE display order) and zidecar (LE internal).
71pub fn hashes_match(a: &[u8], b: &[u8]) -> bool {
72    if a.is_empty() || b.is_empty() {
73        return true; // can't compare empty hashes
74    }
75    if a == b {
76        return true;
77    }
78    let mut b_rev = b.to_vec();
79    b_rev.reverse();
80    a == b_rev.as_slice()
81}
82
83/// Validate a header proof and extract proven NOMT roots.
84///
85/// Returns `ProvenRoots` on success, or error if the proof is invalid,
86/// discontinuous, or too stale relative to tip.
87pub fn verify_header_proof(
88    proof_bytes: &[u8],
89    tip: u32,
90    mainnet: bool,
91) -> Result<ProvenRoots, ZyncError> {
92    let result = verifier::verify_proofs_full(proof_bytes)
93        .map_err(|e| ZyncError::InvalidProof(format!("header proof: {}", e)))?;
94
95    if !result.epoch_proof_valid {
96        return Err(ZyncError::InvalidProof("epoch proof invalid".into()));
97    }
98    if !result.tip_valid {
99        return Err(ZyncError::InvalidProof("tip proof invalid".into()));
100    }
101    if !result.continuous {
102        return Err(ZyncError::InvalidProof("proof chain discontinuous".into()));
103    }
104
105    // verify epoch proof anchors to hardcoded activation block hash
106    if mainnet && result.epoch_outputs.start_hash != ACTIVATION_HASH_MAINNET {
107        return Err(ZyncError::InvalidProof(format!(
108            "epoch proof start_hash doesn't match activation anchor: got {}",
109            hex::encode(&result.epoch_outputs.start_hash[..8]),
110        )));
111    }
112
113    // extract proven roots from the most recent proof (tip > epoch proof)
114    let outputs = result
115        .tip_outputs
116        .as_ref()
117        .unwrap_or(&result.epoch_outputs);
118
119    // reject if proof is more than 1 epoch behind tip
120    if outputs.end_height + EPOCH_SIZE < tip {
121        return Err(ZyncError::InvalidProof(format!(
122            "header proof too stale: covers to {} but tip is {} (>{} blocks behind)",
123            outputs.end_height, tip, EPOCH_SIZE,
124        )));
125    }
126
127    Ok(ProvenRoots {
128        tree_root: outputs.tip_tree_root,
129        nullifier_root: outputs.tip_nullifier_root,
130        actions_commitment: outputs.final_actions_commitment,
131    })
132}
133
134/// Verify the running actions commitment chain against the proven value.
135///
136/// Returns the validated commitment, or an error if the chain doesn't match.
137/// For legacy wallets (pre-0.5.1), returns the proven commitment directly.
138pub fn verify_actions_commitment(
139    running: &[u8; 32],
140    proven: &[u8; 32],
141    has_saved_commitment: bool,
142) -> Result<[u8; 32], ZyncError> {
143    if !has_saved_commitment {
144        // legacy wallet: no saved actions commitment from pre-0.5.1 sync.
145        // trust the proven value and save it for future chaining.
146        Ok(*proven)
147    } else if running != proven {
148        Err(ZyncError::StateMismatch(format!(
149            "actions commitment mismatch: server tampered with block actions (computed={} proven={})",
150            hex::encode(&running[..8]),
151            hex::encode(&proven[..8]),
152        )))
153    } else {
154        Ok(*running)
155    }
156}
157
158/// Commitment proof from a server, ready for verification.
159pub struct CommitmentProofData {
160    pub cmx: [u8; 32],
161    pub tree_root: [u8; 32],
162    pub path_proof_raw: Vec<u8>,
163    pub value_hash: [u8; 32],
164}
165
166impl CommitmentProofData {
167    pub fn verify(&self) -> Result<bool, crate::nomt::NomtVerifyError> {
168        crate::nomt::verify_commitment_proof(
169            &self.cmx,
170            self.tree_root,
171            &self.path_proof_raw,
172            self.value_hash,
173        )
174    }
175}
176
177/// Nullifier proof from a server, ready for verification.
178pub struct NullifierProofData {
179    pub nullifier: [u8; 32],
180    pub nullifier_root: [u8; 32],
181    pub is_spent: bool,
182    pub path_proof_raw: Vec<u8>,
183    pub value_hash: [u8; 32],
184}
185
186impl NullifierProofData {
187    pub fn verify(&self) -> Result<bool, crate::nomt::NomtVerifyError> {
188        crate::nomt::verify_nullifier_proof(
189            &self.nullifier,
190            self.nullifier_root,
191            self.is_spent,
192            &self.path_proof_raw,
193            self.value_hash,
194        )
195    }
196}
197
198/// Verify a batch of commitment proofs against proven roots.
199///
200/// Checks: root binding, proof count, cmx membership, cryptographic validity.
201pub fn verify_commitment_proofs(
202    proofs: &[CommitmentProofData],
203    requested_cmxs: &[[u8; 32]],
204    proven: &ProvenRoots,
205    server_root: &[u8; 32],
206) -> Result<(), ZyncError> {
207    // bind server-returned root to ligerito-proven root
208    if server_root != &proven.tree_root {
209        return Err(ZyncError::VerificationFailed(format!(
210            "commitment tree root mismatch: server={} proven={}",
211            hex::encode(server_root),
212            hex::encode(proven.tree_root),
213        )));
214    }
215
216    // verify proof count matches requested count
217    if proofs.len() != requested_cmxs.len() {
218        return Err(ZyncError::VerificationFailed(format!(
219            "commitment proof count mismatch: requested {} but got {}",
220            requested_cmxs.len(),
221            proofs.len(),
222        )));
223    }
224
225    // verify each returned proof's cmx matches one we requested
226    let cmx_set: std::collections::HashSet<[u8; 32]> = requested_cmxs.iter().copied().collect();
227    for proof in proofs {
228        if !cmx_set.contains(&proof.cmx) {
229            return Err(ZyncError::VerificationFailed(format!(
230                "server returned commitment proof for unrequested cmx {}",
231                hex::encode(proof.cmx),
232            )));
233        }
234
235        // verify merkle path walks to the claimed root
236        match proof.verify() {
237            Ok(true) => {}
238            Ok(false) => {
239                return Err(ZyncError::VerificationFailed(format!(
240                    "commitment proof invalid for cmx {}",
241                    hex::encode(proof.cmx),
242                )))
243            }
244            Err(e) => {
245                return Err(ZyncError::VerificationFailed(format!(
246                    "commitment proof verification error: {}",
247                    e,
248                )))
249            }
250        }
251
252        // verify proof root matches the proven root
253        if proof.tree_root != proven.tree_root {
254            return Err(ZyncError::VerificationFailed(format!(
255                "commitment proof root mismatch for cmx {}",
256                hex::encode(proof.cmx),
257            )));
258        }
259    }
260
261    Ok(())
262}
263
264/// Verify a batch of nullifier proofs against proven roots.
265///
266/// Returns list of nullifiers proven spent on-chain.
267pub fn verify_nullifier_proofs(
268    proofs: &[NullifierProofData],
269    requested_nullifiers: &[[u8; 32]],
270    proven: &ProvenRoots,
271    server_root: &[u8; 32],
272) -> Result<Vec<[u8; 32]>, ZyncError> {
273    // bind server-returned root to ligerito-proven root
274    if server_root != &proven.nullifier_root {
275        return Err(ZyncError::VerificationFailed(format!(
276            "nullifier root mismatch: server={} proven={}",
277            hex::encode(server_root),
278            hex::encode(proven.nullifier_root),
279        )));
280    }
281
282    if proofs.len() != requested_nullifiers.len() {
283        return Err(ZyncError::VerificationFailed(format!(
284            "nullifier proof count mismatch: requested {} but got {}",
285            requested_nullifiers.len(),
286            proofs.len(),
287        )));
288    }
289
290    let nf_set: std::collections::HashSet<[u8; 32]> =
291        requested_nullifiers.iter().copied().collect();
292    let mut spent = Vec::new();
293
294    for proof in proofs {
295        if !nf_set.contains(&proof.nullifier) {
296            return Err(ZyncError::VerificationFailed(format!(
297                "server returned nullifier proof for unrequested nullifier {}",
298                hex::encode(proof.nullifier),
299            )));
300        }
301
302        match proof.verify() {
303            Ok(true) => {
304                if proof.is_spent {
305                    spent.push(proof.nullifier);
306                }
307            }
308            Ok(false) => {
309                return Err(ZyncError::VerificationFailed(format!(
310                    "nullifier proof invalid for {}",
311                    hex::encode(proof.nullifier),
312                )))
313            }
314            Err(e) => {
315                return Err(ZyncError::VerificationFailed(format!(
316                    "nullifier proof verification error: {}",
317                    e,
318                )))
319            }
320        }
321
322        if proof.nullifier_root != proven.nullifier_root {
323            return Err(ZyncError::VerificationFailed(format!(
324                "nullifier proof root mismatch for {}: server={} proven={}",
325                hex::encode(proof.nullifier),
326                hex::encode(proof.nullifier_root),
327                hex::encode(proven.nullifier_root),
328            )));
329        }
330    }
331
332    Ok(spent)
333}
334
335/// Extract the 580-byte enc_ciphertext for an action matching cmx+epk from raw tx bytes.
336///
337/// V5 orchard action layout: cv(32) + nf(32) + rk(32) + cmx(32) + epk(32) + enc(580) + out(80) = 820 bytes
338/// enc_ciphertext immediately follows epk within each action.
339pub fn extract_enc_ciphertext(
340    raw_tx: &[u8],
341    cmx: &[u8; 32],
342    epk: &[u8; 32],
343) -> Option<[u8; ENC_CIPHERTEXT_SIZE]> {
344    for i in 0..raw_tx.len().saturating_sub(64 + ENC_CIPHERTEXT_SIZE) {
345        if &raw_tx[i..i + 32] == cmx && &raw_tx[i + 32..i + 64] == epk {
346            let start = i + 64;
347            let end = start + ENC_CIPHERTEXT_SIZE;
348            if end <= raw_tx.len() {
349                let mut enc = [0u8; ENC_CIPHERTEXT_SIZE];
350                enc.copy_from_slice(&raw_tx[start..end]);
351                return Some(enc);
352            }
353        }
354    }
355    None
356}
357
358/// Compute running actions commitment for a sequence of blocks.
359///
360/// Processes each block's actions through the commitment chain,
361/// returning the final running commitment.
362pub fn chain_actions_commitment(
363    initial: &[u8; 32],
364    blocks: &[(u32, Vec<([u8; 32], [u8; 32], [u8; 32])>)], // (height, actions)
365) -> [u8; 32] {
366    let mut running = *initial;
367    for (height, block_actions) in blocks {
368        let actions_root = actions::compute_actions_root(block_actions);
369        running = actions::update_actions_commitment(&running, &actions_root, *height);
370    }
371    running
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377
378    #[test]
379    fn test_hashes_match_same() {
380        let h = [1u8; 32];
381        assert!(hashes_match(&h, &h));
382    }
383
384    #[test]
385    fn test_hashes_match_reversed() {
386        let a: Vec<u8> = (0..32).collect();
387        let b: Vec<u8> = (0..32).rev().collect();
388        assert!(hashes_match(&a, &b));
389    }
390
391    #[test]
392    fn test_hashes_match_empty() {
393        assert!(hashes_match(&[], &[1u8; 32]));
394        assert!(hashes_match(&[1u8; 32], &[]));
395    }
396
397    #[test]
398    fn test_hashes_no_match() {
399        let a = [1u8; 32];
400        let b = [2u8; 32];
401        assert!(!hashes_match(&a, &b));
402    }
403
404    #[test]
405    fn test_cross_verify_tally_majority() {
406        let tally = CrossVerifyTally {
407            agree: 3,
408            disagree: 1,
409        };
410        assert!(tally.has_majority()); // 3/4 > 2/3
411
412        let tally = CrossVerifyTally {
413            agree: 1,
414            disagree: 2,
415        };
416        assert!(!tally.has_majority()); // 1/3 < 2/3
417    }
418
419    #[test]
420    fn test_cross_verify_tally_empty() {
421        let tally = CrossVerifyTally {
422            agree: 0,
423            disagree: 0,
424        };
425        assert!(!tally.has_majority());
426    }
427
428    #[test]
429    fn test_actions_commitment_legacy() {
430        let proven = [42u8; 32];
431        let result = verify_actions_commitment(&[0u8; 32], &proven, false).unwrap();
432        assert_eq!(result, proven);
433    }
434
435    #[test]
436    fn test_actions_commitment_match() {
437        let commitment = [42u8; 32];
438        let result = verify_actions_commitment(&commitment, &commitment, true).unwrap();
439        assert_eq!(result, commitment);
440    }
441
442    #[test]
443    fn test_actions_commitment_mismatch() {
444        let running = [1u8; 32];
445        let proven = [2u8; 32];
446        assert!(verify_actions_commitment(&running, &proven, true).is_err());
447    }
448
449    #[test]
450    fn test_extract_enc_ciphertext_not_found() {
451        let raw = vec![0u8; 100];
452        let cmx = [1u8; 32];
453        let epk = [2u8; 32];
454        assert!(extract_enc_ciphertext(&raw, &cmx, &epk).is_none());
455    }
456
457    #[test]
458    fn test_chain_actions_commitment_empty() {
459        let initial = [0u8; 32];
460        let result = chain_actions_commitment(&initial, &[]);
461        assert_eq!(result, initial);
462    }
463}