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 #[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 #[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}