eq_common/
lib.rs

1use celestia_types::{
2    consts::appconsts::{
3        CONTINUATION_SPARSE_SHARE_CONTENT_SIZE, FIRST_SPARSE_SHARE_CONTENT_SIZE, NAMESPACE_SIZE,
4        SEQUENCE_LEN_BYTES, SHARE_INFO_BYTES, SHARE_SIZE, SIGNER_SIZE,
5    },
6    ShareProof,
7};
8use serde::{Deserialize, Serialize};
9use sha3::{Digest, Keccak256};
10
11#[cfg(feature = "host")]
12mod error;
13#[cfg(feature = "host")]
14pub use error::{ErrorLabels, InclusionServiceError};
15
16#[cfg(feature = "grpc")]
17/// gRPC generated bindings
18pub mod eqs {
19    include!("generated/eqs.rs");
20}
21
22/*
23    For now, we only support ZKStackEqProofs
24    These are used for Celestia integrations with Matter Labs' ZKStack
25    TODO: Add support for Payy Celestia integration
26*/
27#[derive(Serialize, Deserialize, Clone, Debug)]
28pub struct ZKStackEqProofInput {
29    pub share_proof: ShareProof,
30    pub share_version: bool,
31    pub tail_padding: usize,
32    pub data_availability_root: [u8; 32],
33    pub batch_number: u32,
34    pub chain_id: u64,
35}
36
37pub struct ZKStackEqProofOutput {
38    pub keccak_hash: [u8; 32],
39    pub data_availability_root: [u8; 32],
40    pub batch_number: u32,
41    pub chain_id: u64,
42}
43
44impl ZKStackEqProofOutput {
45    // Simple encoding, rather than use any Ethereum libraries
46    pub fn to_vec(&self) -> Vec<u8> {
47        let mut encoded = Vec::new();
48        encoded.extend_from_slice(&self.keccak_hash);
49        encoded.extend_from_slice(&self.data_availability_root);
50        encoded.extend_from_slice(&self.batch_number.to_le_bytes());
51        encoded.extend_from_slice(&self.chain_id.to_le_bytes());
52        encoded
53    }
54
55    #[cfg(feature = "host")]
56    pub fn from_bytes(data: &[u8]) -> Result<Self, InclusionServiceError> {
57        if data.len() != 76 {
58            return Err(InclusionServiceError::OutputDeserializationError);
59        }
60        let decoded = ZKStackEqProofOutput {
61            keccak_hash: data[0..32]
62                .try_into()
63                .map_err(|_| InclusionServiceError::OutputDeserializationError)?,
64            data_availability_root: data[32..64]
65                .try_into()
66                .map_err(|_| InclusionServiceError::OutputDeserializationError)?,
67            batch_number: u32::from_le_bytes(
68                data[64..68]
69                    .try_into()
70                    .map_err(|_| InclusionServiceError::OutputDeserializationError)?,
71            ),
72            chain_id: u64::from_le_bytes(
73                data[68..76]
74                    .try_into()
75                    .map_err(|_| InclusionServiceError::OutputDeserializationError)?,
76            ),
77        };
78        Ok(decoded)
79    }
80}
81
82/// Computes Keccak-256 over the blob bytes reconstructed from `raw_shares`,
83/// stopping at the end of the last share minus `tail_padding`.
84///
85/// See: https://celestiaorg.github.io/celestia-app/shares.html
86///
87/// - `share_version`: false => v0; true => v1 (adds SIGNER in first share)
88/// - `tail_padding`: number of *padding bytes in the final share*
89///   (0 means last share is completely full of data)
90///
91/// Caller MUST guarantee:
92/// - `raw_shares` is non-empty and belongs to one blob
93/// - `share_version` is correct for the sequence
94/// - `tail_padding < FIRST_SPARSE_SHARE_CONTENT_SIZE` when `raw_shares.len() == 1`
95/// - `tail_padding < CONTINUATION_SPARSE_SHARE_CONTENT_SIZE` when `raw_shares.len() >= 2`
96/// - Sufficient shares for the underlying data length
97///
98/// Violations may panic (intended for performance).
99pub fn compute_share_raw_data_keccak(
100    raw_shares: &[[u8; SHARE_SIZE]],
101    share_version: bool,
102    tail_padding: usize,
103) -> [u8; 32] {
104    let mut hasher = Keccak256::new();
105    let n = raw_shares.len();
106    let off_first = first_data_offset(share_version);
107
108    if n == 1 {
109        // Single-share blob: only first sparse-share payload is present.
110        let take = FIRST_SPARSE_SHARE_CONTENT_SIZE - tail_padding;
111        let s0 = raw_shares[0].as_ref();
112        hasher.update(&s0[off_first..off_first + take]);
113        return hasher.finalize().into();
114    }
115
116    // n >= 2
117    // 1) Full first-share payload
118    {
119        let s0 = raw_shares[0].as_ref();
120        let end0 = off_first + FIRST_SPARSE_SHARE_CONTENT_SIZE;
121        hasher.update(&s0[off_first..end0]);
122    }
123
124    let off_cont = NAMESPACE_SIZE + SHARE_INFO_BYTES;
125    let last_take = CONTINUATION_SPARSE_SHARE_CONTENT_SIZE - tail_padding;
126    let last_full = tail_padding == 0;
127
128    // 2) Full continuation shares in the middle (and optionally the last if full)
129    let full_end = if last_full { n } else { n - 1 };
130    for i in 1..full_end {
131        let si = raw_shares[i].as_ref();
132        let endi = off_cont + CONTINUATION_SPARSE_SHARE_CONTENT_SIZE;
133        hasher.update(&si[off_cont..endi]);
134    }
135
136    // 3) Tail of the final continuation share (only if not full)
137    if !last_full {
138        let slast = raw_shares[n - 1].as_ref();
139        hasher.update(&slast[off_cont..off_cont + last_take]);
140    }
141
142    hasher.finalize().into()
143}
144
145#[inline(always)]
146fn first_data_offset(version: bool) -> usize {
147    let base = NAMESPACE_SIZE + SHARE_INFO_BYTES + SEQUENCE_LEN_BYTES;
148    if version {
149        base + SIGNER_SIZE
150    } else {
151        base
152    }
153}
154
155/// Returns the number of padding bytes in the **final share** for a blob's raw data of `total_len` bytes.
156///
157/// Layout:
158/// - First share carries up to `FIRST_SPARSE_SHARE_CONTENT_SIZE` data bytes
159/// - Each continuation share carries up to `CONTINUATION_SPARSE_SHARE_CONTENT_SIZE` data bytes
160#[inline(always)]
161pub fn tail_padding_for_len(total_len: usize) -> usize {
162    if total_len <= FIRST_SPARSE_SHARE_CONTENT_SIZE {
163        // All data fits in the first share
164        FIRST_SPARSE_SHARE_CONTENT_SIZE - total_len
165    } else {
166        let rem = total_len - FIRST_SPARSE_SHARE_CONTENT_SIZE;
167        let tail_bytes = rem % CONTINUATION_SPARSE_SHARE_CONTENT_SIZE;
168        if tail_bytes == 0 {
169            0 // last continuation share is completely full
170        } else {
171            CONTINUATION_SPARSE_SHARE_CONTENT_SIZE - tail_bytes
172        }
173    }
174}
175
176// NOTE: we only support share versions 0 and 1 - ALL future versions will panic
177// for the zkVM proof, we should never be able to create a valid proof with
178// forged versions/mangled shares, as the ShareProof.verify will fail
179#[cfg(feature = "host")]
180#[inline(always)]
181pub fn exact_u8_to_bool(val: u8) -> bool {
182    match val {
183        0 => false,
184        1 => true,
185        _ => panic!("u8->bool not 0 or 1: {}", val),
186    }
187}
188
189#[cfg(all(test, feature = "host"))]
190mod test {
191    use super::*; // brings compute_blob_keccak etc. into scope
192    use celestia_types::{nmt::Namespace, AppVersion, Blob};
193    use rand::{rngs::StdRng, Rng, SeedableRng};
194    use sha3::{Digest, Keccak256};
195
196    #[test]
197    fn test_serialization() {
198        let output = ZKStackEqProofOutput {
199            keccak_hash: [0; 32],
200            data_availability_root: [0; 32],
201            batch_number: 0u32,
202            chain_id: 0u64,
203        };
204        let encoded = output.to_vec();
205        let decoded = ZKStackEqProofOutput::from_bytes(&encoded).unwrap();
206        assert_eq!(output.keccak_hash, decoded.keccak_hash);
207        assert_eq!(
208            output.data_availability_root,
209            decoded.data_availability_root
210        );
211    }
212
213    #[test]
214    fn keccak_of_data_matches_keccak_from_blob_shares_randomized() {
215        // deterministic RNG so test is reproducible
216        let mut rng = StdRng::seed_from_u64(0xCE1E5);
217
218        // Run multiple randomized trials
219        for _ in 0..5 {
220            let len = rng.gen_range(100usize..=1_000_000usize);
221            let mut data = vec![0u8; len];
222            rng.fill(&mut data[..]);
223
224            let ns = Namespace::new_v0(&[1, 2, 3, 4, 5]).expect("invalid namespace");
225
226            let blob = Blob::new(ns, data.clone(), None, AppVersion::latest())
227                .expect("blob construction failed");
228            let share_version = exact_u8_to_bool(blob.share_version);
229
230            let shares: Vec<[u8; SHARE_SIZE]> = blob
231                .to_shares()
232                .expect("invalid blob->shares")
233                .iter()
234                .map(|s| s.data().to_owned())
235                .collect();
236
237            // Sanity: derive the version from the first share's info byte; it should match
238            let first = shares.first().expect("no shares emitted");
239            let info = first[NAMESPACE_SIZE]; // info byte follows namespace
240            let derived_version_byte = info >> 1; // upper 7 bits = share version
241            let derived_version_bool = exact_u8_to_bool(derived_version_byte);
242            assert_eq!(
243                derived_version_bool, share_version,
244                "share version mismatch"
245            );
246
247            // Expected hash: Keccak256 over the original raw data
248            let expected = {
249                let mut h = Keccak256::new();
250                h.update(&data);
251                <[u8; 32]>::from(h.finalize())
252            };
253
254            let tail_padding = tail_padding_for_len(data.len());
255
256            // Hash via the share-based pipeline
257            let got = compute_share_raw_data_keccak(&shares, derived_version_bool, tail_padding);
258
259            assert_eq!(
260                got, expected,
261                "keccak(blob shares) != keccak(raw data) for len={len}, version={share_version}"
262            );
263        }
264    }
265}