Skip to main content

chio_web3/
anchors.rs

1use alloy_primitives::keccak256;
2use serde::{Deserialize, Deserializer, Serialize};
3
4pub use chio_core_types::oracle::OracleConversionEvidence;
5
6use crate::canonical::canonical_json_bytes;
7use crate::crypto::{Keypair, PublicKey, Signature, SigningAlgorithm};
8use crate::error::Web3ContractError;
9use crate::hashing::Hash;
10use crate::identity::{
11    validate_web3_identity_binding, verify_web3_identity_binding, SignedWeb3IdentityBinding,
12    Web3KeyBindingPurpose,
13};
14use crate::merkle::{leaf_hash, MerkleProof};
15use crate::receipt::body::ChioReceipt;
16use crate::validation::{ensure_non_empty, evm_addresses_match};
17
18pub const CHIO_CHECKPOINT_STATEMENT_SCHEMA_V1: &str = "chio.checkpoint_statement.v1";
19pub const CHIO_CHECKPOINT_STATEMENT_SCHEMA_V2: &str = "chio.checkpoint_statement.v2";
20pub const CHIO_CHECKPOINT_STATEMENT_SCHEMA: &str = CHIO_CHECKPOINT_STATEMENT_SCHEMA_V2;
21pub const CHIO_ANCHOR_INCLUSION_PROOF_SCHEMA_V1: &str = "chio.anchor-inclusion-proof.v1";
22pub const CHIO_ANCHOR_INCLUSION_PROOF_SCHEMA_V2: &str = "chio.anchor-inclusion-proof.v2";
23pub const CHIO_ANCHOR_INCLUSION_PROOF_SCHEMA: &str = CHIO_ANCHOR_INCLUSION_PROOF_SCHEMA_V2;
24pub const CHIO_ORACLE_CONVERSION_EVIDENCE_SCHEMA: &str = "chio.oracle-conversion-evidence.v1";
25pub const CHIO_LINK_ORACLE_AUTHORITY: &str = "chio_link_runtime_v1";
26pub const CHIO_ANCHOR_CONTROL_STATE_SCHEMA: &str = "chio.anchor.control-state.v1";
27pub const CHIO_ANCHOR_CONTROL_TRACE_SCHEMA: &str = "chio.anchor.control-trace.v1";
28
29fn deserialize_non_null_option<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
30where
31    D: Deserializer<'de>,
32    T: serde::de::DeserializeOwned,
33{
34    let value = serde_json::Value::deserialize(deserializer)?;
35    if value.is_null() {
36        return Err(<D::Error as serde::de::Error>::custom(
37            "explicit null is not permitted; omit the optional field",
38        ));
39    }
40    T::deserialize(value)
41        .map(Some)
42        .map_err(<D::Error as serde::de::Error>::custom)
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
46#[serde(deny_unknown_fields)]
47pub struct Web3ReceiptInclusion {
48    pub checkpoint_seq: u64,
49    pub merkle_root: Hash,
50    pub proof: MerkleProof,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
54#[serde(deny_unknown_fields)]
55pub struct Web3CheckpointStatement {
56    pub schema: String,
57    pub checkpoint_seq: u64,
58    pub batch_start_seq: u64,
59    pub batch_end_seq: u64,
60    pub tree_size: u64,
61    pub merkle_root: Hash,
62    pub issued_at: u64,
63    #[serde(
64        default,
65        skip_serializing_if = "Option::is_none",
66        deserialize_with = "deserialize_non_null_option"
67    )]
68    pub previous_checkpoint_sha256: Option<String>,
69    /// Checkpoint-chain commitment carried by the signed kernel body; must
70    /// round-trip losslessly or the reconstructed body fails signature
71    /// verification.
72    #[serde(
73        default,
74        skip_serializing_if = "Option::is_none",
75        deserialize_with = "deserialize_non_null_option"
76    )]
77    pub chain_root: Option<Hash>,
78    pub kernel_key: PublicKey,
79    pub signature: Signature,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
83#[serde(deny_unknown_fields)]
84pub struct Web3ChainAnchorRecord {
85    pub chain_id: String,
86    pub contract_address: String,
87    pub operator_address: String,
88    pub tx_hash: String,
89    pub block_number: u64,
90    pub block_hash: String,
91    pub operator_key_hash: String,
92    pub operator_epoch: u64,
93    pub anchored_merkle_root: Hash,
94    pub anchored_checkpoint_seq: u64,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
98#[serde(deny_unknown_fields)]
99pub struct Web3BitcoinAnchor {
100    pub method: String,
101    pub ots_proof_b64: String,
102    pub bitcoin_block_height: u64,
103    pub bitcoin_block_hash: String,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
107#[serde(deny_unknown_fields)]
108pub struct Web3SuperRootInclusion {
109    pub super_root: Hash,
110    pub proof: MerkleProof,
111    pub aggregated_checkpoint_start: u64,
112    pub aggregated_checkpoint_end: u64,
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize)]
116#[serde(deny_unknown_fields)]
117pub struct AnchorInclusionProof {
118    pub schema: String,
119    pub receipt: ChioReceipt,
120    pub receipt_inclusion: Web3ReceiptInclusion,
121    pub checkpoint_statement: Web3CheckpointStatement,
122    #[serde(
123        default,
124        skip_serializing_if = "Option::is_none",
125        deserialize_with = "deserialize_non_null_option"
126    )]
127    pub chain_anchor: Option<Web3ChainAnchorRecord>,
128    #[serde(
129        default,
130        skip_serializing_if = "Option::is_none",
131        deserialize_with = "deserialize_non_null_option"
132    )]
133    pub bitcoin_anchor: Option<Web3BitcoinAnchor>,
134    #[serde(
135        default,
136        skip_serializing_if = "Option::is_none",
137        deserialize_with = "deserialize_non_null_option"
138    )]
139    pub super_root_inclusion: Option<Web3SuperRootInclusion>,
140    pub key_binding_certificate: SignedWeb3IdentityBinding,
141}
142
143pub(crate) fn expected_operator_key_hash(
144    public_key: &PublicKey,
145) -> Result<Hash, Web3ContractError> {
146    if public_key.algorithm() != SigningAlgorithm::Ed25519 {
147        return Err(Web3ContractError::InvalidBinding(format!(
148            "operator key hash requires an Ed25519 public key, got {:?}",
149            public_key.algorithm()
150        )));
151    }
152    Ok(Hash::from_bytes(*keccak256(public_key.as_bytes()).as_ref()))
153}
154
155pub fn validate_oracle_conversion_evidence(
156    evidence: &OracleConversionEvidence,
157) -> Result<(), Web3ContractError> {
158    if evidence.schema != CHIO_ORACLE_CONVERSION_EVIDENCE_SCHEMA {
159        return Err(Web3ContractError::UnsupportedSchema(
160            evidence.schema.clone(),
161        ));
162    }
163    for field in [
164        &evidence.base,
165        &evidence.quote,
166        &evidence.authority,
167        &evidence.source,
168        &evidence.feed_address,
169        &evidence.original_currency,
170        &evidence.grant_currency,
171    ] {
172        ensure_non_empty(field, "oracle_conversion_evidence.field")?;
173    }
174    if evidence.authority != CHIO_LINK_ORACLE_AUTHORITY {
175        return Err(Web3ContractError::InvalidProof(format!(
176            "oracle conversion evidence authority {} is unsupported",
177            evidence.authority
178        )));
179    }
180    if evidence.rate_denominator == 0 {
181        return Err(Web3ContractError::InvalidProof(
182            "oracle conversion evidence rate_denominator must be non-zero".to_string(),
183        ));
184    }
185    if evidence.max_age_seconds == 0 {
186        return Err(Web3ContractError::InvalidProof(
187            "oracle conversion evidence max_age_seconds must be non-zero".to_string(),
188        ));
189    }
190    if evidence.cache_age_seconds > evidence.max_age_seconds {
191        return Err(Web3ContractError::InvalidProof(
192            "oracle conversion evidence is stale".to_string(),
193        ));
194    }
195    if evidence.original_cost_units == 0 || evidence.converted_cost_units == 0 {
196        return Err(Web3ContractError::InvalidProof(
197            "oracle conversion evidence cost units must be non-zero".to_string(),
198        ));
199    }
200    if evidence.base != evidence.original_currency {
201        return Err(Web3ContractError::InvalidProof(
202            "oracle conversion evidence base must match original_currency".to_string(),
203        ));
204    }
205    if evidence.quote != evidence.grant_currency {
206        return Err(Web3ContractError::InvalidProof(
207            "oracle conversion evidence quote must match grant_currency".to_string(),
208        ));
209    }
210    let expected_converted_cost_units = expected_oracle_converted_cost_units(evidence)?;
211    if evidence.converted_cost_units != expected_converted_cost_units {
212        return Err(Web3ContractError::InvalidProof(format!(
213            "oracle conversion evidence converted_cost_units must equal {expected_converted_cost_units}"
214        )));
215    }
216    Ok(())
217}
218
219pub fn sign_oracle_conversion_evidence(
220    evidence: &mut OracleConversionEvidence,
221    keypair: &Keypair,
222) -> Result<(), Web3ContractError> {
223    evidence.oracle_public_key = Some(keypair.public_key());
224    evidence.signature = None;
225    let body = oracle_conversion_evidence_signature_body(evidence);
226    let (signature, _) = keypair.sign_canonical(&body).map_err(|error| {
227        Web3ContractError::InvalidProof(format!(
228            "oracle conversion evidence signing failed: {error}"
229        ))
230    })?;
231    evidence.signature = Some(signature);
232    Ok(())
233}
234
235pub fn verify_oracle_conversion_evidence_signature(
236    evidence: &OracleConversionEvidence,
237    trusted_oracle_keys: &[PublicKey],
238) -> Result<(), Web3ContractError> {
239    if trusted_oracle_keys.is_empty() {
240        return Err(Web3ContractError::InvalidProof(
241            "trusted public settlement oracle keys missing".to_string(),
242        ));
243    }
244    let oracle_public_key = evidence.oracle_public_key.as_ref().ok_or_else(|| {
245        Web3ContractError::InvalidProof("oracle conversion evidence signer key missing".to_string())
246    })?;
247    if !trusted_oracle_keys
248        .iter()
249        .any(|trusted_key| trusted_key == oracle_public_key)
250    {
251        return Err(Web3ContractError::InvalidProof(
252            "oracle conversion evidence signer key is not trusted".to_string(),
253        ));
254    }
255    let signature = evidence.signature.as_ref().ok_or_else(|| {
256        Web3ContractError::InvalidProof("oracle conversion evidence signature missing".to_string())
257    })?;
258    let body = oracle_conversion_evidence_signature_body(evidence);
259    let signature_valid = oracle_public_key
260        .verify_canonical(&body, signature)
261        .map_err(|error| {
262            Web3ContractError::InvalidProof(format!(
263                "oracle conversion evidence signature verification failed: {error}"
264            ))
265        })?;
266    if signature_valid {
267        Ok(())
268    } else {
269        Err(Web3ContractError::InvalidProof(
270            "oracle conversion evidence signature verification failed".to_string(),
271        ))
272    }
273}
274
275fn oracle_conversion_evidence_signature_body(
276    evidence: &OracleConversionEvidence,
277) -> OracleConversionEvidence {
278    let mut body = evidence.clone();
279    body.signature = None;
280    body
281}
282
283fn expected_oracle_converted_cost_units(
284    evidence: &OracleConversionEvidence,
285) -> Result<u64, Web3ContractError> {
286    let base_minor_units = oracle_minor_units_per_unit(&evidence.original_currency)?;
287    let quote_minor_units = oracle_minor_units_per_unit(&evidence.grant_currency)?;
288    let numerator = u128::from(evidence.original_cost_units)
289        .checked_mul(u128::from(evidence.rate_numerator))
290        .and_then(|value| value.checked_mul(quote_minor_units))
291        .ok_or_else(|| {
292            Web3ContractError::InvalidProof(
293                "oracle conversion evidence numerator overflowed".to_string(),
294            )
295        })?;
296    let denominator = base_minor_units
297        .checked_mul(u128::from(evidence.rate_denominator))
298        .ok_or_else(|| {
299            Web3ContractError::InvalidProof(
300                "oracle conversion evidence denominator overflowed".to_string(),
301            )
302        })?;
303    let converted = numerator.div_ceil(denominator);
304    u64::try_from(converted).map_err(|_| {
305        Web3ContractError::InvalidProof(
306            "oracle conversion evidence converted_cost_units overflowed".to_string(),
307        )
308    })
309}
310
311fn oracle_minor_units_per_unit(currency: &str) -> Result<u128, Web3ContractError> {
312    match currency {
313        "USD" | "EUR" | "GBP" => Ok(100),
314        "JPY" => Ok(1),
315        "USDC" | "USDT" => Ok(1_000_000),
316        "BTC" => Ok(100_000_000),
317        "ETH" | "LINK" => Ok(1_000_000_000_000_000_000),
318        other => Err(Web3ContractError::InvalidProof(format!(
319            "oracle conversion evidence currency {other} is unsupported"
320        ))),
321    }
322}
323pub fn validate_anchor_inclusion_proof(
324    proof: &AnchorInclusionProof,
325) -> Result<(), Web3ContractError> {
326    match proof.schema.as_str() {
327        CHIO_ANCHOR_INCLUSION_PROOF_SCHEMA_V1 => {
328            if proof.checkpoint_statement.schema != CHIO_CHECKPOINT_STATEMENT_SCHEMA_V1 {
329                return Err(Web3ContractError::InvalidProof(
330                    "v1 anchor inclusion proofs must embed a v1 checkpoint statement".to_string(),
331                ));
332            }
333        }
334        CHIO_ANCHOR_INCLUSION_PROOF_SCHEMA_V2 => {
335            if proof.checkpoint_statement.schema != CHIO_CHECKPOINT_STATEMENT_SCHEMA_V2 {
336                return Err(Web3ContractError::InvalidProof(
337                    "v2 anchor inclusion proofs must embed a v2 checkpoint statement".to_string(),
338                ));
339            }
340        }
341        _ => return Err(Web3ContractError::UnsupportedSchema(proof.schema.clone())),
342    }
343    validate_web3_identity_binding(&proof.key_binding_certificate)?;
344    if proof.receipt.id.trim().is_empty() {
345        return Err(Web3ContractError::MissingField(
346            "anchor_inclusion.receipt.id",
347        ));
348    }
349    if proof.receipt_inclusion.proof.tree_size == 0 {
350        return Err(Web3ContractError::InvalidProof(
351            "anchor inclusion proof tree_size must be non-zero".to_string(),
352        ));
353    }
354    if proof.receipt_inclusion.proof.leaf_index >= proof.receipt_inclusion.proof.tree_size {
355        return Err(Web3ContractError::InvalidProof(
356            "anchor inclusion proof leaf_index exceeds tree_size".to_string(),
357        ));
358    }
359    if proof.receipt_inclusion.checkpoint_seq != proof.checkpoint_statement.checkpoint_seq {
360        return Err(Web3ContractError::InvalidProof(
361            "receipt inclusion checkpoint_seq must match checkpoint statement".to_string(),
362        ));
363    }
364    if proof.receipt_inclusion.merkle_root != proof.checkpoint_statement.merkle_root {
365        return Err(Web3ContractError::InvalidProof(
366            "receipt inclusion merkle_root must match checkpoint statement".to_string(),
367        ));
368    }
369    validate_checkpoint_statement_shape(&proof.checkpoint_statement)?;
370    let checkpoint_tree_size =
371        usize::try_from(proof.checkpoint_statement.tree_size).map_err(|_| {
372            Web3ContractError::InvalidProof(
373                "checkpoint statement tree_size cannot be represented by this platform's Merkle proof index"
374                    .to_string(),
375            )
376        })?;
377    if checkpoint_tree_size != proof.receipt_inclusion.proof.tree_size {
378        return Err(Web3ContractError::InvalidProof(
379            "checkpoint statement tree_size must match receipt inclusion proof".to_string(),
380        ));
381    }
382    if let Some(chain_anchor) = proof.chain_anchor.as_ref() {
383        ensure_non_empty(
384            &chain_anchor.chain_id,
385            "anchor_inclusion.chain_anchor.chain_id",
386        )?;
387        ensure_non_empty(
388            &chain_anchor.contract_address,
389            "anchor_inclusion.chain_anchor.contract_address",
390        )?;
391        ensure_non_empty(
392            &chain_anchor.operator_address,
393            "anchor_inclusion.chain_anchor.operator_address",
394        )?;
395        ensure_non_empty(
396            &chain_anchor.tx_hash,
397            "anchor_inclusion.chain_anchor.tx_hash",
398        )?;
399        ensure_non_empty(
400            &chain_anchor.block_hash,
401            "anchor_inclusion.chain_anchor.block_hash",
402        )?;
403        let operator_key_hash =
404            Hash::from_hex(&chain_anchor.operator_key_hash).map_err(|error| {
405                Web3ContractError::InvalidBinding(format!(
406                    "chain anchor operator_key_hash must be a 32-byte hex hash: {error}"
407                ))
408            })?;
409        if operator_key_hash == Hash::zero() {
410            return Err(Web3ContractError::InvalidBinding(
411                "chain anchor operator_key_hash must not be zero".to_string(),
412            ));
413        }
414        if chain_anchor.operator_epoch == 0 {
415            return Err(Web3ContractError::InvalidBinding(
416                "chain anchor operator_epoch must be non-zero".to_string(),
417            ));
418        }
419        let expected_operator_key =
420            expected_operator_key_hash(&proof.key_binding_certificate.certificate.chio_public_key)?;
421        if operator_key_hash != expected_operator_key {
422            return Err(Web3ContractError::InvalidBinding(
423                "chain anchor operator_key_hash must match binding certificate public key"
424                    .to_string(),
425            ));
426        }
427        if chain_anchor.anchored_checkpoint_seq != proof.checkpoint_statement.checkpoint_seq {
428            return Err(Web3ContractError::InvalidProof(
429                "chain anchor checkpoint seq must match checkpoint statement".to_string(),
430            ));
431        }
432        if chain_anchor.anchored_merkle_root != proof.checkpoint_statement.merkle_root {
433            return Err(Web3ContractError::InvalidProof(
434                "chain anchor root must match checkpoint statement".to_string(),
435            ));
436        }
437        if !evm_addresses_match(
438            &chain_anchor.operator_address,
439            &proof.key_binding_certificate.certificate.settlement_address,
440        )? {
441            return Err(Web3ContractError::InvalidBinding(
442                "chain anchor operator address must match settlement binding".to_string(),
443            ));
444        }
445        if !proof
446            .key_binding_certificate
447            .certificate
448            .purpose
449            .contains(&Web3KeyBindingPurpose::Anchor)
450        {
451            return Err(Web3ContractError::InvalidBinding(
452                "anchor proof requires a binding certificate scoped to anchor".to_string(),
453            ));
454        }
455        if !proof
456            .key_binding_certificate
457            .certificate
458            .chain_scope
459            .iter()
460            .any(|chain_id| chain_id == &chain_anchor.chain_id)
461        {
462            return Err(Web3ContractError::InvalidBinding(format!(
463                "binding certificate does not cover anchor chain {}",
464                chain_anchor.chain_id
465            )));
466        }
467    }
468    if let Some(bitcoin_anchor) = proof.bitcoin_anchor.as_ref() {
469        ensure_non_empty(
470            &bitcoin_anchor.method,
471            "anchor_inclusion.bitcoin_anchor.method",
472        )?;
473        ensure_non_empty(
474            &bitcoin_anchor.ots_proof_b64,
475            "anchor_inclusion.bitcoin_anchor.ots_proof_b64",
476        )?;
477        ensure_non_empty(
478            &bitcoin_anchor.bitcoin_block_hash,
479            "anchor_inclusion.bitcoin_anchor.bitcoin_block_hash",
480        )?;
481        if proof.super_root_inclusion.is_none() {
482            return Err(Web3ContractError::InvalidProof(
483                "Bitcoin anchor requires super-root inclusion metadata".to_string(),
484            ));
485        }
486    }
487    if let Some(super_root) = proof.super_root_inclusion.as_ref() {
488        if super_root.aggregated_checkpoint_start > super_root.aggregated_checkpoint_end {
489            return Err(Web3ContractError::InvalidProof(
490                "super-root inclusion checkpoint range must be ordered".to_string(),
491            ));
492        }
493        if proof.checkpoint_statement.checkpoint_seq < super_root.aggregated_checkpoint_start
494            || proof.checkpoint_statement.checkpoint_seq > super_root.aggregated_checkpoint_end
495        {
496            return Err(Web3ContractError::InvalidProof(
497                "checkpoint statement must fall within the super-root aggregation range"
498                    .to_string(),
499            ));
500        }
501        if super_root.proof.tree_size == 0 {
502            return Err(Web3ContractError::InvalidProof(
503                "super-root inclusion tree_size must be non-zero".to_string(),
504            ));
505        }
506    }
507    Ok(())
508}
509
510pub fn verify_checkpoint_statement(
511    statement: &Web3CheckpointStatement,
512) -> Result<(), Web3ContractError> {
513    validate_checkpoint_statement_shape(statement)?;
514    let body = checkpoint_statement_body(statement);
515    let verified = statement
516        .kernel_key
517        .verify_canonical(&body, &statement.signature)
518        .map_err(|error| Web3ContractError::InvalidProof(error.to_string()))?;
519    if !verified {
520        return Err(Web3ContractError::InvalidProof(
521            "checkpoint statement signature verification failed".to_string(),
522        ));
523    }
524    Ok(())
525}
526
527#[derive(Serialize)]
528struct Web3CheckpointChainLeaf {
529    checkpoint_seq: u64,
530    batch_start_seq: u64,
531    batch_end_seq: u64,
532    merkle_root: Hash,
533}
534
535fn checkpoint_chain_leaf_hash(
536    statement: &Web3CheckpointStatement,
537) -> Result<Hash, Web3ContractError> {
538    let leaf = Web3CheckpointChainLeaf {
539        checkpoint_seq: statement.checkpoint_seq,
540        batch_start_seq: statement.batch_start_seq,
541        batch_end_seq: statement.batch_end_seq,
542        merkle_root: statement.merkle_root,
543    };
544    let bytes = canonical_json_bytes(&leaf)
545        .map_err(|error| Web3ContractError::InvalidProof(error.to_string()))?;
546    Ok(leaf_hash(&bytes))
547}
548
549pub(crate) fn validate_checkpoint_statement_shape(
550    statement: &Web3CheckpointStatement,
551) -> Result<(), Web3ContractError> {
552    if !matches!(
553        statement.schema.as_str(),
554        CHIO_CHECKPOINT_STATEMENT_SCHEMA_V1 | CHIO_CHECKPOINT_STATEMENT_SCHEMA_V2
555    ) {
556        return Err(Web3ContractError::UnsupportedSchema(
557            statement.schema.clone(),
558        ));
559    }
560    if statement.schema == CHIO_CHECKPOINT_STATEMENT_SCHEMA_V1 && statement.chain_root.is_some() {
561        return Err(Web3ContractError::InvalidProof(
562            "v1 checkpoint statements cannot carry chain_root".to_string(),
563        ));
564    }
565    if statement.checkpoint_seq == 0 {
566        return Err(Web3ContractError::InvalidProof(
567            "checkpoint statement checkpoint_seq must be greater than zero".to_string(),
568        ));
569    }
570    if statement.batch_start_seq == 0 {
571        return Err(Web3ContractError::InvalidProof(
572            "checkpoint statement batch_start_seq must be greater than zero".to_string(),
573        ));
574    }
575    if statement.issued_at == 0 {
576        return Err(Web3ContractError::InvalidProof(
577            "checkpoint statement issued_at must be greater than zero".to_string(),
578        ));
579    }
580    if statement
581        .previous_checkpoint_sha256
582        .as_deref()
583        .is_some_and(|digest| !is_lowercase_sha256(digest))
584    {
585        return Err(Web3ContractError::InvalidProof(
586            "checkpoint statement previous_checkpoint_sha256 must be 64 lowercase hex characters"
587                .to_string(),
588        ));
589    }
590    if statement.batch_start_seq > statement.batch_end_seq {
591        return Err(Web3ContractError::InvalidProof(
592            "checkpoint statement batch_start_seq must be <= batch_end_seq".to_string(),
593        ));
594    }
595    if statement.tree_size == 0 {
596        return Err(Web3ContractError::InvalidProof(
597            "checkpoint statement tree_size must be non-zero".to_string(),
598        ));
599    }
600    if statement.schema == CHIO_CHECKPOINT_STATEMENT_SCHEMA_V2 {
601        let covered_entries = statement
602            .batch_end_seq
603            .checked_sub(statement.batch_start_seq)
604            .and_then(|count| count.checked_add(1))
605            .ok_or_else(|| {
606                Web3ContractError::InvalidProof(format!(
607                    "invalid checkpoint statement entry range {}-{}",
608                    statement.batch_start_seq, statement.batch_end_seq
609                ))
610            })?;
611        if statement.tree_size != covered_entries {
612            return Err(Web3ContractError::InvalidProof(format!(
613                "checkpoint statement tree_size {} does not match covered entry count {covered_entries}",
614                statement.tree_size
615            )));
616        }
617    }
618    if statement.schema == CHIO_CHECKPOINT_STATEMENT_SCHEMA_V2 && statement.checkpoint_seq == 1 {
619        let Some(chain_root) = statement.chain_root else {
620            return Err(Web3ContractError::InvalidProof(
621                "v2 checkpoint 1 must carry chain_root".to_string(),
622            ));
623        };
624        let expected = checkpoint_chain_leaf_hash(statement)?;
625        if chain_root != expected {
626            return Err(Web3ContractError::InvalidProof(
627                "chain_root of the first checkpoint does not commit its own chain leaf".to_string(),
628            ));
629        }
630    }
631    Ok(())
632}
633
634fn is_lowercase_sha256(value: &str) -> bool {
635    value.len() == 64
636        && value
637            .bytes()
638            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
639}
640
641pub fn verify_anchor_inclusion_proof(
642    proof: &AnchorInclusionProof,
643) -> Result<(), Web3ContractError> {
644    validate_anchor_inclusion_proof(proof)?;
645    let receipt_verified = proof
646        .receipt
647        .verify_signature()
648        .map_err(|error| Web3ContractError::InvalidProof(error.to_string()))?;
649    if !receipt_verified {
650        return Err(Web3ContractError::InvalidProof(
651            "receipt signature verification failed".to_string(),
652        ));
653    }
654    verify_web3_identity_binding(&proof.key_binding_certificate)?;
655    verify_checkpoint_statement(&proof.checkpoint_statement)?;
656    if proof.key_binding_certificate.certificate.chio_public_key != proof.receipt.kernel_key {
657        return Err(Web3ContractError::InvalidBinding(
658            "binding certificate public key must match receipt kernel_key".to_string(),
659        ));
660    }
661    if proof.checkpoint_statement.kernel_key != proof.receipt.kernel_key {
662        return Err(Web3ContractError::InvalidProof(
663            "checkpoint statement kernel_key must match receipt kernel_key".to_string(),
664        ));
665    }
666
667    let receipt_body = proof.receipt.body();
668    let receipt_bytes = canonical_json_bytes(&receipt_body)
669        .map_err(|error| Web3ContractError::InvalidProof(error.to_string()))?;
670    let receipt_leaf = leaf_hash(&receipt_bytes);
671    if !proof
672        .receipt_inclusion
673        .proof
674        .verify_hash(receipt_leaf, &proof.receipt_inclusion.merkle_root)
675    {
676        return Err(Web3ContractError::InvalidProof(
677            "receipt inclusion Merkle proof verification failed".to_string(),
678        ));
679    }
680    if let Some(super_root) = proof.super_root_inclusion.as_ref() {
681        if !super_root
682            .proof
683            .verify_hash(proof.receipt_inclusion.merkle_root, &super_root.super_root)
684        {
685            return Err(Web3ContractError::InvalidProof(
686                "super-root inclusion verification failed".to_string(),
687            ));
688        }
689    }
690    Ok(())
691}
692#[derive(Debug, Clone, Serialize)]
693pub(crate) struct Web3CheckpointStatementBody {
694    schema: String,
695    checkpoint_seq: u64,
696    batch_start_seq: u64,
697    batch_end_seq: u64,
698    tree_size: u64,
699    merkle_root: Hash,
700    issued_at: u64,
701    #[serde(default, skip_serializing_if = "Option::is_none")]
702    previous_checkpoint_sha256: Option<String>,
703    kernel_key: PublicKey,
704    // Must mirror every signed field of the kernel checkpoint body: this
705    // struct reconstructs the canonical bytes the kernel signed, so an omitted
706    // field makes the signature unverifiable rather than merely unbound.
707    #[serde(default, skip_serializing_if = "Option::is_none")]
708    chain_root: Option<Hash>,
709}
710
711pub(crate) fn checkpoint_statement_body(
712    statement: &Web3CheckpointStatement,
713) -> Web3CheckpointStatementBody {
714    Web3CheckpointStatementBody {
715        schema: statement.schema.clone(),
716        checkpoint_seq: statement.checkpoint_seq,
717        batch_start_seq: statement.batch_start_seq,
718        batch_end_seq: statement.batch_end_seq,
719        tree_size: statement.tree_size,
720        merkle_root: statement.merkle_root,
721        issued_at: statement.issued_at,
722        previous_checkpoint_sha256: statement.previous_checkpoint_sha256.clone(),
723        kernel_key: statement.kernel_key.clone(),
724        chain_root: statement.chain_root,
725    }
726}