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}