Skip to main content

zync_core/
trustless.rs

1//! Trustless state verification for secure light clients
2//!
3//! This module provides verification of:
4//! 1. FROST threshold signatures on epoch checkpoints
5//! 2. Ligerito state transition proofs
6//! 3. NOMT inclusion/exclusion proofs
7//!
8//! ## Security Model
9//!
10//! The trust hierarchy is:
11//! - Level 0: Trust FROST signer majority (k-of-n honest)
12//! - Level 1: Everything else is cryptographically verified
13//!
14//! The client ONLY trusts that the FROST checkpoint was signed by
15//! honest signers. All state after the checkpoint is verified via
16//! ligerito proofs and NOMT proofs.
17
18use crate::{verifier_config_for_log_size, Result, ZyncError};
19use sha2::{Digest, Sha256};
20
21/// FROST public key (aggregated Schnorr key)
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub struct FrostPublicKey(pub [u8; 32]);
24
25/// FROST threshold signature (Schnorr)
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub struct FrostSignature {
28    pub r: [u8; 32],
29    pub s: [u8; 32],
30}
31
32impl FrostSignature {
33    pub fn is_present(&self) -> bool {
34        self.r != [0u8; 32] || self.s != [0u8; 32]
35    }
36}
37
38/// Epoch checkpoint - trust anchor for state verification
39#[derive(Debug, Clone)]
40pub struct EpochCheckpoint {
41    pub epoch_index: u64,
42    pub height: u32,
43    pub block_hash: [u8; 32],
44    pub tree_root: [u8; 32],
45    pub nullifier_root: [u8; 32],
46    pub timestamp: u64,
47    pub signature: FrostSignature,
48    pub signer_set_id: [u8; 32],
49}
50
51impl EpochCheckpoint {
52    /// compute message hash for verification
53    pub fn message_hash(&self) -> [u8; 32] {
54        let mut hasher = Sha256::new();
55        hasher.update(b"ZIDECAR_CHECKPOINT_V1");
56        hasher.update(self.epoch_index.to_le_bytes());
57        hasher.update(self.height.to_le_bytes());
58        hasher.update(&self.block_hash);
59        hasher.update(&self.tree_root);
60        hasher.update(&self.nullifier_root);
61        hasher.update(self.timestamp.to_le_bytes());
62        hasher.finalize().into()
63    }
64
65    /// verify FROST signature
66    pub fn verify(&self, public_key: &FrostPublicKey) -> Result<()> {
67        if !self.signature.is_present() {
68            return Err(ZyncError::Verification("checkpoint not signed".into()));
69        }
70
71        // TODO: implement actual Schnorr verification
72        // For now, check that signature is non-zero and signer set is valid
73        if self.signer_set_id == [0u8; 32] {
74            return Err(ZyncError::Verification("invalid signer set".into()));
75        }
76
77        // placeholder: verify signature matches public key
78        let _ = public_key;
79
80        Ok(())
81    }
82}
83
84/// State transition proof from checkpoint to current height
85#[derive(Debug, Clone)]
86pub struct StateTransitionProof {
87    /// serialized ligerito proof
88    pub proof_bytes: Vec<u8>,
89    /// checkpoint we're proving from
90    pub from_height: u32,
91    pub from_tree_root: [u8; 32],
92    pub from_nullifier_root: [u8; 32],
93    /// state we're proving to
94    pub to_height: u32,
95    pub to_tree_root: [u8; 32],
96    pub to_nullifier_root: [u8; 32],
97    /// proof size for verifier config
98    pub proof_log_size: u32,
99}
100
101impl StateTransitionProof {
102    /// verify the ligerito state transition proof
103    pub fn verify(&self) -> Result<bool> {
104        use ligerito::{verify, FinalizedLigeritoProof};
105        use ligerito_binary_fields::{BinaryElem32, BinaryElem128};
106
107        if self.proof_bytes.is_empty() {
108            return Ok(false);
109        }
110
111        // deserialize proof
112        let proof: FinalizedLigeritoProof<BinaryElem32, BinaryElem128> =
113            bincode::deserialize(&self.proof_bytes)
114                .map_err(|e| ZyncError::Verification(format!("invalid proof: {}", e)))?;
115
116        // get verifier config for proof size
117        let config = verifier_config_for_log_size(self.proof_log_size);
118
119        // verify
120        verify(&config, &proof)
121            .map_err(|e| ZyncError::Verification(format!("proof verification failed: {:?}", e)))
122    }
123}
124
125/// Complete trustless state proof
126#[derive(Debug, Clone)]
127pub struct TrustlessStateProof {
128    /// FROST-signed checkpoint (trust anchor)
129    pub checkpoint: EpochCheckpoint,
130    /// state transition from checkpoint to current
131    pub transition: StateTransitionProof,
132    /// current verified state
133    pub current_height: u32,
134    pub current_hash: [u8; 32],
135}
136
137impl TrustlessStateProof {
138    /// full verification
139    pub fn verify(&self, signer_key: &FrostPublicKey) -> Result<VerifiedState> {
140        // 1. verify FROST checkpoint signature
141        self.checkpoint.verify(signer_key)?;
142
143        // 2. verify transition starts from checkpoint
144        if self.transition.from_height != self.checkpoint.height {
145            return Err(ZyncError::Verification(
146                "transition doesn't start from checkpoint".into(),
147            ));
148        }
149        if self.transition.from_tree_root != self.checkpoint.tree_root {
150            return Err(ZyncError::Verification(
151                "transition tree root doesn't match checkpoint".into(),
152            ));
153        }
154        if self.transition.from_nullifier_root != self.checkpoint.nullifier_root {
155            return Err(ZyncError::Verification(
156                "transition nullifier root doesn't match checkpoint".into(),
157            ));
158        }
159
160        // 3. verify ligerito state transition proof
161        if !self.transition.verify()? {
162            return Err(ZyncError::Verification(
163                "state transition proof invalid".into(),
164            ));
165        }
166
167        // 4. check freshness
168        if self.transition.to_height != self.current_height {
169            return Err(ZyncError::Verification(
170                "transition doesn't reach current height".into(),
171            ));
172        }
173
174        Ok(VerifiedState {
175            height: self.current_height,
176            block_hash: self.current_hash,
177            tree_root: self.transition.to_tree_root,
178            nullifier_root: self.transition.to_nullifier_root,
179            checkpoint_epoch: self.checkpoint.epoch_index,
180        })
181    }
182}
183
184/// Result of successful verification
185#[derive(Debug, Clone)]
186pub struct VerifiedState {
187    pub height: u32,
188    pub block_hash: [u8; 32],
189    pub tree_root: [u8; 32],
190    pub nullifier_root: [u8; 32],
191    pub checkpoint_epoch: u64,
192}
193
194/// NOMT merkle proof for inclusion/exclusion
195#[derive(Debug, Clone)]
196pub struct NomtProof {
197    pub key: [u8; 32],
198    pub root: [u8; 32],
199    pub exists: bool,
200    pub path: Vec<[u8; 32]>,
201    pub indices: Vec<bool>,
202}
203
204impl NomtProof {
205    /// verify this NOMT proof
206    pub fn verify(&self) -> Result<bool> {
207        if self.path.is_empty() {
208            // empty proof - only valid for empty tree
209            return Ok(self.root == [0u8; 32] && !self.exists);
210        }
211
212        // compute merkle root from path
213        let mut current = self.key;
214        for (sibling, is_right) in self.path.iter().zip(self.indices.iter()) {
215            let mut hasher = Sha256::new();
216            hasher.update(b"NOMT_NODE");
217            if *is_right {
218                hasher.update(sibling);
219                hasher.update(&current);
220            } else {
221                hasher.update(&current);
222                hasher.update(sibling);
223            }
224            current = hasher.finalize().into();
225        }
226
227        Ok(current == self.root)
228    }
229}
230
231/// Commitment tree inclusion proof
232#[derive(Debug, Clone)]
233pub struct CommitmentProof {
234    pub cmx: [u8; 32],
235    pub position: u64,
236    pub tree_root: [u8; 32],
237    pub proof: NomtProof,
238}
239
240impl CommitmentProof {
241    pub fn verify(&self) -> Result<bool> {
242        if !self.proof.exists {
243            return Ok(false);
244        }
245        self.proof.verify()
246    }
247}
248
249/// Nullifier status proof (spent or unspent)
250#[derive(Debug, Clone)]
251pub struct NullifierProof {
252    pub nullifier: [u8; 32],
253    pub nullifier_root: [u8; 32],
254    pub is_spent: bool,
255    pub proof: NomtProof,
256}
257
258impl NullifierProof {
259    pub fn verify(&self) -> Result<bool> {
260        if self.is_spent != self.proof.exists {
261            return Ok(false);
262        }
263        self.proof.verify()
264    }
265}
266
267/// Known signer sets for checkpoint verification
268pub struct SignerRegistry {
269    /// mainnet genesis signer key (hardcoded)
270    mainnet_key: FrostPublicKey,
271    /// testnet genesis signer key (hardcoded)
272    testnet_key: FrostPublicKey,
273}
274
275impl SignerRegistry {
276    pub fn new() -> Self {
277        // TODO: replace with actual keys from DKG ceremony
278        Self {
279            mainnet_key: FrostPublicKey([0x01; 32]),
280            testnet_key: FrostPublicKey([0x02; 32]),
281        }
282    }
283
284    pub fn mainnet_key(&self) -> &FrostPublicKey {
285        &self.mainnet_key
286    }
287
288    pub fn testnet_key(&self) -> &FrostPublicKey {
289        &self.testnet_key
290    }
291}
292
293impl Default for SignerRegistry {
294    fn default() -> Self {
295        Self::new()
296    }
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    #[test]
304    fn test_checkpoint_message_hash() {
305        let cp = EpochCheckpoint {
306            epoch_index: 1,
307            height: 1024,
308            block_hash: [0x11; 32],
309            tree_root: [0x22; 32],
310            nullifier_root: [0x33; 32],
311            timestamp: 1234567890,
312            signature: FrostSignature {
313                r: [0; 32],
314                s: [0; 32],
315            },
316            signer_set_id: [0; 32],
317        };
318
319        let hash1 = cp.message_hash();
320        let hash2 = cp.message_hash();
321        assert_eq!(hash1, hash2);
322
323        // different checkpoint should have different hash
324        let cp2 = EpochCheckpoint {
325            epoch_index: 2,
326            ..cp
327        };
328        assert_ne!(cp.message_hash(), cp2.message_hash());
329    }
330
331    #[test]
332    fn test_frost_signature_present() {
333        let empty = FrostSignature {
334            r: [0; 32],
335            s: [0; 32],
336        };
337        assert!(!empty.is_present());
338
339        let present = FrostSignature {
340            r: [1; 32],
341            s: [0; 32],
342        };
343        assert!(present.is_present());
344    }
345}