1use super::*;
4
5pub fn scale_chio_amount_to_token_minor_units(
6 amount: &MonetaryAmount,
7 config: &SettlementChainConfig,
8) -> Result<u128, SettlementError> {
9 config.validate()?;
10 let chio_decimals = u32::from(config.policy.chio_minor_unit_decimals);
11 let token_decimals = u32::from(config.policy.token_minor_unit_decimals);
12 let amount_units = u128::from(amount.units);
13 if token_decimals >= chio_decimals {
14 let scale = 10_u128
15 .checked_pow(token_decimals - chio_decimals)
16 .ok_or_else(|| {
17 SettlementError::InvalidInput("amount scaling overflowed".to_string())
18 })?;
19 amount_units
20 .checked_mul(scale)
21 .ok_or_else(|| SettlementError::InvalidInput("scaled amount overflowed".to_string()))
22 } else {
23 let divisor = 10_u128
24 .checked_pow(chio_decimals - token_decimals)
25 .ok_or_else(|| {
26 SettlementError::InvalidInput("amount scaling overflowed".to_string())
27 })?;
28 if amount_units % divisor != 0 {
29 return Err(SettlementError::InvalidInput(
30 "Chio amount cannot be represented exactly in settlement token units".to_string(),
31 ));
32 }
33 Ok(amount_units / divisor)
34 }
35}
36
37const ESCROW_PROOF_LEAF_TYPE: &str = "ChioEscrowProof(uint256 chainId,address escrow,bytes32 escrowId,address token,address beneficiary,bytes32 operatorKeyHash,bytes32 receiptHash,uint256 amount,bool partial)";
38const BOND_PROOF_LEAF_TYPE: &str = "ChioBondProof(uint256 chainId,address vault,bytes32 vaultId,bytes32 operatorKeyHash,bytes32 evidenceHash,uint8 action,uint256 slashAmount,bytes32 distributionHash)";
39const BOND_ACTION_RELEASE: u8 = 0;
40const BOND_ACTION_IMPAIR: u8 = 1;
41const MAX_IMPAIR_BENEFICIARIES: usize = 16;
42pub(super) const GUARDED_MONEY_EXIT_SELECTORS: [[u8; 4]; 11] = [
43 IChioRootRegistry::publishRootCall::SELECTOR,
44 IChioRootRegistry::publishRootBatchCall::SELECTOR,
45 IChioEscrow::releaseWithProofCall::SELECTOR,
46 IChioEscrow::releaseWithProofDetailedCall::SELECTOR,
47 IChioEscrow::partialReleaseWithProofCall::SELECTOR,
48 IChioEscrow::partialReleaseWithProofDetailedCall::SELECTOR,
49 IChioEscrow::releaseWithSignatureCall::SELECTOR,
50 IChioEscrow::refundCall::SELECTOR,
51 IChioBondVault::releaseBondDetailedCall::SELECTOR,
52 IChioBondVault::impairBondDetailedCall::SELECTOR,
53 IChioBondVault::expireReleaseCall::SELECTOR,
54];
55
56#[derive(Debug, Clone, Copy)]
57pub enum PreparedBondProofRoot<'a> {
58 Release(&'a PreparedBondRelease),
59 Impair(&'a PreparedBondImpair),
60}
61
62impl PreparedBondProofRoot<'_> {
63 fn chain_id(self) -> String {
64 match self {
65 Self::Release(prepared) => prepared.chain_id.clone(),
66 Self::Impair(prepared) => prepared.chain_id.clone(),
67 }
68 }
69}
70
71pub fn scale_token_minor_units_to_chio_amount(
72 units: u128,
73 currency: &str,
74 config: &SettlementChainConfig,
75) -> Result<MonetaryAmount, SettlementError> {
76 config.validate()?;
77 if currency.len() != 3 || !currency.bytes().all(|byte| byte.is_ascii_uppercase()) {
78 return Err(SettlementError::InvalidInput(
79 "currency must be an uppercase ISO 4217 code".to_string(),
80 ));
81 }
82 let chio_decimals = u32::from(config.policy.chio_minor_unit_decimals);
83 let token_decimals = u32::from(config.policy.token_minor_unit_decimals);
84 let chio_units = if token_decimals >= chio_decimals {
85 let divisor = 10_u128
86 .checked_pow(token_decimals - chio_decimals)
87 .ok_or_else(|| {
88 SettlementError::InvalidInput("amount scaling overflowed".to_string())
89 })?;
90 if !units.is_multiple_of(divisor) {
91 return Err(SettlementError::InvalidInput(
92 "token amount cannot be represented exactly in Chio units".to_string(),
93 ));
94 }
95 units / divisor
96 } else {
97 let scale = 10_u128
98 .checked_pow(chio_decimals - token_decimals)
99 .ok_or_else(|| {
100 SettlementError::InvalidInput("amount scaling overflowed".to_string())
101 })?;
102 units
103 .checked_mul(scale)
104 .ok_or_else(|| SettlementError::InvalidInput("scaled amount overflowed".to_string()))?
105 };
106 let amount = u64::try_from(chio_units)
107 .map_err(|_| SettlementError::InvalidInput("Chio amount does not fit u64".to_string()))?;
108 if amount > (1_u64 << 53) - 1 {
109 return Err(SettlementError::InvalidInput(
110 "Chio amount exceeds the I-JSON safe integer range".to_string(),
111 ));
112 }
113 Ok(MonetaryAmount {
114 units: amount,
115 currency: currency.to_string(),
116 })
117}
118
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct SettlementAnchorContentBinding {
121 pub execution_receipt_id: String,
122 pub settlement_reference: String,
123}
124
125pub fn prepare_erc20_approval(
126 token_address: &str,
127 owner_address: &str,
128 spender_address: &str,
129 amount_minor_units: u128,
130) -> Result<PreparedErc20Approval, SettlementError> {
131 let spender = parse_address(spender_address, "spender_address")?;
132 let amount = U256::from(amount_minor_units);
133 let call = IERC20ApproveOnly::approveCall { spender, amount };
134 Ok(PreparedErc20Approval {
135 owner_address: owner_address.to_string(),
136 token_address: token_address.to_string(),
137 spender_address: spender_address.to_string(),
138 amount_minor_units,
139 call: PreparedEvmCall {
140 from_address: owner_address.to_string(),
141 to_address: token_address.to_string(),
142 data: encode_call(call),
143 gas_limit: None,
144 },
145 })
146}
147
148pub async fn prepare_web3_escrow_dispatch(
149 config: &SettlementChainConfig,
150 request: &EscrowDispatchRequest,
151 binding: &SignedWeb3IdentityBinding,
152) -> Result<PreparedEscrowCreate, SettlementError> {
153 config.validate()?;
154 ensure_instruction_ready(
155 config,
156 &request.capital_instruction,
157 &request.beneficiary_address,
158 )?;
159 ensure_settlement_binding(config, binding, Web3KeyBindingPurpose::Settle)?;
160
161 if request.dispatch_id.trim().is_empty() {
162 return Err(SettlementError::InvalidInput(
163 "dispatch_id is required".to_string(),
164 ));
165 }
166 if request.capability_id.trim().is_empty() {
167 return Err(SettlementError::InvalidInput(
168 "capability_id is required".to_string(),
169 ));
170 }
171
172 let settlement_amount = request
173 .capital_instruction
174 .body
175 .amount
176 .clone()
177 .ok_or_else(|| {
178 SettlementError::InvalidDispatch("capital instruction amount is required".to_string())
179 })?;
180 let amount_minor_units = scale_chio_amount_to_token_minor_units(&settlement_amount, config)?;
181 let operator_key_hash = settlement_operator_key_hash(binding)?;
182 let terms = IChioEscrow::EscrowTerms {
183 capabilityId: hash_string_id(&request.capability_id),
184 depositor: parse_address(&request.depositor_address, "depositor_address")?,
185 beneficiary: parse_address(&request.beneficiary_address, "beneficiary_address")?,
186 token: parse_address(&config.settlement_token_address, "settlement_token_address")?,
187 maxAmount: U256::from(amount_minor_units),
188 deadline: U256::from(request.capital_instruction.body.execution_window.not_after),
189 operator: parse_address(&config.operator_address, "operator_address")?,
190 operatorKeyHash: operator_key_hash,
191 };
192
193 let derive_call = IChioEscrow::deriveEscrowIdCall {
194 terms: terms.clone(),
195 };
196 let static_result = eth_call_raw(
197 config,
198 &PreparedEvmCall {
199 from_address: request.depositor_address.clone(),
200 to_address: config.escrow_contract.clone(),
201 data: encode_call(derive_call),
202 gas_limit: None,
203 },
204 )
205 .await?;
206 let result_bytes = decode_hex_bytes(&static_result)?;
207 let expected_escrow_id = IChioEscrow::deriveEscrowIdCall::abi_decode_returns(&result_bytes)
208 .map_err(|error| {
209 SettlementError::Serialization(format!("deriveEscrowId decode failed: {error}"))
210 })?;
211 let expected_escrow_id = format_b256(expected_escrow_id);
212 let create_call_data = encode_call(IChioEscrow::createEscrowCall { terms });
213
214 let dispatch = Web3SettlementDispatchArtifact {
215 schema: CHIO_WEB3_SETTLEMENT_DISPATCH_SCHEMA.to_string(),
216 dispatch_id: request.dispatch_id.clone(),
217 issued_at: request.issued_at,
218 trust_profile_id: request.trust_profile_id.clone(),
219 contract_package_id: request.contract_package_id.clone(),
220 chain_id: config.chain_id.clone(),
221 capital_instruction: request.capital_instruction.clone(),
222 bond: None,
223 settlement_path: request.settlement_path,
224 settlement_amount: settlement_amount.clone(),
225 escrow_id: expected_escrow_id.clone(),
226 escrow_contract: config.escrow_contract.clone(),
227 bond_vault_contract: config.bond_vault_contract.clone(),
228 settlement_token_address: config.settlement_token_address.clone(),
229 beneficiary_address: request.beneficiary_address.clone(),
230 operator_key_hash: format_b256(operator_key_hash),
231 support_boundary: Web3SettlementSupportBoundary {
232 real_dispatch_supported: true,
233 anchor_proof_required: request.settlement_path == Web3SettlementPath::MerkleProof,
234 oracle_evidence_required_for_fx: request.oracle_evidence_required_for_fx,
235 custody_boundary_explicit: true,
236 reversal_supported: true,
237 },
238 note: request.note.clone(),
239 };
240 validate_web3_settlement_dispatch(&dispatch)
241 .map_err(|error| SettlementError::InvalidDispatch(error.to_string()))?;
242
243 Ok(PreparedEscrowCreate {
244 expected_escrow_id,
245 capability_commitment: format_b256(hash_string_id(&request.capability_id)),
246 settlement_amount_minor_units: amount_minor_units,
247 dispatch,
248 call: PreparedEvmCall {
249 from_address: request.depositor_address.clone(),
250 to_address: config.escrow_contract.clone(),
251 data: create_call_data,
252 gas_limit: None,
253 },
254 })
255}
256
257fn settlement_operator_key_hash(
258 binding: &SignedWeb3IdentityBinding,
259) -> Result<B256, SettlementError> {
260 if !matches!(
261 binding.certificate.chio_public_key.algorithm(),
262 chio_core::crypto::SigningAlgorithm::Ed25519
263 ) {
264 return Err(SettlementError::InvalidBinding(format!(
265 "settlement identity binding requires an Ed25519 chio_public_key, got {:?}",
266 binding.certificate.chio_public_key.algorithm()
267 )));
268 }
269 Ok(keccak256(binding.certificate.chio_public_key.as_bytes()))
270}
271
272pub fn prepare_merkle_release(
273 config: &SettlementChainConfig,
274 dispatch: &Web3SettlementDispatchArtifact,
275 anchor_proof: &AnchorInclusionProof,
276 anchor_content: &SettlementAnchorContentBinding,
277 amount: EscrowExecutionAmount,
278) -> Result<PreparedMerkleRelease, SettlementError> {
279 config.validate()?;
280 validate_web3_settlement_dispatch(dispatch)
281 .map_err(|error| SettlementError::InvalidDispatch(error.to_string()))?;
282 validate_merkle_dispatch_config(config, dispatch)?;
283 verify_anchor_inclusion_proof(anchor_proof)
284 .map_err(|error| SettlementError::Verification(error.to_string()))?;
285 ensure_escrow_anchor_matches_config(config, dispatch, anchor_proof, anchor_content)?;
286
287 let receipt_bytes = canonical_json_bytes(&anchor_proof.receipt.body())
288 .map_err(|error| SettlementError::Serialization(error.to_string()))?;
289 let leaf = leaf_hash(&receipt_bytes);
290 let receipt_hash = keccak256(&receipt_bytes);
291 let observed_amount = match amount {
292 EscrowExecutionAmount::Full => dispatch.settlement_amount.clone(),
293 EscrowExecutionAmount::Partial(amount) => amount,
294 };
295 let amount_minor_units = scale_chio_amount_to_token_minor_units(&observed_amount, config)?;
296 let escrow_id = parse_b256_hex(&dispatch.escrow_id, "dispatch.escrow_id")?;
297 let typed_root = escrow_proof_leaf(
298 dispatch,
299 escrow_id,
300 receipt_hash,
301 amount_minor_units,
302 observed_amount != dispatch.settlement_amount,
303 )?;
304 let proof = ChioMerkleProof {
305 audit_path: Vec::new(),
306 leaf_index: U256::from(0_u8),
307 tree_size: U256::from(1_u8),
308 };
309 let call = if observed_amount == dispatch.settlement_amount {
310 IChioEscrow::releaseWithProofDetailedCall {
311 escrowId: escrow_id,
312 proof: (&proof).into(),
313 root: typed_root,
314 receiptHash: receipt_hash,
315 settledAmount: U256::from(amount_minor_units),
316 }
317 .abi_encode()
318 } else {
319 IChioEscrow::partialReleaseWithProofDetailedCall {
320 escrowId: escrow_id,
321 proof: (&proof).into(),
322 root: typed_root,
323 receiptHash: receipt_hash,
324 amount: U256::from(amount_minor_units),
325 }
326 .abi_encode()
327 };
328
329 Ok(PreparedMerkleRelease {
330 escrow_id: dispatch.escrow_id.clone(),
331 chain_id: dispatch.chain_id.clone(),
332 receipt_hash: format_b256(receipt_hash),
333 receipt_leaf_hash: leaf.to_hex_prefixed(),
334 merkle_root: format_b256(typed_root),
335 partial: observed_amount != dispatch.settlement_amount,
336 settlement_amount_minor_units: amount_minor_units,
337 observed_amount,
338 call: PreparedEvmCall {
339 from_address: dispatch.beneficiary_address.clone(),
340 to_address: config.escrow_contract.clone(),
341 data: format!("0x{}", hex::encode(call)),
342 gas_limit: None,
343 },
344 })
345}
346
347pub fn prepare_authorized_channel_merkle_release(
348 config: &SettlementChainConfig,
349 dispatch: &Web3SettlementDispatchArtifact,
350 anchor_proof: &AnchorInclusionProof,
351 anchor_content: &SettlementAnchorContentBinding,
352 authorization: &VerifiedChannelReleaseAuthorizationV1,
353) -> Result<Option<PreparedAuthorizedChannelMerkleReleaseV1>, SettlementError> {
354 config.validate()?;
355 validate_web3_settlement_dispatch(dispatch)
356 .map_err(|error| SettlementError::InvalidDispatch(error.to_string()))?;
357 validate_merkle_dispatch_config(config, dispatch)?;
358 let dispatch_digest = sha256_hex(
359 &canonical_json_bytes(dispatch)
360 .map_err(|error| SettlementError::Serialization(error.to_string()))?,
361 );
362 let release_amount = authorization
363 .close()
364 .effective_state()
365 .body()
366 .cumulative_owed
367 .clone();
368 let facts = ChannelReleasePreparationFacts {
369 dispatch_digest,
370 chain_id: config.chain_id.clone(),
371 escrow_contract: config.escrow_contract.clone(),
372 escrow_id: dispatch.escrow_id.clone(),
373 token_address: config.settlement_token_address.clone(),
374 token_symbol: config.settlement_token_symbol.clone(),
375 beneficiary_address: dispatch.beneficiary_address.clone(),
376 operator: config.operator_address.clone(),
377 operator_key_hash: dispatch.operator_key_hash.clone(),
378 protocol_minor_unit_decimals: config.policy.chio_minor_unit_decimals,
379 token_decimals: config.policy.token_minor_unit_decimals,
380 escrow_bound: dispatch.settlement_amount.clone(),
381 release_amount: release_amount.clone(),
382 release_token_base_units: authorization
383 .binding()
384 .expected_release_token_base_units()
385 .to_owned(),
386 };
387 verify_channel_release_preparation_parts(authorization, &facts).map_err(|_| {
388 SettlementError::Verification("channel release authority mismatch".to_owned())
389 })?;
390 if release_amount.units == 0 {
391 return Ok(None);
392 }
393 let amount = if release_amount == dispatch.settlement_amount {
394 EscrowExecutionAmount::Full
395 } else {
396 EscrowExecutionAmount::Partial(release_amount.clone())
397 };
398 let release = prepare_merkle_release(config, dispatch, anchor_proof, anchor_content, amount)?;
399 if release.observed_amount != release_amount
400 || release.settlement_amount_minor_units.to_string()
401 != authorization.binding().expected_release_token_base_units()
402 {
403 return Err(SettlementError::Verification(
404 "prepared release does not match channel release authority".to_owned(),
405 ));
406 }
407 Ok(Some(PreparedAuthorizedChannelMerkleReleaseV1 {
408 release,
409 authorization: authorization.binding().clone(),
410 authorization_digest: authorization.authorization_digest().to_owned(),
411 }))
412}
413
414fn ensure_escrow_anchor_matches_config(
415 config: &SettlementChainConfig,
416 dispatch: &Web3SettlementDispatchArtifact,
417 anchor_proof: &AnchorInclusionProof,
418 anchor_content: &SettlementAnchorContentBinding,
419) -> Result<(), SettlementError> {
420 ensure_anchor_receipt_binds_dispatch(dispatch, anchor_proof, anchor_content)?;
421 let chain_anchor = anchor_proof.chain_anchor.as_ref().ok_or_else(|| {
422 SettlementError::InvalidDispatch(
423 "escrow release anchor proof requires an on-chain chain_anchor".to_string(),
424 )
425 })?;
426 if chain_anchor.chain_id != config.chain_id || chain_anchor.chain_id != dispatch.chain_id {
427 return Err(SettlementError::InvalidDispatch(format!(
428 "escrow release chain_anchor.chain_id {} does not match config {} and dispatch {}",
429 chain_anchor.chain_id, config.chain_id, dispatch.chain_id
430 )));
431 }
432 let anchor_registry = parse_address(
433 &chain_anchor.contract_address,
434 "anchor_proof.chain_anchor.contract_address",
435 )?;
436 let config_registry = parse_address(
437 &config.root_registry_contract,
438 "config.root_registry_contract",
439 )?;
440 if anchor_registry != config_registry {
441 return Err(SettlementError::InvalidDispatch(
442 "escrow release chain_anchor.contract_address does not match config root_registry_contract"
443 .to_string(),
444 ));
445 }
446 let anchor_operator = parse_address(
447 &chain_anchor.operator_address,
448 "anchor_proof.chain_anchor.operator_address",
449 )?;
450 let config_operator = parse_address(&config.operator_address, "config.operator_address")?;
451 if anchor_operator != config_operator {
452 return Err(SettlementError::InvalidDispatch(
453 "escrow release operator address does not match config operator_address".to_string(),
454 ));
455 }
456 let operator_key_hash = parse_b256_hex(
457 &chain_anchor.operator_key_hash,
458 "anchor_proof.chain_anchor.operator_key_hash",
459 )?;
460 if operator_key_hash == B256::ZERO {
461 return Err(SettlementError::InvalidDispatch(
462 "escrow release operator key hash must not be zero".to_string(),
463 ));
464 }
465 let dispatch_operator_key_hash =
466 parse_b256_hex(&dispatch.operator_key_hash, "dispatch.operator_key_hash")?;
467 if operator_key_hash != dispatch_operator_key_hash {
468 return Err(SettlementError::InvalidDispatch(
469 "escrow release operator_key_hash does not match dispatch operator_key_hash"
470 .to_string(),
471 ));
472 }
473 Ok(())
474}
475
476fn ensure_anchor_receipt_binds_dispatch(
477 dispatch: &Web3SettlementDispatchArtifact,
478 anchor_proof: &AnchorInclusionProof,
479 anchor_content: &SettlementAnchorContentBinding,
480) -> Result<(), SettlementError> {
481 let governed_receipt_id = dispatch
482 .capital_instruction
483 .body
484 .governed_receipt_id
485 .as_deref()
486 .ok_or_else(|| {
487 SettlementError::InvalidDispatch(
488 "dispatch capital_instruction governed_receipt_id is required".to_string(),
489 )
490 })?;
491 let receipt_nonce = anchor_proof
492 .receipt
493 .metadata
494 .as_ref()
495 .and_then(|metadata| {
496 metadata.get(chio_core::receipt::signing::CHIO_RECEIPT_SIGNING_NONCE_METADATA_KEY)
497 })
498 .and_then(Value::as_str)
499 .ok_or_else(|| {
500 SettlementError::InvalidDispatch(
501 "anchor proof receipt must carry signing nonce".to_string(),
502 )
503 })?;
504 if receipt_nonce != governed_receipt_id {
505 return Err(SettlementError::InvalidDispatch(
506 "anchor proof receipt must match dispatch governed_receipt_id".to_string(),
507 ));
508 }
509 let expected_content_hash = settlement_anchor_receipt_content_hash_parts(
510 &anchor_content.execution_receipt_id,
511 &anchor_content.settlement_reference,
512 &dispatch.dispatch_id,
513 governed_receipt_id,
514 )
515 .map_err(|error| SettlementError::InvalidDispatch(error.to_string()))?;
516 if anchor_proof.receipt.content_hash != expected_content_hash {
517 return Err(SettlementError::InvalidDispatch(
518 "anchor proof receipt content hash must bind settlement execution".to_string(),
519 ));
520 }
521 Ok(())
522}
523
524fn validate_merkle_dispatch_config(
525 config: &SettlementChainConfig,
526 dispatch: &Web3SettlementDispatchArtifact,
527) -> Result<(), SettlementError> {
528 if dispatch.chain_id != config.chain_id {
529 return Err(SettlementError::InvalidDispatch(format!(
530 "dispatch chain_id {} does not match config {}",
531 dispatch.chain_id, config.chain_id
532 )));
533 }
534 let dispatch_escrow = parse_address(&dispatch.escrow_contract, "dispatch.escrow_contract")?;
535 let config_escrow = parse_address(&config.escrow_contract, "config.escrow_contract")?;
536 if dispatch_escrow != config_escrow {
537 return Err(SettlementError::InvalidDispatch(
538 "dispatch escrow_contract does not match config escrow_contract".to_string(),
539 ));
540 }
541 let dispatch_token = parse_address(
542 &dispatch.settlement_token_address,
543 "dispatch.settlement_token_address",
544 )?;
545 let config_token = parse_address(
546 &config.settlement_token_address,
547 "config.settlement_token_address",
548 )?;
549 if dispatch_token != config_token {
550 return Err(SettlementError::InvalidDispatch(
551 "dispatch settlement_token_address does not match config settlement_token_address"
552 .to_string(),
553 ));
554 }
555 if dispatch.settlement_path != Web3SettlementPath::MerkleProof {
556 return Err(SettlementError::Unsupported(
557 "dispatch is not configured for the Merkle settlement path".to_string(),
558 ));
559 }
560 Ok(())
561}
562
563fn validate_merkle_release_matches_dispatch(
564 config: &SettlementChainConfig,
565 dispatch: &Web3SettlementDispatchArtifact,
566 release: &PreparedMerkleRelease,
567) -> Result<B256, SettlementError> {
568 if release.chain_id != dispatch.chain_id || release.escrow_id != dispatch.escrow_id {
569 return Err(SettlementError::InvalidDispatch(
570 "settlement root publication release does not match dispatch".to_string(),
571 ));
572 }
573 let scaled_amount = scale_chio_amount_to_token_minor_units(&release.observed_amount, config)?;
574 if scaled_amount != release.settlement_amount_minor_units {
575 return Err(SettlementError::InvalidDispatch(
576 "release settlement_amount_minor_units does not match observed_amount".to_string(),
577 ));
578 }
579 let partial = release.observed_amount != dispatch.settlement_amount;
580 if release.partial != partial {
581 return Err(SettlementError::InvalidDispatch(
582 "release partial flag does not match observed_amount".to_string(),
583 ));
584 }
585
586 let escrow_id = parse_b256_hex(&release.escrow_id, "release.escrow_id")?;
587 let receipt_hash = parse_b256_hex(&release.receipt_hash, "release.receipt_hash")?;
588 parse_b256_hex(&release.receipt_leaf_hash, "release.receipt_leaf_hash")?;
589 let expected_root = escrow_proof_leaf(
590 dispatch,
591 escrow_id,
592 receipt_hash,
593 release.settlement_amount_minor_units,
594 release.partial,
595 )?;
596 if parse_b256_hex(&release.merkle_root, "release.merkle_root")? != expected_root {
597 return Err(SettlementError::InvalidDispatch(
598 "release merkle_root does not match dispatch commitment".to_string(),
599 ));
600 }
601
602 if parse_address(&release.call.from_address, "release.call.from_address")?
603 != parse_address(
604 &dispatch.beneficiary_address,
605 "dispatch.beneficiary_address",
606 )?
607 {
608 return Err(SettlementError::InvalidDispatch(
609 "release call from_address does not match dispatch beneficiary".to_string(),
610 ));
611 }
612 if parse_address(&release.call.to_address, "release.call.to_address")?
613 != parse_address(&config.escrow_contract, "config.escrow_contract")?
614 {
615 return Err(SettlementError::InvalidDispatch(
616 "release call to_address does not match config escrow_contract".to_string(),
617 ));
618 }
619
620 let call_data = decode_hex_bytes(&release.call.data)?;
621 if release.partial {
622 let call = IChioEscrow::partialReleaseWithProofDetailedCall::abi_decode(&call_data)
623 .map_err(|error| {
624 SettlementError::InvalidDispatch(format!(
625 "partial release call data does not decode: {error}"
626 ))
627 })?;
628 if call.escrowId != escrow_id
629 || call.root != expected_root
630 || call.receiptHash != receipt_hash
631 || call.amount != U256::from(release.settlement_amount_minor_units)
632 || call.proof.treeSize != U256::from(1_u8)
633 || call.proof.leafIndex != U256::from(0_u8)
634 || !call.proof.auditPath.is_empty()
635 {
636 return Err(SettlementError::InvalidDispatch(
637 "partial release call data does not match release commitment".to_string(),
638 ));
639 }
640 } else {
641 let call =
642 IChioEscrow::releaseWithProofDetailedCall::abi_decode(&call_data).map_err(|error| {
643 SettlementError::InvalidDispatch(format!(
644 "release call data does not decode: {error}"
645 ))
646 })?;
647 if call.escrowId != escrow_id
648 || call.root != expected_root
649 || call.receiptHash != receipt_hash
650 || call.settledAmount != U256::from(release.settlement_amount_minor_units)
651 || call.proof.treeSize != U256::from(1_u8)
652 || call.proof.leafIndex != U256::from(0_u8)
653 || !call.proof.auditPath.is_empty()
654 {
655 return Err(SettlementError::InvalidDispatch(
656 "release call data does not match release commitment".to_string(),
657 ));
658 }
659 }
660
661 Ok(expected_root)
662}
663
664pub fn prepare_merkle_release_root_publication(
665 config: &SettlementChainConfig,
666 dispatch: &Web3SettlementDispatchArtifact,
667 release: &PreparedMerkleRelease,
668 checkpoint_seq: u64,
669 batch_seq: u64,
670) -> Result<PreparedRootPublication, SettlementError> {
671 config.validate()?;
672 validate_web3_settlement_dispatch(dispatch)
673 .map_err(|error| SettlementError::InvalidDispatch(error.to_string()))?;
674 validate_merkle_dispatch_config(config, dispatch)?;
675 if checkpoint_seq == 0 || batch_seq == 0 {
676 return Err(SettlementError::InvalidInput(
677 "settlement root publication sequence values must be non-zero".to_string(),
678 ));
679 }
680 let merkle_root = validate_merkle_release_matches_dispatch(config, dispatch, release)?;
681 let call = IChioRootRegistry::publishRootCall {
682 operator: parse_address(&config.operator_address, "operator_address")?,
683 merkleRoot: merkle_root,
684 checkpointSeq: checkpoint_seq,
685 batchStartSeq: batch_seq,
686 batchEndSeq: batch_seq,
687 treeSize: 1,
688 operatorKeyHash: parse_b256_hex(&dispatch.operator_key_hash, "dispatch.operator_key_hash")?,
689 };
690 Ok(PreparedRootPublication {
691 call: PreparedEvmCall {
692 from_address: config.operator_address.clone(),
693 to_address: config.root_registry_contract.clone(),
694 data: encode_call(call),
695 gas_limit: None,
696 },
697 })
698}
699
700fn escrow_proof_leaf(
701 dispatch: &Web3SettlementDispatchArtifact,
702 escrow_id: B256,
703 receipt_hash: B256,
704 amount_minor_units: u128,
705 partial: bool,
706) -> Result<B256, SettlementError> {
707 let chain_id = parse_eip155_chain_id(&dispatch.chain_id)?;
708 let encoded = (
709 keccak256(ESCROW_PROOF_LEAF_TYPE.as_bytes()),
710 U256::from(chain_id),
711 parse_address(&dispatch.escrow_contract, "dispatch.escrow_contract")?,
712 escrow_id,
713 parse_address(
714 &dispatch.settlement_token_address,
715 "dispatch.settlement_token_address",
716 )?,
717 parse_address(
718 &dispatch.beneficiary_address,
719 "dispatch.beneficiary_address",
720 )?,
721 parse_b256_hex(&dispatch.operator_key_hash, "dispatch.operator_key_hash")?,
722 receipt_hash,
723 U256::from(amount_minor_units),
724 partial,
725 )
726 .abi_encode();
727 Ok(keccak256(encoded))
728}
729
730fn singleton_typed_proof() -> ChioMerkleProof {
731 ChioMerkleProof {
732 audit_path: Vec::new(),
733 leaf_index: U256::from(0_u8),
734 tree_size: U256::from(1_u8),
735 }
736}
737
738fn ensure_bond_anchor_matches_config(
739 config: &SettlementChainConfig,
740 operator_address: &str,
741 bond_snapshot: &EvmBondSnapshot,
742 anchor_proof: &AnchorInclusionProof,
743) -> Result<B256, SettlementError> {
744 let chain_anchor = anchor_proof.chain_anchor.as_ref().ok_or_else(|| {
745 SettlementError::InvalidDispatch(
746 "bond evidence anchor proof requires an on-chain chain_anchor".to_string(),
747 )
748 })?;
749 if chain_anchor.chain_id != config.chain_id {
750 return Err(SettlementError::InvalidDispatch(format!(
751 "bond evidence chain_anchor.chain_id {} does not match config {}",
752 chain_anchor.chain_id, config.chain_id
753 )));
754 }
755 let anchor_registry = parse_address(
756 &chain_anchor.contract_address,
757 "anchor_proof.chain_anchor.contract_address",
758 )?;
759 let config_registry = parse_address(
760 &config.root_registry_contract,
761 "config.root_registry_contract",
762 )?;
763 if anchor_registry != config_registry {
764 return Err(SettlementError::InvalidDispatch(
765 "bond evidence chain_anchor.contract_address does not match config root_registry_contract"
766 .to_string(),
767 ));
768 }
769 let anchor_operator = parse_address(
770 &chain_anchor.operator_address,
771 "anchor_proof.chain_anchor.operator_address",
772 )?;
773 let config_operator = parse_address(&config.operator_address, "config.operator_address")?;
774 let caller_operator = parse_address(operator_address, "operator_address")?;
775 if anchor_operator != config_operator || caller_operator != config_operator {
776 return Err(SettlementError::InvalidDispatch(
777 "bond evidence operator address does not match config operator_address".to_string(),
778 ));
779 }
780 let operator_key_hash = parse_b256_hex(
781 &chain_anchor.operator_key_hash,
782 "anchor_proof.chain_anchor.operator_key_hash",
783 )?;
784 if operator_key_hash == B256::ZERO {
785 return Err(SettlementError::InvalidDispatch(
786 "bond evidence operator key hash must not be zero".to_string(),
787 ));
788 }
789 let expected_operator_key_hash = parse_b256_hex(
790 &bond_snapshot.operator_key_hash,
791 "bond_snapshot.operator_key_hash",
792 )?;
793 if operator_key_hash != expected_operator_key_hash {
794 return Err(SettlementError::InvalidDispatch(
795 "bond evidence operator_key_hash does not match bond operator_key_hash".to_string(),
796 ));
797 }
798 Ok(operator_key_hash)
799}
800
801fn ensure_bond_snapshot_live(
802 bond_snapshot: &EvmBondSnapshot,
803 action: &'static str,
804) -> Result<(), SettlementError> {
805 if bond_snapshot.released || bond_snapshot.expired {
806 return Err(SettlementError::InvalidInput(format!(
807 "bond snapshot is already closed for {action}"
808 )));
809 }
810 if bond_snapshot.observed_at == 0 {
811 return Err(SettlementError::InvalidInput(format!(
812 "bond snapshot has no observed_at timestamp for {action}"
813 )));
814 }
815 if bond_snapshot.observed_at > bond_snapshot.expires_at {
816 return Err(SettlementError::InvalidInput(format!(
817 "bond snapshot is past expires_at for {action}"
818 )));
819 }
820 if bond_snapshot.locked_minor_units == 0 {
821 return Err(SettlementError::InvalidInput(format!(
822 "bond snapshot has no locked collateral for {action}"
823 )));
824 }
825 if bond_snapshot.slashed_minor_units > bond_snapshot.locked_minor_units {
826 return Err(SettlementError::InvalidInput(format!(
827 "bond snapshot slashed amount exceeds locked collateral for {action}"
828 )));
829 }
830 Ok(())
831}
832
833fn validate_bond_release_matches_call(
834 config: &SettlementChainConfig,
835 release: &PreparedBondRelease,
836) -> Result<(B256, B256), SettlementError> {
837 if release.chain_id != config.chain_id {
838 return Err(SettlementError::InvalidDispatch(
839 "bond release chain_id does not match settlement config".to_string(),
840 ));
841 }
842 if parse_address(&release.call.from_address, "bond release.call.from_address")?
843 != parse_address(&config.operator_address, "config.operator_address")?
844 {
845 return Err(SettlementError::InvalidDispatch(
846 "bond release call from_address does not match config operator_address".to_string(),
847 ));
848 }
849 if parse_address(&release.call.to_address, "bond release.call.to_address")?
850 != parse_address(&config.bond_vault_contract, "config.bond_vault_contract")?
851 {
852 return Err(SettlementError::InvalidDispatch(
853 "bond release call to_address does not match config bond_vault_contract".to_string(),
854 ));
855 }
856
857 let vault_id = parse_b256_hex(&release.vault_id, "bond release.vault_id")?;
858 let operator_key_hash =
859 parse_b256_hex(&release.operator_key_hash, "bond release.operator_key_hash")?;
860 let evidence_hash = parse_b256_hex(&release.evidence_hash, "bond release.evidence_hash")?;
861 let expected_root = bond_proof_leaf(
862 config,
863 vault_id,
864 operator_key_hash,
865 evidence_hash,
866 BOND_ACTION_RELEASE,
867 0,
868 B256::ZERO,
869 )?;
870 if parse_b256_hex(&release.merkle_root, "bond release.merkle_root")? != expected_root {
871 return Err(SettlementError::InvalidDispatch(
872 "bond release merkle_root does not match release commitment".to_string(),
873 ));
874 }
875
876 let call_data = decode_hex_bytes(&release.call.data)?;
877 let call =
878 IChioBondVault::releaseBondDetailedCall::abi_decode(&call_data).map_err(|error| {
879 SettlementError::InvalidDispatch(format!(
880 "bond release call data does not decode: {error}"
881 ))
882 })?;
883 if call.vaultId != vault_id
884 || call.root != expected_root
885 || call.evidenceHash != evidence_hash
886 || call.proof.treeSize != U256::from(1_u8)
887 || call.proof.leafIndex != U256::from(0_u8)
888 || !call.proof.auditPath.is_empty()
889 {
890 return Err(SettlementError::InvalidDispatch(
891 "bond release call data does not match release commitment".to_string(),
892 ));
893 }
894 Ok((expected_root, operator_key_hash))
895}
896
897fn validate_bond_impair_matches_call(
898 config: &SettlementChainConfig,
899 impair: &PreparedBondImpair,
900) -> Result<(B256, B256), SettlementError> {
901 if impair.chain_id != config.chain_id {
902 return Err(SettlementError::InvalidDispatch(
903 "bond impair chain_id does not match settlement config".to_string(),
904 ));
905 }
906 if parse_address(&impair.call.from_address, "bond impair.call.from_address")?
907 != parse_address(&config.operator_address, "config.operator_address")?
908 {
909 return Err(SettlementError::InvalidDispatch(
910 "bond impair call from_address does not match config operator_address".to_string(),
911 ));
912 }
913 if parse_address(&impair.call.to_address, "bond impair.call.to_address")?
914 != parse_address(&config.bond_vault_contract, "config.bond_vault_contract")?
915 {
916 return Err(SettlementError::InvalidDispatch(
917 "bond impair call to_address does not match config bond_vault_contract".to_string(),
918 ));
919 }
920
921 let vault_id = parse_b256_hex(&impair.vault_id, "bond impair.vault_id")?;
922 let operator_key_hash =
923 parse_b256_hex(&impair.operator_key_hash, "bond impair.operator_key_hash")?;
924 let evidence_hash = parse_b256_hex(&impair.evidence_hash, "bond impair.evidence_hash")?;
925 let call_data = decode_hex_bytes(&impair.call.data)?;
926 let call = IChioBondVault::impairBondDetailedCall::abi_decode(&call_data).map_err(|error| {
927 SettlementError::InvalidDispatch(format!("bond impair call data does not decode: {error}"))
928 })?;
929 if call.vaultId != vault_id
930 || call.evidenceHash != evidence_hash
931 || call.slashAmount != U256::from(impair.slash_amount_minor_units)
932 || call.proof.treeSize != U256::from(1_u8)
933 || call.proof.leafIndex != U256::from(0_u8)
934 || !call.proof.auditPath.is_empty()
935 {
936 return Err(SettlementError::InvalidDispatch(
937 "bond impair call data does not match impair commitment".to_string(),
938 ));
939 }
940
941 validate_bond_impair_distribution(
942 &call.beneficiaries,
943 &call.shares,
944 impair.slash_amount_minor_units,
945 )?;
946 let distribution_hash = bond_distribution_hash(&call.beneficiaries, &call.shares);
947 let expected_root = bond_proof_leaf(
948 config,
949 vault_id,
950 operator_key_hash,
951 evidence_hash,
952 BOND_ACTION_IMPAIR,
953 impair.slash_amount_minor_units,
954 distribution_hash,
955 )?;
956 if parse_b256_hex(&impair.merkle_root, "bond impair.merkle_root")? != expected_root
957 || call.root != expected_root
958 {
959 return Err(SettlementError::InvalidDispatch(
960 "bond impair merkle_root does not match impair commitment".to_string(),
961 ));
962 }
963 Ok((expected_root, operator_key_hash))
964}
965
966fn validate_bond_prepared_root(
967 config: &SettlementChainConfig,
968 prepared_root: PreparedBondProofRoot<'_>,
969) -> Result<(B256, B256), SettlementError> {
970 match prepared_root {
971 PreparedBondProofRoot::Release(release) => {
972 validate_bond_release_matches_call(config, release)
973 }
974 PreparedBondProofRoot::Impair(impair) => validate_bond_impair_matches_call(config, impair),
975 }
976}
977
978fn validate_bond_snapshot_matches_prepared_root(
979 bond_snapshot: &EvmBondSnapshot,
980 prepared_root: PreparedBondProofRoot<'_>,
981) -> Result<(), SettlementError> {
982 ensure_bond_snapshot_live(bond_snapshot, "root publication")?;
983 match prepared_root {
984 PreparedBondProofRoot::Release(release) => {
985 if parse_b256_hex(&release.vault_id, "bond release.vault_id")?
986 != parse_b256_hex(&bond_snapshot.vault_id, "bond_snapshot.vault_id")?
987 {
988 return Err(SettlementError::InvalidDispatch(
989 "bond release vault_id does not match bond snapshot".to_string(),
990 ));
991 }
992 if parse_b256_hex(&release.operator_key_hash, "bond release.operator_key_hash")?
993 != parse_b256_hex(
994 &bond_snapshot.operator_key_hash,
995 "bond_snapshot.operator_key_hash",
996 )?
997 {
998 return Err(SettlementError::InvalidDispatch(
999 "bond release operator_key_hash does not match bond snapshot".to_string(),
1000 ));
1001 }
1002 }
1003 PreparedBondProofRoot::Impair(impair) => {
1004 if parse_b256_hex(&impair.vault_id, "bond impair.vault_id")?
1005 != parse_b256_hex(&bond_snapshot.vault_id, "bond_snapshot.vault_id")?
1006 {
1007 return Err(SettlementError::InvalidDispatch(
1008 "bond impair vault_id does not match bond snapshot".to_string(),
1009 ));
1010 }
1011 if parse_b256_hex(&impair.operator_key_hash, "bond impair.operator_key_hash")?
1012 != parse_b256_hex(
1013 &bond_snapshot.operator_key_hash,
1014 "bond_snapshot.operator_key_hash",
1015 )?
1016 {
1017 return Err(SettlementError::InvalidDispatch(
1018 "bond impair operator_key_hash does not match bond snapshot".to_string(),
1019 ));
1020 }
1021 let remaining_collateral = bond_snapshot
1022 .locked_minor_units
1023 .checked_sub(bond_snapshot.slashed_minor_units)
1024 .ok_or_else(|| {
1025 SettlementError::InvalidInput(
1026 "bond snapshot slashed amount exceeds locked collateral".to_string(),
1027 )
1028 })?;
1029 if impair.slash_amount_minor_units > remaining_collateral {
1030 return Err(SettlementError::InvalidInput(
1031 "slash_amount exceeds remaining bond collateral".to_string(),
1032 ));
1033 }
1034 }
1035 }
1036 Ok(())
1037}
1038
1039pub(super) fn bond_proof_leaf(
1040 config: &SettlementChainConfig,
1041 vault_id: B256,
1042 operator_key_hash: B256,
1043 evidence_hash: B256,
1044 action: u8,
1045 slash_amount_minor_units: u128,
1046 distribution_hash: B256,
1047) -> Result<B256, SettlementError> {
1048 let chain_id = parse_eip155_chain_id(&config.chain_id)?;
1049 let encoded = (
1050 keccak256(BOND_PROOF_LEAF_TYPE.as_bytes()),
1051 U256::from(chain_id),
1052 parse_address(&config.bond_vault_contract, "bond_vault_contract")?,
1053 vault_id,
1054 operator_key_hash,
1055 evidence_hash,
1056 U256::from(action),
1057 U256::from(slash_amount_minor_units),
1058 distribution_hash,
1059 )
1060 .abi_encode();
1061 Ok(keccak256(encoded))
1062}
1063
1064pub(super) fn bond_distribution_hash(beneficiaries: &[Address], shares: &[U256]) -> B256 {
1065 let beneficiaries_tail_len = 32 + beneficiaries.len() * 32;
1066 let shares_offset = 64 + beneficiaries_tail_len;
1067 let mut encoded = Vec::with_capacity(shares_offset + 32 + shares.len() * 32);
1068 push_abi_u256(&mut encoded, U256::from(64_u64));
1069 push_abi_u256(&mut encoded, U256::from(shares_offset as u64));
1070 push_abi_u256(&mut encoded, U256::from(beneficiaries.len() as u64));
1071 for beneficiary in beneficiaries {
1072 encoded.extend_from_slice(&[0_u8; 12]);
1073 encoded.extend_from_slice(beneficiary.as_slice());
1074 }
1075 push_abi_u256(&mut encoded, U256::from(shares.len() as u64));
1076 for share in shares {
1077 push_abi_u256(&mut encoded, *share);
1078 }
1079 keccak256(encoded)
1080}
1081
1082fn validate_bond_impair_distribution(
1083 beneficiaries: &[Address],
1084 shares: &[U256],
1085 slash_amount_minor_units: u128,
1086) -> Result<(), SettlementError> {
1087 if beneficiaries.is_empty()
1088 || beneficiaries.len() > MAX_IMPAIR_BENEFICIARIES
1089 || beneficiaries.len() != shares.len()
1090 || slash_amount_minor_units == 0
1091 {
1092 return Err(SettlementError::InvalidInput(
1093 "InvalidSlashDistribution".to_string(),
1094 ));
1095 }
1096
1097 let mut total = U256::ZERO;
1098 for (beneficiary, share) in beneficiaries.iter().zip(shares) {
1099 if *beneficiary == Address::ZERO {
1100 return Err(SettlementError::InvalidInput(
1101 "InvalidSlashDistribution".to_string(),
1102 ));
1103 }
1104 total = total
1105 .checked_add(*share)
1106 .ok_or_else(|| SettlementError::InvalidInput("InvalidSlashDistribution".to_string()))?;
1107 }
1108 if total != U256::from(slash_amount_minor_units) {
1109 return Err(SettlementError::InvalidInput(
1110 "InvalidSlashDistribution".to_string(),
1111 ));
1112 }
1113 Ok(())
1114}
1115
1116fn push_abi_u256(encoded: &mut Vec<u8>, value: U256) {
1117 encoded.extend_from_slice(&value.to_be_bytes::<32>());
1118}
1119
1120pub async fn prepare_dual_sign_release(
1121 config: &SettlementChainConfig,
1122 dispatch: &Web3SettlementDispatchArtifact,
1123 receipt: &ChioReceipt,
1124 input: &DualSignReleaseInput,
1125) -> Result<PreparedDualSignRelease, SettlementError> {
1126 config.validate()?;
1127 validate_web3_settlement_dispatch(dispatch)
1128 .map_err(|error| SettlementError::InvalidDispatch(error.to_string()))?;
1129 if dispatch.settlement_path != Web3SettlementPath::DualSignature {
1130 return Err(SettlementError::Unsupported(
1131 "dispatch is not configured for the dual-signature path".to_string(),
1132 ));
1133 }
1134 let verified = receipt
1135 .verify_signature()
1136 .map_err(|error| SettlementError::Verification(error.to_string()))?;
1137 if !verified {
1138 return Err(SettlementError::Verification(
1139 "receipt signature verification failed".to_string(),
1140 ));
1141 }
1142 if input.observed_amount != dispatch.settlement_amount {
1143 return Err(SettlementError::Unsupported(
1144 "dual-signature release is bounded to full settlement on the official stack"
1145 .to_string(),
1146 ));
1147 }
1148 let registry_evidence = read_identity_operator_record_at_block(config).await?;
1149 let operator_key_hash = parse_b256_hex(
1150 ®istry_evidence.operator_key_hash,
1151 "identity_registry_evidence.operator_key_hash",
1152 )?;
1153 let settlement_key = parse_address(
1154 ®istry_evidence.settlement_key,
1155 "identity_registry_evidence.settlement_key",
1156 )?;
1157 if !registry_evidence.active {
1158 return Err(SettlementError::InvalidInput(
1159 "identity registry operator is not active".to_string(),
1160 ));
1161 }
1162 if registry_evidence.operator_epoch == 0 {
1163 return Err(SettlementError::InvalidInput(
1164 "operator_epoch must be nonzero".to_string(),
1165 ));
1166 }
1167 let expected_key_hash =
1168 parse_b256_hex(&dispatch.operator_key_hash, "dispatch.operator_key_hash")?;
1169 if operator_key_hash != expected_key_hash {
1170 return Err(SettlementError::InvalidDispatch(
1171 "identity registry operator key hash does not match dispatch operator_key_hash"
1172 .to_string(),
1173 ));
1174 }
1175 let signer_address = signer_address_from_private_key(&input.operator_private_key_hex)?;
1176 if settlement_key != signer_address {
1177 return Err(SettlementError::InvalidInput(
1178 "operator signing key does not match identity registry settlement key".to_string(),
1179 ));
1180 }
1181 let amount_minor_units =
1182 scale_chio_amount_to_token_minor_units(&input.observed_amount, config)?;
1183 let receipt_hash = keccak256(
1184 canonical_json_bytes(&receipt.body())
1185 .map_err(|error| SettlementError::Serialization(error.to_string()))?,
1186 );
1187 let escrow_id = parse_b256_hex(&dispatch.escrow_id, "dispatch.escrow_id")?;
1188 let digest = dual_sign_digest(
1189 config,
1190 &config.escrow_contract,
1191 &escrow_id,
1192 &receipt_hash,
1193 amount_minor_units,
1194 registry_evidence.operator_epoch,
1195 )?;
1196 let signature = sign_digest(&input.operator_private_key_hex, &digest)?;
1197
1198 let call = IChioEscrow::releaseWithSignatureCall {
1199 escrowId: escrow_id,
1200 receiptHash: receipt_hash,
1201 settledAmount: U256::from(amount_minor_units),
1202 operatorEpoch: registry_evidence.operator_epoch,
1203 v: signature.v,
1204 r: parse_b256_hex(&signature.r, "signature.r")?,
1205 s: parse_b256_hex(&signature.s, "signature.s")?,
1206 };
1207
1208 Ok(PreparedDualSignRelease {
1209 escrow_id: dispatch.escrow_id.clone(),
1210 chain_id: dispatch.chain_id.clone(),
1211 receipt_hash: format_b256(receipt_hash),
1212 digest: format_b256(digest),
1213 operator_epoch: registry_evidence.operator_epoch,
1214 identity_registry_evidence_binding: DualSignRegistryEvidenceBinding {
1215 identity_registry_contract: config.identity_registry_contract.clone(),
1216 operator_address: config.operator_address.clone(),
1217 settlement_key: format!("{signer_address:?}"),
1218 },
1219 identity_registry_evidence: registry_evidence,
1220 settlement_amount_minor_units: amount_minor_units,
1221 observed_amount: input.observed_amount.clone(),
1222 signature,
1223 call: PreparedEvmCall {
1224 from_address: dispatch.beneficiary_address.clone(),
1225 to_address: config.escrow_contract.clone(),
1226 data: encode_call(call),
1227 gas_limit: None,
1228 },
1229 })
1230}
1231
1232async fn read_identity_operator_record_at_block(
1233 config: &SettlementChainConfig,
1234) -> Result<DualSignRegistryEvidence, SettlementError> {
1235 let block = latest_block_header(config).await?;
1236 let block_tag = format!("0x{:x}", block.number);
1237 let call = IChioIdentityRegistry::getOperatorCall {
1238 operator: parse_address(&config.operator_address, "config.operator_address")?,
1239 };
1240 let raw = eth_call_raw_at_block(
1241 config,
1242 &PreparedEvmCall {
1243 from_address: config.operator_address.clone(),
1244 to_address: config.identity_registry_contract.clone(),
1245 data: encode_call(call),
1246 gas_limit: None,
1247 },
1248 &block_tag,
1249 )
1250 .await?;
1251 let checked_block = block_header_by_number(config, block.number).await?;
1252 if checked_block.hash != block.hash {
1253 return Err(SettlementError::Rpc(
1254 "identity registry block hash changed before signing".to_string(),
1255 ));
1256 }
1257 let bytes = decode_hex_bytes(&raw)?;
1258 let decoded = IChioIdentityRegistry::getOperatorCall::abi_decode_returns(&bytes)
1259 .map_err(|error| SettlementError::Rpc(format!("decode getOperator: {error}")))?;
1260 Ok(DualSignRegistryEvidence {
1261 chain_id: config.chain_id.clone(),
1262 identity_registry_contract: config.identity_registry_contract.clone(),
1263 operator_address: config.operator_address.clone(),
1264 block_number: block.number,
1265 block_hash: block.hash,
1266 observed_at: block.timestamp,
1267 operator_key_hash: format_b256(decoded.edKeyHash),
1268 settlement_key: format!("{:?}", decoded.settlementKey),
1269 registered_at: decoded.registeredAt,
1270 operator_epoch: decoded.operatorEpoch,
1271 active: decoded.active,
1272 })
1273}
1274
1275pub fn prepare_escrow_refund(
1276 config: &SettlementChainConfig,
1277 dispatch: &Web3SettlementDispatchArtifact,
1278 caller_address: &str,
1279) -> Result<PreparedEscrowRefund, SettlementError> {
1280 config.validate()?;
1281 let call = IChioEscrow::refundCall {
1282 escrowId: parse_b256_hex(&dispatch.escrow_id, "dispatch.escrow_id")?,
1283 };
1284 Ok(PreparedEscrowRefund {
1285 escrow_id: dispatch.escrow_id.clone(),
1286 chain_id: config.chain_id.clone(),
1287 call: PreparedEvmCall {
1288 from_address: caller_address.to_string(),
1289 to_address: config.escrow_contract.clone(),
1290 data: encode_call(call),
1291 gas_limit: None,
1292 },
1293 })
1294}
1295
1296pub async fn prepare_bond_lock(
1297 config: &SettlementChainConfig,
1298 request: &BondLockRequest,
1299 binding: &SignedWeb3IdentityBinding,
1300) -> Result<PreparedBondLock, SettlementError> {
1301 config.validate()?;
1302 ensure_settlement_binding(config, binding, Web3KeyBindingPurpose::Settle)?;
1303 let operator_key_hash = settlement_operator_key_hash(binding)?;
1304 let verified = request
1305 .bond
1306 .verify_signature()
1307 .map_err(|error| SettlementError::Verification(error.to_string()))?;
1308 if !verified {
1309 return Err(SettlementError::Verification(
1310 "credit bond signature verification failed".to_string(),
1311 ));
1312 }
1313 if request.bond.body.lifecycle_state != CreditBondLifecycleState::Active {
1314 return Err(SettlementError::InvalidDispatch(
1315 "bond lifecycle must be active before on-chain lock".to_string(),
1316 ));
1317 }
1318 let terms = request.bond.body.report.terms.clone().ok_or_else(|| {
1319 SettlementError::InvalidDispatch("credit bond terms are required".to_string())
1320 })?;
1321 let collateral_minor_units =
1322 scale_chio_amount_to_token_minor_units(&terms.collateral_amount, config)?;
1323 let reserve_requirement_minor_units =
1324 scale_chio_amount_to_token_minor_units(&terms.reserve_requirement_amount, config)?;
1325 let bond_terms = IChioBondVault::BondTerms {
1326 bondId: hash_string_id(&request.bond.body.bond_id),
1327 facilityId: hash_string_id(&terms.facility_id),
1328 principal: parse_address(&request.principal_address, "principal_address")?,
1329 token: parse_address(&config.settlement_token_address, "settlement_token_address")?,
1330 collateralAmount: U256::from(collateral_minor_units),
1331 reserveRequirementAmount: U256::from(reserve_requirement_minor_units),
1332 expiresAt: U256::from(request.bond.body.expires_at),
1333 reserveRequirementRatioBps: terms.reserve_ratio_bps,
1334 operator: parse_address(&config.operator_address, "operator_address")?,
1335 operatorKeyHash: operator_key_hash,
1336 };
1337 let derive_call = IChioBondVault::deriveVaultIdCall {
1338 terms: bond_terms.clone(),
1339 };
1340 let static_result = eth_call_raw(
1341 config,
1342 &PreparedEvmCall {
1343 from_address: request.principal_address.clone(),
1344 to_address: config.bond_vault_contract.clone(),
1345 data: encode_call(derive_call),
1346 gas_limit: None,
1347 },
1348 )
1349 .await?;
1350 let result_bytes = decode_hex_bytes(&static_result)?;
1351 let vault_id =
1352 IChioBondVault::deriveVaultIdCall::abi_decode_returns(&result_bytes).map_err(|error| {
1353 SettlementError::Serialization(format!("deriveVaultId decode failed: {error}"))
1354 })?;
1355 let call_data = encode_call(IChioBondVault::lockBondCall { terms: bond_terms });
1356
1357 Ok(PreparedBondLock {
1358 vault_id: format_b256(vault_id),
1359 bond_id_hash: format_b256(hash_string_id(&request.bond.body.bond_id)),
1360 facility_id_hash: format_b256(hash_string_id(&terms.facility_id)),
1361 operator_key_hash: format_b256(operator_key_hash),
1362 collateral_minor_units,
1363 reserve_requirement_minor_units,
1364 call: PreparedEvmCall {
1365 from_address: request.principal_address.clone(),
1366 to_address: config.bond_vault_contract.clone(),
1367 data: call_data,
1368 gas_limit: None,
1369 },
1370 })
1371}
1372
1373pub fn prepare_bond_release(
1374 config: &SettlementChainConfig,
1375 operator_address: &str,
1376 bond_snapshot: &EvmBondSnapshot,
1377 anchor_proof: &AnchorInclusionProof,
1378) -> Result<PreparedBondRelease, SettlementError> {
1379 config.validate()?;
1380 ensure_bond_snapshot_live(bond_snapshot, "release")?;
1381 verify_anchor_inclusion_proof(anchor_proof)
1382 .map_err(|error| SettlementError::Verification(error.to_string()))?;
1383 let operator_key_hash =
1384 ensure_bond_anchor_matches_config(config, operator_address, bond_snapshot, anchor_proof)?;
1385 let (_anchor_proof, _anchor_root, evidence_hash) = proof_components(anchor_proof)?;
1386 let proof = singleton_typed_proof();
1387 let vault_id = parse_b256_hex(&bond_snapshot.vault_id, "bond_snapshot.vault_id")?;
1388 let root = bond_proof_leaf(
1389 config,
1390 vault_id,
1391 operator_key_hash,
1392 evidence_hash,
1393 BOND_ACTION_RELEASE,
1394 0,
1395 B256::ZERO,
1396 )?;
1397 let call = IChioBondVault::releaseBondDetailedCall {
1398 vaultId: vault_id,
1399 proof: proof.into(),
1400 root,
1401 evidenceHash: evidence_hash,
1402 };
1403 Ok(PreparedBondRelease {
1404 vault_id: format_b256(vault_id),
1405 chain_id: config.chain_id.clone(),
1406 operator_key_hash: format_b256(operator_key_hash),
1407 evidence_hash: format_b256(evidence_hash),
1408 merkle_root: format_b256(root),
1409 call: PreparedEvmCall {
1410 from_address: operator_address.to_string(),
1411 to_address: config.bond_vault_contract.clone(),
1412 data: encode_call(call),
1413 gas_limit: None,
1414 },
1415 })
1416}
1417
1418pub fn prepare_bond_impair(
1419 config: &SettlementChainConfig,
1420 operator_address: &str,
1421 bond_snapshot: &EvmBondSnapshot,
1422 slash_amount: &MonetaryAmount,
1423 beneficiaries: &[String],
1424 shares: &[MonetaryAmount],
1425 anchor_proof: &AnchorInclusionProof,
1426) -> Result<PreparedBondImpair, SettlementError> {
1427 config.validate()?;
1428 ensure_bond_snapshot_live(bond_snapshot, "impair")?;
1429 if beneficiaries.is_empty() || beneficiaries.len() != shares.len() {
1430 return Err(SettlementError::InvalidInput(
1431 "beneficiaries and shares must be non-empty and aligned".to_string(),
1432 ));
1433 }
1434 if beneficiaries.len() > MAX_IMPAIR_BENEFICIARIES {
1435 return Err(SettlementError::InvalidInput(format!(
1436 "bond impair beneficiary count must not exceed {MAX_IMPAIR_BENEFICIARIES}"
1437 )));
1438 }
1439 verify_anchor_inclusion_proof(anchor_proof)
1440 .map_err(|error| SettlementError::Verification(error.to_string()))?;
1441 let operator_key_hash =
1442 ensure_bond_anchor_matches_config(config, operator_address, bond_snapshot, anchor_proof)?;
1443 let slash_amount_minor_units = scale_chio_amount_to_token_minor_units(slash_amount, config)?;
1444 if slash_amount_minor_units == 0 {
1445 return Err(SettlementError::InvalidInput(
1446 "slash_amount must be greater than zero".to_string(),
1447 ));
1448 }
1449 let remaining_collateral = bond_snapshot
1450 .locked_minor_units
1451 .checked_sub(bond_snapshot.slashed_minor_units)
1452 .ok_or_else(|| {
1453 SettlementError::InvalidInput(
1454 "bond snapshot slashed amount exceeds locked collateral".to_string(),
1455 )
1456 })?;
1457 if slash_amount_minor_units > remaining_collateral {
1458 return Err(SettlementError::InvalidInput(
1459 "slash_amount exceeds remaining bond collateral".to_string(),
1460 ));
1461 }
1462 let mut share_units = Vec::with_capacity(shares.len());
1463 let mut total = 0_u128;
1464 for share in shares {
1465 let scaled = scale_chio_amount_to_token_minor_units(share, config)?;
1466 total = total
1467 .checked_add(scaled)
1468 .ok_or_else(|| SettlementError::InvalidInput("slash shares overflowed".to_string()))?;
1469 share_units.push(U256::from(scaled));
1470 }
1471 if total != slash_amount_minor_units {
1472 return Err(SettlementError::InvalidInput(
1473 "slash shares must sum to slash_amount".to_string(),
1474 ));
1475 }
1476 let beneficiary_addresses = beneficiaries
1477 .iter()
1478 .map(|value| parse_address(value, "beneficiary"))
1479 .collect::<Result<Vec<_>, _>>()?;
1480 validate_bond_impair_distribution(
1481 &beneficiary_addresses,
1482 &share_units,
1483 slash_amount_minor_units,
1484 )?;
1485 let (_anchor_proof, _anchor_root, evidence_hash) = proof_components(anchor_proof)?;
1486 let proof = singleton_typed_proof();
1487 let vault_id = parse_b256_hex(&bond_snapshot.vault_id, "bond_snapshot.vault_id")?;
1488 let distribution_hash = bond_distribution_hash(&beneficiary_addresses, &share_units);
1489 let root = bond_proof_leaf(
1490 config,
1491 vault_id,
1492 operator_key_hash,
1493 evidence_hash,
1494 BOND_ACTION_IMPAIR,
1495 slash_amount_minor_units,
1496 distribution_hash,
1497 )?;
1498 let call = IChioBondVault::impairBondDetailedCall {
1499 vaultId: vault_id,
1500 slashAmount: U256::from(slash_amount_minor_units),
1501 beneficiaries: beneficiary_addresses,
1502 shares: share_units,
1503 proof: proof.into(),
1504 root,
1505 evidenceHash: evidence_hash,
1506 };
1507 Ok(PreparedBondImpair {
1508 vault_id: format_b256(vault_id),
1509 chain_id: config.chain_id.clone(),
1510 operator_key_hash: format_b256(operator_key_hash),
1511 evidence_hash: format_b256(evidence_hash),
1512 merkle_root: format_b256(root),
1513 slash_amount_minor_units,
1514 call: PreparedEvmCall {
1515 from_address: operator_address.to_string(),
1516 to_address: config.bond_vault_contract.clone(),
1517 data: encode_call(call),
1518 gas_limit: None,
1519 },
1520 })
1521}
1522
1523pub fn prepare_bond_proof_root_publication(
1524 config: &SettlementChainConfig,
1525 bond_snapshot: &EvmBondSnapshot,
1526 prepared_root: PreparedBondProofRoot<'_>,
1527 checkpoint_seq: u64,
1528 batch_seq: u64,
1529) -> Result<PreparedRootPublication, SettlementError> {
1530 config.validate()?;
1531 if prepared_root.chain_id() != config.chain_id {
1532 return Err(SettlementError::InvalidInput(
1533 "prepared bond proof chain_id does not match settlement config".to_string(),
1534 ));
1535 }
1536 if checkpoint_seq == 0 || batch_seq == 0 {
1537 return Err(SettlementError::InvalidInput(
1538 "bond root publication sequence values must be non-zero".to_string(),
1539 ));
1540 }
1541 validate_bond_snapshot_matches_prepared_root(bond_snapshot, prepared_root)?;
1542 let (merkle_root, operator_key_hash) = validate_bond_prepared_root(config, prepared_root)?;
1543 if operator_key_hash == B256::ZERO {
1544 return Err(SettlementError::InvalidInput(
1545 "operator_key_hash must not be zero".to_string(),
1546 ));
1547 }
1548 let call = IChioRootRegistry::publishRootCall {
1549 operator: parse_address(&config.operator_address, "operator_address")?,
1550 merkleRoot: merkle_root,
1551 checkpointSeq: checkpoint_seq,
1552 batchStartSeq: batch_seq,
1553 batchEndSeq: batch_seq,
1554 treeSize: 1,
1555 operatorKeyHash: operator_key_hash,
1556 };
1557 Ok(PreparedRootPublication {
1558 call: PreparedEvmCall {
1559 from_address: config.operator_address.clone(),
1560 to_address: config.root_registry_contract.clone(),
1561 data: encode_call(call),
1562 gas_limit: None,
1563 },
1564 })
1565}
1566
1567pub fn prepare_bond_expiry(
1568 config: &SettlementChainConfig,
1569 vault_id: &str,
1570 caller_address: &str,
1571) -> Result<PreparedBondExpiry, SettlementError> {
1572 config.validate()?;
1573 let call = IChioBondVault::expireReleaseCall {
1574 vaultId: parse_b256_hex(vault_id, "vault_id")?,
1575 };
1576 Ok(PreparedBondExpiry {
1577 vault_id: vault_id.to_string(),
1578 chain_id: config.chain_id.clone(),
1579 call: PreparedEvmCall {
1580 from_address: caller_address.to_string(),
1581 to_address: config.bond_vault_contract.clone(),
1582 data: encode_call(call),
1583 gas_limit: None,
1584 },
1585 })
1586}
1587
1588pub async fn static_validate_call(
1589 config: &SettlementChainConfig,
1590 call: &PreparedEvmCall,
1591) -> Result<String, SettlementError> {
1592 eth_call_raw(config, call).await
1593}
1594
1595pub async fn estimate_call_gas(
1596 config: &SettlementChainConfig,
1597 call: &PreparedEvmCall,
1598) -> Result<u64, SettlementError> {
1599 let result = rpc_call(config, "eth_estimateGas", json!([request_value(call)])).await?;
1600 parse_hex_u64(
1601 result.as_str().ok_or_else(|| {
1602 SettlementError::Rpc("eth_estimateGas returned non-string".to_string())
1603 })?,
1604 )
1605}
1606
1607pub async fn submit_call<T: PreparedEvmSubmission + ?Sized>(
1608 config: &SettlementChainConfig,
1609 prepared: &T,
1610) -> Result<String, SettlementError> {
1611 let call = prepared_submission_call(prepared);
1612 reject_guarded_money_exit_selector(call)?;
1613 submit_raw_call(config, call).await
1614}
1615
1616fn reject_guarded_money_exit_selector(call: &PreparedEvmCall) -> Result<(), SettlementError> {
1617 let data = decode_hex_bytes(&call.data)?;
1618 if data.get(..4).is_some_and(|selector| {
1619 GUARDED_MONEY_EXIT_SELECTORS
1620 .iter()
1621 .any(|guarded| selector == guarded)
1622 }) {
1623 return Err(SettlementError::Unsupported(
1624 "root publication, escrow release, escrow refund, and bond exits require a durable publisher"
1625 .to_owned(),
1626 ));
1627 }
1628 Ok(())
1629}
1630
1631async fn submit_raw_call(
1632 config: &SettlementChainConfig,
1633 call: &PreparedEvmCall,
1634) -> Result<String, SettlementError> {
1635 let mut request = request_value(call);
1636 let gas_limit = match call.gas_limit {
1637 Some(gas_limit) => gas_limit,
1638 None => estimate_call_gas(config, call)
1639 .await?
1640 .saturating_mul(12)
1641 .saturating_div(10)
1642 .saturating_add(50_000),
1643 };
1644 request["gas"] = Value::String(format!("0x{gas_limit:x}"));
1645 let result = rpc_call(config, "eth_sendTransaction", json!([request])).await?;
1646 result
1647 .as_str()
1648 .map(ToString::to_string)
1649 .ok_or_else(|| SettlementError::Rpc("eth_sendTransaction returned non-string".to_string()))
1650}
1651
1652pub async fn confirm_transaction(
1653 config: &SettlementChainConfig,
1654 tx_hash: &str,
1655) -> Result<EvmTransactionReceipt, SettlementError> {
1656 for _ in 0..100 {
1657 let result = rpc_call(config, "eth_getTransactionReceipt", json!([tx_hash])).await?;
1658 if result.is_null() {
1659 tokio::time::sleep(Duration::from_millis(100)).await;
1660 continue;
1661 }
1662 let block_hash = result
1663 .get("blockHash")
1664 .and_then(Value::as_str)
1665 .ok_or_else(|| SettlementError::Rpc("receipt missing blockHash".to_string()))?
1666 .to_string();
1667 let block_number = parse_hex_u64(
1668 result
1669 .get("blockNumber")
1670 .and_then(Value::as_str)
1671 .ok_or_else(|| SettlementError::Rpc("receipt missing blockNumber".to_string()))?,
1672 )?;
1673 let status = result
1674 .get("status")
1675 .and_then(Value::as_str)
1676 .map(|value| value == "0x1")
1677 .unwrap_or(false);
1678 let gas_used = parse_hex_u64(
1679 result
1680 .get("gasUsed")
1681 .and_then(Value::as_str)
1682 .ok_or_else(|| SettlementError::Rpc("receipt missing gasUsed".to_string()))?,
1683 )?;
1684 let from_address = result
1685 .get("from")
1686 .and_then(Value::as_str)
1687 .ok_or_else(|| SettlementError::Rpc("receipt missing from".to_string()))?
1688 .to_string();
1689 let to_address = result
1690 .get("to")
1691 .and_then(Value::as_str)
1692 .ok_or_else(|| SettlementError::Rpc("receipt missing to".to_string()))?
1693 .to_string();
1694 let logs = result
1695 .get("logs")
1696 .and_then(Value::as_array)
1697 .ok_or_else(|| SettlementError::Rpc("receipt missing logs".to_string()))?
1698 .iter()
1699 .map(parse_log_entry)
1700 .collect::<Result<Vec<_>, _>>()?;
1701 let block = rpc_call(config, "eth_getBlockByHash", json!([block_hash, false])).await?;
1702 let observed_at = parse_hex_u64(
1703 block
1704 .get("timestamp")
1705 .and_then(Value::as_str)
1706 .ok_or_else(|| SettlementError::Rpc("block missing timestamp".to_string()))?,
1707 )?;
1708 return Ok(EvmTransactionReceipt {
1709 tx_hash: tx_hash.to_string(),
1710 block_number,
1711 block_hash,
1712 status,
1713 from_address,
1714 to_address,
1715 gas_used,
1716 observed_at,
1717 logs,
1718 });
1719 }
1720 Err(SettlementError::Rpc(format!(
1721 "timed out waiting for transaction receipt {tx_hash}"
1722 )))
1723}
1724
1725struct EvmBlockHeader {
1726 number: u64,
1727 hash: String,
1728 timestamp: u64,
1729}
1730
1731async fn latest_block_header(
1732 config: &SettlementChainConfig,
1733) -> Result<EvmBlockHeader, SettlementError> {
1734 block_header_by_tag(config, "latest").await
1735}
1736
1737async fn block_header_by_number(
1738 config: &SettlementChainConfig,
1739 block_number: u64,
1740) -> Result<EvmBlockHeader, SettlementError> {
1741 block_header_by_tag(config, &format!("0x{block_number:x}")).await
1742}
1743
1744async fn block_header_by_tag(
1745 config: &SettlementChainConfig,
1746 block_tag: &str,
1747) -> Result<EvmBlockHeader, SettlementError> {
1748 let block = rpc_call(config, "eth_getBlockByNumber", json!([block_tag, false])).await?;
1749 let number = parse_hex_u64(
1750 block
1751 .get("number")
1752 .and_then(Value::as_str)
1753 .ok_or_else(|| SettlementError::Rpc(format!("{block_tag} block missing number")))?,
1754 )?;
1755 let hash = block
1756 .get("hash")
1757 .and_then(Value::as_str)
1758 .ok_or_else(|| SettlementError::Rpc(format!("{block_tag} block missing hash")))?
1759 .to_string();
1760 parse_b256_hex(&hash, "block.hash")?;
1761 let timestamp = parse_hex_u64(
1762 block
1763 .get("timestamp")
1764 .and_then(Value::as_str)
1765 .ok_or_else(|| SettlementError::Rpc(format!("{block_tag} block missing timestamp")))?,
1766 )?;
1767 Ok(EvmBlockHeader {
1768 number,
1769 hash,
1770 timestamp,
1771 })
1772}
1773
1774pub async fn read_escrow_snapshot(
1775 config: &SettlementChainConfig,
1776 escrow_id: &str,
1777) -> Result<EscrowSnapshot, SettlementError> {
1778 let call = IChioEscrow::getEscrowCall {
1779 escrowId: parse_b256_hex(escrow_id, "escrow_id")?,
1780 };
1781 let raw = eth_call_raw(
1782 config,
1783 &PreparedEvmCall {
1784 from_address: config.operator_address.clone(),
1785 to_address: config.escrow_contract.clone(),
1786 data: encode_call(call),
1787 gas_limit: None,
1788 },
1789 )
1790 .await?;
1791 let bytes = decode_hex_bytes(&raw)?;
1792 let decoded = IChioEscrow::getEscrowCall::abi_decode_returns(&bytes).map_err(|error| {
1793 SettlementError::Serialization(format!("getEscrow decode failed: {error}"))
1794 })?;
1795 let deposited_minor_units = u256_to_u128(decoded.deposited, "escrow.deposited")?;
1796 let released_minor_units = u256_to_u128(decoded.released, "escrow.released")?;
1797 Ok(EscrowSnapshot {
1798 escrow_id: escrow_id.to_string(),
1799 depositor_address: format!("{:?}", decoded.terms.depositor),
1800 beneficiary_address: format!("{:?}", decoded.terms.beneficiary),
1801 deadline: decoded.terms.deadline.to::<u64>(),
1802 deposited_minor_units,
1803 released_minor_units,
1804 refunded: decoded.refunded,
1805 remaining_minor_units: deposited_minor_units.saturating_sub(released_minor_units),
1806 })
1807}
1808
1809pub async fn read_bond_snapshot(
1810 config: &SettlementChainConfig,
1811 vault_id: &str,
1812) -> Result<EvmBondSnapshot, SettlementError> {
1813 let block = latest_block_header(config).await?;
1814 let block_number = block.number;
1815 let observed_at = block.timestamp;
1816 let block_tag = format!("0x{block_number:x}");
1817 let call = IChioBondVault::getBondCall {
1818 vaultId: parse_b256_hex(vault_id, "vault_id")?,
1819 };
1820 let raw = eth_call_raw_at_block(
1821 config,
1822 &PreparedEvmCall {
1823 from_address: config.operator_address.clone(),
1824 to_address: config.bond_vault_contract.clone(),
1825 data: encode_call(call),
1826 gas_limit: None,
1827 },
1828 &block_tag,
1829 )
1830 .await?;
1831 let bytes = decode_hex_bytes(&raw)?;
1832 let decoded = IChioBondVault::getBondCall::abi_decode_returns(&bytes).map_err(|error| {
1833 SettlementError::Serialization(format!("getBond decode failed: {error}"))
1834 })?;
1835 Ok(EvmBondSnapshot {
1836 vault_id: vault_id.to_string(),
1837 principal_address: format!("{:?}", decoded.terms.principal),
1838 operator_key_hash: format_b256(decoded.terms.operatorKeyHash),
1839 expires_at: decoded.terms.expiresAt.to::<u64>(),
1840 observed_at,
1841 locked_minor_units: u256_to_u128(decoded.lockedAmount, "bond.lockedAmount")?,
1842 reserve_requirement_minor_units: u256_to_u128(
1843 decoded.terms.reserveRequirementAmount,
1844 "bond.terms.reserveRequirementAmount",
1845 )?,
1846 reserve_requirement_ratio_bps: decoded.terms.reserveRequirementRatioBps,
1847 slashed_minor_units: u256_to_u128(decoded.slashedAmount, "bond.slashedAmount")?,
1848 released: decoded.released,
1849 expired: decoded.expired,
1850 })
1851}