Skip to main content

chio_settle/
payments.rs

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/// Approval-bound settlement parameters the caller MUST supply.
187///
188/// EIP-3009 authorizations are independently replay-able and are not, by
189/// themselves, tied to the approval that governs the spend. This binding
190/// requires the prepared authorization to be bound to its governing
191/// approval so a captured signature cannot be redirected to a different
192/// payee, inflated to a different amount, or replayed on a different chain.
193///
194/// This type is the seam to the governed-approval layer: once a verified
195/// [`chio_core_types::capability::governance::GovernedApprovalToken`] is
196/// available at the settlement layer, the caller derives these bound
197/// values from that token's governed intent and passes them here. Until
198/// then, the caller is the trust boundary that MUST extract the values
199/// from the verified approval before calling
200/// [`prepare_transfer_with_authorization`]. Either way the bound values
201/// are asserted against the authorization and any mismatch fails closed.
202///
203/// The `GovernedApprovalToken` itself does not carry chain/amount/payee/token
204/// as discrete fields (they are folded into its `governed_intent_hash`), so
205/// this layer cannot re-derive them from the token alone; it asserts the
206/// explicitly-bound values the caller resolved from the verified intent.
207#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
208#[serde(deny_unknown_fields)]
209pub struct ApprovalBinding {
210    /// Chain id the governing approval authorized the spend on. Must equal
211    /// the EIP-3009 domain `chain_id`.
212    pub chain_id: u64,
213    /// Payee the governing approval authorized. Must equal the EIP-3009
214    /// authorization `to` address (case-insensitive hex / checksum).
215    pub payee_address: String,
216    /// Amount in token minor units the governing approval authorized. Must
217    /// equal the EIP-3009 authorization `value`.
218    pub amount_minor_units: u128,
219    /// Token/currency symbol the governing approval authorized (for example
220    /// `"USDC"`). The chain id alone does not pin the token: a captured
221    /// authorization for one token contract can otherwise be redirected to a
222    /// different token on the same chain with the same payee and numeric
223    /// amount. Each lane asserts its lane-specific token identity against
224    /// this symbol (x402 accepted tokens, Circle token symbol). Compared
225    /// case-insensitively after trimming.
226    pub token_symbol: String,
227    /// Token contract address the governing approval authorized (for example
228    /// the USDC contract on the target chain). It is asserted against the
229    /// EIP-3009 domain `verifying_contract`, which is the contract the signed
230    /// transfer actually targets, so a captured authorization cannot be
231    /// redirected to a different token contract on the same chain. Compared as
232    /// parsed `Address` bytes so checksum vs lowercase hex compare equal.
233    ///
234    /// REQUIRED for the EIP-3009 lane: [`prepare_transfer_with_authorization`]
235    /// fails closed when this is `None`, because a symbol alone cannot pin the
236    /// on-chain token. The field stays `Option` only because lanes that
237    /// identify their token by symbol and have no contract to compare against
238    /// (the x402 accepted-token list and the Circle token symbol) still bind
239    /// via `token_symbol` and legitimately leave this `None`.
240    #[serde(default, skip_serializing_if = "Option::is_none")]
241    pub token_contract: Option<String>,
242    /// Approval expiry as a Unix timestamp in seconds. The prepared
243    /// authorization MUST NOT outlive the governing approval: when an
244    /// EIP-3009 `valid_before` would let a signed transfer stay broadcastable
245    /// past this instant, preparation fails closed. Binds the off-chain
246    /// authorization window to the approval window so a captured signature
247    /// cannot be broadcast after the approval that governs it has expired.
248    pub approval_expires_at: u64,
249}
250
251/// Outcome of a [`Eip3009NonceStore::record_if_fresh`] call.
252///
253/// Mirrors the `RecordOutcome` contract in
254/// `chio-custody-hw::nonce_store` for consistency across the trust
255/// boundaries: recording a fresh nonce returns [`NonceOutcome::Fresh`];
256/// a previously-seen nonce returns [`NonceOutcome::Replayed`] and the
257/// caller MUST abort settlement.
258#[derive(Debug, Clone, Copy, PartialEq, Eq)]
259pub enum NonceOutcome {
260    /// The `(authorizer, nonce)` pair was not present and has now been
261    /// recorded.
262    Fresh,
263    /// The pair was already present. Settlement MUST fail closed.
264    Replayed,
265}
266
267/// Default hard capacity for retained EIP-3009 authorization nonces.
268///
269/// A malicious or buggy client can present an unbounded stream of distinct
270/// nonces. The store fails closed once this many entries are retained and
271/// relies on the explicit [`Eip3009NonceStore::gc_expired`] path to reopen
272/// capacity. Mirrors `DEFAULT_MAX_NONCE_ENTRIES` in `chio-custody-hw`.
273pub const DEFAULT_MAX_EIP3009_NONCE_ENTRIES: usize = 65_536;
274
275/// Single-use nonce store for EIP-3009 authorization replay resistance.
276///
277/// This is the trust-boundary surface settlement consults BEFORE
278/// preparing a transfer. The store is keyed on the EIP-3009 authorizer
279/// (`from` address) and a nonce key that scopes the 32-byte authorization
280/// `nonce` to its EIP-712 domain (chain id + verifying contract): per
281/// EIP-3009 the `(from, nonce)` pair is consumed on-chain by a SPECIFIC
282/// token contract, so the same nonce value is an independent spend on a
283/// different token or chain. Recording the domain-scoped pair here makes
284/// the off-chain preparation path single-use per token contract as well,
285/// closing the replay window before a signature is ever broadcast without
286/// rejecting a legitimate reuse of the same nonce on a different contract.
287///
288/// [`prepare_transfer_with_authorization`] keys the store on the canonical
289/// `0x` lowercase hex of the PARSED `from`/`nonce` bytes (with the chain id
290/// and verifying contract folded into the nonce key), so a re-prefixed or
291/// re-cased submission of the same authorization on the same domain maps to
292/// the same entry. Implementations additionally lowercase the supplied key
293/// defensively, so direct callers cannot evade detection through casing
294/// either.
295///
296/// Implementations are `Send + Sync` so callers can hold them in an
297/// `Arc<dyn Eip3009NonceStore>`. The contract intentionally mirrors
298/// `chio_custody_hw::nonce_store::PasskeyNonceStore`:
299///
300/// - `record_if_fresh` is the only mutating entry point and MUST be
301///   atomic with respect to concurrent calls, so two parallel replays of
302///   the same `(from, nonce)` cannot both observe `Fresh`.
303/// - Any present entry is treated as a replay; the record path never
304///   prunes. `gc_expired` is the only entry point that drops entries, so
305///   replay decisions stay decoupled from the wall clock.
306pub trait Eip3009NonceStore: Send + Sync {
307    /// Record `(from_address, nonce)` for replay detection.
308    ///
309    /// `retain_until_unix_seconds` is the time the entry stays GC-able
310    /// until (typically the authorization `valid_before`). Atomicity:
311    /// two concurrent calls with the same key cannot both observe
312    /// [`NonceOutcome::Fresh`].
313    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    /// Sweep entries whose retention bound is below `now_unix_seconds`.
321    ///
322    /// Returns the number of records pruned. Advisory: failing to run it
323    /// never causes a false [`NonceOutcome::Fresh`].
324    fn gc_expired(&self, now_unix_seconds: u64) -> Result<usize, SettlementError>;
325
326    /// Number of currently retained entries. Used by tests and metrics.
327    fn len(&self) -> Result<usize, SettlementError>;
328
329    /// True if no entries are retained.
330    fn is_empty(&self) -> Result<bool, SettlementError> {
331        Ok(self.len()? == 0)
332    }
333}
334
335/// Internal map keyed on `(from_address, nonce)` whose value is the
336/// Unix-seconds retention bound past which the entry is GC-able. Keys are
337/// canonicalized (optional `0x` prefix stripped, then lowercased) so that
338/// checksum-cased, lowercase, and `0x`-prefixed-vs-bare hex all map to the
339/// same entry and cannot evade replay detection.
340type Eip3009NonceMap = HashMap<(String, String), u64>;
341
342/// Canonicalize a hex key component for the nonce store: strip an optional
343/// `0x`/`0X` prefix and lowercase. This makes `"0xABC"`, `"0xabc"`, and
344/// `"abc"` collapse to the same key so prefix/casing formatting cannot be
345/// used to replay a previously-seen `(from, nonce)` pair as `Fresh`.
346fn 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
355/// Process-local single-use EIP-3009 nonce store.
356///
357/// Backed by `Mutex<Eip3009NonceMap>`. Suitable for tests and
358/// explicitly ephemeral single-process deployments. No durable implementation
359/// is provided in-tree; production callers must supply durable replay protection
360/// before broadcast.
361pub 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    /// Build a fresh store with the default hard capacity.
374    #[must_use]
375    pub fn new() -> Self {
376        Self::default()
377    }
378
379    /// Build a fresh store with a custom hard capacity.
380    #[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        // Fail-closed: any present entry, even past its retention bound,
409        // is treated as a replay. `gc_expired` is the ONLY entry point
410        // that drops entries; the record path never prunes so replay
411        // decisions stay decoupled from the wall clock.
412        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
438/// Prepare an EIP-3009 `transferWithAuthorization` digest, enforcing the
439/// money-safety invariants before any signature is broadcast:
440///
441/// 1. **Single-use nonce.** `nonce_store.record_if_fresh(from, nonce)` is
442///    consulted first; a previously-seen `(from, nonce)` pair fails closed
443///    with [`SettlementError::InvalidBinding`], rejecting replays.
444/// 2. **Time window vs. now.** The authorization is accepted only when
445///    `valid_before > now_unix_seconds > valid_after`; an expired or
446///    not-yet-valid window fails closed.
447/// 3. **Authorization must not outlive the approval.** The EIP-3009
448///    `valid_before` must not exceed `binding.approval_expires_at`. A signed
449///    transfer stays broadcastable on-chain until `valid_before`, so an
450///    authorization whose window outlasts the governing approval is rejected
451///    to keep the off-chain spend bounded by the approval window.
452/// 4. **Approval binding.** The domain `chain_id`, authorization `to`
453///    (payee), `value` (amount), and token identity (the domain
454///    `verifying_contract` against the REQUIRED `binding.token_contract`) are
455///    asserted against `binding`; any mismatch (or an absent token contract)
456///    fails closed. The caller resolves `binding` from the verified governing
457///    approval from governed approval validation.
458///
459/// All checks fail closed. The nonce is recorded only after the time-window,
460/// expiry, and binding checks pass, so a rejected authorization does not burn
461/// its nonce.
462pub 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    // (2) Time window vs. current time: accept only when
503    // valid_before > now > valid_after. Fail closed outside the window.
504    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    // (3) Authorization must not outlive the governing approval. A signed
518    // transfer remains broadcastable on-chain until valid_before, so reject
519    // any authorization whose window outlasts approval_expires_at. Fail
520    // closed so a captured signature cannot be broadcast after the approval
521    // that governs it has expired.
522    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    // (4) Approval binding: chain / payee / amount / token must match the
530    // governing approval. Parse the bound payee through the same address
531    // codec so checksum vs. lowercase hex compare equal. Fail closed on
532    // any mismatch.
533    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    // Token contract: the domain `verifying_contract` selects the token the
555    // signed transfer actually moves. The EIP-3009 lane REQUIRES the approval
556    // to pin a token contract: a symbol alone does not identify the on-chain
557    // token, so a captured authorization could otherwise be redirected to a
558    // different token contract on the same chain with the same payee and
559    // numeric amount. Fail closed when the binding carries no contract, then
560    // assert the domain contract equals it. Compare parsed Address bytes so
561    // checksum vs lowercase hex compare equal. (`verifying_contract` was
562    // already parsed above.)
563    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    // (1) Single-use nonce: record AFTER the cheap fail-closed checks so a
584    // rejected authorization does not consume its nonce, but BEFORE
585    // returning a broadcastable digest so the first successful preparation
586    // is the only one. Retain until the authorization's own expiry.
587    //
588    // Key the store on the PARSED canonical bytes (`from` Address, `nonce`
589    // B256) rendered as their canonical lowercase `0x` hex, not on the
590    // caller's raw text. `Address::from_str`/`B256::from_str` accept both
591    // `0x`-prefixed and bare hex and either casing, so two submissions that
592    // differ only in prefix/casing would otherwise lowercase to DIFFERENT
593    // raw keys and the second would be treated as Fresh, evading replay
594    // detection. Canonicalizing here closes that gap.
595    //
596    // Scope the nonce by the EIP-712 domain (chain id + verifying contract)
597    // as well. Per EIP-3009 the same random `(from, nonce)` is an independent
598    // authorization on each token contract (and chain): the on-chain nonce
599    // state lives in the token contract, and the domain separator already
600    // distinguishes the signatures. Keying only on `(from, nonce)` would
601    // reject a legitimate second authorization that reuses the same nonce
602    // value for a different token (for example USDC vs EURC) or chain. Folding
603    // the chain id and canonical verifying-contract bytes into the nonce key
604    // keeps each (chain, contract, from, nonce) tuple distinct while still
605    // canonicalizing the parsed bytes.
606    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/// Resolved EVM rail parameters sourced from operator config.
671///
672/// Carries the on-chain identity of a seller's payment rail (chain, token
673/// contract, payee address) as resolved by the CLI/control-plane layer from
674/// the operator-configured seller-to-rail table. The kernel adapter stays
675/// rail-agnostic; the caller resolves and validates the rail before bridging
676/// to [`ApprovalBinding`] via [`approval_binding_from_governed`].
677#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
678#[serde(deny_unknown_fields)]
679pub struct RailBinding {
680    /// EVM chain id for the target payment rail.
681    pub chain_id: u64,
682    /// Token contract address for the stablecoin on this rail (e.g. USDC).
683    pub token_contract: String,
684    /// Payee (seller) address that receives the transfer on this rail.
685    pub payee_address: String,
686    /// Token decimal precision (informational; not asserted at the binding layer).
687    pub token_decimals: u8,
688    /// Token symbol (e.g. `"USDC"`). Asserted case-insensitively by the
689    /// token-symbol binding check on the approval.
690    pub token_symbol: String,
691}
692
693/// Bridge a verified [`GovernedApprovalToken`] and a resolved [`RailBinding`]
694/// into an [`ApprovalBinding`] suitable for [`prepare_transfer_with_authorization`].
695///
696/// The caller is the trust boundary: it resolves the rail from the operator
697/// config and independently supplies the amount and approval expiry that the
698/// verified token authorizes. The token is consulted for its `decision` and
699/// its `expires_at`; the discrete rail fields (chain, token contract, payee,
700/// symbol) come from the operator-configured [`RailBinding`] the caller
701/// resolved and the caller-supplied `amount_minor_units` and
702/// `approval_expires_at`.
703///
704/// The effective binding expiry is clamped to `min(approval_expires_at,
705/// token.expires_at)`. The signed approval cannot justify any spend after it
706/// lapses, so the binding must never outlive `token.expires_at`. A caller may
707/// legitimately request a tighter (shorter) window for a specific dispatch and
708/// that shorter value is preserved; a value later than the token's own expiry
709/// is narrowed to the token expiry. Clamping only shrinks the window, so no
710/// caller input can produce a binding whose
711/// [`ApprovalBinding::approval_expires_at`] outlives the approval token, and
712/// the invariant [`prepare_transfer_with_authorization`] enforces
713/// (`validBefore <= approval_expires_at`) holds structurally at this bridge.
714///
715/// Fails closed when:
716/// - `token.decision` is not [`GovernedApprovalDecision::Approved`].
717/// - Any required rail field is empty.
718/// - `amount_minor_units` is zero.
719pub 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        // Clamp to the token's own expiry: the signed approval cannot justify
757        // any spend after it lapses, so the effective binding window must never
758        // outlive `token.expires_at`. A shorter caller value is a legitimately
759        // tighter window and is preserved; a later value is narrowed to the
760        // token's expiry. Clamping only shrinks the window, so no caller input
761        // can produce a binding that outlives the approval that justified it.
762        approval_expires_at: approval_expires_at.min(token.expires_at),
763    })
764}
765
766/// Versioned schema identifier for off-chain settlement receipts.
767pub const CHIO_OFFCHAIN_SETTLEMENT_RECEIPT_SCHEMA: &str = "chio.settle.offchain_receipt.v1";
768
769/// Prepare-only off-chain settlement receipt binding the EIP-3009
770/// authorization digest to a governed receipt.
771///
772/// Minted after a successful [`prepare_transfer_with_authorization`] call.
773/// This off-chain EIP-3009 preparation lane does not broadcast: the artifact
774/// records that an authorization was prepared and binds it to the governed
775/// receipt that authorized the spend. The `execution_nonce_ref` and `hold_ref`
776/// fields are reserved linkage fields for the comptroller surface and are
777/// `None` until the corresponding on-chain execution stage is implemented.
778#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
779#[serde(deny_unknown_fields)]
780pub struct OffchainSettlementReceiptArtifact {
781    /// Schema identifier. Must equal [`CHIO_OFFCHAIN_SETTLEMENT_RECEIPT_SCHEMA`].
782    pub schema: String,
783    /// Stable unique identifier for this off-chain settlement receipt.
784    pub settlement_receipt_id: String,
785    /// Unix timestamp (seconds) when this receipt was issued.
786    pub issued_at: u64,
787    /// EIP-712 digest from the prepared `transferWithAuthorization` call.
788    /// Binds the off-chain authorization to this receipt.
789    pub authorization_digest: String,
790    /// Receipt id of the governed tool-call receipt that authorized this
791    /// spend. Binds the settlement back to the governed receipt so a
792    /// captured digest cannot be redirected to a different tool-call receipt.
793    pub governed_receipt_id: String,
794    /// Settled monetary amount.
795    pub settled_amount: MonetaryAmount,
796    /// Reserved linkage to the on-chain execution nonce reference used by the
797    /// comptroller surface. `None` until on-chain execution is implemented.
798    #[serde(default, skip_serializing_if = "Option::is_none")]
799    pub execution_nonce_ref: Option<String>,
800    /// Reserved linkage to the budget hold that backs this settlement.
801    /// `None` until the comptroller hold-linkage stage is implemented.
802    #[serde(default, skip_serializing_if = "Option::is_none")]
803    pub hold_ref: Option<String>,
804    /// Optional human-readable note.
805    #[serde(default, skip_serializing_if = "Option::is_none")]
806    pub note: Option<String>,
807}
808
809/// Validate an [`OffchainSettlementReceiptArtifact`] for structural integrity.
810///
811/// Fails closed when:
812/// - Any of `schema`, `settlement_receipt_id`, `authorization_digest`, or
813///   `governed_receipt_id` is empty.
814/// - `settled_amount.units` is zero.
815/// - `settled_amount.units` is positive but `settled_amount.currency` is blank
816///   (empty or whitespace-only), leaving the paid amount without a denomination.
817///
818/// `execution_nonce_ref` and `hold_ref` are optional reserved linkage fields
819/// and are not validated here.
820pub 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    // A settled amount with positive units but no denomination cannot be
850    // reconciled: downstream ledgers and dashboards cannot tell what was paid.
851    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    /// A `now` strictly inside `(validAfter, validBefore)`.
988    const SAMPLE_NOW: u64 = 1_744_000_300;
989    const SAMPLE_NONCE: &str = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
990    /// The token symbol the sample approval authorizes.
991    const SAMPLE_TOKEN_SYMBOL: &str = "USDC";
992    /// The token contract the sample EIP-3009 domain targets; the sample
993    /// binding pins this so the contract-level check passes by default.
994    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 expiry at least as late as the authorization window so
1024            // the default sample does not trip the outlives-approval check.
1025            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        // First preparation with a fresh nonce succeeds.
1097        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        // Replaying the same authorization (same from + nonce) against the
1107        // same store must fail closed.
1108        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        // EIP-3009 token contracts consume `(from, nonce)`; a re-cased hex
1125        // string is the same authorizer/nonce and must not bypass replay
1126        // detection.
1127        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        // now == validBefore is outside the open window (requires now < validBefore).
1157        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        // now == validAfter is outside the open window (requires now > validAfter).
1179        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        // Chain mismatch.
1202        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        // Amount mismatch.
1215        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        // Payee mismatch.
1231        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        // None of the rejected bindings may have consumed the nonce.
1244        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        // The bound payee may be checksum-cased while the authorization is
1253        // lowercase (or vice versa); they must compare equal.
1254        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        // A fresh, in-window, correctly-bound authorization records exactly
1273        // one nonce entry.
1274        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        // The store must collapse `0x`-prefixed vs bare and checksum vs
1288        // lowercase keys so none of those formatting differences can present
1289        // the same (from, nonce) pair as Fresh twice.
1290        let store = InMemoryEip3009NonceStore::new();
1291        assert_eq!(
1292            store.record_if_fresh("0xABC", "0xDEF", 0).test_unwrap(),
1293            NonceOutcome::Fresh
1294        );
1295        // Same bytes, prefix stripped and lowercased: must be a replay.
1296        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        // Same bytes, re-cased with prefix: must also replay.
1302        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        // End-to-end: the SAME authorization submitted once with 0x-prefixed
1312        // from/nonce and again WITHOUT the prefix parses to identical
1313        // Address/B256 bytes. Keying the store on the parsed canonical bytes
1314        // means the second submission is a replay, not Fresh.
1315        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        // Strip the `0x` prefix from from/nonce; Address/B256 parse both.
1326        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        // The EIP-3009 window is open and well-bound, but valid_before runs
1354        // past the approval's expiry: the signed transfer would stay
1355        // broadcastable after the approval lapses, so it must fail closed and
1356        // not consume the nonce.
1357        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        // valid_before == approval_expires_at is the boundary the approval
1381        // still covers; it must be accepted.
1382        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        // The approval is bound to a DIFFERENT token contract than the
1401        // EIP-3009 domain targets. Even with matching chain, payee, and
1402        // amount, redirecting to another token on the same chain must fail
1403        // closed and not consume the nonce.
1404        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        // The bound token contract may be cased differently from the domain
1428        // verifyingContract; they must compare equal as parsed addresses.
1429        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        // The EIP-3009 lane REQUIRES a bound token contract: a symbol alone
1448        // cannot pin the on-chain token, so a captured authorization could be
1449        // redirected to a different token contract on the same chain. With no
1450        // bound contract the lane must fail closed and not consume the nonce.
1451        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        // Per EIP-3009 the same `(from, nonce)` is an independent spend on a
1477        // different token contract: the on-chain nonce state lives in the token
1478        // contract and the EIP-712 domain separates the signatures. The
1479        // off-chain store keys the nonce by its domain (chain + verifying
1480        // contract), so reusing the same nonce value against a different token
1481        // contract must still be Fresh, not a replay.
1482        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        // A different token contract on the same chain, with a binding that
1493        // pins that contract. Same from + nonce value, different domain.
1494        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        // Sibling to the cross-contract test: with the SAME domain (chain +
1521        // verifying contract) the same `(from, nonce)` must still be a replay,
1522        // so folding the contract into the key does not weaken replay
1523        // detection on the contract that actually consumes the nonce.
1524        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        // Case and surrounding whitespace must not matter.
1550        assert!(binding.assert_token_symbol("x402", " usdc ").is_ok());
1551        assert!(binding.assert_token_symbol("circle", "USDC").is_ok());
1552        // A genuinely different token must be rejected.
1553        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        // Use a `now` inside the valid time window so the time-window gate
1756        // passes and the payee check is actually exercised.
1757        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        // A settled amount with positive units but no denomination cannot be
1835        // reconciled: dashboards and ledgers cannot tell what was paid.
1836        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        // Pins the wire names of both reserved comptroller-surface linkage
1861        // fields. A present field must round-trip; absent fields must not
1862        // appear in serialized JSON (skip_serializing_if = "Option::is_none").
1863        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        // Absent fields must not appear in serialized output.
1888        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        // A caller-supplied approval_expires_at LATER than the token's own
1929        // expiry cannot be honored: the token does not authorize a window
1930        // longer than its own life. The effective binding expiry must clamp
1931        // to token.expires_at so a prepared transfer can never stay valid
1932        // after the approval that justified it has lapsed.
1933        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        // A caller may bind a tighter (shorter) window than the token's own
1947        // expiry for a specific dispatch. That shorter expiry must be
1948        // preserved, never widened to the token's expiry.
1949        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}