1use serde::{Deserialize, Serialize};
2
3use crate::anchors::{
4 expected_operator_key_hash, validate_oracle_conversion_evidence, verify_anchor_inclusion_proof,
5 AnchorInclusionProof, OracleConversionEvidence,
6};
7use crate::canonical::canonical_json_bytes;
8use crate::capability::scope::MonetaryAmount;
9use crate::credit::{
10 CapitalExecutionInstructionAction, CapitalExecutionRailKind, CapitalExecutionReconciledState,
11 CreditBondLifecycleState, SignedCapitalExecutionInstruction, SignedCreditBond,
12 CAPITAL_EXECUTION_INSTRUCTION_ARTIFACT_SCHEMA,
13};
14use crate::crypto::sha256_hex;
15use crate::error::Web3ContractError;
16use crate::hashing::Hash;
17use crate::receipt::{
18 body::ChioReceipt, lineage::SignedExportEnvelope,
19 signing::CHIO_RECEIPT_SIGNING_NONCE_METADATA_KEY,
20};
21use crate::trust_profile::Web3SettlementPath;
22use crate::validation::{ensure_evm_address, ensure_money, ensure_non_empty, evm_addresses_match};
23
24pub const CHIO_WEB3_SETTLEMENT_DISPATCH_V1_SCHEMA: &str = "chio.web3-settlement-dispatch.v1";
25pub const CHIO_WEB3_SETTLEMENT_DISPATCH_V2_SCHEMA: &str = "chio.web3-settlement-dispatch.v2";
26pub const CHIO_WEB3_SETTLEMENT_DISPATCH_SCHEMA: &str = CHIO_WEB3_SETTLEMENT_DISPATCH_V2_SCHEMA;
27pub const CHIO_WEB3_SETTLEMENT_RECEIPT_V1_SCHEMA: &str =
28 "chio.web3-settlement-execution-receipt.v1";
29pub const CHIO_WEB3_SETTLEMENT_RECEIPT_V2_SCHEMA: &str =
30 "chio.web3-settlement-execution-receipt.v2";
31pub const CHIO_WEB3_SETTLEMENT_RECEIPT_SCHEMA: &str = CHIO_WEB3_SETTLEMENT_RECEIPT_V2_SCHEMA;
32pub const CHIO_LINK_CONTROL_STATE_SCHEMA: &str = "chio.link.control-state.v1";
33pub const CHIO_LINK_CONTROL_TRACE_SCHEMA: &str = "chio.link.control-trace.v1";
34pub const CHIO_SETTLE_CONTROL_STATE_SCHEMA: &str = "chio.settle.control-state.v1";
35pub const CHIO_SETTLE_CONTROL_TRACE_SCHEMA: &str = "chio.settle.control-trace.v1";
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
38#[serde(rename_all = "snake_case")]
39pub enum Web3SettlementLifecycleState {
40 PendingDispatch,
41 EscrowLocked,
42 PartiallySettled,
43 Settled,
44 Reversed,
45 ChargedBack,
46 TimedOut,
47 Failed,
48 Reorged,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
52#[serde(deny_unknown_fields)]
53pub struct Web3SettlementSupportBoundary {
54 pub real_dispatch_supported: bool,
55 pub anchor_proof_required: bool,
56 pub oracle_evidence_required_for_fx: bool,
57 pub custody_boundary_explicit: bool,
58 pub reversal_supported: bool,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
62#[serde(deny_unknown_fields)]
63pub struct Web3SettlementDispatchArtifact {
64 pub schema: String,
65 pub dispatch_id: String,
66 pub issued_at: u64,
67 pub trust_profile_id: String,
68 pub contract_package_id: String,
69 pub chain_id: String,
70 pub capital_instruction: SignedCapitalExecutionInstruction,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub bond: Option<SignedCreditBond>,
73 pub settlement_path: Web3SettlementPath,
74 pub settlement_amount: MonetaryAmount,
75 pub escrow_id: String,
76 pub escrow_contract: String,
77 pub bond_vault_contract: String,
78 #[serde(default, skip_serializing_if = "String::is_empty")]
79 pub settlement_token_address: String,
80 pub beneficiary_address: String,
81 #[serde(default, skip_serializing_if = "String::is_empty")]
82 pub operator_key_hash: String,
83 pub support_boundary: Web3SettlementSupportBoundary,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub note: Option<String>,
86}
87
88pub type SignedWeb3SettlementDispatch = SignedExportEnvelope<Web3SettlementDispatchArtifact>;
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(deny_unknown_fields)]
92pub struct Web3SettlementIdentityRegistryEvidence {
93 pub chain_id: String,
94 pub identity_registry_contract: String,
95 pub operator_address: String,
96 pub block_number: u64,
97 pub block_hash: String,
98 pub observed_at: u64,
99 pub operator_key_hash: String,
100 pub settlement_key: String,
101 pub registered_at: u64,
102 pub operator_epoch: u64,
103 pub active: bool,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(deny_unknown_fields)]
108pub struct Web3SettlementIdentityRegistryEvidenceBinding {
109 pub identity_registry_contract: String,
110 pub operator_address: String,
111 pub settlement_key: String,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
115#[serde(deny_unknown_fields)]
116pub struct Web3SettlementExecutionReceiptArtifact {
117 pub schema: String,
118 pub execution_receipt_id: String,
119 pub issued_at: u64,
120 pub dispatch: Web3SettlementDispatchArtifact,
121 pub observed_execution: crate::credit::CapitalExecutionObservation,
122 pub lifecycle_state: Web3SettlementLifecycleState,
123 pub settlement_reference: String,
124 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub reconciled_anchor_proof: Option<AnchorInclusionProof>,
126 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub identity_registry_evidence: Option<Web3SettlementIdentityRegistryEvidence>,
128 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub identity_registry_evidence_binding: Option<Web3SettlementIdentityRegistryEvidenceBinding>,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub oracle_evidence: Option<OracleConversionEvidence>,
132 pub settled_amount: MonetaryAmount,
133 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub reversal_of: Option<String>,
135 #[serde(default, skip_serializing_if = "Option::is_none")]
136 pub failure_reason: Option<String>,
137 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub note: Option<String>,
139}
140
141pub type SignedWeb3SettlementExecutionReceipt =
142 SignedExportEnvelope<Web3SettlementExecutionReceiptArtifact>;
143
144pub fn validate_web3_settlement_dispatch(
145 dispatch: &Web3SettlementDispatchArtifact,
146) -> Result<(), Web3ContractError> {
147 let dispatch_v2 = match dispatch.schema.as_str() {
148 CHIO_WEB3_SETTLEMENT_DISPATCH_V2_SCHEMA => true,
149 CHIO_WEB3_SETTLEMENT_DISPATCH_V1_SCHEMA => false,
150 _ => {
151 return Err(Web3ContractError::UnsupportedSchema(
152 dispatch.schema.clone(),
153 ))
154 }
155 };
156 for field in [
157 &dispatch.dispatch_id,
158 &dispatch.trust_profile_id,
159 &dispatch.contract_package_id,
160 &dispatch.chain_id,
161 &dispatch.escrow_id,
162 &dispatch.escrow_contract,
163 &dispatch.bond_vault_contract,
164 &dispatch.beneficiary_address,
165 ] {
166 ensure_non_empty(field, "web3_settlement_dispatch.field")?;
167 }
168 if dispatch_v2 {
169 ensure_non_empty(
170 &dispatch.settlement_token_address,
171 "web3_settlement_dispatch.settlement_token_address",
172 )?;
173 ensure_evm_address(
174 &dispatch.settlement_token_address,
175 "web3_settlement_dispatch.settlement_token_address",
176 )?;
177 ensure_non_empty(
178 &dispatch.operator_key_hash,
179 "web3_settlement_dispatch.operator_key_hash",
180 )?;
181 }
182 if !dispatch.operator_key_hash.is_empty() {
183 let operator_key_hash = Hash::from_hex(&dispatch.operator_key_hash).map_err(|error| {
184 Web3ContractError::invalid_settlement(format!(
185 "web3 settlement dispatch operator_key_hash must be a 32-byte hex hash: {error}"
186 ))
187 })?;
188 if dispatch_v2 && operator_key_hash == Hash::zero() {
189 return Err(Web3ContractError::invalid_settlement(
190 "web3 settlement dispatch operator_key_hash must not be zero",
191 ));
192 }
193 }
194 ensure_money(
195 &dispatch.settlement_amount,
196 "web3_settlement_dispatch.settlement_amount",
197 )?;
198 if !dispatch.support_boundary.real_dispatch_supported {
199 return Err(Web3ContractError::invalid_settlement(
200 "web3 settlement dispatch must explicitly mark real dispatch as supported",
201 ));
202 }
203 if !dispatch.support_boundary.custody_boundary_explicit {
204 return Err(Web3ContractError::invalid_settlement(
205 "web3 settlement dispatch must keep custody boundaries explicit",
206 ));
207 }
208 if dispatch.settlement_path == Web3SettlementPath::MerkleProof
209 && !dispatch.support_boundary.anchor_proof_required
210 {
211 return Err(Web3ContractError::invalid_settlement(
212 "Merkle-proof settlement dispatch must require anchor proof reconciliation",
213 ));
214 }
215 if dispatch.capital_instruction.body.schema != CAPITAL_EXECUTION_INSTRUCTION_ARTIFACT_SCHEMA {
216 return Err(Web3ContractError::UnsupportedSchema(
217 dispatch.capital_instruction.body.schema.clone(),
218 ));
219 }
220 let signature_valid = dispatch
221 .capital_instruction
222 .verify_signature()
223 .map_err(|error| {
224 Web3ContractError::invalid_settlement(format!(
225 "capital instruction signature verification failed: {error}"
226 ))
227 })?;
228 if !signature_valid {
229 return Err(Web3ContractError::invalid_settlement(
230 "capital instruction signature verification failed",
231 ));
232 }
233 if dispatch.capital_instruction.body.action
234 == CapitalExecutionInstructionAction::CancelInstruction
235 {
236 return Err(Web3ContractError::invalid_settlement(
237 "web3 settlement dispatch cannot use cancel_instruction as the primary action",
238 ));
239 }
240 if dispatch.capital_instruction.body.rail.kind != CapitalExecutionRailKind::Web3 {
241 return Err(Web3ContractError::invalid_settlement(
242 "web3 settlement dispatch requires capital_instruction rail.kind = web3",
243 ));
244 }
245 let Some(destination_account_ref) = dispatch
246 .capital_instruction
247 .body
248 .rail
249 .destination_account_ref
250 .as_deref()
251 else {
252 return Err(Web3ContractError::MissingField(
253 "web3_settlement_dispatch.capital_instruction.rail.destination_account_ref",
254 ));
255 };
256 ensure_non_empty(
257 destination_account_ref,
258 "web3_settlement_dispatch.capital_instruction.rail.destination_account_ref",
259 )?;
260 if !evm_addresses_match(destination_account_ref, &dispatch.beneficiary_address)? {
261 return Err(Web3ContractError::invalid_settlement(
262 "web3 settlement dispatch beneficiary_address must match capital_instruction rail.destination_account_ref",
263 ));
264 }
265 let Some(amount) = dispatch.capital_instruction.body.amount.as_ref() else {
266 return Err(Web3ContractError::MissingField(
267 "web3_settlement_dispatch.capital_instruction.amount",
268 ));
269 };
270 if amount != &dispatch.settlement_amount {
271 return Err(Web3ContractError::invalid_settlement(
272 "web3 settlement dispatch settlement_amount must match capital_instruction amount",
273 ));
274 }
275 if dispatch.capital_instruction.body.reconciled_state
276 != CapitalExecutionReconciledState::NotObserved
277 {
278 return Err(Web3ContractError::invalid_settlement(
279 "web3 settlement dispatch capital_instruction must remain unreconciled until execution receipt",
280 ));
281 }
282 validate_transfer_completion_flow_binding(&dispatch.capital_instruction.body)?;
283 dispatch
284 .capital_instruction
285 .body
286 .validate()
287 .map_err(|error| {
288 Web3ContractError::invalid_settlement(format!(
289 "capital instruction validation failed: {error}"
290 ))
291 })?;
292 if let Some(bond) = dispatch.bond.as_ref() {
293 let signature_valid = bond.verify_signature().map_err(|error| {
294 Web3ContractError::invalid_settlement(format!(
295 "credit bond signature verification failed: {error}"
296 ))
297 })?;
298 if !signature_valid {
299 return Err(Web3ContractError::invalid_settlement(
300 "credit bond signature verification failed",
301 ));
302 }
303 if bond.body.lifecycle_state != CreditBondLifecycleState::Active {
304 return Err(Web3ContractError::invalid_settlement(
305 "web3 settlement dispatch requires an active bond when bond backing is present",
306 ));
307 }
308 }
309 Ok(())
310}
311
312pub fn validate_web3_settlement_execution_receipt(
313 receipt: &Web3SettlementExecutionReceiptArtifact,
314) -> Result<(), Web3ContractError> {
315 let receipt_v2 = match receipt.schema.as_str() {
316 CHIO_WEB3_SETTLEMENT_RECEIPT_V2_SCHEMA => true,
317 CHIO_WEB3_SETTLEMENT_RECEIPT_V1_SCHEMA => false,
318 _ => return Err(Web3ContractError::UnsupportedSchema(receipt.schema.clone())),
319 };
320 let dispatch_v2 = match receipt.dispatch.schema.as_str() {
321 CHIO_WEB3_SETTLEMENT_DISPATCH_V2_SCHEMA => true,
322 CHIO_WEB3_SETTLEMENT_DISPATCH_V1_SCHEMA => false,
323 _ => {
324 return Err(Web3ContractError::UnsupportedSchema(
325 receipt.dispatch.schema.clone(),
326 ))
327 }
328 };
329 if receipt_v2 != dispatch_v2 {
330 return Err(Web3ContractError::invalid_settlement(
331 "web3 settlement receipt schema must match dispatch schema generation",
332 ));
333 }
334 ensure_non_empty(
335 &receipt.execution_receipt_id,
336 "web3_settlement_receipt.execution_receipt_id",
337 )?;
338 ensure_non_empty(
339 &receipt.settlement_reference,
340 "web3_settlement_receipt.settlement_reference",
341 )?;
342 validate_web3_settlement_dispatch(&receipt.dispatch)?;
343 let escrow_locked = receipt.lifecycle_state == Web3SettlementLifecycleState::EscrowLocked;
344 ensure_receipt_money(
345 &receipt.observed_execution.amount,
346 "web3_settlement_receipt.observed_amount",
347 escrow_locked,
348 )?;
349 ensure_non_empty(
350 &receipt.observed_execution.external_reference_id,
351 "web3_settlement_receipt.observed_execution.external_reference_id",
352 )?;
353 ensure_receipt_money(
354 &receipt.settled_amount,
355 "web3_settlement_receipt.settled_amount",
356 escrow_locked,
357 )?;
358 if receipt.observed_execution.amount.currency != receipt.dispatch.settlement_amount.currency {
359 return Err(Web3ContractError::invalid_settlement(
360 "observed execution currency must match dispatch settlement currency",
361 ));
362 }
363 if receipt.settled_amount.currency != receipt.dispatch.settlement_amount.currency {
364 return Err(Web3ContractError::invalid_settlement(
365 "settled amount currency must match dispatch settlement currency",
366 ));
367 }
368 if receipt.observed_execution.amount != receipt.settled_amount {
369 return Err(Web3ContractError::invalid_settlement(
370 "observed execution amount must equal settled_amount",
371 ));
372 }
373 let has_transaction_reference = validate_observed_execution_reference(
374 &receipt.dispatch.chain_id,
375 &receipt.observed_execution.external_reference_id,
376 )
377 .is_ok();
378 if receipt.lifecycle_state == Web3SettlementLifecycleState::Failed {
379 if !has_transaction_reference
380 && (receipt.observed_execution.amount.units != 0 || receipt.settled_amount.units != 0)
381 {
382 return Err(Web3ContractError::invalid_settlement(
383 "failed settlement with non-zero amount requires transaction reference",
384 ));
385 }
386 } else if !has_transaction_reference {
387 validate_observed_execution_reference(
388 &receipt.dispatch.chain_id,
389 &receipt.observed_execution.external_reference_id,
390 )?;
391 }
392 let execution_window = &receipt.dispatch.capital_instruction.body.execution_window;
393 let observed_before_window =
394 receipt.observed_execution.observed_at < execution_window.not_before;
395 let observed_after_window = receipt.observed_execution.observed_at > execution_window.not_after;
396 let timeout_refund_after_deadline =
397 receipt.lifecycle_state == Web3SettlementLifecycleState::TimedOut && observed_after_window;
398 if observed_before_window || (observed_after_window && !timeout_refund_after_deadline) {
399 return Err(Web3ContractError::invalid_settlement(
400 "observed execution timestamp falls outside dispatch execution window",
401 ));
402 }
403 if let Some(anchor_proof) = receipt.reconciled_anchor_proof.as_ref() {
404 verify_anchor_inclusion_proof(anchor_proof)?;
405 validate_anchor_receipt_binding(receipt, &anchor_proof.receipt)?;
406 if let Some(chain_anchor) = anchor_proof.chain_anchor.as_ref() {
407 if chain_anchor.chain_id != receipt.dispatch.chain_id {
408 return Err(Web3ContractError::invalid_settlement(
409 "anchor proof chain_id must match settlement dispatch chain_id",
410 ));
411 }
412 if dispatch_v2 {
413 let dispatch_operator_key =
414 Hash::from_hex(&receipt.dispatch.operator_key_hash).map_err(|error| {
415 Web3ContractError::invalid_settlement(format!(
416 "web3 settlement dispatch operator_key_hash must be a 32-byte hex hash: {error}"
417 ))
418 })?;
419 let anchor_operator_key =
420 Hash::from_hex(&chain_anchor.operator_key_hash).map_err(|error| {
421 Web3ContractError::invalid_settlement(format!(
422 "anchor proof operator_key_hash must be a 32-byte hex hash: {error}"
423 ))
424 })?;
425 if dispatch_operator_key != anchor_operator_key {
426 return Err(Web3ContractError::invalid_settlement(
427 "dispatch operator_key_hash must match anchor proof operator_key_hash",
428 ));
429 }
430 let binding_operator_key = expected_operator_key_hash(
431 &anchor_proof
432 .key_binding_certificate
433 .certificate
434 .chio_public_key,
435 )?;
436 if dispatch_operator_key != binding_operator_key {
437 return Err(Web3ContractError::invalid_settlement(
438 "dispatch operator_key_hash must match binding certificate public key",
439 ));
440 }
441 }
442 }
443 }
444 if let Some(oracle_evidence) = receipt.oracle_evidence.as_ref() {
445 validate_oracle_conversion_evidence(oracle_evidence)?;
446 if oracle_evidence.grant_currency != receipt.dispatch.settlement_amount.currency {
447 return Err(Web3ContractError::invalid_settlement(
448 "oracle conversion grant_currency must match settlement currency",
449 ));
450 }
451 }
452 if let Some(registry_evidence) = receipt.identity_registry_evidence.as_ref() {
453 validate_identity_registry_evidence(receipt, registry_evidence)?;
454 if let Some(binding) = receipt.identity_registry_evidence_binding.as_ref() {
455 validate_identity_registry_evidence_binding(registry_evidence, binding)?;
456 }
457 } else if receipt.identity_registry_evidence_binding.is_some() {
458 return Err(Web3ContractError::invalid_settlement(
459 "identity_registry_evidence_binding requires identity_registry_evidence",
460 ));
461 }
462 let requires_registry_evidence = receipt_v2
463 && receipt.dispatch.settlement_path == Web3SettlementPath::DualSignature
464 && matches!(
465 receipt.lifecycle_state,
466 Web3SettlementLifecycleState::Settled | Web3SettlementLifecycleState::PartiallySettled
467 );
468 if requires_registry_evidence && receipt.identity_registry_evidence.is_none() {
469 return Err(Web3ContractError::invalid_settlement(
470 "dual-sign settlement receipts require identity_registry_evidence",
471 ));
472 }
473 if requires_registry_evidence && receipt.identity_registry_evidence_binding.is_none() {
474 return Err(Web3ContractError::invalid_settlement(
475 "dual-sign settlement receipts require identity_registry_evidence_binding",
476 ));
477 }
478 if receipt
479 .dispatch
480 .support_boundary
481 .oracle_evidence_required_for_fx
482 && !matches!(
483 receipt.lifecycle_state,
484 Web3SettlementLifecycleState::TimedOut
485 | Web3SettlementLifecycleState::Failed
486 | Web3SettlementLifecycleState::Reorged
487 | Web3SettlementLifecycleState::EscrowLocked
488 )
489 && receipt.oracle_evidence.is_none()
490 {
491 return Err(Web3ContractError::invalid_settlement(
492 "receipt requires oracle_evidence for FX-sensitive settlement paths",
493 ));
494 }
495
496 match receipt.lifecycle_state {
497 Web3SettlementLifecycleState::PendingDispatch => {
498 return Err(Web3ContractError::invalid_settlement(
499 "execution receipts must record an observed terminal or reconciled lifecycle state",
500 ));
501 }
502 Web3SettlementLifecycleState::EscrowLocked => {
503 if receipt.observed_execution.amount.units != 0 || receipt.settled_amount.units != 0 {
504 return Err(Web3ContractError::invalid_settlement(
505 "escrow_locked execution receipts must not record durable settlement amount",
506 ));
507 }
508 }
509 Web3SettlementLifecycleState::PartiallySettled => {
510 if receipt.settled_amount.units == 0
511 || receipt.settled_amount.units >= receipt.dispatch.settlement_amount.units
512 {
513 return Err(Web3ContractError::invalid_settlement(
514 "partially_settled receipts must settle a non-zero amount smaller than the dispatch amount",
515 ));
516 }
517 }
518 Web3SettlementLifecycleState::Settled => {
519 if receipt.settled_amount != receipt.dispatch.settlement_amount {
520 return Err(Web3ContractError::invalid_settlement(
521 "settled receipts must match the dispatch settlement amount",
522 ));
523 }
524 }
525 Web3SettlementLifecycleState::Reversed | Web3SettlementLifecycleState::ChargedBack => {
526 ensure_non_empty(
527 receipt.reversal_of.as_deref().unwrap_or_default(),
528 "web3_settlement_receipt.reversal_of",
529 )?;
530 if !receipt.dispatch.support_boundary.reversal_supported {
531 return Err(Web3ContractError::invalid_settlement(
532 "receipt records reversal state but dispatch did not declare reversal support",
533 ));
534 }
535 }
536 Web3SettlementLifecycleState::TimedOut
537 | Web3SettlementLifecycleState::Failed
538 | Web3SettlementLifecycleState::Reorged => {
539 ensure_non_empty(
540 receipt.failure_reason.as_deref().unwrap_or_default(),
541 "web3_settlement_receipt.failure_reason",
542 )?;
543 }
544 }
545
546 let must_have_anchor = receipt.dispatch.support_boundary.anchor_proof_required
547 && !matches!(
548 receipt.lifecycle_state,
549 Web3SettlementLifecycleState::TimedOut
550 | Web3SettlementLifecycleState::Failed
551 | Web3SettlementLifecycleState::EscrowLocked
552 );
553 if must_have_anchor && receipt.reconciled_anchor_proof.is_none() {
554 return Err(Web3ContractError::invalid_settlement(
555 "receipt requires reconciled anchor proof for the selected settlement path",
556 ));
557 }
558
559 Ok(())
560}
561
562fn validate_identity_registry_evidence(
563 receipt: &Web3SettlementExecutionReceiptArtifact,
564 evidence: &Web3SettlementIdentityRegistryEvidence,
565) -> Result<(), Web3ContractError> {
566 if evidence.chain_id != receipt.dispatch.chain_id {
567 return Err(Web3ContractError::invalid_settlement(
568 "identity registry evidence chain_id must match settlement dispatch chain_id",
569 ));
570 }
571 ensure_non_zero_evm_address(
572 &evidence.identity_registry_contract,
573 "identity_registry_evidence.identity_registry_contract",
574 )?;
575 ensure_non_zero_evm_address(
576 &evidence.operator_address,
577 "identity_registry_evidence.operator_address",
578 )?;
579 ensure_non_zero_evm_address(
580 &evidence.settlement_key,
581 "identity_registry_evidence.settlement_key",
582 )?;
583 if evidence.block_number == 0 {
584 return Err(Web3ContractError::invalid_settlement(
585 "identity registry evidence block_number must be non-zero",
586 ));
587 }
588 if evidence.observed_at == 0 {
589 return Err(Web3ContractError::invalid_settlement(
590 "identity registry evidence observed_at must be non-zero",
591 ));
592 }
593 if evidence.registered_at == 0 {
594 return Err(Web3ContractError::invalid_settlement(
595 "identity registry evidence registered_at must be non-zero",
596 ));
597 }
598 if evidence.operator_epoch == 0 {
599 return Err(Web3ContractError::invalid_settlement(
600 "identity registry evidence operator_epoch must be non-zero",
601 ));
602 }
603 if !evidence.active {
604 return Err(Web3ContractError::invalid_settlement(
605 "identity registry evidence operator must be active",
606 ));
607 }
608 let block_hash = Hash::from_hex(&evidence.block_hash).map_err(|error| {
609 Web3ContractError::invalid_settlement(format!(
610 "identity_registry_evidence.block_hash must be a 32-byte hex hash: {error}"
611 ))
612 })?;
613 if block_hash.as_bytes().iter().all(|byte| *byte == 0) {
614 return Err(Web3ContractError::invalid_settlement(
615 "identity registry evidence block_hash must be non-zero",
616 ));
617 }
618 let evidence_operator_key = Hash::from_hex(&evidence.operator_key_hash).map_err(|error| {
619 Web3ContractError::invalid_settlement(format!(
620 "identity_registry_evidence.operator_key_hash must be a 32-byte hex hash: {error}"
621 ))
622 })?;
623 let dispatch_operator_key =
624 Hash::from_hex(&receipt.dispatch.operator_key_hash).map_err(|error| {
625 Web3ContractError::invalid_settlement(format!(
626 "web3 settlement dispatch operator_key_hash must be a 32-byte hex hash: {error}"
627 ))
628 })?;
629 if evidence_operator_key != dispatch_operator_key {
630 return Err(Web3ContractError::invalid_settlement(
631 "identity registry evidence operator_key_hash must match dispatch operator_key_hash",
632 ));
633 }
634 Ok(())
635}
636
637fn validate_identity_registry_evidence_binding(
638 evidence: &Web3SettlementIdentityRegistryEvidence,
639 binding: &Web3SettlementIdentityRegistryEvidenceBinding,
640) -> Result<(), Web3ContractError> {
641 ensure_non_zero_evm_address(
642 &binding.identity_registry_contract,
643 "identity_registry_evidence_binding.identity_registry_contract",
644 )?;
645 ensure_non_zero_evm_address(
646 &binding.operator_address,
647 "identity_registry_evidence_binding.operator_address",
648 )?;
649 ensure_non_zero_evm_address(
650 &binding.settlement_key,
651 "identity_registry_evidence_binding.settlement_key",
652 )?;
653 if !evm_addresses_match(
654 &evidence.identity_registry_contract,
655 &binding.identity_registry_contract,
656 )? {
657 return Err(Web3ContractError::invalid_settlement(
658 "identity registry evidence contract must match binding",
659 ));
660 }
661 if !evm_addresses_match(&evidence.operator_address, &binding.operator_address)? {
662 return Err(Web3ContractError::invalid_settlement(
663 "identity registry evidence operator must match binding",
664 ));
665 }
666 if !evm_addresses_match(&evidence.settlement_key, &binding.settlement_key)? {
667 return Err(Web3ContractError::invalid_settlement(
668 "identity registry evidence settlement_key must match binding",
669 ));
670 }
671 Ok(())
672}
673
674fn ensure_non_zero_evm_address(value: &str, field: &'static str) -> Result<(), Web3ContractError> {
675 ensure_evm_address(value, field)?;
676 if value
677 .strip_prefix("0x")
678 .is_some_and(|hex| hex.bytes().all(|byte| byte == b'0'))
679 {
680 return Err(Web3ContractError::invalid_settlement(format!(
681 "{field} must be non-zero"
682 )));
683 }
684 Ok(())
685}
686
687fn validate_anchor_receipt_binding(
688 receipt: &Web3SettlementExecutionReceiptArtifact,
689 anchor_receipt: &ChioReceipt,
690) -> Result<(), Web3ContractError> {
691 let governed_receipt_id = receipt
692 .dispatch
693 .capital_instruction
694 .body
695 .governed_receipt_id
696 .as_deref()
697 .ok_or(Web3ContractError::MissingField(
698 "web3_settlement_dispatch.capital_instruction.governed_receipt_id",
699 ))?;
700 let Some(receipt_nonce) = anchor_receipt
701 .metadata
702 .as_ref()
703 .and_then(|metadata| metadata.get(CHIO_RECEIPT_SIGNING_NONCE_METADATA_KEY))
704 .and_then(serde_json::Value::as_str)
705 else {
706 return Err(Web3ContractError::invalid_settlement(
707 "anchor proof receipt must carry signing nonce",
708 ));
709 };
710 if receipt_nonce != governed_receipt_id {
711 return Err(Web3ContractError::invalid_settlement(
712 "anchor proof receipt must match governed receipt",
713 ));
714 }
715 let expected_content_hash = settlement_anchor_receipt_content_hash(receipt)?;
716 if anchor_receipt.content_hash != expected_content_hash {
717 return Err(Web3ContractError::invalid_settlement(
718 "anchor proof receipt content hash must bind settlement execution",
719 ));
720 }
721 Ok(())
722}
723
724pub(crate) fn settlement_anchor_receipt_content_hash(
725 receipt: &Web3SettlementExecutionReceiptArtifact,
726) -> Result<String, Web3ContractError> {
727 let governed_receipt_id = receipt
728 .dispatch
729 .capital_instruction
730 .body
731 .governed_receipt_id
732 .as_deref()
733 .ok_or(Web3ContractError::MissingField(
734 "web3_settlement_dispatch.capital_instruction.governed_receipt_id",
735 ))?;
736 settlement_anchor_receipt_content_hash_parts(
737 &receipt.execution_receipt_id,
738 &receipt.settlement_reference,
739 &receipt.dispatch.dispatch_id,
740 governed_receipt_id,
741 )
742}
743
744pub fn settlement_anchor_receipt_content_hash_parts(
745 execution_receipt_id: &str,
746 settlement_reference: &str,
747 dispatch_id: &str,
748 governed_receipt_id: &str,
749) -> Result<String, Web3ContractError> {
750 let body = SettlementAnchorReceiptBinding {
751 execution_receipt_id,
752 settlement_reference,
753 dispatch_id,
754 governed_receipt_id,
755 };
756 let bytes = canonical_json_bytes(&body).map_err(|error| {
757 Web3ContractError::invalid_settlement(format!(
758 "settlement anchor receipt binding canonicalization failed: {error}"
759 ))
760 })?;
761 Ok(sha256_hex(&bytes))
762}
763
764#[derive(Serialize)]
765struct SettlementAnchorReceiptBinding<'a> {
766 execution_receipt_id: &'a str,
767 settlement_reference: &'a str,
768 dispatch_id: &'a str,
769 governed_receipt_id: &'a str,
770}
771
772fn validate_observed_execution_reference(
773 chain_id: &str,
774 reference_id: &str,
775) -> Result<(), Web3ContractError> {
776 if chain_id.starts_with("eip155:") && !is_eip155_transaction_hash(reference_id) {
777 return Err(Web3ContractError::invalid_settlement(
778 "observed execution reference must be an eip155 transaction hash",
779 ));
780 }
781 Ok(())
782}
783
784fn is_eip155_transaction_hash(value: &str) -> bool {
785 let Some(hex) = value.strip_prefix("0x") else {
786 return false;
787 };
788 hex.len() == 64
789 && hex
790 .bytes()
791 .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'))
792}
793
794fn ensure_receipt_money(
795 amount: &MonetaryAmount,
796 field: &'static str,
797 allow_zero: bool,
798) -> Result<(), Web3ContractError> {
799 if amount.units == 0 && allow_zero {
800 if amount.currency.trim().is_empty() {
801 return Err(Web3ContractError::invalid_settlement(format!(
802 "{field} currency is required"
803 )));
804 }
805 if amount.currency.len() != 3
806 || !amount
807 .currency
808 .chars()
809 .all(|character| character.is_ascii_uppercase())
810 {
811 return Err(Web3ContractError::invalid_settlement(format!(
812 "{field} currency must be a 3-letter uppercase code"
813 )));
814 }
815 return Ok(());
816 }
817 ensure_money(amount, field)
818}
819
820fn validate_transfer_completion_flow_binding(
821 instruction: &crate::credit::CapitalExecutionInstructionArtifact,
822) -> Result<(), Web3ContractError> {
823 if instruction.action != CapitalExecutionInstructionAction::TransferFunds {
824 return Ok(());
825 }
826 let governed_receipt_id =
827 instruction
828 .governed_receipt_id
829 .as_deref()
830 .ok_or(Web3ContractError::MissingField(
831 "web3_settlement_dispatch.capital_instruction.governed_receipt_id",
832 ))?;
833 ensure_non_empty(
834 governed_receipt_id,
835 "web3_settlement_dispatch.capital_instruction.governed_receipt_id",
836 )?;
837 let completion_flow_row_id =
838 instruction
839 .completion_flow_row_id
840 .as_deref()
841 .ok_or(Web3ContractError::MissingField(
842 "web3_settlement_dispatch.capital_instruction.completion_flow_row_id",
843 ))?;
844 ensure_non_empty(
845 completion_flow_row_id,
846 "web3_settlement_dispatch.capital_instruction.completion_flow_row_id",
847 )?;
848 let expected_row_id = format!("economic-completion-flow:{governed_receipt_id}");
849 if completion_flow_row_id != expected_row_id {
850 return Err(Web3ContractError::invalid_settlement(
851 "web3 settlement dispatch completion_flow_row_id must match governed_receipt_id",
852 ));
853 }
854 Ok(())
855}