Skip to main content

chio_settle/
observe.rs

1use chio_core::web3::anchors::{AnchorInclusionProof, OracleConversionEvidence};
2use chio_core::web3::settlement::{
3    validate_web3_settlement_execution_receipt, Web3SettlementDispatchArtifact,
4    Web3SettlementExecutionReceiptArtifact, Web3SettlementIdentityRegistryEvidence,
5    Web3SettlementIdentityRegistryEvidenceBinding, Web3SettlementLifecycleState,
6    CHIO_WEB3_SETTLEMENT_RECEIPT_SCHEMA,
7};
8use chio_egress_contract::{client_builder_with_contract, send_with_contract};
9use serde::{Deserialize, Serialize};
10use serde_json::{json, Value};
11
12use crate::evm::{
13    confirm_transaction, read_bond_snapshot, read_escrow_snapshot,
14    scale_token_minor_units_to_chio_amount, EscrowSnapshot, EvmBondSnapshot, EvmTransactionReceipt,
15};
16use crate::{SettlementChainConfig, SettlementError};
17
18#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
19#[serde(rename_all = "snake_case")]
20pub enum EscrowLifecycleStatus {
21    Locked,
22    PartiallyReleased,
23    Released,
24    Refunded,
25}
26
27#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
28#[serde(rename_all = "snake_case")]
29pub enum BondLifecycleStatus {
30    Active,
31    Released,
32    Impaired,
33    Expired,
34}
35
36#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
37#[serde(rename_all = "snake_case")]
38pub enum SettlementFinalityStatus {
39    AwaitingConfirmations,
40    AwaitingDisputeWindow,
41    Finalized,
42    Reorged,
43}
44
45#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
46#[serde(rename_all = "snake_case")]
47pub enum SettlementRecoveryAction {
48    WaitForConfirmations,
49    WaitForDisputeWindow,
50    RetrySubmission,
51    ResubmitAfterReorg,
52    ExecuteRefund,
53    ManualReview,
54    ExpireBond,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
58#[serde(deny_unknown_fields)]
59pub struct SettlementFinalityAssessment {
60    pub chain_id: String,
61    pub required_confirmations: u32,
62    pub current_confirmations: u32,
63    pub dispute_window_secs: u64,
64    pub dispute_window_closes_at: u64,
65    pub status: SettlementFinalityStatus,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
69#[serde(deny_unknown_fields)]
70pub struct EscrowExecutionProjection {
71    pub receipt: Web3SettlementExecutionReceiptArtifact,
72    pub finality: SettlementFinalityAssessment,
73    pub escrow_snapshot: EscrowSnapshot,
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub recovery_action: Option<SettlementRecoveryAction>,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
79#[serde(deny_unknown_fields)]
80pub struct BondLifecycleObservation {
81    pub chain_id: String,
82    pub vault_id: String,
83    pub snapshot: EvmBondSnapshot,
84    pub status: BondLifecycleStatus,
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub recovery_action: Option<SettlementRecoveryAction>,
87}
88
89#[derive(Debug, Clone)]
90pub struct ExecutionProjectionInput<'a> {
91    pub dispatch: &'a Web3SettlementDispatchArtifact,
92    pub tx_hash: &'a str,
93    pub execution_receipt_id: String,
94    pub settlement_reference: String,
95    pub observed_at: Option<u64>,
96    pub observed_amount: chio_core::capability::scope::MonetaryAmount,
97    pub anchor_proof: Option<&'a AnchorInclusionProof>,
98    pub identity_registry_evidence: Option<Web3SettlementIdentityRegistryEvidence>,
99    pub identity_registry_evidence_binding: Option<Web3SettlementIdentityRegistryEvidenceBinding>,
100    pub oracle_evidence: Option<&'a OracleConversionEvidence>,
101    pub failure_reason: Option<String>,
102    pub reversal_of: Option<String>,
103    pub note: Option<String>,
104}
105
106pub async fn inspect_finality(
107    config: &SettlementChainConfig,
108    tx_hash: &str,
109    amount_units: u64,
110    now_override: Option<u64>,
111) -> Result<(EvmTransactionReceipt, SettlementFinalityAssessment), SettlementError> {
112    let receipt = confirm_transaction(config, tx_hash).await?;
113    let assessment =
114        inspect_finality_for_receipt(config, &receipt, amount_units, now_override).await?;
115    Ok((receipt, assessment))
116}
117
118pub async fn inspect_finality_for_receipt(
119    config: &SettlementChainConfig,
120    receipt: &EvmTransactionReceipt,
121    amount_units: u64,
122    now_override: Option<u64>,
123) -> Result<SettlementFinalityAssessment, SettlementError> {
124    let latest_block_number = latest_block_number(config).await?;
125    let current_confirmations = latest_block_number
126        .saturating_sub(receipt.block_number)
127        .saturating_add(1) as u32;
128    let current_hash = block_hash_at_number_optional(config, receipt.block_number).await?;
129    let tier = config.policy.tier_for_amount(amount_units);
130    let dispute_window_closes_at = receipt.observed_at.saturating_add(tier.dispute_window_secs);
131    let status = if current_hash.as_deref() != Some(receipt.block_hash.as_str()) {
132        SettlementFinalityStatus::Reorged
133    } else if current_confirmations < tier.min_confirmations {
134        SettlementFinalityStatus::AwaitingConfirmations
135    } else if now_override
136        .unwrap_or(receipt.observed_at)
137        .lt(&dispute_window_closes_at)
138    {
139        SettlementFinalityStatus::AwaitingDisputeWindow
140    } else {
141        SettlementFinalityStatus::Finalized
142    };
143    Ok(SettlementFinalityAssessment {
144        chain_id: config.chain_id.clone(),
145        required_confirmations: tier.min_confirmations,
146        current_confirmations,
147        dispute_window_secs: tier.dispute_window_secs,
148        dispute_window_closes_at,
149        status,
150    })
151}
152
153pub async fn project_escrow_execution_receipt(
154    config: &SettlementChainConfig,
155    input: ExecutionProjectionInput<'_>,
156) -> Result<EscrowExecutionProjection, SettlementError> {
157    let escrow_snapshot = read_escrow_snapshot(config, &input.dispatch.escrow_id).await?;
158    let (_, finality) = inspect_finality(
159        config,
160        input.tx_hash,
161        input.dispatch.settlement_amount.units,
162        input.observed_at,
163    )
164    .await?;
165
166    let lifecycle_state = lifecycle_state_for_projection(finality.status, &escrow_snapshot);
167
168    let failure_reason = match lifecycle_state {
169        Web3SettlementLifecycleState::TimedOut => Some(
170            input
171                .failure_reason
172                .clone()
173                .unwrap_or_else(|| "escrow refunded after deadline".to_string()),
174        ),
175        Web3SettlementLifecycleState::Failed => {
176            Some(input.failure_reason.clone().unwrap_or_else(|| {
177                "settlement submission failed before a durable on-chain release".to_string()
178            }))
179        }
180        Web3SettlementLifecycleState::Reorged => {
181            Some(input.failure_reason.clone().unwrap_or_else(|| {
182                "transaction receipt disappeared from the canonical chain".to_string()
183            }))
184        }
185        _ => input.failure_reason.clone(),
186    };
187
188    let settled_amount = if lifecycle_state == Web3SettlementLifecycleState::TimedOut {
189        scale_token_minor_units_to_chio_amount(
190            escrow_snapshot.remaining_minor_units,
191            &input.dispatch.settlement_amount.currency,
192            config,
193        )?
194    } else if lifecycle_state == Web3SettlementLifecycleState::EscrowLocked {
195        chio_core::capability::scope::MonetaryAmount {
196            units: 0,
197            currency: input.dispatch.settlement_amount.currency.clone(),
198        }
199    } else {
200        input.observed_amount.clone()
201    };
202
203    let observed_execution = chio_core::credit::CapitalExecutionObservation {
204        observed_at: input.observed_at.unwrap_or_else(|| {
205            finality
206                .dispute_window_closes_at
207                .saturating_sub(finality.dispute_window_secs)
208        }),
209        external_reference_id: input.tx_hash.to_string(),
210        amount: settled_amount.clone(),
211    };
212
213    let receipt = Web3SettlementExecutionReceiptArtifact {
214        schema: CHIO_WEB3_SETTLEMENT_RECEIPT_SCHEMA.to_string(),
215        execution_receipt_id: input.execution_receipt_id,
216        issued_at: observed_execution.observed_at,
217        dispatch: input.dispatch.clone(),
218        observed_execution,
219        lifecycle_state,
220        settlement_reference: input.settlement_reference,
221        reconciled_anchor_proof: input.anchor_proof.cloned(),
222        identity_registry_evidence: input.identity_registry_evidence,
223        identity_registry_evidence_binding: input.identity_registry_evidence_binding,
224        oracle_evidence: input.oracle_evidence.cloned(),
225        settled_amount,
226        reversal_of: input.reversal_of.clone(),
227        failure_reason,
228        note: input.note.clone(),
229    };
230    validate_web3_settlement_execution_receipt(&receipt)
231        .map_err(|error| SettlementError::Verification(error.to_string()))?;
232
233    let recovery_action = recovery_action_for_projection(finality.status, lifecycle_state);
234
235    Ok(EscrowExecutionProjection {
236        receipt,
237        finality,
238        escrow_snapshot,
239        recovery_action,
240    })
241}
242
243pub async fn observe_bond(
244    config: &SettlementChainConfig,
245    vault_id: &str,
246) -> Result<BondLifecycleObservation, SettlementError> {
247    let snapshot = read_bond_snapshot(config, vault_id).await?;
248    let time_expired = snapshot.observed_at > snapshot.expires_at;
249    let status = if snapshot.expired || time_expired {
250        BondLifecycleStatus::Expired
251    } else if snapshot.released {
252        BondLifecycleStatus::Released
253    } else if snapshot.slashed_minor_units > 0 {
254        BondLifecycleStatus::Impaired
255    } else {
256        BondLifecycleStatus::Active
257    };
258    let recovery_action = if time_expired && !snapshot.expired {
259        Some(SettlementRecoveryAction::ExpireBond)
260    } else if matches!(
261        status,
262        BondLifecycleStatus::Active | BondLifecycleStatus::Expired
263    ) {
264        None
265    } else {
266        Some(SettlementRecoveryAction::ManualReview)
267    };
268    Ok(BondLifecycleObservation {
269        chain_id: config.chain_id.clone(),
270        vault_id: vault_id.to_string(),
271        snapshot,
272        status,
273        recovery_action,
274    })
275}
276
277async fn latest_block_number(config: &SettlementChainConfig) -> Result<u64, SettlementError> {
278    let result =
279        settlement_rpc_call(config, "eth_getBlockByNumber", json!(["latest", false])).await?;
280    parse_hex_u64(
281        result
282            .get("number")
283            .and_then(serde_json::Value::as_str)
284            .ok_or_else(|| SettlementError::Rpc("latest block missing number".to_string()))?,
285    )
286}
287
288async fn block_hash_at_number_optional(
289    config: &SettlementChainConfig,
290    block_number: u64,
291) -> Result<Option<String>, SettlementError> {
292    let result = settlement_rpc_call(
293        config,
294        "eth_getBlockByNumber",
295        json!([format!("0x{block_number:x}"), false]),
296    )
297    .await?;
298    if result.is_null() {
299        return Ok(None);
300    }
301    Ok(result
302        .get("hash")
303        .and_then(serde_json::Value::as_str)
304        .map(ToString::to_string))
305}
306
307#[derive(Debug, Deserialize)]
308struct JsonRpcEnvelope {
309    #[serde(rename = "jsonrpc")]
310    _jsonrpc: String,
311    #[serde(rename = "id")]
312    _id: u64,
313    #[serde(default)]
314    result: Value,
315    error: Option<JsonRpcError>,
316}
317
318#[derive(Debug, Deserialize)]
319struct JsonRpcError {
320    code: i64,
321    message: String,
322}
323
324async fn settlement_rpc_call(
325    config: &SettlementChainConfig,
326    method: &str,
327    params: Value,
328) -> Result<Value, SettlementError> {
329    config.validate_rpc_egress_contract()?;
330    let contract = &config.egress_contract;
331    let client = client_builder_with_contract(contract)
332        .build()
333        .map_err(|error| SettlementError::Rpc(format!("reqwest build: {error}")))?;
334    let request = client
335        .post(&config.rpc_url)
336        .json(&json!({
337            "jsonrpc": "2.0",
338            "id": 1u64,
339            "method": method,
340            "params": params,
341        }))
342        .build()
343        .map_err(|error| SettlementError::Rpc(format!("reqwest build request: {error}")))?;
344    let response = send_with_contract(contract, &client, request)
345        .await
346        .map_err(|error| {
347            SettlementError::Rpc(format!(
348                "HttpEgressContract rejects settlement observer RPC dispatch: {error}"
349            ))
350        })?;
351    let envelope: JsonRpcEnvelope = response
352        .json()
353        .await
354        .map_err(|error| SettlementError::Rpc(error.to_string()))?;
355    if let Some(error) = envelope.error {
356        return Err(SettlementError::Rpc(format!(
357            "{} (code {})",
358            error.message, error.code
359        )));
360    }
361    Ok(envelope.result)
362}
363
364fn parse_hex_u64(value: &str) -> Result<u64, SettlementError> {
365    u64::from_str_radix(value.trim_start_matches("0x"), 16)
366        .map_err(|error| SettlementError::Rpc(error.to_string()))
367}
368
369fn recovery_action_for_projection(
370    finality_status: SettlementFinalityStatus,
371    lifecycle_state: Web3SettlementLifecycleState,
372) -> Option<SettlementRecoveryAction> {
373    match finality_status {
374        SettlementFinalityStatus::AwaitingConfirmations => {
375            Some(SettlementRecoveryAction::WaitForConfirmations)
376        }
377        SettlementFinalityStatus::AwaitingDisputeWindow => {
378            Some(SettlementRecoveryAction::WaitForDisputeWindow)
379        }
380        SettlementFinalityStatus::Reorged => Some(SettlementRecoveryAction::ResubmitAfterReorg),
381        SettlementFinalityStatus::Finalized => match lifecycle_state {
382            Web3SettlementLifecycleState::Failed => Some(SettlementRecoveryAction::RetrySubmission),
383            Web3SettlementLifecycleState::TimedOut => Some(SettlementRecoveryAction::ExecuteRefund),
384            _ => None,
385        },
386    }
387}
388
389fn lifecycle_state_for_projection(
390    finality_status: SettlementFinalityStatus,
391    escrow_snapshot: &EscrowSnapshot,
392) -> Web3SettlementLifecycleState {
393    if finality_status == SettlementFinalityStatus::Reorged {
394        return Web3SettlementLifecycleState::Reorged;
395    }
396    if finality_status != SettlementFinalityStatus::Finalized {
397        return Web3SettlementLifecycleState::EscrowLocked;
398    }
399    if escrow_snapshot.refunded {
400        return Web3SettlementLifecycleState::TimedOut;
401    }
402    if escrow_snapshot.released_minor_units == 0 {
403        Web3SettlementLifecycleState::Failed
404    } else if escrow_snapshot.released_minor_units < escrow_snapshot.deposited_minor_units {
405        Web3SettlementLifecycleState::PartiallySettled
406    } else {
407        Web3SettlementLifecycleState::Settled
408    }
409}
410
411#[cfg(test)]
412mod tests {
413    use std::io::{Read, Write};
414    use std::net::TcpListener;
415    use std::str::FromStr;
416    use std::sync::{Arc, Mutex};
417    use std::thread;
418    use std::time::Duration;
419
420    use alloy_primitives::{Address, B256, U256};
421    use alloy_sol_types::SolCall;
422    use chio_core::web3::settlement::{
423        Web3SettlementDispatchArtifact, Web3SettlementLifecycleState,
424    };
425    use chio_core::web3::trust_profile::{Web3FinalityMode, Web3SettlementPath};
426    use chio_web3_bindings::{IChioBondVault, IChioEscrow};
427    use serde_json::{json, Value};
428
429    use super::*;
430
431    use chio_test_support::prelude::*;
432
433    struct MockJsonRpcServer {
434        base_url: String,
435        requests: Arc<Mutex<Vec<Value>>>,
436        handle: thread::JoinHandle<()>,
437    }
438
439    struct MockRawHttpServer {
440        base_url: String,
441        handle: thread::JoinHandle<()>,
442    }
443
444    impl MockJsonRpcServer {
445        fn spawn(envelopes: Vec<Value>) -> Self {
446            let listener =
447                TcpListener::bind("127.0.0.1:0").test_expect("bind mock JSON-RPC listener");
448            let address = listener.local_addr().test_expect("listener address");
449            let base_url = format!("http://127.0.0.1:{}", address.port());
450            let requests = Arc::new(Mutex::new(Vec::new()));
451            let requests_for_thread = Arc::clone(&requests);
452
453            let handle = thread::spawn(move || {
454                for envelope in envelopes {
455                    let (mut stream, _) = listener.accept().test_expect("accept mock request");
456                    stream
457                        .set_read_timeout(Some(Duration::from_secs(2)))
458                        .test_expect("set read timeout");
459                    let request = read_http_request(&mut stream);
460                    requests_for_thread
461                        .lock()
462                        .test_expect("lock request log")
463                        .push(parse_json_request(&request));
464                    write_http_json_response(&mut stream, 200, &envelope);
465                    stream.flush().test_expect("flush mock response");
466                }
467            });
468
469            Self {
470                base_url,
471                requests,
472                handle,
473            }
474        }
475
476        fn base_url(&self) -> &str {
477            &self.base_url
478        }
479
480        fn requests(&self) -> Vec<Value> {
481            self.requests.lock().test_expect("lock request log").clone()
482        }
483
484        fn join(self) {
485            self.handle.join().test_expect("join mock JSON-RPC server");
486        }
487    }
488
489    impl MockRawHttpServer {
490        fn spawn(response: String) -> Self {
491            let listener =
492                TcpListener::bind("127.0.0.1:0").test_expect("bind mock raw HTTP listener");
493            let address = listener.local_addr().test_expect("listener address");
494            let base_url = format!("http://127.0.0.1:{}", address.port());
495
496            let handle = thread::spawn(move || {
497                let (mut stream, _) = listener.accept().test_expect("accept mock request");
498                stream
499                    .set_read_timeout(Some(Duration::from_secs(2)))
500                    .test_expect("set read timeout");
501                let _request = read_http_request(&mut stream);
502                stream
503                    .write_all(response.as_bytes())
504                    .test_expect("write mock response");
505                stream.flush().test_expect("flush mock response");
506            });
507
508            Self { base_url, handle }
509        }
510
511        fn base_url(&self) -> &str {
512            &self.base_url
513        }
514
515        fn join(self) {
516            self.handle.join().test_expect("join mock raw server");
517        }
518    }
519
520    fn sample_config(rpc_url: &str) -> SettlementChainConfig {
521        SettlementChainConfig {
522            chain_id: "eip155:31337".to_string(),
523            network_name: "Ganache".to_string(),
524            rpc_url: rpc_url.to_string(),
525            egress_contract: crate::settlement_devnet_rpc_egress_contract(rpc_url)
526                .test_expect("devnet egress contract"),
527            escrow_contract: "0x69011eD3D9792Ea93595EeBd919EE621764B19e0".to_string(),
528            bond_vault_contract: "0x621c302d6EC93b7186bEF18dF5D6436C6ea30125".to_string(),
529            identity_registry_contract: "0x0eAFb60DD4F4b3863eb5490752238aC37A625dc6".to_string(),
530            root_registry_contract: "0x3a167ACFC3348a8f8df11BF383aF3cA86a8A2B42".to_string(),
531            operator_address: "0x8d6d63c22D114C18C2a0dA6Db0A8972Ed9C40343".to_string(),
532            settlement_token_symbol: "mUSDC".to_string(),
533            settlement_token_address: "0x735F1Ba389D9D350501dB8FBbB5b52477DcaddA8".to_string(),
534            oracle: crate::SettlementOracleConfig::default(),
535            evidence_substrate: crate::SettlementEvidenceConfig::default(),
536            policy: crate::SettlementPolicyConfig {
537                chio_minor_unit_decimals: 2,
538                token_minor_unit_decimals: 6,
539                tiers: vec![
540                    crate::SettlementAmountTier {
541                        upper_bound_units: 1_000,
542                        dispute_window_secs: 0,
543                        min_confirmations: 1,
544                        finality_mode: Web3FinalityMode::OptimisticL2,
545                    },
546                    crate::SettlementAmountTier {
547                        upper_bound_units: 100_000,
548                        dispute_window_secs: 3_600,
549                        min_confirmations: 1,
550                        finality_mode: Web3FinalityMode::OptimisticL2,
551                    },
552                    crate::SettlementAmountTier {
553                        upper_bound_units: 1_000_000,
554                        dispute_window_secs: 14_400,
555                        min_confirmations: 12,
556                        finality_mode: Web3FinalityMode::L1Finalized,
557                    },
558                    crate::SettlementAmountTier {
559                        upper_bound_units: u64::MAX,
560                        dispute_window_secs: 86_400,
561                        min_confirmations: 64,
562                        finality_mode: Web3FinalityMode::L1Finalized,
563                    },
564                ],
565            },
566        }
567    }
568
569    fn hostname_rpc_contract(authority: &str) -> chio_egress_contract::HttpEgressContract {
570        chio_egress_contract::HttpEgressContract {
571            tenant_egress_namespace: "chio-settle-observer-unit-rpc".to_string(),
572            allowed_schemes: std::collections::BTreeSet::from(["https".to_string()]),
573            allowed_authority_set: std::collections::BTreeSet::from([authority.to_string()]),
574            deny_loopback: true,
575            deny_link_local: true,
576            deny_ipv6_ula: true,
577            max_redirect_chain: 0,
578            max_response_bytes: 64 * 1024 * 1024,
579        }
580    }
581
582    fn sample_receipt(
583        block_number: u64,
584        block_hash: &str,
585        observed_at: u64,
586    ) -> EvmTransactionReceipt {
587        EvmTransactionReceipt {
588            tx_hash: "0xabc".to_string(),
589            block_number,
590            block_hash: block_hash.to_string(),
591            status: true,
592            from_address: "0x1000000000000000000000000000000000000001".to_string(),
593            to_address: "0x1000000000000000000000000000000000000002".to_string(),
594            gas_used: 21_000,
595            observed_at,
596            logs: Vec::new(),
597        }
598    }
599
600    fn sample_dispatch() -> Web3SettlementDispatchArtifact {
601        let mut dispatch: Web3SettlementDispatchArtifact = serde_json::from_str(include_str!(
602            "../../../../docs/standards/CHIO_WEB3_SETTLEMENT_DISPATCH_EXAMPLE.json"
603        ))
604        .test_expect("dispatch example should parse");
605        dispatch.escrow_id =
606            "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string();
607        dispatch.settlement_path = Web3SettlementPath::DualSignature;
608        dispatch.support_boundary.anchor_proof_required = false;
609        dispatch.support_boundary.oracle_evidence_required_for_fx = false;
610        dispatch
611    }
612
613    fn sample_identity_registry_evidence(
614        dispatch: &Web3SettlementDispatchArtifact,
615        config: &SettlementChainConfig,
616    ) -> Web3SettlementIdentityRegistryEvidence {
617        Web3SettlementIdentityRegistryEvidence {
618            chain_id: dispatch.chain_id.clone(),
619            identity_registry_contract: config.identity_registry_contract.clone(),
620            operator_address: config.operator_address.clone(),
621            block_number: 120,
622            block_hash: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
623                .to_string(),
624            observed_at: 1_743_292_850,
625            operator_key_hash: dispatch.operator_key_hash.clone(),
626            settlement_key: dispatch.beneficiary_address.clone(),
627            registered_at: 1_743_292_700,
628            operator_epoch: 1,
629            active: true,
630        }
631    }
632
633    fn sample_identity_registry_evidence_binding(
634        dispatch: &Web3SettlementDispatchArtifact,
635        config: &SettlementChainConfig,
636    ) -> Web3SettlementIdentityRegistryEvidenceBinding {
637        Web3SettlementIdentityRegistryEvidenceBinding {
638            identity_registry_contract: config.identity_registry_contract.clone(),
639            operator_address: config.operator_address.clone(),
640            settlement_key: dispatch.beneficiary_address.clone(),
641        }
642    }
643
644    fn escrow_result(released_minor_units: u64) -> Value {
645        rpc_result(json!(encode_hex(
646            IChioEscrow::getEscrowCall::abi_encode_returns(&IChioEscrow::getEscrowReturn {
647                terms: IChioEscrow::EscrowTerms {
648                    capabilityId: B256::from([0x99; 32]),
649                    depositor: Address::from_str("0x1000000000000000000000000000000000000001",)
650                        .test_unwrap(),
651                    beneficiary: Address::from_str("0x1000000000000000000000000000000000000002",)
652                        .test_unwrap(),
653                    token: Address::from_str("0x1000000000000000000000000000000000000003",)
654                        .test_unwrap(),
655                    maxAmount: U256::from(1_500_000_u64),
656                    deadline: U256::from(1_700_050_000_u64),
657                    operator: Address::from_str("0x1000000000000000000000000000000000000004",)
658                        .test_unwrap(),
659                    operatorKeyHash: B256::from([0xaa; 32]),
660                },
661                deposited: U256::from(1_500_000_u64),
662                released: U256::from(released_minor_units),
663                refunded: false,
664            })
665        )))
666    }
667
668    fn rpc_result(result: Value) -> Value {
669        json!({
670            "jsonrpc": "2.0",
671            "id": 1,
672            "result": result,
673        })
674    }
675
676    fn rpc_error(code: i64, message: &str) -> Value {
677        json!({
678            "jsonrpc": "2.0",
679            "id": 1,
680            "error": {
681                "code": code,
682                "message": message,
683            }
684        })
685    }
686
687    fn encode_hex(data: Vec<u8>) -> String {
688        format!("0x{}", hex::encode(data))
689    }
690
691    fn read_http_request<R: Read>(stream: &mut R) -> String {
692        let mut request = Vec::new();
693        let mut chunk = [0_u8; 1024];
694        let mut header_end = None;
695        let mut content_length = 0_usize;
696
697        loop {
698            let read = stream.read(&mut chunk).test_expect("read request");
699            if read == 0 {
700                break;
701            }
702            request.extend_from_slice(&chunk[..read]);
703            if header_end.is_none() {
704                header_end = find_header_end(&request);
705                if let Some(end) = header_end {
706                    content_length = parse_content_length(&request[..end]);
707                }
708            }
709            if let Some(end) = header_end {
710                if request.len() >= end + content_length {
711                    break;
712                }
713            }
714        }
715
716        String::from_utf8(request).test_expect("request should be valid UTF-8")
717    }
718
719    fn find_header_end(request: &[u8]) -> Option<usize> {
720        request
721            .windows(4)
722            .position(|window| window == b"\r\n\r\n")
723            .map(|position| position + 4)
724    }
725
726    fn parse_content_length(headers: &[u8]) -> usize {
727        String::from_utf8_lossy(headers)
728            .lines()
729            .find_map(|line| {
730                let (name, value) = line.split_once(':')?;
731                if name.eq_ignore_ascii_case("content-length") {
732                    value.trim().parse::<usize>().ok()
733                } else {
734                    None
735                }
736            })
737            .unwrap_or(0)
738    }
739
740    fn parse_json_request(request: &str) -> Value {
741        let body = request
742            .split_once("\r\n\r\n")
743            .map(|(_, body)| body)
744            .unwrap_or_default();
745        serde_json::from_str(body).test_expect("request body should be JSON")
746    }
747
748    fn write_http_json_response<W: Write>(stream: &mut W, status: u16, body: &Value) {
749        let body_text = body.to_string();
750        let response = format!(
751            "HTTP/1.1 {status} {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
752            http_status_text(status),
753            body_text.len(),
754            body_text
755        );
756        stream
757            .write_all(response.as_bytes())
758            .test_expect("write mock response");
759    }
760
761    fn http_status_text(status: u16) -> &'static str {
762        match status {
763            200 => "OK",
764            500 => "Internal Server Error",
765            _ => "Unknown",
766        }
767    }
768
769    #[test]
770    fn parse_hex_u64_accepts_and_rejects_expected_values() {
771        assert_eq!(parse_hex_u64("0x2a").test_expect("hex should parse"), 42);
772        let error = parse_hex_u64("nope").test_expect_err("bad hex should fail");
773        assert!(matches!(error, SettlementError::Rpc(_)));
774    }
775
776    #[test]
777    fn recovery_action_mapping_covers_terminal_states() {
778        assert_eq!(
779            recovery_action_for_projection(
780                SettlementFinalityStatus::AwaitingConfirmations,
781                Web3SettlementLifecycleState::Settled,
782            ),
783            Some(SettlementRecoveryAction::WaitForConfirmations)
784        );
785        assert_eq!(
786            recovery_action_for_projection(
787                SettlementFinalityStatus::AwaitingDisputeWindow,
788                Web3SettlementLifecycleState::Settled,
789            ),
790            Some(SettlementRecoveryAction::WaitForDisputeWindow)
791        );
792        assert_eq!(
793            recovery_action_for_projection(
794                SettlementFinalityStatus::Reorged,
795                Web3SettlementLifecycleState::Settled,
796            ),
797            Some(SettlementRecoveryAction::ResubmitAfterReorg)
798        );
799        assert_eq!(
800            recovery_action_for_projection(
801                SettlementFinalityStatus::Finalized,
802                Web3SettlementLifecycleState::Failed,
803            ),
804            Some(SettlementRecoveryAction::RetrySubmission)
805        );
806        assert_eq!(
807            recovery_action_for_projection(
808                SettlementFinalityStatus::Finalized,
809                Web3SettlementLifecycleState::TimedOut,
810            ),
811            Some(SettlementRecoveryAction::ExecuteRefund)
812        );
813        assert_eq!(
814            recovery_action_for_projection(
815                SettlementFinalityStatus::Finalized,
816                Web3SettlementLifecycleState::Settled,
817            ),
818            None
819        );
820    }
821
822    #[tokio::test]
823    async fn inspect_finality_for_receipt_awaits_confirmations() {
824        let server = MockJsonRpcServer::spawn(vec![
825            rpc_result(json!({ "number": "0x69" })),
826            rpc_result(json!({ "hash": "0xaaa" })),
827        ]);
828        let config = sample_config(server.base_url());
829        let receipt = sample_receipt(100, "0xaaa", 1_700_000_000);
830
831        let assessment = inspect_finality_for_receipt(&config, &receipt, 500_000, None)
832            .await
833            .test_expect("assessment should succeed");
834
835        let requests = server.requests();
836        server.join();
837
838        assert_eq!(
839            assessment.status,
840            SettlementFinalityStatus::AwaitingConfirmations
841        );
842        assert_eq!(assessment.required_confirmations, 12);
843        assert_eq!(assessment.current_confirmations, 6);
844        assert_eq!(assessment.dispute_window_secs, 14_400);
845        assert_eq!(requests[0]["method"], "eth_getBlockByNumber");
846        assert_eq!(requests[1]["method"], "eth_getBlockByNumber");
847    }
848
849    #[tokio::test]
850    async fn inspect_finality_for_receipt_awaits_dispute_window() {
851        let server = MockJsonRpcServer::spawn(vec![
852            rpc_result(json!({ "number": "0x78" })),
853            rpc_result(json!({ "hash": "0xaaa" })),
854        ]);
855        let config = sample_config(server.base_url());
856        let receipt = sample_receipt(100, "0xaaa", 1_700_000_000);
857
858        let assessment =
859            inspect_finality_for_receipt(&config, &receipt, 500_000, Some(1_700_010_000))
860                .await
861                .test_expect("assessment should succeed");
862
863        server.join();
864
865        assert_eq!(
866            assessment.status,
867            SettlementFinalityStatus::AwaitingDisputeWindow
868        );
869        assert_eq!(assessment.current_confirmations, 21);
870        assert_eq!(assessment.dispute_window_closes_at, 1_700_014_400);
871    }
872
873    #[tokio::test]
874    async fn inspect_finality_for_receipt_finalizes_after_dispute_window() {
875        let server = MockJsonRpcServer::spawn(vec![
876            rpc_result(json!({ "number": "0x78" })),
877            rpc_result(json!({ "hash": "0xaaa" })),
878        ]);
879        let mut config = sample_config(server.base_url());
880        config.policy.tiers[0].dispute_window_secs = 3_600;
881        let receipt = sample_receipt(100, "0xaaa", 1_700_000_000);
882
883        let assessment =
884            inspect_finality_for_receipt(&config, &receipt, 500_000, Some(1_700_020_000))
885                .await
886                .test_expect("assessment should succeed");
887
888        server.join();
889
890        assert_eq!(assessment.status, SettlementFinalityStatus::Finalized);
891    }
892
893    #[tokio::test]
894    async fn inspect_finality_for_receipt_marks_reorgs_for_hash_mismatch_or_missing_block() {
895        let mismatch_server = MockJsonRpcServer::spawn(vec![
896            rpc_result(json!({ "number": "0x78" })),
897            rpc_result(json!({ "hash": "0xbbb" })),
898        ]);
899        let mismatch_config = sample_config(mismatch_server.base_url());
900        let receipt = sample_receipt(100, "0xaaa", 1_700_000_000);
901
902        let mismatch =
903            inspect_finality_for_receipt(&mismatch_config, &receipt, 500_000, Some(1_700_020_000))
904                .await
905                .test_expect("assessment should succeed");
906        mismatch_server.join();
907        assert_eq!(mismatch.status, SettlementFinalityStatus::Reorged);
908
909        let missing_server = MockJsonRpcServer::spawn(vec![
910            rpc_result(json!({ "number": "0x78" })),
911            rpc_result(Value::Null),
912        ]);
913        let missing_config = sample_config(missing_server.base_url());
914        let missing =
915            inspect_finality_for_receipt(&missing_config, &receipt, 500_000, Some(1_700_020_000))
916                .await
917                .test_expect("assessment should succeed");
918        missing_server.join();
919        assert_eq!(missing.status, SettlementFinalityStatus::Reorged);
920    }
921
922    #[tokio::test]
923    async fn inspect_finality_fetches_receipt_and_timestamp() {
924        let server = MockJsonRpcServer::spawn(vec![
925            rpc_result(json!({
926                "blockHash": "0xabc",
927                "blockNumber": "0x64",
928                "status": "0x1",
929                "gasUsed": "0x5208",
930                "from": "0x1000000000000000000000000000000000000001",
931                "to": "0x1000000000000000000000000000000000000002",
932                "logs": []
933            })),
934            rpc_result(json!({
935                "timestamp": "0x6553f100"
936            })),
937            rpc_result(json!({ "number": "0x64" })),
938            rpc_result(json!({ "hash": "0xabc" })),
939        ]);
940        let config = sample_config(server.base_url());
941
942        let (receipt, assessment) =
943            inspect_finality(&config, "0xdeadbeef", 50_000, Some(1_700_004_000))
944                .await
945                .test_expect("finality inspection should succeed");
946
947        let requests = server.requests();
948        server.join();
949
950        assert_eq!(receipt.tx_hash, "0xdeadbeef");
951        assert_eq!(receipt.block_number, 100);
952        assert_eq!(receipt.block_hash, "0xabc");
953        assert_eq!(receipt.gas_used, 21_000);
954        assert_eq!(receipt.observed_at, 1_700_000_000);
955        assert_eq!(assessment.status, SettlementFinalityStatus::Finalized);
956        assert_eq!(assessment.required_confirmations, 1);
957        assert_eq!(requests[0]["method"], "eth_getTransactionReceipt");
958        assert_eq!(requests[1]["method"], "eth_getBlockByHash");
959        assert_eq!(requests[2]["method"], "eth_getBlockByNumber");
960        assert_eq!(requests[3]["method"], "eth_getBlockByNumber");
961    }
962
963    #[tokio::test]
964    async fn inspect_finality_for_receipt_surfaces_rpc_errors_and_missing_fields() {
965        let error_server = MockJsonRpcServer::spawn(vec![rpc_error(-32000, "boom")]);
966        let error_config = sample_config(error_server.base_url());
967        let receipt = sample_receipt(100, "0xaaa", 1_700_000_000);
968        let error = inspect_finality_for_receipt(&error_config, &receipt, 500_000, None)
969            .await
970            .test_expect_err("RPC error should fail");
971        error_server.join();
972        assert!(matches!(error, SettlementError::Rpc(_)));
973
974        let missing_field_server =
975            MockJsonRpcServer::spawn(vec![rpc_result(json!({ "hash": "0xaaa" }))]);
976        let missing_field_config = sample_config(missing_field_server.base_url());
977        let error = inspect_finality_for_receipt(&missing_field_config, &receipt, 500_000, None)
978            .await
979            .test_expect_err("missing block number should fail");
980        missing_field_server.join();
981        assert!(error.to_string().contains("latest block missing number"));
982    }
983
984    #[test]
985    fn observer_config_accepts_hostname_rpc_url_with_pinned_resolver() {
986        let mut config = sample_config("http://127.0.0.1:8545");
987        config.rpc_url = "https://mainnet-rpc.example.com".to_string();
988        config.egress_contract = hostname_rpc_contract("mainnet-rpc.example.com");
989
990        config
991            .validate_rpc_egress_contract()
992            .test_expect("hostname observer RPC dispatch is resolver-enforced");
993    }
994
995    #[tokio::test]
996    async fn observer_rpc_rejects_redirects() {
997        let server = MockRawHttpServer::spawn(
998            "HTTP/1.1 302 Found\r\nLocation: /redirected\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
999                .to_string(),
1000        );
1001        let config = sample_config(server.base_url());
1002        let receipt = sample_receipt(100, "0xaaa", 1_700_000_000);
1003
1004        let error = inspect_finality_for_receipt(&config, &receipt, 50_000, None)
1005            .await
1006            .test_expect_err("observer RPC redirect should fail");
1007        let message = error.to_string();
1008
1009        server.join();
1010        assert!(
1011            message.contains("HttpEgressContract") && message.contains("redirect chain length"),
1012            "unexpected observer RPC redirect denial: {message}"
1013        );
1014    }
1015
1016    #[tokio::test]
1017    async fn observer_rpc_rejects_oversized_responses() {
1018        let server = MockRawHttpServer::spawn(
1019            "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 67108865\r\nConnection: close\r\n\r\n"
1020                .to_string(),
1021        );
1022        let config = sample_config(server.base_url());
1023        let receipt = sample_receipt(100, "0xaaa", 1_700_000_000);
1024
1025        let error = inspect_finality_for_receipt(&config, &receipt, 50_000, None)
1026            .await
1027            .test_expect_err("oversized observer RPC response should fail");
1028        let message = error.to_string();
1029
1030        server.join();
1031        assert!(
1032            message.contains("HttpEgressContract") && message.contains("response size"),
1033            "unexpected observer RPC response-size denial: {message}"
1034        );
1035    }
1036
1037    #[tokio::test]
1038    async fn project_escrow_execution_receipt_projects_timed_out_refund() {
1039        let dispatch = sample_dispatch();
1040        let server = MockJsonRpcServer::spawn(vec![
1041            rpc_result(json!(encode_hex(
1042                IChioEscrow::getEscrowCall::abi_encode_returns(&IChioEscrow::getEscrowReturn {
1043                    terms: IChioEscrow::EscrowTerms {
1044                        capabilityId: B256::from([0x11; 32]),
1045                        depositor: Address::from_str("0x1000000000000000000000000000000000000001",)
1046                            .test_unwrap(),
1047                        beneficiary: Address::from_str(
1048                            "0x1000000000000000000000000000000000000002",
1049                        )
1050                        .test_unwrap(),
1051                        token: Address::from_str("0x1000000000000000000000000000000000000003",)
1052                            .test_unwrap(),
1053                        maxAmount: U256::from(1_500_000_u64),
1054                        deadline: U256::from(1_700_003_000_u64),
1055                        operator: Address::from_str("0x1000000000000000000000000000000000000004",)
1056                            .test_unwrap(),
1057                        operatorKeyHash: B256::from([0x22; 32]),
1058                    },
1059                    deposited: U256::from(1_500_000_u64),
1060                    released: U256::from(250_000_u64),
1061                    refunded: true,
1062                })
1063            ))),
1064            rpc_result(json!({
1065                "blockHash": "0xabc",
1066                "blockNumber": "0x64",
1067                "status": "0x1",
1068                "gasUsed": "0x5208",
1069                "from": "0x1000000000000000000000000000000000000001",
1070                "to": "0x1000000000000000000000000000000000000002",
1071                "logs": []
1072            })),
1073            rpc_result(json!({
1074                "timestamp": "0x6553f100"
1075            })),
1076            rpc_result(json!({ "number": "0x64" })),
1077            rpc_result(json!({ "hash": "0xabc" })),
1078        ]);
1079        let config = sample_config(server.base_url());
1080
1081        let projection = project_escrow_execution_receipt(
1082            &config,
1083            ExecutionProjectionInput {
1084                dispatch: &dispatch,
1085                tx_hash: "0xdddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
1086                execution_receipt_id: "exec-1".to_string(),
1087                settlement_reference: "settlement-1".to_string(),
1088                observed_at: Some(1_743_292_900),
1089                observed_amount: dispatch.settlement_amount.clone(),
1090                anchor_proof: None,
1091                identity_registry_evidence: None,
1092                identity_registry_evidence_binding: None,
1093                oracle_evidence: None,
1094                failure_reason: None,
1095                reversal_of: None,
1096                note: Some("timed out".to_string()),
1097            },
1098        )
1099        .await
1100        .test_expect("projection should succeed");
1101
1102        server.join();
1103
1104        assert_eq!(
1105            projection.receipt.lifecycle_state,
1106            Web3SettlementLifecycleState::TimedOut
1107        );
1108        assert_eq!(
1109            projection.receipt.failure_reason.as_deref(),
1110            Some("escrow refunded after deadline")
1111        );
1112        assert_eq!(projection.receipt.settled_amount.units, 125);
1113        assert_eq!(
1114            projection.recovery_action,
1115            Some(SettlementRecoveryAction::ExecuteRefund)
1116        );
1117        assert!(projection.escrow_snapshot.refunded);
1118    }
1119
1120    #[tokio::test]
1121    async fn project_escrow_execution_receipt_projects_partial_settlement() {
1122        let dispatch = sample_dispatch();
1123        let server = MockJsonRpcServer::spawn(vec![
1124            rpc_result(json!(encode_hex(
1125                IChioEscrow::getEscrowCall::abi_encode_returns(&IChioEscrow::getEscrowReturn {
1126                    terms: IChioEscrow::EscrowTerms {
1127                        capabilityId: B256::from([0x33; 32]),
1128                        depositor: Address::from_str("0x1000000000000000000000000000000000000001",)
1129                            .test_unwrap(),
1130                        beneficiary: Address::from_str(
1131                            "0x1000000000000000000000000000000000000002",
1132                        )
1133                        .test_unwrap(),
1134                        token: Address::from_str("0x1000000000000000000000000000000000000003",)
1135                            .test_unwrap(),
1136                        maxAmount: U256::from(1_500_000_u64),
1137                        deadline: U256::from(1_700_050_000_u64),
1138                        operator: Address::from_str("0x1000000000000000000000000000000000000004",)
1139                            .test_unwrap(),
1140                        operatorKeyHash: B256::from([0x44; 32]),
1141                    },
1142                    deposited: U256::from(1_500_000_u64),
1143                    released: U256::from(750_000_u64),
1144                    refunded: false,
1145                })
1146            ))),
1147            rpc_result(json!({
1148                "blockHash": "0xabc",
1149                "blockNumber": "0x64",
1150                "status": "0x1",
1151                "gasUsed": "0x5208",
1152                "from": "0x1000000000000000000000000000000000000001",
1153                "to": "0x1000000000000000000000000000000000000002",
1154                "logs": []
1155            })),
1156            rpc_result(json!({
1157                "timestamp": "0x6553f100"
1158            })),
1159            rpc_result(json!({ "number": "0x78" })),
1160            rpc_result(json!({ "hash": "0xabc" })),
1161        ]);
1162        let config = sample_config(server.base_url());
1163
1164        let projection = project_escrow_execution_receipt(
1165            &config,
1166            ExecutionProjectionInput {
1167                dispatch: &dispatch,
1168                tx_hash: "0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
1169                execution_receipt_id: "exec-2".to_string(),
1170                settlement_reference: "settlement-2".to_string(),
1171                observed_at: Some(1_743_292_900),
1172                observed_amount: chio_core::capability::scope::MonetaryAmount {
1173                    units: dispatch.settlement_amount.units / 2,
1174                    currency: dispatch.settlement_amount.currency.clone(),
1175                },
1176                anchor_proof: None,
1177                identity_registry_evidence: Some(sample_identity_registry_evidence(
1178                    &dispatch, &config,
1179                )),
1180                identity_registry_evidence_binding: Some(
1181                    sample_identity_registry_evidence_binding(&dispatch, &config),
1182                ),
1183                oracle_evidence: None,
1184                failure_reason: Some("partial release".to_string()),
1185                reversal_of: None,
1186                note: None,
1187            },
1188        )
1189        .await
1190        .test_expect("projection should succeed");
1191
1192        server.join();
1193
1194        assert_eq!(
1195            projection.receipt.lifecycle_state,
1196            Web3SettlementLifecycleState::PartiallySettled
1197        );
1198        assert_eq!(
1199            projection.receipt.failure_reason.as_deref(),
1200            Some("partial release")
1201        );
1202        assert_eq!(
1203            projection.finality.status,
1204            SettlementFinalityStatus::Finalized
1205        );
1206        assert_eq!(projection.recovery_action, None);
1207        assert!(!projection.escrow_snapshot.refunded);
1208    }
1209
1210    #[tokio::test]
1211    async fn project_escrow_execution_receipt_waits_for_finality_before_settled_state() {
1212        let dispatch = sample_dispatch();
1213        let server = MockJsonRpcServer::spawn(vec![
1214            rpc_result(json!(encode_hex(
1215                IChioEscrow::getEscrowCall::abi_encode_returns(&IChioEscrow::getEscrowReturn {
1216                    terms: IChioEscrow::EscrowTerms {
1217                        capabilityId: B256::from([0x77; 32]),
1218                        depositor: Address::from_str("0x1000000000000000000000000000000000000001",)
1219                            .test_unwrap(),
1220                        beneficiary: Address::from_str(
1221                            "0x1000000000000000000000000000000000000002",
1222                        )
1223                        .test_unwrap(),
1224                        token: Address::from_str("0x1000000000000000000000000000000000000003",)
1225                            .test_unwrap(),
1226                        maxAmount: U256::from(1_500_000_u64),
1227                        deadline: U256::from(1_700_050_000_u64),
1228                        operator: Address::from_str("0x1000000000000000000000000000000000000004",)
1229                            .test_unwrap(),
1230                        operatorKeyHash: B256::from([0x88; 32]),
1231                    },
1232                    deposited: U256::from(1_500_000_u64),
1233                    released: U256::from(750_000_u64),
1234                    refunded: false,
1235                })
1236            ))),
1237            rpc_result(json!({
1238                "blockHash": "0xabc",
1239                "blockNumber": "0x64",
1240                "status": "0x1",
1241                "gasUsed": "0x5208",
1242                "from": "0x1000000000000000000000000000000000000001",
1243                "to": "0x1000000000000000000000000000000000000002",
1244                "logs": []
1245            })),
1246            rpc_result(json!({
1247                "timestamp": "0x67e88660"
1248            })),
1249            rpc_result(json!({ "number": "0x78" })),
1250            rpc_result(json!({ "hash": "0xabc" })),
1251        ]);
1252        let mut config = sample_config(server.base_url());
1253        config.policy.tiers[0].dispute_window_secs = 3_600;
1254
1255        let projection = project_escrow_execution_receipt(
1256            &config,
1257            ExecutionProjectionInput {
1258                dispatch: &dispatch,
1259                tx_hash: "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
1260                execution_receipt_id: "exec-pending".to_string(),
1261                settlement_reference: "settlement-pending".to_string(),
1262                observed_at: Some(1_743_292_900),
1263                observed_amount: chio_core::capability::scope::MonetaryAmount {
1264                    units: dispatch.settlement_amount.units / 2,
1265                    currency: dispatch.settlement_amount.currency.clone(),
1266                },
1267                anchor_proof: None,
1268                identity_registry_evidence: Some(sample_identity_registry_evidence(
1269                    &dispatch, &config,
1270                )),
1271                identity_registry_evidence_binding: Some(
1272                    sample_identity_registry_evidence_binding(&dispatch, &config),
1273                ),
1274                oracle_evidence: None,
1275                failure_reason: None,
1276                reversal_of: None,
1277                note: None,
1278            },
1279        )
1280        .await
1281        .test_expect("projection should succeed");
1282
1283        server.join();
1284
1285        assert_eq!(
1286            projection.finality.status,
1287            SettlementFinalityStatus::AwaitingDisputeWindow
1288        );
1289        assert_eq!(
1290            projection.receipt.lifecycle_state,
1291            Web3SettlementLifecycleState::EscrowLocked
1292        );
1293        assert_eq!(
1294            projection.recovery_action,
1295            Some(SettlementRecoveryAction::WaitForDisputeWindow)
1296        );
1297        assert_eq!(projection.receipt.settled_amount.units, 0);
1298        assert_eq!(projection.receipt.observed_execution.amount.units, 0);
1299        assert_eq!(projection.receipt.failure_reason, None);
1300    }
1301
1302    #[tokio::test]
1303    async fn project_escrow_execution_receipt_waits_for_confirmations_without_release() {
1304        let dispatch = sample_dispatch();
1305        let server = MockJsonRpcServer::spawn(vec![
1306            escrow_result(0),
1307            rpc_result(json!({
1308                "blockHash": "0xabc",
1309                "blockNumber": "0x64",
1310                "status": "0x1",
1311                "gasUsed": "0x5208",
1312                "from": "0x1000000000000000000000000000000000000001",
1313                "to": "0x1000000000000000000000000000000000000002",
1314                "logs": []
1315            })),
1316            rpc_result(json!({
1317                "timestamp": "0x6553f100"
1318            })),
1319            rpc_result(json!({ "number": "0x64" })),
1320            rpc_result(json!({ "hash": "0xabc" })),
1321        ]);
1322        let mut config = sample_config(server.base_url());
1323        config.policy.tiers[0].min_confirmations = 2;
1324
1325        let projection = project_escrow_execution_receipt(
1326            &config,
1327            ExecutionProjectionInput {
1328                dispatch: &dispatch,
1329                tx_hash: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1330                execution_receipt_id: "exec-pending-zero".to_string(),
1331                settlement_reference: "settlement-pending-zero".to_string(),
1332                observed_at: Some(1_743_292_900),
1333                observed_amount: dispatch.settlement_amount.clone(),
1334                anchor_proof: None,
1335                identity_registry_evidence: Some(sample_identity_registry_evidence(
1336                    &dispatch, &config,
1337                )),
1338                identity_registry_evidence_binding: Some(
1339                    sample_identity_registry_evidence_binding(&dispatch, &config),
1340                ),
1341                oracle_evidence: None,
1342                failure_reason: None,
1343                reversal_of: None,
1344                note: None,
1345            },
1346        )
1347        .await
1348        .test_expect("pending zero-release projection should still return a wait action");
1349
1350        server.join();
1351
1352        assert_eq!(
1353            projection.finality.status,
1354            SettlementFinalityStatus::AwaitingConfirmations
1355        );
1356        assert_eq!(
1357            projection.receipt.lifecycle_state,
1358            Web3SettlementLifecycleState::EscrowLocked
1359        );
1360        assert_eq!(
1361            projection.recovery_action,
1362            Some(SettlementRecoveryAction::WaitForConfirmations)
1363        );
1364        assert_eq!(projection.receipt.settled_amount.units, 0);
1365        assert_eq!(projection.receipt.observed_execution.amount.units, 0);
1366        assert_eq!(projection.receipt.failure_reason, None);
1367    }
1368
1369    #[tokio::test]
1370    async fn observe_bond_classifies_statuses_and_recovery_actions() {
1371        let cases = [
1372            (
1373                false,
1374                false,
1375                0_u64,
1376                1_700_000_000_u64,
1377                BondLifecycleStatus::Active,
1378                None,
1379            ),
1380            (
1381                true,
1382                false,
1383                0_u64,
1384                1_700_000_000_u64,
1385                BondLifecycleStatus::Released,
1386                Some(SettlementRecoveryAction::ManualReview),
1387            ),
1388            (
1389                false,
1390                false,
1391                5_u64,
1392                1_700_000_000_u64,
1393                BondLifecycleStatus::Impaired,
1394                Some(SettlementRecoveryAction::ManualReview),
1395            ),
1396            (
1397                false,
1398                false,
1399                0_u64,
1400                1_700_100_001_u64,
1401                BondLifecycleStatus::Expired,
1402                Some(SettlementRecoveryAction::ExpireBond),
1403            ),
1404            (
1405                false,
1406                true,
1407                0_u64,
1408                1_700_100_001_u64,
1409                BondLifecycleStatus::Expired,
1410                None,
1411            ),
1412        ];
1413
1414        for (
1415            released,
1416            expired,
1417            slashed_minor_units,
1418            observed_at,
1419            expected_status,
1420            expected_recovery,
1421        ) in cases
1422        {
1423            let server = MockJsonRpcServer::spawn(vec![
1424                rpc_result(json!({
1425                    "number": "0x65",
1426                    "hash": "0xabababababababababababababababababababababababababababababababab",
1427                    "timestamp": format!("0x{observed_at:x}")
1428                })),
1429                rpc_result(json!(encode_hex(
1430                    IChioBondVault::getBondCall::abi_encode_returns(
1431                        &IChioBondVault::getBondReturn {
1432                            terms: IChioBondVault::BondTerms {
1433                                bondId: B256::from([0x55; 32]),
1434                                facilityId: B256::from([0x66; 32]),
1435                                principal: Address::from_str(
1436                                    "0x1000000000000000000000000000000000000001",
1437                                )
1438                                .test_unwrap(),
1439                                token: Address::from_str(
1440                                    "0x1000000000000000000000000000000000000002",
1441                                )
1442                                .test_unwrap(),
1443                                collateralAmount: U256::from(1_000_u64),
1444                                reserveRequirementAmount: U256::from(250_u64),
1445                                expiresAt: U256::from(1_700_100_000_u64),
1446                                reserveRequirementRatioBps: 2_500_u16,
1447                                operator: Address::from_str(
1448                                    "0x1000000000000000000000000000000000000003",
1449                                )
1450                                .test_unwrap(),
1451                                operatorKeyHash: B256::from([0x77; 32]),
1452                            },
1453                            lockedAmount: U256::from(1_000_u64),
1454                            slashedAmount: U256::from(slashed_minor_units),
1455                            released,
1456                            expired,
1457                        }
1458                    )
1459                ))),
1460            ]);
1461            let config = sample_config(server.base_url());
1462
1463            let observation = observe_bond(
1464                &config,
1465                "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
1466            )
1467            .await
1468            .test_expect("bond observation should succeed");
1469
1470            server.join();
1471
1472            assert_eq!(observation.status, expected_status);
1473            assert_eq!(observation.recovery_action, expected_recovery);
1474        }
1475    }
1476}