1use std::collections::HashMap;
2use std::str::FromStr;
3use std::sync::Mutex;
4
5use alloy_primitives::{keccak256, Address, B256, U256};
6use alloy_sol_types::SolValue;
7use chio_core::capability::governance::{GovernedApprovalDecision, GovernedApprovalToken};
8use chio_core::capability::scope::MonetaryAmount;
9use chio_core::hashing::sha256;
10use chio_core::web3::settlement::Web3SettlementDispatchArtifact;
11use serde::{Deserialize, Serialize};
12
13use crate::SettlementError;
14
15#[path = "payments_approval.rs"]
16mod approval_binding;
17
18#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
19#[serde(rename_all = "snake_case")]
20pub enum X402SettlementMode {
21 PrepaidAuthorization,
22 EscrowBacked,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
26#[serde(deny_unknown_fields)]
27pub struct X402PaymentRequirements {
28 pub version: String,
29 pub chain_id: String,
30 pub facilitator_url: String,
31 pub resource: String,
32 pub pay_to: String,
33 pub accepted_tokens: Vec<String>,
34 pub dispatch_id: String,
35 pub capability_id: String,
36 pub amount_minor_units: u64,
37 pub currency: String,
38 pub settlement_mode: X402SettlementMode,
39 pub governed_authorization_required: bool,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
43#[serde(deny_unknown_fields)]
44pub struct Eip3009Domain {
45 pub name: String,
46 pub version: String,
47 pub chain_id: u64,
48 pub verifying_contract: String,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
52#[serde(deny_unknown_fields)]
53pub struct TransferWithAuthorizationInput {
54 pub from_address: String,
55 pub to_address: String,
56 pub value_minor_units: u128,
57 pub valid_after: u64,
58 pub valid_before: u64,
59 pub nonce: String,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
63#[serde(deny_unknown_fields)]
64pub struct PreparedTransferWithAuthorization {
65 pub domain: Eip3009Domain,
66 pub authorization: TransferWithAuthorizationInput,
67 pub domain_separator: String,
68 pub struct_hash: String,
69 pub authorization_digest: String,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
73#[serde(deny_unknown_fields)]
74pub struct CircleNanopaymentPolicy {
75 pub enabled: bool,
76 pub managed_balance_id: String,
77 pub supported_chain_ids: Vec<String>,
78 pub supported_token_symbols: Vec<String>,
79 pub max_amount_minor_units: u64,
80 pub operator_managed_custody_explicit: bool,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
84#[serde(deny_unknown_fields)]
85pub struct PreparedCircleNanopayment {
86 pub payment_id: String,
87 pub managed_balance_id: String,
88 pub chain_id: String,
89 pub amount_minor_units: u64,
90 pub currency: String,
91 pub beneficiary_address: String,
92 pub dispatch_id: String,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
96#[serde(deny_unknown_fields)]
97pub struct Erc4337PaymasterPolicy {
98 pub entry_point: String,
99 pub paymaster_address: String,
100 pub supported_chain_ids: Vec<String>,
101 pub max_sponsor_gas_limit: u64,
102 pub max_reimbursement_minor_units: u64,
103 pub settlement_deduction_explicit: bool,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
107#[serde(deny_unknown_fields)]
108pub struct PreparedPaymasterCompatibility {
109 pub dispatch_id: String,
110 pub chain_id: String,
111 pub entry_point: String,
112 pub paymaster_address: String,
113 pub user_operation_hash: String,
114 pub sponsor_gas_limit: u64,
115 pub estimated_reimbursement_minor_units: u64,
116 pub allowed: bool,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub rejection_reason: Option<String>,
119}
120
121pub fn build_x402_payment_requirements(
122 dispatch: &Web3SettlementDispatchArtifact,
123 approval_binding: &ApprovalBinding,
124 facilitator_url: &str,
125 resource: &str,
126 accepted_tokens: Vec<String>,
127 settlement_mode: X402SettlementMode,
128) -> Result<X402PaymentRequirements, SettlementError> {
129 approval_binding.assert_dispatch("x402", dispatch)?;
130 validate_x402_field("facilitator URL", facilitator_url)?;
131 validate_x402_field("resource", resource)?;
132 if accepted_tokens.is_empty() {
133 return Err(SettlementError::InvalidInput(
134 "x402 compatibility requires at least one accepted token".to_string(),
135 ));
136 }
137 for (index, token) in accepted_tokens.iter().enumerate() {
138 validate_x402_field(&format!("accepted token {index}"), token)?;
139 }
140 if !accepted_tokens.iter().any(|token| {
141 token
142 .trim()
143 .eq_ignore_ascii_case(approval_binding.token_symbol.trim())
144 }) {
145 return Err(SettlementError::InvalidBinding(format!(
146 "x402 accepted tokens do not include the approval-bound token {:?}",
147 approval_binding.token_symbol
148 )));
149 }
150 Ok(X402PaymentRequirements {
151 version: "x402".to_string(),
152 chain_id: dispatch.chain_id.clone(),
153 facilitator_url: facilitator_url.to_string(),
154 resource: resource.to_string(),
155 pay_to: dispatch.beneficiary_address.clone(),
156 accepted_tokens,
157 dispatch_id: dispatch.dispatch_id.clone(),
158 capability_id: dispatch
159 .capital_instruction
160 .body
161 .query
162 .capability_id
163 .clone()
164 .unwrap_or_else(|| dispatch.dispatch_id.clone()),
165 amount_minor_units: dispatch.settlement_amount.units,
166 currency: dispatch.settlement_amount.currency.clone(),
167 settlement_mode,
168 governed_authorization_required: true,
169 })
170}
171
172fn validate_x402_field(label: &str, value: &str) -> Result<(), SettlementError> {
173 if value.trim().is_empty() {
174 return Err(SettlementError::InvalidInput(format!(
175 "x402 compatibility requires {label}"
176 )));
177 }
178 if value.trim() != value || value.chars().any(char::is_whitespace) {
179 return Err(SettlementError::InvalidInput(format!(
180 "x402 compatibility {label} must not contain whitespace"
181 )));
182 }
183 Ok(())
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
208#[serde(deny_unknown_fields)]
209pub struct ApprovalBinding {
210 pub chain_id: u64,
213 pub payee_address: String,
216 pub amount_minor_units: u128,
219 pub token_symbol: String,
227 #[serde(default, skip_serializing_if = "Option::is_none")]
241 pub token_contract: Option<String>,
242 pub approval_expires_at: u64,
249}
250
251#[derive(Debug, Clone, Copy, PartialEq, Eq)]
259pub enum NonceOutcome {
260 Fresh,
263 Replayed,
265}
266
267pub const DEFAULT_MAX_EIP3009_NONCE_ENTRIES: usize = 65_536;
274
275pub trait Eip3009NonceStore: Send + Sync {
307 fn record_if_fresh(
314 &self,
315 from_address: &str,
316 nonce: &str,
317 retain_until_unix_seconds: u64,
318 ) -> Result<NonceOutcome, SettlementError>;
319
320 fn gc_expired(&self, now_unix_seconds: u64) -> Result<usize, SettlementError>;
325
326 fn len(&self) -> Result<usize, SettlementError>;
328
329 fn is_empty(&self) -> Result<bool, SettlementError> {
331 Ok(self.len()? == 0)
332 }
333}
334
335type Eip3009NonceMap = HashMap<(String, String), u64>;
341
342fn canonicalize_nonce_key_component(value: &str) -> String {
347 let trimmed = value.trim();
348 let without_prefix = trimmed
349 .strip_prefix("0x")
350 .or_else(|| trimmed.strip_prefix("0X"))
351 .unwrap_or(trimmed);
352 without_prefix.to_ascii_lowercase()
353}
354
355pub struct InMemoryEip3009NonceStore {
362 inner: Mutex<Eip3009NonceMap>,
363 max_entries: usize,
364}
365
366impl Default for InMemoryEip3009NonceStore {
367 fn default() -> Self {
368 Self::with_max_entries(DEFAULT_MAX_EIP3009_NONCE_ENTRIES)
369 }
370}
371
372impl InMemoryEip3009NonceStore {
373 #[must_use]
375 pub fn new() -> Self {
376 Self::default()
377 }
378
379 #[must_use]
381 pub fn with_max_entries(max_entries: usize) -> Self {
382 Self {
383 inner: Mutex::new(HashMap::new()),
384 max_entries,
385 }
386 }
387
388 fn lock(&self) -> Result<std::sync::MutexGuard<'_, Eip3009NonceMap>, SettlementError> {
389 self.inner.lock().map_err(|err| {
390 SettlementError::InvalidBinding(format!("EIP-3009 nonce store mutex poisoned: {err}"))
391 })
392 }
393}
394
395impl Eip3009NonceStore for InMemoryEip3009NonceStore {
396 fn record_if_fresh(
397 &self,
398 from_address: &str,
399 nonce: &str,
400 retain_until_unix_seconds: u64,
401 ) -> Result<NonceOutcome, SettlementError> {
402 let key = (
403 canonicalize_nonce_key_component(from_address),
404 canonicalize_nonce_key_component(nonce),
405 );
406 let mut guard = self.lock()?;
407
408 if guard.contains_key(&key) {
413 return Ok(NonceOutcome::Replayed);
414 }
415 if guard.len() >= self.max_entries {
416 return Err(SettlementError::InvalidBinding(format!(
417 "EIP-3009 nonce store capacity exceeded: {} retained entries (max {})",
418 guard.len(),
419 self.max_entries
420 )));
421 }
422 guard.insert(key, retain_until_unix_seconds);
423 Ok(NonceOutcome::Fresh)
424 }
425
426 fn gc_expired(&self, now_unix_seconds: u64) -> Result<usize, SettlementError> {
427 let mut guard = self.lock()?;
428 let before = guard.len();
429 guard.retain(|_, retain_until| *retain_until >= now_unix_seconds);
430 Ok(before - guard.len())
431 }
432
433 fn len(&self) -> Result<usize, SettlementError> {
434 Ok(self.lock()?.len())
435 }
436}
437
438pub fn prepare_transfer_with_authorization(
463 domain: Eip3009Domain,
464 authorization: TransferWithAuthorizationInput,
465 binding: &ApprovalBinding,
466 now_unix_seconds: u64,
467 nonce_store: &dyn Eip3009NonceStore,
468) -> Result<PreparedTransferWithAuthorization, SettlementError> {
469 if domain.name.trim().is_empty()
470 || domain.version.trim().is_empty()
471 || domain.verifying_contract.trim().is_empty()
472 {
473 return Err(SettlementError::InvalidInput(
474 "EIP-3009 domain fields are required".to_string(),
475 ));
476 }
477 if authorization.from_address.trim().is_empty()
478 || authorization.to_address.trim().is_empty()
479 || authorization.nonce.trim().is_empty()
480 {
481 return Err(SettlementError::InvalidInput(
482 "EIP-3009 authorization requires from, to, and nonce".to_string(),
483 ));
484 }
485 if authorization.value_minor_units == 0
486 || authorization.valid_before <= authorization.valid_after
487 {
488 return Err(SettlementError::InvalidInput(
489 "EIP-3009 authorization requires non-zero value and a valid time window".to_string(),
490 ));
491 }
492
493 let verifying_contract = Address::from_str(&domain.verifying_contract)
494 .map_err(|error| SettlementError::InvalidInput(error.to_string()))?;
495 let from = Address::from_str(&authorization.from_address)
496 .map_err(|error| SettlementError::InvalidInput(error.to_string()))?;
497 let to = Address::from_str(&authorization.to_address)
498 .map_err(|error| SettlementError::InvalidInput(error.to_string()))?;
499 let nonce = B256::from_str(&authorization.nonce)
500 .map_err(|error| SettlementError::InvalidInput(error.to_string()))?;
501
502 if now_unix_seconds <= authorization.valid_after {
505 return Err(SettlementError::InvalidBinding(format!(
506 "EIP-3009 authorization is not yet valid: now {now_unix_seconds} <= validAfter {}",
507 authorization.valid_after
508 )));
509 }
510 if now_unix_seconds >= authorization.valid_before {
511 return Err(SettlementError::InvalidBinding(format!(
512 "EIP-3009 authorization is expired: now {now_unix_seconds} >= validBefore {}",
513 authorization.valid_before
514 )));
515 }
516
517 if authorization.valid_before > binding.approval_expires_at {
523 return Err(SettlementError::InvalidBinding(format!(
524 "EIP-3009 authorization outlives approval: validBefore {} > approval expiry {}",
525 authorization.valid_before, binding.approval_expires_at
526 )));
527 }
528
529 if domain.chain_id != binding.chain_id {
534 return Err(SettlementError::InvalidBinding(format!(
535 "EIP-3009 chain mismatch: domain chain_id {} != approval-bound chain_id {}",
536 domain.chain_id, binding.chain_id
537 )));
538 }
539 let bound_payee = Address::from_str(&binding.payee_address).map_err(|error| {
540 SettlementError::InvalidBinding(format!("approval-bound payee address invalid: {error}"))
541 })?;
542 if to != bound_payee {
543 return Err(SettlementError::InvalidBinding(
544 "EIP-3009 payee mismatch: authorization `to` is not the approval-bound payee"
545 .to_string(),
546 ));
547 }
548 if authorization.value_minor_units != binding.amount_minor_units {
549 return Err(SettlementError::InvalidBinding(format!(
550 "EIP-3009 amount mismatch: authorization value {} != approval-bound amount {}",
551 authorization.value_minor_units, binding.amount_minor_units
552 )));
553 }
554 let bound_token_contract = binding.token_contract.as_deref().ok_or_else(|| {
564 SettlementError::InvalidBinding(
565 "EIP-3009 requires an approval-bound token contract: a symbol alone cannot pin \
566 the on-chain token the signed transfer targets"
567 .to_string(),
568 )
569 })?;
570 let bound_contract = Address::from_str(bound_token_contract.trim()).map_err(|error| {
571 SettlementError::InvalidBinding(format!(
572 "approval-bound token contract address invalid: {error}"
573 ))
574 })?;
575 if verifying_contract != bound_contract {
576 return Err(SettlementError::InvalidBinding(
577 "EIP-3009 token contract mismatch: domain verifyingContract is not the \
578 approval-bound token contract"
579 .to_string(),
580 ));
581 }
582
583 let canonical_from = format!("0x{}", hex::encode(from.as_slice()));
607 let canonical_contract = format!("0x{}", hex::encode(verifying_contract.as_slice()));
608 let canonical_nonce = format!(
609 "{}:{}:0x{}",
610 domain.chain_id,
611 canonical_contract,
612 hex::encode(nonce.as_slice())
613 );
614 match nonce_store.record_if_fresh(
615 &canonical_from,
616 &canonical_nonce,
617 authorization.valid_before,
618 )? {
619 NonceOutcome::Fresh => {}
620 NonceOutcome::Replayed => {
621 return Err(SettlementError::InvalidBinding(
622 "EIP-3009 authorization nonce has already been used (replay rejected)".to_string(),
623 ));
624 }
625 }
626
627 let domain_typehash = keccak256(
628 b"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)",
629 );
630 let domain_separator = keccak256(
631 (
632 domain_typehash,
633 keccak256(domain.name.as_bytes()),
634 keccak256(domain.version.as_bytes()),
635 U256::from(domain.chain_id),
636 verifying_contract,
637 )
638 .abi_encode(),
639 );
640 let auth_typehash = keccak256(
641 b"TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)",
642 );
643 let struct_hash = keccak256(
644 (
645 auth_typehash,
646 from,
647 to,
648 U256::from(authorization.value_minor_units),
649 U256::from(authorization.valid_after),
650 U256::from(authorization.valid_before),
651 nonce,
652 )
653 .abi_encode(),
654 );
655 let mut digest_bytes = Vec::with_capacity(66);
656 digest_bytes.extend_from_slice(&[0x19, 0x01]);
657 digest_bytes.extend_from_slice(domain_separator.as_slice());
658 digest_bytes.extend_from_slice(struct_hash.as_slice());
659 let authorization_digest = keccak256(digest_bytes);
660
661 Ok(PreparedTransferWithAuthorization {
662 domain,
663 authorization,
664 domain_separator: format!("0x{}", hex::encode(domain_separator)),
665 struct_hash: format!("0x{}", hex::encode(struct_hash)),
666 authorization_digest: format!("0x{}", hex::encode(authorization_digest)),
667 })
668}
669
670#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
678#[serde(deny_unknown_fields)]
679pub struct RailBinding {
680 pub chain_id: u64,
682 pub token_contract: String,
684 pub payee_address: String,
686 pub token_decimals: u8,
688 pub token_symbol: String,
691}
692
693pub fn approval_binding_from_governed(
720 token: &GovernedApprovalToken,
721 rail: &RailBinding,
722 amount_minor_units: u128,
723 approval_expires_at: u64,
724) -> Result<ApprovalBinding, SettlementError> {
725 if token.decision != GovernedApprovalDecision::Approved {
726 return Err(SettlementError::InvalidBinding(
727 "governed approval token is not approved".to_string(),
728 ));
729 }
730 if rail.token_contract.trim().is_empty() {
731 return Err(SettlementError::InvalidInput(
732 "rail binding requires a non-empty token contract".to_string(),
733 ));
734 }
735 if rail.payee_address.trim().is_empty() {
736 return Err(SettlementError::InvalidInput(
737 "rail binding requires a non-empty payee address".to_string(),
738 ));
739 }
740 if rail.token_symbol.trim().is_empty() {
741 return Err(SettlementError::InvalidInput(
742 "rail binding requires a non-empty token symbol".to_string(),
743 ));
744 }
745 if amount_minor_units == 0 {
746 return Err(SettlementError::InvalidInput(
747 "rail binding requires a non-zero amount".to_string(),
748 ));
749 }
750 Ok(ApprovalBinding {
751 chain_id: rail.chain_id,
752 payee_address: rail.payee_address.clone(),
753 amount_minor_units,
754 token_symbol: rail.token_symbol.clone(),
755 token_contract: Some(rail.token_contract.clone()),
756 approval_expires_at: approval_expires_at.min(token.expires_at),
763 })
764}
765
766pub const CHIO_OFFCHAIN_SETTLEMENT_RECEIPT_SCHEMA: &str = "chio.settle.offchain_receipt.v1";
768
769#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
779#[serde(deny_unknown_fields)]
780pub struct OffchainSettlementReceiptArtifact {
781 pub schema: String,
783 pub settlement_receipt_id: String,
785 pub issued_at: u64,
787 pub authorization_digest: String,
790 pub governed_receipt_id: String,
794 pub settled_amount: MonetaryAmount,
796 #[serde(default, skip_serializing_if = "Option::is_none")]
799 pub execution_nonce_ref: Option<String>,
800 #[serde(default, skip_serializing_if = "Option::is_none")]
803 pub hold_ref: Option<String>,
804 #[serde(default, skip_serializing_if = "Option::is_none")]
806 pub note: Option<String>,
807}
808
809pub fn validate_offchain_settlement_receipt(
821 receipt: &OffchainSettlementReceiptArtifact,
822) -> Result<(), SettlementError> {
823 if receipt.schema != CHIO_OFFCHAIN_SETTLEMENT_RECEIPT_SCHEMA {
824 return Err(SettlementError::InvalidInput(format!(
825 "off-chain settlement receipt schema must be \"{CHIO_OFFCHAIN_SETTLEMENT_RECEIPT_SCHEMA}\", got \"{}\"",
826 receipt.schema,
827 )));
828 }
829 if receipt.settlement_receipt_id.trim().is_empty() {
830 return Err(SettlementError::InvalidInput(
831 "off-chain settlement receipt requires a non-empty settlement_receipt_id".to_string(),
832 ));
833 }
834 if receipt.authorization_digest.trim().is_empty() {
835 return Err(SettlementError::InvalidInput(
836 "off-chain settlement receipt requires a non-empty authorization_digest".to_string(),
837 ));
838 }
839 if receipt.governed_receipt_id.trim().is_empty() {
840 return Err(SettlementError::InvalidInput(
841 "off-chain settlement receipt requires a non-empty governed_receipt_id".to_string(),
842 ));
843 }
844 if receipt.settled_amount.units == 0 {
845 return Err(SettlementError::InvalidInput(
846 "off-chain settlement receipt requires a positive settled_amount".to_string(),
847 ));
848 }
849 if receipt.settled_amount.currency.trim().is_empty() {
852 return Err(SettlementError::InvalidInput(
853 "off-chain settlement receipt requires a non-empty settled_amount.currency".to_string(),
854 ));
855 }
856 Ok(())
857}
858
859pub fn evaluate_circle_nanopayment(
860 dispatch: &Web3SettlementDispatchArtifact,
861 approval_binding: &ApprovalBinding,
862 policy: &CircleNanopaymentPolicy,
863) -> Result<Option<PreparedCircleNanopayment>, SettlementError> {
864 if !policy.enabled {
865 return Ok(None);
866 }
867 approval_binding.assert_dispatch("circle", dispatch)?;
868 if !policy.operator_managed_custody_explicit {
869 return Err(SettlementError::InvalidInput(
870 "Circle nanopayment policy must keep operator-managed custody explicit".to_string(),
871 ));
872 }
873 if !policy
874 .supported_chain_ids
875 .iter()
876 .any(|chain_id| chain_id == &dispatch.chain_id)
877 {
878 return Ok(None);
879 }
880 if !policy
881 .supported_token_symbols
882 .iter()
883 .any(|symbol| symbol == &dispatch.settlement_amount.currency)
884 {
885 return Ok(None);
886 }
887 if dispatch.settlement_amount.units > policy.max_amount_minor_units {
888 return Ok(None);
889 }
890 Ok(Some(PreparedCircleNanopayment {
891 payment_id: format!(
892 "chio-circle-{}",
893 &sha256(
894 format!(
895 "{}:{}:{}",
896 dispatch.dispatch_id, dispatch.chain_id, dispatch.settlement_amount.units
897 )
898 .as_bytes()
899 )
900 .to_hex()[..16]
901 ),
902 managed_balance_id: policy.managed_balance_id.clone(),
903 chain_id: dispatch.chain_id.clone(),
904 amount_minor_units: dispatch.settlement_amount.units,
905 currency: dispatch.settlement_amount.currency.clone(),
906 beneficiary_address: dispatch.beneficiary_address.clone(),
907 dispatch_id: dispatch.dispatch_id.clone(),
908 }))
909}
910
911pub fn prepare_paymaster_compatibility(
912 dispatch: &Web3SettlementDispatchArtifact,
913 policy: &Erc4337PaymasterPolicy,
914 user_operation_hash: &str,
915 sponsor_gas_limit: u64,
916 estimated_reimbursement_minor_units: u64,
917) -> Result<PreparedPaymasterCompatibility, SettlementError> {
918 if user_operation_hash.trim().is_empty() {
919 return Err(SettlementError::InvalidInput(
920 "ERC-4337 compatibility requires a user operation hash".to_string(),
921 ));
922 }
923 let supported_chain = policy
924 .supported_chain_ids
925 .iter()
926 .any(|chain_id| chain_id == &dispatch.chain_id);
927 let within_budget = sponsor_gas_limit <= policy.max_sponsor_gas_limit
928 && estimated_reimbursement_minor_units <= policy.max_reimbursement_minor_units;
929 let allowed = supported_chain && within_budget && policy.settlement_deduction_explicit;
930 let rejection_reason = if allowed {
931 None
932 } else if !supported_chain {
933 Some("requested chain is outside the bounded paymaster surface".to_string())
934 } else if !policy.settlement_deduction_explicit {
935 Some(
936 "paymaster reimbursement must remain an explicit settlement-side deduction".to_string(),
937 )
938 } else {
939 Some("requested sponsorship exceeds the bounded gas or reimbursement policy".to_string())
940 };
941
942 Ok(PreparedPaymasterCompatibility {
943 dispatch_id: dispatch.dispatch_id.clone(),
944 chain_id: dispatch.chain_id.clone(),
945 entry_point: policy.entry_point.clone(),
946 paymaster_address: policy.paymaster_address.clone(),
947 user_operation_hash: user_operation_hash.to_string(),
948 sponsor_gas_limit,
949 estimated_reimbursement_minor_units,
950 allowed,
951 rejection_reason,
952 })
953}
954
955#[cfg(test)]
956mod tests {
957 use super::{
958 approval_binding_from_governed, build_x402_payment_requirements,
959 evaluate_circle_nanopayment, prepare_paymaster_compatibility,
960 prepare_transfer_with_authorization, validate_offchain_settlement_receipt, ApprovalBinding,
961 CircleNanopaymentPolicy, Eip3009Domain, Eip3009NonceStore, Erc4337PaymasterPolicy,
962 InMemoryEip3009NonceStore, NonceOutcome, OffchainSettlementReceiptArtifact, RailBinding,
963 SettlementError, TransferWithAuthorizationInput, X402SettlementMode,
964 CHIO_OFFCHAIN_SETTLEMENT_RECEIPT_SCHEMA,
965 };
966 use chio_core::capability::governance::{
967 GovernedApprovalDecision, GovernedApprovalToken, GovernedApprovalTokenBody,
968 };
969 use chio_core::capability::scope::MonetaryAmount;
970 use chio_core::crypto::Keypair;
971 use chio_core::web3::settlement::Web3SettlementDispatchArtifact;
972
973 use chio_test_support::prelude::*;
974
975 fn sample_dispatch() -> Web3SettlementDispatchArtifact {
976 serde_json::from_str(include_str!(
977 "../../../../docs/standards/CHIO_WEB3_SETTLEMENT_DISPATCH_EXAMPLE.json"
978 ))
979 .test_unwrap()
980 }
981
982 const SAMPLE_CHAIN_ID: u64 = 8453;
983 const SAMPLE_PAYEE: &str = "0x1000000000000000000000000000000000000002";
984 const SAMPLE_VALUE: u128 = 42_000;
985 const SAMPLE_VALID_AFTER: u64 = 1_744_000_000;
986 const SAMPLE_VALID_BEFORE: u64 = 1_744_000_600;
987 const SAMPLE_NOW: u64 = 1_744_000_300;
989 const SAMPLE_NONCE: &str = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
990 const SAMPLE_TOKEN_SYMBOL: &str = "USDC";
992 const SAMPLE_TOKEN_CONTRACT: &str = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
995
996 fn sample_domain() -> Eip3009Domain {
997 Eip3009Domain {
998 name: "USD Coin".to_string(),
999 version: "2".to_string(),
1000 chain_id: SAMPLE_CHAIN_ID,
1001 verifying_contract: SAMPLE_TOKEN_CONTRACT.to_string(),
1002 }
1003 }
1004
1005 fn sample_authorization() -> TransferWithAuthorizationInput {
1006 TransferWithAuthorizationInput {
1007 from_address: "0x1000000000000000000000000000000000000001".to_string(),
1008 to_address: SAMPLE_PAYEE.to_string(),
1009 value_minor_units: SAMPLE_VALUE,
1010 valid_after: SAMPLE_VALID_AFTER,
1011 valid_before: SAMPLE_VALID_BEFORE,
1012 nonce: SAMPLE_NONCE.to_string(),
1013 }
1014 }
1015
1016 fn sample_binding() -> ApprovalBinding {
1017 ApprovalBinding {
1018 chain_id: SAMPLE_CHAIN_ID,
1019 payee_address: SAMPLE_PAYEE.to_string(),
1020 amount_minor_units: SAMPLE_VALUE,
1021 token_symbol: SAMPLE_TOKEN_SYMBOL.to_string(),
1022 token_contract: Some(SAMPLE_TOKEN_CONTRACT.to_string()),
1023 approval_expires_at: SAMPLE_VALID_BEFORE,
1026 }
1027 }
1028
1029 fn binding_for_dispatch(dispatch: &Web3SettlementDispatchArtifact) -> ApprovalBinding {
1030 ApprovalBinding {
1031 chain_id: 8453,
1032 payee_address: dispatch.beneficiary_address.clone(),
1033 amount_minor_units: u128::from(dispatch.settlement_amount.units),
1034 token_symbol: dispatch.settlement_amount.currency.clone(),
1035 token_contract: None,
1036 approval_expires_at: SAMPLE_VALID_BEFORE,
1037 }
1038 }
1039
1040 #[test]
1041 fn builds_x402_requirements() {
1042 let dispatch = sample_dispatch();
1043 let binding = binding_for_dispatch(&dispatch);
1044 let requirements = build_x402_payment_requirements(
1045 &dispatch,
1046 &binding,
1047 "https://facilitator.example/x402",
1048 "https://tool.example/v1/run",
1049 vec!["USD".to_string(), "EURC".to_string()],
1050 X402SettlementMode::PrepaidAuthorization,
1051 )
1052 .test_unwrap();
1053
1054 assert!(requirements.governed_authorization_required);
1055 assert_eq!(requirements.dispatch_id, dispatch.dispatch_id);
1056 }
1057
1058 #[test]
1059 fn x402_requirements_reject_blank_accepted_tokens() {
1060 let dispatch = sample_dispatch();
1061 let binding = binding_for_dispatch(&dispatch);
1062
1063 let error = build_x402_payment_requirements(
1064 &dispatch,
1065 &binding,
1066 "https://facilitator.example/x402",
1067 "https://tool.example/v1/run",
1068 vec!["USD".to_string(), " ".to_string()],
1069 X402SettlementMode::PrepaidAuthorization,
1070 )
1071 .test_unwrap_err();
1072
1073 assert!(error.to_string().contains("accepted token"));
1074 }
1075
1076 #[test]
1077 fn prepares_transfer_with_authorization_digest() {
1078 let store = InMemoryEip3009NonceStore::new();
1079 let prepared = prepare_transfer_with_authorization(
1080 sample_domain(),
1081 sample_authorization(),
1082 &sample_binding(),
1083 SAMPLE_NOW,
1084 &store,
1085 )
1086 .test_unwrap();
1087
1088 assert!(prepared.authorization_digest.starts_with("0x"));
1089 assert_eq!(prepared.authorization_digest.len(), 66);
1090 }
1091
1092 #[test]
1093 fn replayed_nonce_is_rejected_on_second_use() {
1094 let store = InMemoryEip3009NonceStore::new();
1095
1096 let first = prepare_transfer_with_authorization(
1098 sample_domain(),
1099 sample_authorization(),
1100 &sample_binding(),
1101 SAMPLE_NOW,
1102 &store,
1103 );
1104 assert!(first.is_ok(), "first use of a fresh nonce must succeed");
1105
1106 let replay = prepare_transfer_with_authorization(
1109 sample_domain(),
1110 sample_authorization(),
1111 &sample_binding(),
1112 SAMPLE_NOW,
1113 &store,
1114 );
1115 let error = replay.test_unwrap_err();
1116 assert!(
1117 error.to_string().contains("replay"),
1118 "second use of the same nonce must be rejected by the nonce store, got: {error}"
1119 );
1120 }
1121
1122 #[test]
1123 fn replay_is_detected_even_with_checksum_cased_address_and_nonce() {
1124 let store = InMemoryEip3009NonceStore::new();
1128 let _ = prepare_transfer_with_authorization(
1129 sample_domain(),
1130 sample_authorization(),
1131 &sample_binding(),
1132 SAMPLE_NOW,
1133 &store,
1134 )
1135 .test_unwrap();
1136
1137 let mut recased = sample_authorization();
1138 recased.from_address = recased.from_address.to_uppercase().replace("0X", "0x");
1139 recased.nonce = recased.nonce.to_uppercase().replace("0X", "0x");
1140 let replay = prepare_transfer_with_authorization(
1141 sample_domain(),
1142 recased,
1143 &sample_binding(),
1144 SAMPLE_NOW,
1145 &store,
1146 );
1147 assert!(
1148 replay.test_unwrap_err().to_string().contains("replay"),
1149 "case-variant of the same (from, nonce) must still be a replay"
1150 );
1151 }
1152
1153 #[test]
1154 fn expired_authorization_is_rejected() {
1155 let store = InMemoryEip3009NonceStore::new();
1156 let error = prepare_transfer_with_authorization(
1158 sample_domain(),
1159 sample_authorization(),
1160 &sample_binding(),
1161 SAMPLE_VALID_BEFORE,
1162 &store,
1163 )
1164 .test_unwrap_err();
1165 assert!(
1166 error.to_string().contains("expired"),
1167 "an authorization at/after validBefore must be rejected, got: {error}"
1168 );
1169 assert!(
1170 store.is_empty().test_unwrap(),
1171 "a rejected (expired) authorization must not consume its nonce"
1172 );
1173 }
1174
1175 #[test]
1176 fn not_yet_valid_authorization_is_rejected() {
1177 let store = InMemoryEip3009NonceStore::new();
1178 let error = prepare_transfer_with_authorization(
1180 sample_domain(),
1181 sample_authorization(),
1182 &sample_binding(),
1183 SAMPLE_VALID_AFTER,
1184 &store,
1185 )
1186 .test_unwrap_err();
1187 assert!(
1188 error.to_string().contains("not yet valid"),
1189 "an authorization at/before validAfter must be rejected, got: {error}"
1190 );
1191 assert!(
1192 store.is_empty().test_unwrap(),
1193 "a rejected (not-yet-valid) authorization must not consume its nonce"
1194 );
1195 }
1196
1197 #[test]
1198 fn chain_amount_and_payee_binding_mismatches_are_rejected() {
1199 let store = InMemoryEip3009NonceStore::new();
1200
1201 let mut wrong_chain = sample_binding();
1203 wrong_chain.chain_id = 1;
1204 let error = prepare_transfer_with_authorization(
1205 sample_domain(),
1206 sample_authorization(),
1207 &wrong_chain,
1208 SAMPLE_NOW,
1209 &store,
1210 )
1211 .test_unwrap_err();
1212 assert!(error.to_string().contains("chain mismatch"), "got: {error}");
1213
1214 let mut wrong_amount = sample_binding();
1216 wrong_amount.amount_minor_units = SAMPLE_VALUE + 1;
1217 let error = prepare_transfer_with_authorization(
1218 sample_domain(),
1219 sample_authorization(),
1220 &wrong_amount,
1221 SAMPLE_NOW,
1222 &store,
1223 )
1224 .test_unwrap_err();
1225 assert!(
1226 error.to_string().contains("amount mismatch"),
1227 "got: {error}"
1228 );
1229
1230 let mut wrong_payee = sample_binding();
1232 wrong_payee.payee_address = "0x1000000000000000000000000000000000000009".to_string();
1233 let error = prepare_transfer_with_authorization(
1234 sample_domain(),
1235 sample_authorization(),
1236 &wrong_payee,
1237 SAMPLE_NOW,
1238 &store,
1239 )
1240 .test_unwrap_err();
1241 assert!(error.to_string().contains("payee mismatch"), "got: {error}");
1242
1243 assert!(
1245 store.is_empty().test_unwrap(),
1246 "binding-mismatch rejections must not consume the nonce"
1247 );
1248 }
1249
1250 #[test]
1251 fn payee_binding_accepts_checksum_vs_lowercase_hex() {
1252 let store = InMemoryEip3009NonceStore::new();
1255 let mut binding = sample_binding();
1256 binding.payee_address = SAMPLE_PAYEE.to_uppercase().replace("0X", "0x");
1257 let prepared = prepare_transfer_with_authorization(
1258 sample_domain(),
1259 sample_authorization(),
1260 &binding,
1261 SAMPLE_NOW,
1262 &store,
1263 );
1264 assert!(
1265 prepared.is_ok(),
1266 "checksum vs lowercase payee hex must be treated as the same address"
1267 );
1268 }
1269
1270 #[test]
1271 fn nonce_store_records_only_after_checks_pass() {
1272 let store = InMemoryEip3009NonceStore::new();
1275 assert_eq!(
1276 store.record_if_fresh("0xabc", "0xdef", 0).test_unwrap(),
1277 NonceOutcome::Fresh
1278 );
1279 assert_eq!(
1280 store.record_if_fresh("0xabc", "0xdef", 0).test_unwrap(),
1281 NonceOutcome::Replayed
1282 );
1283 }
1284
1285 #[test]
1286 fn nonce_store_canonicalizes_prefix_and_casing() {
1287 let store = InMemoryEip3009NonceStore::new();
1291 assert_eq!(
1292 store.record_if_fresh("0xABC", "0xDEF", 0).test_unwrap(),
1293 NonceOutcome::Fresh
1294 );
1295 assert_eq!(
1297 store.record_if_fresh("abc", "def", 0).test_unwrap(),
1298 NonceOutcome::Replayed,
1299 "bare-hex form of an already-recorded 0x-prefixed key must replay"
1300 );
1301 assert_eq!(
1303 store.record_if_fresh("0xAbC", "0xDeF", 0).test_unwrap(),
1304 NonceOutcome::Replayed,
1305 "re-cased form of an already-recorded key must replay"
1306 );
1307 }
1308
1309 #[test]
1310 fn replay_is_detected_across_prefixed_and_bare_authorization_forms() {
1311 let store = InMemoryEip3009NonceStore::new();
1316 let _ = prepare_transfer_with_authorization(
1317 sample_domain(),
1318 sample_authorization(),
1319 &sample_binding(),
1320 SAMPLE_NOW,
1321 &store,
1322 )
1323 .test_unwrap();
1324
1325 let mut bare = sample_authorization();
1327 bare.from_address = bare
1328 .from_address
1329 .strip_prefix("0x")
1330 .unwrap_or(&bare.from_address)
1331 .to_string();
1332 bare.nonce = bare
1333 .nonce
1334 .strip_prefix("0x")
1335 .unwrap_or(&bare.nonce)
1336 .to_string();
1337
1338 let replay = prepare_transfer_with_authorization(
1339 sample_domain(),
1340 bare,
1341 &sample_binding(),
1342 SAMPLE_NOW,
1343 &store,
1344 );
1345 assert!(
1346 replay.test_unwrap_err().to_string().contains("replay"),
1347 "the unprefixed form of an already-prepared authorization must be a replay"
1348 );
1349 }
1350
1351 #[test]
1352 fn authorization_outliving_approval_is_rejected() {
1353 let store = InMemoryEip3009NonceStore::new();
1358 let mut short_approval = sample_binding();
1359 short_approval.approval_expires_at = SAMPLE_VALID_BEFORE - 1;
1360 let error = prepare_transfer_with_authorization(
1361 sample_domain(),
1362 sample_authorization(),
1363 &short_approval,
1364 SAMPLE_NOW,
1365 &store,
1366 )
1367 .test_unwrap_err();
1368 assert!(
1369 error.to_string().contains("outlives approval"),
1370 "an authorization that outlives its approval must be rejected, got: {error}"
1371 );
1372 assert!(
1373 store.is_empty().test_unwrap(),
1374 "an authorization rejected for outliving its approval must not consume its nonce"
1375 );
1376 }
1377
1378 #[test]
1379 fn authorization_ending_exactly_at_approval_expiry_is_accepted() {
1380 let store = InMemoryEip3009NonceStore::new();
1383 let mut binding = sample_binding();
1384 binding.approval_expires_at = SAMPLE_VALID_BEFORE;
1385 let prepared = prepare_transfer_with_authorization(
1386 sample_domain(),
1387 sample_authorization(),
1388 &binding,
1389 SAMPLE_NOW,
1390 &store,
1391 );
1392 assert!(
1393 prepared.is_ok(),
1394 "valid_before == approval_expires_at must be within the approval window"
1395 );
1396 }
1397
1398 #[test]
1399 fn token_contract_mismatch_is_rejected() {
1400 let store = InMemoryEip3009NonceStore::new();
1405 let mut wrong_token = sample_binding();
1406 wrong_token.token_contract = Some("0x4444444444444444444444444444444444444444".to_string());
1407 let error = prepare_transfer_with_authorization(
1408 sample_domain(),
1409 sample_authorization(),
1410 &wrong_token,
1411 SAMPLE_NOW,
1412 &store,
1413 )
1414 .test_unwrap_err();
1415 assert!(
1416 error.to_string().contains("token contract mismatch"),
1417 "a different token contract on the same chain must be rejected, got: {error}"
1418 );
1419 assert!(
1420 store.is_empty().test_unwrap(),
1421 "a token-contract-mismatch rejection must not consume the nonce"
1422 );
1423 }
1424
1425 #[test]
1426 fn token_contract_binding_accepts_checksum_vs_lowercase_hex() {
1427 let store = InMemoryEip3009NonceStore::new();
1430 let mut binding = sample_binding();
1431 binding.token_contract = Some(SAMPLE_TOKEN_CONTRACT.to_lowercase());
1432 let prepared = prepare_transfer_with_authorization(
1433 sample_domain(),
1434 sample_authorization(),
1435 &binding,
1436 SAMPLE_NOW,
1437 &store,
1438 );
1439 assert!(
1440 prepared.is_ok(),
1441 "checksum vs lowercase token contract hex must be the same address"
1442 );
1443 }
1444
1445 #[test]
1446 fn absent_token_contract_is_rejected_for_eip3009() {
1447 let store = InMemoryEip3009NonceStore::new();
1452 let mut binding = sample_binding();
1453 binding.token_contract = None;
1454 let error = prepare_transfer_with_authorization(
1455 sample_domain(),
1456 sample_authorization(),
1457 &binding,
1458 SAMPLE_NOW,
1459 &store,
1460 )
1461 .test_unwrap_err();
1462 assert!(
1463 error
1464 .to_string()
1465 .contains("requires an approval-bound token contract"),
1466 "an EIP-3009 binding with no token contract must be rejected, got: {error}"
1467 );
1468 assert!(
1469 store.is_empty().test_unwrap(),
1470 "a binding rejected for an absent token contract must not consume the nonce"
1471 );
1472 }
1473
1474 #[test]
1475 fn same_nonce_on_a_different_token_contract_is_not_a_replay() {
1476 let store = InMemoryEip3009NonceStore::new();
1483 let first = prepare_transfer_with_authorization(
1484 sample_domain(),
1485 sample_authorization(),
1486 &sample_binding(),
1487 SAMPLE_NOW,
1488 &store,
1489 );
1490 assert!(first.is_ok(), "first use of a fresh nonce must succeed");
1491
1492 const OTHER_TOKEN_CONTRACT: &str = "0x4444444444444444444444444444444444444444";
1495 let mut other_domain = sample_domain();
1496 other_domain.verifying_contract = OTHER_TOKEN_CONTRACT.to_string();
1497 let mut other_binding = sample_binding();
1498 other_binding.token_contract = Some(OTHER_TOKEN_CONTRACT.to_string());
1499
1500 let second = prepare_transfer_with_authorization(
1501 other_domain,
1502 sample_authorization(),
1503 &other_binding,
1504 SAMPLE_NOW,
1505 &store,
1506 );
1507 assert!(
1508 second.is_ok(),
1509 "the same nonce on a DIFFERENT token contract must not be a replay, got: {second:?}"
1510 );
1511 assert_eq!(
1512 store.len().test_unwrap(),
1513 2,
1514 "each (chain, contract, from, nonce) tuple must record a distinct nonce entry"
1515 );
1516 }
1517
1518 #[test]
1519 fn same_nonce_on_the_same_token_contract_is_a_replay() {
1520 let store = InMemoryEip3009NonceStore::new();
1525 let _ = prepare_transfer_with_authorization(
1526 sample_domain(),
1527 sample_authorization(),
1528 &sample_binding(),
1529 SAMPLE_NOW,
1530 &store,
1531 )
1532 .test_unwrap();
1533 let replay = prepare_transfer_with_authorization(
1534 sample_domain(),
1535 sample_authorization(),
1536 &sample_binding(),
1537 SAMPLE_NOW,
1538 &store,
1539 );
1540 assert!(
1541 replay.test_unwrap_err().to_string().contains("replay"),
1542 "the same nonce on the same token contract must still be a replay"
1543 );
1544 }
1545
1546 #[test]
1547 fn assert_token_symbol_is_case_insensitive_and_fails_closed() {
1548 let binding = sample_binding();
1549 assert!(binding.assert_token_symbol("x402", " usdc ").is_ok());
1551 assert!(binding.assert_token_symbol("circle", "USDC").is_ok());
1552 let error = binding
1554 .assert_token_symbol("x402", "EURC")
1555 .test_unwrap_err();
1556 assert!(
1557 error.to_string().contains("token mismatch"),
1558 "a different token symbol must be rejected, got: {error}"
1559 );
1560 }
1561
1562 #[test]
1563 fn evaluates_circle_nanopayment_candidate() {
1564 let dispatch = sample_dispatch();
1565 let binding = binding_for_dispatch(&dispatch);
1566 let prepared = evaluate_circle_nanopayment(
1567 &dispatch,
1568 &binding,
1569 &CircleNanopaymentPolicy {
1570 enabled: true,
1571 managed_balance_id: "bal_123".to_string(),
1572 supported_chain_ids: vec!["eip155:8453".to_string()],
1573 supported_token_symbols: vec!["USD".to_string()],
1574 max_amount_minor_units: 200,
1575 operator_managed_custody_explicit: true,
1576 },
1577 )
1578 .test_unwrap()
1579 .test_unwrap();
1580
1581 assert_eq!(prepared.dispatch_id, dispatch.dispatch_id);
1582 }
1583
1584 #[test]
1585 fn settlement_compatibility_lanes_reject_mismatched_approval_binding() {
1586 let dispatch = sample_dispatch();
1587 let mut binding = binding_for_dispatch(&dispatch);
1588 binding.amount_minor_units += 1;
1589
1590 let error = build_x402_payment_requirements(
1591 &dispatch,
1592 &binding,
1593 "https://facilitator.example/x402",
1594 "https://tool.example/v1/run",
1595 vec!["USD".to_string()],
1596 X402SettlementMode::PrepaidAuthorization,
1597 )
1598 .test_unwrap_err();
1599
1600 assert!(error.to_string().contains("amount mismatch"));
1601 }
1602
1603 #[test]
1604 fn evaluates_paymaster_compatibility() {
1605 let dispatch = sample_dispatch();
1606 let prepared = prepare_paymaster_compatibility(
1607 &dispatch,
1608 &Erc4337PaymasterPolicy {
1609 entry_point: "0x1000000000000000000000000000000000000100".to_string(),
1610 paymaster_address: "0x1000000000000000000000000000000000000101".to_string(),
1611 supported_chain_ids: vec!["eip155:8453".to_string()],
1612 max_sponsor_gas_limit: 300_000,
1613 max_reimbursement_minor_units: 10,
1614 settlement_deduction_explicit: true,
1615 },
1616 "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
1617 250_000,
1618 5,
1619 )
1620 .test_unwrap();
1621
1622 assert!(prepared.allowed);
1623 assert!(prepared.rejection_reason.is_none());
1624 }
1625
1626 fn sample_verified_approval_token() -> GovernedApprovalToken {
1627 let kp = Keypair::generate();
1628 GovernedApprovalToken::sign(
1629 GovernedApprovalTokenBody {
1630 id: "test-approval-bridge-1".to_string(),
1631 approver: kp.public_key(),
1632 subject: kp.public_key(),
1633 governed_intent_hash: "test-intent-hash".to_string(),
1634 request_id: "test-req-1".to_string(),
1635 threshold_proposal_hash: None,
1636 issued_at: SAMPLE_VALID_AFTER,
1637 expires_at: SAMPLE_VALID_BEFORE,
1638 decision: GovernedApprovalDecision::Approved,
1639 },
1640 &kp,
1641 )
1642 .test_unwrap()
1643 }
1644
1645 fn sample_denied_approval_token() -> GovernedApprovalToken {
1646 let kp = Keypair::generate();
1647 GovernedApprovalToken::sign(
1648 GovernedApprovalTokenBody {
1649 id: "test-approval-bridge-denied".to_string(),
1650 approver: kp.public_key(),
1651 subject: kp.public_key(),
1652 governed_intent_hash: "test-intent-hash".to_string(),
1653 request_id: "test-req-denied".to_string(),
1654 threshold_proposal_hash: None,
1655 issued_at: SAMPLE_VALID_AFTER,
1656 expires_at: SAMPLE_VALID_BEFORE,
1657 decision: GovernedApprovalDecision::Denied,
1658 },
1659 &kp,
1660 )
1661 .test_unwrap()
1662 }
1663
1664 fn sample_rail() -> RailBinding {
1665 RailBinding {
1666 chain_id: SAMPLE_CHAIN_ID,
1667 token_contract: SAMPLE_TOKEN_CONTRACT.to_string(),
1668 payee_address: SAMPLE_PAYEE.to_string(),
1669 token_decimals: 6,
1670 token_symbol: SAMPLE_TOKEN_SYMBOL.to_string(),
1671 }
1672 }
1673
1674 fn sample_authorization_input(binding: &ApprovalBinding) -> TransferWithAuthorizationInput {
1675 TransferWithAuthorizationInput {
1676 from_address: "0x1000000000000000000000000000000000000001".to_string(),
1677 to_address: binding.payee_address.clone(),
1678 value_minor_units: binding.amount_minor_units,
1679 valid_after: SAMPLE_VALID_AFTER,
1680 valid_before: binding.approval_expires_at,
1681 nonce: SAMPLE_NONCE.to_string(),
1682 }
1683 }
1684
1685 fn sample_authorization_input_with_wrong_payee() -> TransferWithAuthorizationInput {
1686 TransferWithAuthorizationInput {
1687 from_address: "0x1000000000000000000000000000000000000001".to_string(),
1688 to_address: "0x9999999999999999999999999999999999999999".to_string(),
1689 value_minor_units: SAMPLE_VALUE,
1690 valid_after: SAMPLE_VALID_AFTER,
1691 valid_before: SAMPLE_VALID_BEFORE,
1692 nonce: SAMPLE_NONCE.to_string(),
1693 }
1694 }
1695
1696 fn sample_nonce_store() -> InMemoryEip3009NonceStore {
1697 InMemoryEip3009NonceStore::new()
1698 }
1699
1700 #[test]
1701 fn bridge_builds_binding_prepare_accepts_happy_path() {
1702 let token = sample_verified_approval_token();
1703 let rail = sample_rail();
1704 let binding = approval_binding_from_governed(&token, &rail, 1_000_000, token.expires_at)
1705 .test_unwrap();
1706 let prepared = prepare_transfer_with_authorization(
1707 sample_domain(),
1708 sample_authorization_input(&binding),
1709 &binding,
1710 token.issued_at + 1,
1711 &sample_nonce_store(),
1712 )
1713 .test_unwrap();
1714 assert!(!prepared.authorization_digest.is_empty());
1715 }
1716
1717 #[test]
1718 fn approval_binding_from_governed_rejects_denied_decision() {
1719 let token = sample_denied_approval_token();
1720 let error =
1721 approval_binding_from_governed(&token, &sample_rail(), 1_000_000, SAMPLE_VALID_BEFORE)
1722 .test_unwrap_err();
1723 assert!(
1724 matches!(error, SettlementError::InvalidBinding(_)),
1725 "a Denied token must produce InvalidBinding, got: {error}"
1726 );
1727 }
1728
1729 #[test]
1730 fn approval_binding_from_governed_rejects_empty_payee_address() {
1731 let token = sample_verified_approval_token();
1732 let mut rail = sample_rail();
1733 rail.payee_address = String::new();
1734 let error = approval_binding_from_governed(&token, &rail, 1_000_000, SAMPLE_VALID_BEFORE)
1735 .test_unwrap_err();
1736 assert!(
1737 matches!(error, SettlementError::InvalidInput(_)),
1738 "an empty payee_address must produce InvalidInput, got: {error}"
1739 );
1740 }
1741
1742 #[test]
1743 fn approval_binding_from_governed_rejects_zero_amount() {
1744 let token = sample_verified_approval_token();
1745 let error = approval_binding_from_governed(&token, &sample_rail(), 0, SAMPLE_VALID_BEFORE)
1746 .test_unwrap_err();
1747 assert!(
1748 matches!(error, SettlementError::InvalidInput(_)),
1749 "a zero amount must produce InvalidInput, got: {error}"
1750 );
1751 }
1752
1753 #[test]
1754 fn bridge_prepare_rejects_payee_mismatch() {
1755 let error = prepare_transfer_with_authorization(
1758 sample_domain(),
1759 sample_authorization_input_with_wrong_payee(),
1760 &sample_binding(),
1761 SAMPLE_NOW,
1762 &sample_nonce_store(),
1763 )
1764 .test_expect_err("payee mismatch must fail closed");
1765 assert!(
1766 matches!(error, SettlementError::InvalidBinding(_)),
1767 "payee mismatch must produce InvalidBinding"
1768 );
1769 assert!(
1770 error.to_string().contains("payee mismatch"),
1771 "error must identify the payee mismatch, got: {error}"
1772 );
1773 }
1774
1775 fn sample_offchain_receipt() -> OffchainSettlementReceiptArtifact {
1776 OffchainSettlementReceiptArtifact {
1777 schema: CHIO_OFFCHAIN_SETTLEMENT_RECEIPT_SCHEMA.to_string(),
1778 settlement_receipt_id: "osr-1".to_string(),
1779 issued_at: 1_700_000_000,
1780 authorization_digest: "0xdigest".to_string(),
1781 governed_receipt_id: "rc-1".to_string(),
1782 settled_amount: MonetaryAmount {
1783 units: 1_000_000,
1784 currency: "USDC".to_string(),
1785 },
1786 execution_nonce_ref: None,
1787 hold_ref: None,
1788 note: None,
1789 }
1790 }
1791
1792 #[test]
1793 fn offchain_receipt_validate_binds_digest_to_governed_receipt() {
1794 let receipt = sample_offchain_receipt();
1795 validate_offchain_settlement_receipt(&receipt).test_unwrap();
1796 let mut bad = receipt.clone();
1797 bad.governed_receipt_id = String::new();
1798 let error = validate_offchain_settlement_receipt(&bad)
1799 .test_expect_err("empty governed_receipt_id must fail");
1800 assert!(matches!(error, SettlementError::InvalidInput(_)));
1801 }
1802
1803 #[test]
1804 fn offchain_receipt_schema_constant_is_pinned() {
1805 assert_eq!(
1806 CHIO_OFFCHAIN_SETTLEMENT_RECEIPT_SCHEMA,
1807 "chio.settle.offchain_receipt.v1"
1808 );
1809 }
1810
1811 #[test]
1812 fn validate_offchain_settlement_receipt_rejects_wrong_schema() {
1813 let mut receipt = sample_offchain_receipt();
1814 receipt.schema = "chio.settle.offchain_receipt.v9".to_string();
1815 let error = validate_offchain_settlement_receipt(&receipt)
1816 .test_expect_err("wrong schema version must fail");
1817 assert!(
1818 matches!(error, SettlementError::InvalidInput(_)),
1819 "wrong schema must produce InvalidInput, got: {error}"
1820 );
1821
1822 let mut empty_schema = sample_offchain_receipt();
1823 empty_schema.schema = String::new();
1824 let error = validate_offchain_settlement_receipt(&empty_schema)
1825 .test_expect_err("empty schema must fail");
1826 assert!(
1827 matches!(error, SettlementError::InvalidInput(_)),
1828 "empty schema must produce InvalidInput, got: {error}"
1829 );
1830 }
1831
1832 #[test]
1833 fn validate_offchain_settlement_receipt_rejects_blank_currency_on_positive_units() {
1834 let valid = sample_offchain_receipt();
1837 validate_offchain_settlement_receipt(&valid).test_unwrap();
1838
1839 let mut empty_currency = sample_offchain_receipt();
1840 empty_currency.settled_amount.currency = String::new();
1841 let error = validate_offchain_settlement_receipt(&empty_currency)
1842 .test_expect_err("empty currency with positive units must fail");
1843 assert!(
1844 matches!(error, SettlementError::InvalidInput(_)),
1845 "empty currency must produce InvalidInput, got: {error}"
1846 );
1847
1848 let mut whitespace_currency = sample_offchain_receipt();
1849 whitespace_currency.settled_amount.currency = " ".to_string();
1850 let error = validate_offchain_settlement_receipt(&whitespace_currency)
1851 .test_expect_err("whitespace currency with positive units must fail");
1852 assert!(
1853 matches!(error, SettlementError::InvalidInput(_)),
1854 "whitespace currency must produce InvalidInput, got: {error}"
1855 );
1856 }
1857
1858 #[test]
1859 fn offchain_receipt_reserved_linkage_fields_are_present_and_optional() {
1860 let with_refs: OffchainSettlementReceiptArtifact = serde_json::from_str(
1864 r#"{
1865 "schema": "chio.settle.offchain_receipt.v1",
1866 "settlement_receipt_id": "osr-pin-1",
1867 "issued_at": 1700000000,
1868 "authorization_digest": "0xdigest",
1869 "governed_receipt_id": "rc-pin-1",
1870 "settled_amount": { "units": 1, "currency": "USDC" },
1871 "execution_nonce_ref": "nonce-ref-abc",
1872 "hold_ref": "hold-ref-xyz"
1873 }"#,
1874 )
1875 .test_unwrap();
1876 assert_eq!(
1877 with_refs.execution_nonce_ref.as_deref(),
1878 Some("nonce-ref-abc"),
1879 "execution_nonce_ref must deserialize from the wire field name"
1880 );
1881 assert_eq!(
1882 with_refs.hold_ref.as_deref(),
1883 Some("hold-ref-xyz"),
1884 "hold_ref must deserialize from the wire field name"
1885 );
1886
1887 let absent = sample_offchain_receipt();
1889 let json = serde_json::to_string(&absent).test_unwrap();
1890 assert!(
1891 !json.contains("execution_nonce_ref"),
1892 "absent execution_nonce_ref must be omitted from JSON: {json}"
1893 );
1894 assert!(
1895 !json.contains("hold_ref"),
1896 "absent hold_ref must be omitted from JSON: {json}"
1897 );
1898 }
1899
1900 #[test]
1901 fn approval_binding_from_governed_rejects_empty_token_contract() {
1902 let token = sample_verified_approval_token();
1903 let mut rail = sample_rail();
1904 rail.token_contract = String::new();
1905 let error = approval_binding_from_governed(&token, &rail, 1_000_000, SAMPLE_VALID_BEFORE)
1906 .test_unwrap_err();
1907 assert!(
1908 matches!(error, SettlementError::InvalidInput(_)),
1909 "an empty token_contract must produce InvalidInput, got: {error}"
1910 );
1911 }
1912
1913 #[test]
1914 fn approval_binding_from_governed_rejects_empty_token_symbol() {
1915 let token = sample_verified_approval_token();
1916 let mut rail = sample_rail();
1917 rail.token_symbol = String::new();
1918 let error = approval_binding_from_governed(&token, &rail, 1_000_000, SAMPLE_VALID_BEFORE)
1919 .test_unwrap_err();
1920 assert!(
1921 matches!(error, SettlementError::InvalidInput(_)),
1922 "an empty token_symbol must produce InvalidInput, got: {error}"
1923 );
1924 }
1925
1926 #[test]
1927 fn approval_binding_from_governed_clamps_expiry_to_token_expiry() {
1928 let token = sample_verified_approval_token();
1934 let rail = sample_rail();
1935 let binding =
1936 approval_binding_from_governed(&token, &rail, 1_000_000, token.expires_at + 3_600)
1937 .test_unwrap();
1938 assert_eq!(
1939 binding.approval_expires_at, token.expires_at,
1940 "an approval_expires_at later than the token must clamp to token.expires_at"
1941 );
1942 }
1943
1944 #[test]
1945 fn approval_binding_from_governed_preserves_shorter_expiry() {
1946 let token = sample_verified_approval_token();
1950 let rail = sample_rail();
1951 let shorter = token.expires_at - 100;
1952 let binding =
1953 approval_binding_from_governed(&token, &rail, 1_000_000, shorter).test_unwrap();
1954 assert_eq!(
1955 binding.approval_expires_at, shorter,
1956 "a shorter approval_expires_at must be preserved, not widened to token.expires_at"
1957 );
1958 }
1959}