Skip to main content

zync_core/
trace.rs

1//! Header chain trace encoding for ligerito polynomial commitment proofs.
2//!
3//! Encodes block headers into a trace polynomial that ligerito can prove over.
4//! The trace layout binds block hashes, prev_hash linkage, difficulty progression,
5//! state roots at epoch boundaries, and a sentinel row with tip NOMT roots.
6//!
7//! ## Trace layout (32 fields per header)
8//!
9//! ```text
10//! Field  0:      height
11//! Fields 1-8:    block_hash (32 bytes = 8 × 4-byte LE fields)
12//! Fields 9-16:   prev_hash  (32 bytes = 8 × 4-byte LE fields)
13//! Field  17:     nBits (compact difficulty target)
14//! Field  18:     cumulative_difficulty (lower 32 bits)
15//! Field  19:     running header commitment (Blake2b-512 chain, lower 4 bytes)
16//! Fields 20-23:  sapling_root (16 bytes, epoch boundaries only)
17//! Fields 24-27:  orchard_root (16 bytes, epoch boundaries only)
18//! Fields 28-29:  nullifier_root (8 bytes, epoch boundaries only)
19//! Field  30:     state_commitment (Blake2b-512 chain, lower 4 bytes)
20//! Field  31:     reserved
21//! ```
22//!
23//! After all headers, a sentinel row of 24 fields:
24//! ```text
25//! Fields 0-7:   tip_tree_root (32 bytes)
26//! Fields 8-15:  tip_nullifier_root (32 bytes)
27//! Fields 16-23: final_actions_commitment (32 bytes)
28//! ```
29//!
30//! Public outputs are extracted from fixed trace positions by the prover
31//! and bound to the Fiat-Shamir transcript. The Ligerito proximity test
32//! does NOT constrain these values — soundness relies on the honest-prover
33//! assumption plus cross-verification against independent nodes.
34
35use blake2::{Blake2b512, Digest};
36use ligerito_binary_fields::{BinaryElem32, BinaryFieldElement};
37
38use crate::error::ZyncError;
39
40/// Fields encoded per block header in the trace polynomial.
41pub const FIELDS_PER_HEADER: usize = 32;
42
43/// Sentinel row appended after all headers.
44/// Contains tip_tree_root, tip_nullifier_root, final_actions_commitment.
45pub const TIP_SENTINEL_SIZE: usize = 24;
46
47/// State roots at an epoch boundary.
48#[derive(Clone, Debug, Default)]
49pub struct EpochStateRoots {
50    pub epoch: u32,
51    pub height: u32,
52    /// Sapling note commitment tree root (hex-encoded from zebrad).
53    pub sapling_root: String,
54    /// Orchard note commitment tree root (hex-encoded from zebrad).
55    pub orchard_root: String,
56    /// Nullifier set root from NOMT (raw 32 bytes).
57    pub nullifier_root: [u8; 32],
58}
59
60/// Header data for trace encoding.
61/// Minimal fields needed. No full block data, just what goes into the trace.
62#[derive(Clone, Debug)]
63pub struct TraceHeader {
64    pub height: u32,
65    /// Block hash as hex string (LE internal order).
66    pub hash: String,
67    /// Previous block hash as hex string.
68    pub prev_hash: String,
69    /// nBits difficulty target as hex string (e.g. "1d00ffff").
70    pub bits: String,
71}
72
73/// Header chain trace for ligerito proving.
74///
75/// Contains the encoded trace polynomial and metadata extracted during encoding.
76/// Construct via [`encode_trace`], then pass to the prover.
77pub struct HeaderChainTrace {
78    /// Trace polynomial (padded to next power of 2).
79    pub trace: Vec<BinaryElem32>,
80    /// Number of headers encoded.
81    pub num_headers: usize,
82    pub start_height: u32,
83    pub end_height: u32,
84    /// Initial running commitment (zeros for epoch proof, previous proof's
85    /// final commitment for tip proof).
86    pub initial_commitment: [u8; 32],
87    /// Final running commitment (stored in field 19 of last header).
88    pub final_commitment: [u8; 32],
89    pub initial_state_commitment: [u8; 32],
90    pub final_state_commitment: [u8; 32],
91    /// Cumulative difficulty (total chain work) at end of trace.
92    pub cumulative_difficulty: u64,
93    /// Orchard commitment tree root at tip height.
94    pub tip_tree_root: [u8; 32],
95    /// Nullifier root (NOMT) at tip height.
96    pub tip_nullifier_root: [u8; 32],
97}
98
99/// Encode headers into a trace polynomial for ligerito proving.
100///
101/// Returns a `HeaderChainTrace` with the trace padded to the next power of 2.
102/// The sentinel row is appended after all headers with tip roots and actions commitment.
103pub fn encode_trace(
104    headers: &[TraceHeader],
105    state_roots: &[EpochStateRoots],
106    initial_commitment: [u8; 32],
107    initial_state_commitment: [u8; 32],
108    tip_tree_root: [u8; 32],
109    tip_nullifier_root: [u8; 32],
110    final_actions_commitment: [u8; 32],
111) -> Result<HeaderChainTrace, ZyncError> {
112    if headers.is_empty() {
113        return Err(ZyncError::InvalidData("empty headers".into()));
114    }
115
116    let num_elements = headers.len() * FIELDS_PER_HEADER + TIP_SENTINEL_SIZE;
117    let trace_size = num_elements.next_power_of_two();
118    let mut trace = vec![BinaryElem32::zero(); trace_size];
119
120    let mut running_commitment = initial_commitment;
121    let mut state_commitment = initial_state_commitment;
122    let mut cumulative_difficulty: u64 = 0;
123
124    let state_root_map: std::collections::HashMap<u32, &EpochStateRoots> =
125        state_roots.iter().map(|r| (r.height, r)).collect();
126
127    for (i, header) in headers.iter().enumerate() {
128        let offset = i * FIELDS_PER_HEADER;
129
130        let block_hash = hex_to_bytes(&header.hash)?;
131        let prev_hash = if header.prev_hash.is_empty() {
132            if header.height != 0 {
133                return Err(ZyncError::InvalidData(format!(
134                    "block {} has empty prev_hash (only genesis allowed)",
135                    header.height
136                )));
137            }
138            vec![0u8; 32]
139        } else {
140            hex_to_bytes(&header.prev_hash)?
141        };
142
143        let nbits = if header.bits.is_empty() {
144            0u32
145        } else {
146            u32::from_str_radix(&header.bits, 16).unwrap_or(0)
147        };
148
149        let block_difficulty = nbits_to_difficulty(nbits);
150        cumulative_difficulty = cumulative_difficulty.saturating_add(block_difficulty);
151
152        // Field 0: height
153        trace[offset] = BinaryElem32::from(header.height);
154
155        // Fields 1-8: block_hash
156        for j in 0..8 {
157            trace[offset + 1 + j] = bytes_to_field(&block_hash[j * 4..(j + 1) * 4]);
158        }
159
160        // Fields 9-16: prev_hash
161        for j in 0..8 {
162            trace[offset + 9 + j] = bytes_to_field(&prev_hash[j * 4..(j + 1) * 4]);
163        }
164
165        // Field 17: nBits
166        trace[offset + 17] = BinaryElem32::from(nbits);
167
168        // Field 18: cumulative difficulty (lower 32 bits)
169        trace[offset + 18] = BinaryElem32::from(cumulative_difficulty as u32);
170
171        // Field 19: running commitment
172        running_commitment =
173            update_running_commitment(&running_commitment, &block_hash, &prev_hash, header.height);
174        trace[offset + 19] = bytes_to_field(&running_commitment[0..4]);
175
176        // Fields 20-31: state roots at epoch boundaries
177        if let Some(roots) = state_root_map.get(&header.height) {
178            let sapling = if roots.sapling_root.is_empty() {
179                vec![0u8; 32]
180            } else {
181                hex_to_bytes(&roots.sapling_root)?
182            };
183            let orchard = if roots.orchard_root.is_empty() {
184                vec![0u8; 32]
185            } else {
186                hex_to_bytes(&roots.orchard_root)?
187            };
188
189            for j in 0..4 {
190                trace[offset + 20 + j] = bytes_to_field(&sapling[j * 4..(j + 1) * 4]);
191            }
192            for j in 0..4 {
193                trace[offset + 24 + j] = bytes_to_field(&orchard[j * 4..(j + 1) * 4]);
194            }
195
196            let nf_root = &roots.nullifier_root;
197            trace[offset + 28] = bytes_to_field(&nf_root[0..4]);
198            trace[offset + 29] = bytes_to_field(&nf_root[4..8]);
199
200            state_commitment = update_state_commitment(
201                &state_commitment,
202                &sapling,
203                &orchard,
204                nf_root,
205                header.height,
206            );
207            trace[offset + 30] = bytes_to_field(&state_commitment[0..4]);
208        } else {
209            trace[offset + 30] = bytes_to_field(&state_commitment[0..4]);
210        }
211    }
212
213    // Sentinel row
214    let sentinel_offset = headers.len() * FIELDS_PER_HEADER;
215    for j in 0..8 {
216        trace[sentinel_offset + j] = bytes_to_field(&tip_tree_root[j * 4..(j + 1) * 4]);
217    }
218    for j in 0..8 {
219        trace[sentinel_offset + 8 + j] =
220            bytes_to_field(&tip_nullifier_root[j * 4..(j + 1) * 4]);
221    }
222    for j in 0..8 {
223        trace[sentinel_offset + 16 + j] =
224            bytes_to_field(&final_actions_commitment[j * 4..(j + 1) * 4]);
225    }
226
227    Ok(HeaderChainTrace {
228        trace,
229        num_headers: headers.len(),
230        start_height: headers[0].height,
231        end_height: headers.last().unwrap().height,
232        initial_commitment,
233        final_commitment: running_commitment,
234        initial_state_commitment,
235        final_state_commitment: state_commitment,
236        cumulative_difficulty,
237        tip_tree_root,
238        tip_nullifier_root,
239    })
240}
241
242/// Convert 4 bytes (LE) to a BinaryElem32 trace field.
243pub fn bytes_to_field(bytes: &[u8]) -> BinaryElem32 {
244    assert_eq!(bytes.len(), 4);
245    let value = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
246    BinaryElem32::from(value)
247}
248
249/// Hex string to bytes.
250pub fn hex_to_bytes(hex: &str) -> Result<Vec<u8>, ZyncError> {
251    hex::decode(hex).map_err(|e| ZyncError::InvalidData(e.to_string()))
252}
253
254/// Running header commitment chain (Blake2b-512, truncated to 32 bytes).
255///
256/// `commitment_i = Blake2b512("ZIDECAR_header_commitment" || prev || block_hash || prev_hash || height_le)`
257pub fn update_running_commitment(
258    prev_commitment: &[u8; 32],
259    block_hash: &[u8],
260    prev_hash: &[u8],
261    height: u32,
262) -> [u8; 32] {
263    let mut hasher = Blake2b512::new();
264    hasher.update(b"ZIDECAR_header_commitment");
265    hasher.update(prev_commitment);
266    hasher.update(block_hash);
267    hasher.update(prev_hash);
268    hasher.update(height.to_le_bytes());
269
270    let hash = hasher.finalize();
271    let mut result = [0u8; 32];
272    result.copy_from_slice(&hash[..32]);
273    result
274}
275
276/// Running state commitment chain (Blake2b-512, truncated to 32 bytes).
277///
278/// Chains sapling root, orchard root, nullifier root at each epoch boundary.
279pub fn update_state_commitment(
280    prev_commitment: &[u8; 32],
281    sapling_root: &[u8],
282    orchard_root: &[u8],
283    nullifier_root: &[u8],
284    height: u32,
285) -> [u8; 32] {
286    let mut hasher = Blake2b512::new();
287    hasher.update(b"ZIDECAR_state_commitment");
288    hasher.update(prev_commitment);
289    hasher.update(sapling_root);
290    hasher.update(orchard_root);
291    hasher.update(nullifier_root);
292    hasher.update(height.to_le_bytes());
293
294    let hash = hasher.finalize();
295    let mut result = [0u8; 32];
296    result.copy_from_slice(&hash[..32]);
297    result
298}
299
300/// Derive tree root hash from zebrad's hex-encoded final state string.
301pub fn parse_tree_root_bytes(final_state: &str) -> [u8; 32] {
302    use sha2::{Digest as Sha2Digest, Sha256};
303    let mut hasher = Sha256::new();
304    hasher.update(b"ZIDECAR_TREE_ROOT");
305    hasher.update(final_state.as_bytes());
306    hasher.finalize().into()
307}
308
309/// Convert nBits (compact difficulty target) to difficulty value.
310///
311/// nBits format: `0xAABBCCDD` where AA is exponent, BBCCDD is mantissa.
312/// Returns a relative difficulty measure suitable for cumulative chain work.
313pub fn nbits_to_difficulty(nbits: u32) -> u64 {
314    if nbits == 0 {
315        return 0;
316    }
317    let exponent = (nbits >> 24) as u64;
318    let mantissa = (nbits & 0x00FFFFFF) as u64;
319    if mantissa == 0 {
320        return 0;
321    }
322    let shift = exponent.saturating_sub(3);
323    if shift < 32 {
324        let base_diff = (1u64 << 32) / mantissa;
325        base_diff >> (shift * 8).min(63)
326    } else {
327        1
328    }
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334
335    #[test]
336    fn test_bytes_to_field() {
337        let bytes = [0x01, 0x02, 0x03, 0x04];
338        let field = bytes_to_field(&bytes);
339        assert_eq!(field.poly().value(), 0x04030201); // little endian
340    }
341
342    #[test]
343    fn test_hex_to_bytes() {
344        let bytes = hex_to_bytes("deadbeef").unwrap();
345        assert_eq!(bytes, vec![0xde, 0xad, 0xbe, 0xef]);
346    }
347
348    #[test]
349    fn test_running_commitment_deterministic() {
350        let prev = [0u8; 32];
351        let block = [1u8; 32];
352        let prev_hash = [2u8; 32];
353
354        let c1 = update_running_commitment(&prev, &block, &prev_hash, 100);
355        let c2 = update_running_commitment(&prev, &block, &prev_hash, 100);
356        assert_eq!(c1, c2);
357    }
358
359    #[test]
360    fn test_nbits_to_difficulty() {
361        assert_eq!(nbits_to_difficulty(0), 0);
362        // low exponent (shift < 8 bytes) gives non-zero difficulty
363        let d = nbits_to_difficulty(0x0400ffff);
364        assert!(d > 0);
365        // different mantissa gives different difficulty
366        let d2 = nbits_to_difficulty(0x04007fff);
367        assert_ne!(d, d2);
368    }
369
370    #[test]
371    fn test_encode_single_header() {
372        let headers = vec![TraceHeader {
373            height: 100,
374            hash: "00".repeat(32),
375            prev_hash: "00".repeat(32),
376            bits: "1d00ffff".into(),
377        }];
378        let trace = encode_trace(
379            &headers,
380            &[],
381            [0u8; 32],
382            [0u8; 32],
383            [0u8; 32],
384            [0u8; 32],
385            [0u8; 32],
386        )
387        .unwrap();
388        assert_eq!(trace.num_headers, 1);
389        assert_eq!(trace.start_height, 100);
390        assert_eq!(trace.end_height, 100);
391        // trace should be padded to power of 2
392        assert!(trace.trace.len().is_power_of_two());
393        assert!(trace.trace.len() >= FIELDS_PER_HEADER + TIP_SENTINEL_SIZE);
394    }
395}