nitro_da_proofs/compound/
inclusion.rs

1//! This proof module contains the logic for verifying "inclusion" in the sense that a specific
2//! Solana block contains blobs, and that there are no other blobs in the block.
3
4use std::fmt::Debug;
5
6use itertools::Itertools;
7use serde::{Deserialize, Serialize};
8use solana_sdk::{clock::Slot, hash::HASH_BYTES, pubkey::Pubkey};
9use thiserror::Error;
10
11use crate::{
12    accounts_delta_hash::inclusion::InclusionProof,
13    bank_hash::BankHashProof,
14    blob::{BlobProof, BlobProofError},
15    blober_account_state::{self, BloberAccountStateProof},
16};
17
18/// A proof that a specific Solana block contains blobs, and that there are no other blobs in the block.
19///
20/// This proof consists of four parts:
21/// 1. A list of [blob proofs][`BlobProof`] that prove that the blobs uploaded to the [`blober`] program
22///    hash to the given blob digest.
23/// 2. A [blober account state proof][`BloberAccountStateProof`] that proves that the [`blober`] was
24///    invoked exactly as many times as there are blobs.
25/// 3. An [accounts delta hash proof][`InclusionProof`] that proves that
26///    the accounts_delta_hash *does* include the [`blober`] account.
27/// 4. A [bank hash proof][`BankHashProof`] that proves that the root hash of the accounts_delta_hash
28///    is the same as the root in the bank hash.
29///
30/// The proof can then be verified by supplying the blockhash of the block in which the [`blober`] was
31/// invoked, as well as the blobs of data which were published.
32#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
33pub struct CompoundInclusionProof {
34    slot: Slot,
35    blob_proofs: Vec<BlobProof>,
36    blober_account_state_proof: BloberAccountStateProof,
37    blober_inclusion_proof: InclusionProof,
38    pub bank_hash_proof: BankHashProof,
39}
40
41/// All data relevant for proving a single blob. If the `chunks` field is `None`, the blob itself will
42/// not be checked, but the rest of the proof will still be verified.
43pub struct ProofBlob<A: AsRef<[u8]> = Vec<u8>> {
44    pub blob: Pubkey,
45    pub data: Option<A>,
46}
47
48impl ProofBlob<Vec<u8>> {
49    pub fn empty(blob: Pubkey) -> Self {
50        Self { blob, data: None }
51    }
52}
53
54impl<A: AsRef<[u8]>> ProofBlob<A> {
55    pub fn blob_size(&self) -> Option<usize> {
56        let blob = self.data.as_ref()?;
57        Some(blob.as_ref().len())
58    }
59}
60
61impl<A: AsRef<[u8]>> Debug for ProofBlob<A> {
62    #[cfg_attr(test, mutants::skip)]
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        f.debug_struct("Blob")
65            .field("blob", &self.blob)
66            .field("blob_size", &self.blob_size())
67            .finish()
68    }
69}
70
71/// Failures that can occur when verifying a [`CompoundInclusionProof`].
72#[derive(Debug, Clone, Error)]
73pub enum CompoundInclusionProofError {
74    #[error("The number of blobs does not match the number of proofs")]
75    InvalidNumberOfBlobs,
76    #[error(
77        "The number of blob accounts does not match the number of proofs, some blobs are missing"
78    )]
79    MissingBlobs,
80    #[error("The inclusion proof is not for the blober account")]
81    IncludedAccountNotBlober,
82    #[error(
83        "The proof is for a different blockhash than the one provided, expected {expected:?}, found {found:?}"
84    )]
85    BlockHashMismatch {
86        expected: solana_sdk::hash::Hash,
87        found: solana_sdk::hash::Hash,
88    },
89    #[error(
90        "Blob {index} does not match the provided hash, expected {expected:?}, found {found:?}"
91    )]
92    BlobHashMismatch {
93        index: usize,
94        expected: solana_sdk::hash::Hash,
95        found: solana_sdk::hash::Hash,
96    },
97    #[error(
98        "Blob {index} does not match the provided blob size, expected {expected}, found {found}"
99    )]
100    BlobSizeMismatch {
101        index: usize,
102        expected: usize,
103        found: usize,
104    },
105    #[error("Blob {index} has invalid blob account data: 0x{}", hex::encode(.bytes))]
106    InvalidBlobAccountData { index: usize, bytes: Vec<u8> },
107    #[error("The computed accounts delta hash does not match the provided value")]
108    AccountsDeltaHashMismatch,
109    #[error(transparent)]
110    BloberAccountState(#[from] blober_account_state::BloberAccountStateError),
111    #[error(transparent)]
112    Blob(#[from] BlobProofError),
113}
114
115impl CompoundInclusionProof {
116    /// Creates an inclusion proof.
117    pub fn new(
118        slot: Slot,
119        blob_proofs: Vec<BlobProof>,
120        blober_account_state_proof: BloberAccountStateProof,
121        blober_inclusion_proof: InclusionProof,
122        bank_hash_proof: BankHashProof,
123    ) -> Self {
124        Self {
125            slot,
126            blob_proofs,
127            blober_account_state_proof,
128            blober_inclusion_proof,
129            bank_hash_proof,
130        }
131    }
132
133    /// Verifies that a specific Solana block contains the provided blobs, and that no blobs have been excluded.
134    #[tracing::instrument(skip_all, err(Debug), fields(slot = %self.slot, blober = %blober, blockhash = %blockhash))]
135    pub fn verify(
136        &self,
137        blober: Pubkey,
138        blockhash: solana_sdk::hash::Hash,
139        blobs: &[ProofBlob<impl AsRef<[u8]>>],
140    ) -> Result<(), CompoundInclusionProofError> {
141        if blobs.len() != self.blob_proofs.len() {
142            return Err(CompoundInclusionProofError::InvalidNumberOfBlobs);
143        }
144        if self.blober_account_state_proof.blob_accounts.len() != self.blob_proofs.len() {
145            return Err(CompoundInclusionProofError::MissingBlobs);
146        }
147        if self.blober_inclusion_proof.account_pubkey != blober {
148            return Err(CompoundInclusionProofError::IncludedAccountNotBlober);
149        }
150
151        if self.bank_hash_proof.blockhash != blockhash {
152            return Err(CompoundInclusionProofError::BlockHashMismatch {
153                expected: blockhash,
154                found: self.bank_hash_proof.blockhash,
155            });
156        }
157
158        let blob_accounts = &self.blober_account_state_proof.blob_accounts;
159
160        for (index, ((blob, blob_proof), blob_account)) in blobs
161            .iter()
162            .zip_eq(&self.blob_proofs)
163            .zip_eq(blob_accounts)
164            .enumerate()
165        {
166            let (blob_account_digest, blob_account_blob_size) = blob_account.1.split_at(HASH_BYTES);
167            let blob_account_digest: [u8; 32] = blob_account_digest.try_into().map_err(|_| {
168                CompoundInclusionProofError::InvalidBlobAccountData {
169                    index,
170                    bytes: blob_account.1.clone(),
171                }
172            })?;
173            let blob_account_blob_size: [u8; 4] =
174                blob_account_blob_size.try_into().map_err(|_| {
175                    CompoundInclusionProofError::InvalidBlobAccountData {
176                        index,
177                        bytes: blob_account.1.clone(),
178                    }
179                })?;
180            let blob_account_blob_size = u32::from_le_bytes(blob_account_blob_size) as usize;
181
182            if let Some(blob_size) = blob.blob_size() {
183                if blob_account_blob_size != blob_size {
184                    return Err(CompoundInclusionProofError::BlobSizeMismatch {
185                        index,
186                        expected: blob_account_blob_size,
187                        found: blob_size,
188                    });
189                }
190            }
191
192            if blob_account_digest != blob_proof.digest {
193                return Err(CompoundInclusionProofError::BlobHashMismatch {
194                    index,
195                    expected: solana_sdk::hash::Hash::new_from_array(blob_proof.digest),
196                    found: solana_sdk::hash::Hash::new_from_array(blob_account_digest),
197                });
198            }
199
200            if let Some(data) = &blob.data {
201                blob_proof.verify(data.as_ref())?;
202            }
203        }
204
205        self.blober_account_state_proof
206            .verify(&self.blober_inclusion_proof.account_data.data)?;
207
208        if !self
209            .blober_inclusion_proof
210            .verify(self.bank_hash_proof.accounts_delta_hash)
211        {
212            return Err(CompoundInclusionProofError::AccountsDeltaHashMismatch);
213        }
214
215        Ok(())
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use std::collections::HashSet;
222
223    use anchor_lang::{AnchorSerialize, Discriminator};
224    use arbtest::arbtest;
225    use blober_account_state::BlobAccount;
226    use nitro_da_blober::{
227        BLOB_DATA_END, BLOB_DATA_START, CHUNK_SIZE, hash_blob, initial_hash,
228        state::{blob::Blob, blober::Blober},
229    };
230    use solana_sdk::{
231        account::Account, native_token::LAMPORTS_PER_SOL, slot_hashes::SlotHashes, system_program,
232        sysvar, sysvar::SysvarId,
233    };
234
235    use super::*;
236    use crate::{
237        accounts_delta_hash::{
238            AccountMerkleTree,
239            testing::{ArbAccount, ArbKeypair},
240        },
241        bank_hash::BankHashProof,
242        testing::arbitrary_hash,
243    };
244
245    #[test]
246    fn inclusion_construction_single_blob() {
247        arbtest(|u| {
248            // ------------------------- Blob -------------------------
249            let blob: &[u8] = u.arbitrary()?;
250            if blob.is_empty() {
251                // Empty blob, invalid test.
252                return Ok(());
253            } else if blob.len() > u16::MAX as usize {
254                // Blob too large, invalid test.
255                return Ok(());
256            }
257            let mut chunks = blob
258                .chunks(CHUNK_SIZE as usize)
259                .enumerate()
260                .map(|(i, chunk)| (i as u16, chunk))
261                .collect::<Vec<_>>();
262            // Swap a few chunks around to simulate out-of-order submission.
263            for _ in 0..10 {
264                let a = u.choose_index(chunks.len())?;
265                let b = u.choose_index(chunks.len())?;
266                chunks.swap(a, b);
267            }
268
269            let blober = u.arbitrary::<ArbKeypair>()?.pubkey();
270
271            let mut unmodified = true;
272
273            let mut blob_account: (ArbKeypair, ArbAccount) = u.arbitrary()?;
274
275            blob_account.1.data = if u.ratio(1, 10)? {
276                unmodified = false;
277                u.arbitrary::<[u8; BLOB_DATA_END]>()?.to_vec()
278            } else {
279                let mut blob_pda = Blob::new(0, 0, blob.len() as u32, 0);
280                for (chunk_index, chunk_data) in &chunks {
281                    blob_pda.insert(0, *chunk_index, chunk_data);
282                }
283                [Blob::DISCRIMINATOR.to_vec(), blob_pda.try_to_vec().unwrap()]
284                    .into_iter()
285                    .flatten()
286                    .collect()
287            };
288
289            let blob_proof = BlobProof::new(&chunks);
290
291            // ------------------------- Blober account state -------------------------
292            let mut slot = u.arbitrary()?;
293            if slot == 0 {
294                // Slot 0 doesn't work for the contract and will never happen outside of tests.
295                slot = 1;
296            }
297            let mut source_accounts: Vec<_> = vec![BlobAccount(
298                blob_account.0.pubkey(),
299                blob_account.1.data[BLOB_DATA_START..BLOB_DATA_END].to_vec(),
300            )];
301
302            if u.ratio(1, 10)? {
303                // Add an extra source account that hasn't actually called the blober, I.E. false proof.
304                source_accounts.push(BlobAccount(
305                    u.arbitrary::<ArbKeypair>()?.pubkey(),
306                    u.arbitrary()?,
307                ));
308                unmodified = false;
309            }
310
311            let blober_account_state_proof =
312                blober_account_state::BloberAccountStateProof::new(slot, source_accounts.clone());
313
314            // Accounts delta hash, starting with unrelated accounts.
315            let other_accounts: Vec<(ArbKeypair, ArbAccount)> = u.arbitrary()?;
316
317            let mut tree = AccountMerkleTree::builder(
318                [blober, sysvar::slot_hashes::ID]
319                    .into_iter()
320                    .chain(other_accounts.iter().map(|(kp, _)| kp.pubkey()))
321                    .collect(),
322            );
323            for (pubkey, account) in other_accounts.iter() {
324                tree.insert(pubkey.pubkey(), account.clone().into());
325            }
326            // Always include the blober account.
327            let mut blober_data = Blober {
328                caller: nitro_da_blober::id(),
329                hash: initial_hash(),
330                slot: 0,
331            };
332            if u.ratio(1, 10)? {
333                let new_slot = u.arbitrary()?;
334                if new_slot != 0 {
335                    unmodified = new_slot == slot;
336                    slot = new_slot;
337                }
338            }
339
340            if u.ratio(9, 10)? {
341                blober_data.store_hash(
342                    &hash_blob(
343                        &blob_account.0.pubkey().to_bytes().into(),
344                        &blob_account.1.data[BLOB_DATA_START..BLOB_DATA_END],
345                    ),
346                    slot,
347                );
348            } else {
349                // The blober account was not invoked.
350                unmodified = false;
351            }
352            let blober_account = Account {
353                lamports: LAMPORTS_PER_SOL,
354                data: [
355                    Blober::DISCRIMINATOR.to_vec(),
356                    blober_data.try_to_vec().unwrap(),
357                ]
358                .into_iter()
359                .flatten()
360                .collect(),
361                owner: system_program::ID,
362                executable: false,
363                rent_epoch: 0,
364            };
365
366            let (tree, accounts_delta_hash_proof) =
367                if !other_accounts.is_empty() && u.ratio(1, 10)? {
368                    // The blober is never inserted into the tree.
369                    let tree = tree.build();
370                    let false_accounts_delta_hash_proof = tree.unchecked_inclusion_proof(
371                        u.choose_index(other_accounts.len())?,
372                        &blober,
373                        &blober_account,
374                    );
375                    unmodified = false;
376                    (tree, false_accounts_delta_hash_proof)
377                } else if !other_accounts.is_empty() && u.ratio(1, 10)? {
378                    // Valid inclusion proof but for the wrong account.
379                    let keypair = &u.choose(&other_accounts)?.0;
380                    let tree = tree.build();
381                    let accounts_delta_hash_proof = tree.prove_inclusion(keypair.pubkey()).unwrap();
382                    unmodified = keypair.pubkey() == blober;
383                    (tree, accounts_delta_hash_proof)
384                } else {
385                    tree.insert(blober, blober_account);
386                    let tree = tree.build();
387                    let accounts_delta_hash_proof = tree.prove_inclusion(blober).unwrap();
388                    (tree, accounts_delta_hash_proof)
389                };
390
391            // ----------------------- Payer proof -----------------------------------------
392            let writable_blob_account = blob_account.0.pubkey();
393            let read_only_blober_account = nitro_da_blober::id().to_bytes().into();
394
395            // ------------------------- Bank hash -------------------------
396            let parent_bankhash = arbitrary_hash(u)?;
397            let root = tree.root();
398            let signature_count = u.arbitrary()?;
399            let blockhash = arbitrary_hash(u)?;
400
401            let mut bank_hash_proof =
402                BankHashProof::new(parent_bankhash, root, signature_count, blockhash);
403
404            if u.ratio(1, 10)? {
405                // Not testing exhaustively here, just that anything is wrong with the bank hash proof.
406                let new_root = arbitrary_hash(u)?;
407                unmodified = new_root == root;
408                bank_hash_proof.accounts_delta_hash = new_root;
409            }
410
411            // ------------------------- Multi vote proof -------------------------
412            let mut trusted_vote_authorities: Vec<ArbKeypair> = vec![
413                arbitrary::Arbitrary::arbitrary(u)?,
414                arbitrary::Arbitrary::arbitrary(u)?,
415            ];
416            trusted_vote_authorities.sort_by_key(|pk| pk.pubkey());
417
418            let required_votes = 1 + u.choose_index(trusted_vote_authorities.len())?;
419
420            unmodified = unmodified
421                && required_votes <= trusted_vote_authorities.len()
422                && required_votes > 0;
423
424            let proven_slot = u.arbitrary()?;
425            let proven_hash = bank_hash_proof.hash();
426
427            let slot_hashes = u
428                .arbitrary_iter::<(u64, [u8; 32])>()?
429                .map(|tup| Ok((tup?.0, solana_sdk::hash::Hash::new_from_array(tup?.1))))
430                // Include the hash that's being proven.
431                .chain([Ok((proven_slot, proven_hash))].into_iter())
432                .collect::<Result<HashSet<_>, _>>()?
433                .into_iter()
434                .collect::<Vec<_>>();
435            if slot_hashes.is_empty() {
436                return Ok(());
437            }
438
439            let slot_hashes = SlotHashes::new(&slot_hashes);
440
441            let mut slot_hashes_account: Account = u.arbitrary::<ArbAccount>()?.into();
442            slot_hashes_account.data = bincode::serialize(&slot_hashes).unwrap();
443
444            let mut slot_hashes_tree =
445                AccountMerkleTree::builder([read_only_blober_account].into_iter().collect());
446            slot_hashes_tree.insert(SlotHashes::id(), slot_hashes_account);
447
448            // ------------------------- Compound proof -------------------------
449            let blob_proofs = if u.ratio(1, 10)? {
450                // Missing blob proof.
451                unmodified = false;
452                Vec::new()
453            } else if u.ratio(1, 10)? {
454                // Extra blob proof.
455                unmodified = false;
456                vec![blob_proof.clone(), blob_proof]
457            } else {
458                vec![blob_proof]
459            };
460
461            let compound_inclusion_proof = CompoundInclusionProof::new(
462                proven_slot,
463                blob_proofs,
464                blober_account_state_proof,
465                accounts_delta_hash_proof,
466                bank_hash_proof,
467            );
468
469            let blobs = if u.ratio(1, 10)? {
470                // No blobs.
471                unmodified = false;
472                Vec::new()
473            } else if u.ratio(1, 10)? {
474                // An extra blob.
475                unmodified = false;
476                vec![blob.to_vec(), blob.to_vec()]
477            } else if u.ratio(1, 10)? {
478                // A single blob, the right size, but the wrong contents.
479                let mut new_blob = Vec::new();
480                while new_blob.len() < blob.len() {
481                    new_blob.push(u.arbitrary()?);
482                }
483                unmodified = unmodified && new_blob == blob;
484                vec![new_blob]
485            } else if u.ratio(1, 10)? {
486                // A single blob, but the wrong size.
487                let mut new_blob = Vec::new();
488                while new_blob.len() == blob.len() {
489                    new_blob = u.arbitrary()?;
490                }
491                unmodified = unmodified && new_blob == blob;
492                vec![new_blob]
493            } else {
494                vec![blob.to_vec()]
495            };
496
497            let blobs = blobs
498                .into_iter()
499                .map(|data| ProofBlob {
500                    blob: writable_blob_account,
501                    data: Some(data),
502                })
503                .collect::<Vec<_>>();
504
505            if unmodified {
506                dbg!(&compound_inclusion_proof);
507                compound_inclusion_proof
508                    .verify(
509                        blober,
510                        // In real code this value wouldn't come from the proof itself,
511                        // instead it would be sourced from a third-party Solana node.
512                        bank_hash_proof.blockhash,
513                        &blobs,
514                    )
515                    .unwrap();
516                // It should also be possible to verify the proof without the blob data.
517                let empty_blobs: Vec<_> = blobs
518                    .into_iter()
519                    .map(|b| ProofBlob::empty(b.blob))
520                    .collect();
521                compound_inclusion_proof
522                    .verify(blober, bank_hash_proof.blockhash, &empty_blobs)
523                    .unwrap();
524                roundtrip_serialization(compound_inclusion_proof);
525            } else {
526                compound_inclusion_proof
527                    .verify(blober, bank_hash_proof.blockhash, &blobs)
528                    .unwrap_err();
529                roundtrip_serialization(compound_inclusion_proof);
530            }
531
532            Ok(())
533        })
534        .size_max(100_000_000);
535    }
536
537    fn roundtrip_serialization(proof: CompoundInclusionProof) {
538        let serialized_json = serde_json::to_string(&proof).unwrap();
539        let deserialized_json: CompoundInclusionProof =
540            serde_json::from_str(&serialized_json).unwrap();
541        assert_eq!(proof, deserialized_json);
542
543        let serialized_bincode = bincode::serialize(&proof).unwrap();
544        let deserialized_bincode: CompoundInclusionProof =
545            bincode::deserialize(&serialized_bincode).unwrap();
546        assert_eq!(proof, deserialized_bincode);
547
548        let serialized_risc0_zkvm = risc0_zkvm::serde::to_vec(&proof).unwrap();
549        let deserialized_risc0_zkvm: CompoundInclusionProof =
550            risc0_zkvm::serde::from_slice(&serialized_risc0_zkvm).unwrap();
551        assert_eq!(proof, deserialized_risc0_zkvm);
552    }
553}