Skip to main content

chio_web3/
settlement.rs

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}