Skip to main content

chio_settle/evm/
prepare.rs

1//! EVM settlement call preparation and on-chain read/submit helpers.
2
3use 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        &registry_evidence.operator_key_hash,
1151        "identity_registry_evidence.operator_key_hash",
1152    )?;
1153    let settlement_key = parse_address(
1154        &registry_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}