Skip to main content

fd_policy/
x402.rs

1//! x402-aware pre-call spend gate — budget enforcement for autonomous payments.
2//!
3//! FerrumDeck's [`Budget`](crate::budget::Budget) gate already caps token/model
4//! spend: the reversibility ladder's R2 rung admits a `Costly` tool call *only
5//! while [`Budget::has_cost_headroom`](crate::budget::Budget::has_cost_headroom)
6//! returns `true`* (see [`crate::reversibility`]). That covers inference cost.
7//! It does **not** cover the new spend category the [x402 protocol][x402] opens
8//! up: an agent making an outbound HTTP call to a paywalled endpoint that
9//! answers **`402 Payment Required`** and quotes a price the agent is expected
10//! to pay (in a stablecoin, over HTTP) before it retries.
11//!
12//! This module extends the *same* budget gate to that category. Given a parsed
13//! 402 challenge, it:
14//!
15//! 1. **Reads the quoted price** off the challenge (amount, asset, scheme).
16//! 2. **Normalizes it to the common budget unit** — cents — so a paid-API call
17//!    lands in the exact same `cost_cents` ledger as token cost, and a run's
18//!    cost slope includes autonomous payments, not just inference
19//!    ([`X402CostEvent`]).
20//! 3. **Checks it against the per-agent remaining budget *before* authorizing**
21//!    the payment, reusing [`Budget::has_cost_headroom`] — identical semantics
22//!    to the R2 token gate.
23//! 4. **Hard-stops** (deny + exactly one operator alert) if paying would breach
24//!    the ceiling ([`evaluate_x402_payment`] → [`X402GateOutcome::Deny`]).
25//!
26//! ## Deny-by-default on unpriceable quotes
27//!
28//! A quote FerrumDeck cannot convert to cents offline — an asset with no known
29//! USD peg — is **not** waved through. You cannot check a payment against a
30//! cents budget if you cannot price it in cents, so an unpriceable challenge is
31//! denied ([`X402GateOutcome::DenyUnpriceable`]). This mirrors the crate's
32//! deny-by-default posture everywhere else: the unclassified case is the
33//! restrictive case.
34//!
35//! ## This module never moves money
36//!
37//! It is a **gate + cost model**, not a wallet. It reads a challenge, prices it,
38//! and returns an authorize/deny *decision*. Settlement (signing an `X-PAYMENT`
39//! header, broadcasting a transfer) is entirely the caller's concern and lives
40//! outside FerrumDeck. Nothing here touches a key, a chain, or a balance.
41//!
42//! [x402]: https://x402.org
43
44use serde::{Deserialize, Serialize};
45
46use crate::budget::{Budget, BudgetUsage};
47use crate::decision::PolicyDecision;
48
49/// Stable anchor recorded alongside an x402 decision so audit consumers can cite
50/// the protocol without re-reading docstrings. x402 keys the whole flow on the
51/// HTTP `402 Payment Required` status.
52pub const X402_ANCHOR: &str = "x402:http-402-payment-required";
53
54/// The HTTP status code the x402 protocol overloads for its payment challenge.
55pub const X402_PAYMENT_REQUIRED_STATUS: u16 = 402;
56
57/// A parsed x402 `402 Payment Required` challenge — the price quote an agent
58/// must clear before a paywalled call succeeds.
59///
60/// Built from the JSON an x402 server returns (`{ "x402Version", "accepts":
61/// [ requirements… ], "error"? }`) via [`X402Challenge::from_body`], or from a
62/// single requirements object via [`X402Challenge::from_requirements`]. Field
63/// names track the x402 `PaymentRequirements` shape; the amount is kept in the
64/// asset's smallest (atomic) unit, exactly as quoted, so no precision is lost
65/// before normalization.
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67pub struct X402Challenge {
68    /// Payment scheme, e.g. `"exact"` (the canonical x402 scheme).
69    pub scheme: String,
70    /// Settlement asset symbol, e.g. `"USDC"`.
71    pub asset: String,
72    /// Settlement network, e.g. `"base-sepolia"`. Optional — informational.
73    pub network: Option<String>,
74    /// Quoted amount in the asset's atomic unit (e.g. 6-decimal USDC: `10_000`
75    /// == $0.01). Kept exact; normalization to cents happens in
76    /// [`X402Challenge::to_cost_event`].
77    pub amount_atomic: u128,
78    /// Asset decimals used to interpret `amount_atomic` (USDC/USDT/PYUSD = 6,
79    /// DAI = 18).
80    pub decimals: u32,
81    /// Destination address the payment would settle to. Optional; recorded, not
82    /// validated.
83    pub pay_to: Option<String>,
84    /// The paywalled resource URL the 402 guards. Optional.
85    pub resource: Option<String>,
86}
87
88impl X402Challenge {
89    /// Parse the full body of an x402 `402` response
90    /// (`{ "x402Version", "accepts": [ … ], "error"? }`), taking the **first**
91    /// entry of `accepts` (x402 lists a server's acceptable payment options
92    /// most-preferred-first). Returns `None` if the body has no usable
93    /// requirements object.
94    pub fn from_body(body: &serde_json::Value) -> Option<Self> {
95        let first = body.get("accepts").and_then(|a| a.as_array())?.first()?;
96        Self::from_requirements(first)
97    }
98
99    /// Parse a single x402 `PaymentRequirements` object.
100    ///
101    /// Reads `scheme` (default `"exact"`), `network`, `payTo`, `resource`, the
102    /// atomic amount (`maxAmountRequired` | `amount`, string or number), and the
103    /// asset symbol + decimals (from `extra.name`/`extra.decimals`, or the
104    /// top-level `asset`/`symbol`/`decimals`). Missing decimals are inferred
105    /// from the known-stablecoin table. Returns `None` when no amount can be
106    /// read — an unquoted challenge is not a price.
107    pub fn from_requirements(req: &serde_json::Value) -> Option<Self> {
108        let amount_atomic = req
109            .get("maxAmountRequired")
110            .or_else(|| req.get("amount"))
111            .and_then(parse_atomic_amount)?;
112
113        let extra = req.get("extra");
114        let asset = extra
115            .and_then(|e| e.get("name"))
116            .and_then(|v| v.as_str())
117            .or_else(|| req.get("symbol").and_then(|v| v.as_str()))
118            .or_else(|| {
119                req.get("asset")
120                    .and_then(|v| v.as_str())
121                    .filter(|s| !is_address(s))
122            })
123            .unwrap_or("UNKNOWN")
124            .to_string();
125
126        let decimals = extra
127            .and_then(|e| e.get("decimals"))
128            .or_else(|| req.get("decimals"))
129            .and_then(|v| v.as_u64())
130            .map(|d| d as u32)
131            .or_else(|| default_decimals(&asset))
132            .unwrap_or(0);
133
134        let scheme = req
135            .get("scheme")
136            .and_then(|v| v.as_str())
137            .unwrap_or("exact")
138            .to_string();
139
140        Some(Self {
141            scheme,
142            asset,
143            network: req
144                .get("network")
145                .and_then(|v| v.as_str())
146                .map(str::to_string),
147            amount_atomic,
148            decimals,
149            pay_to: req
150                .get("payTo")
151                .and_then(|v| v.as_str())
152                .map(str::to_string),
153            resource: req
154                .get("resource")
155                .and_then(|v| v.as_str())
156                .map(str::to_string),
157        })
158    }
159
160    /// Normalize the quoted price to a [`X402CostEvent`] in cents — the common
161    /// budget unit.
162    ///
163    /// Only assets with a **known 1:1 USD peg** (the stablecoins in
164    /// [`is_usd_pegged`]) can be priced offline. For those,
165    /// `cents = ⌈amount_atomic · 100 / 10^decimals⌉`: integer math, **rounded
166    /// up**, so a sub-cent quote is never charged as free (which would let a
167    /// stream of dust payments slip the budget). Any other asset returns `None`
168    /// — see the module's deny-by-default note.
169    pub fn to_cost_event(&self) -> Option<X402CostEvent> {
170        if !is_usd_pegged(&self.asset) {
171            return None;
172        }
173        let cost_cents = atomic_to_cents_ceil(self.amount_atomic, self.decimals);
174        Some(X402CostEvent {
175            scheme: self.scheme.clone(),
176            asset: self.asset.clone(),
177            network: self.network.clone(),
178            amount_atomic: self.amount_atomic,
179            decimals: self.decimals,
180            cost_cents,
181            resource: self.resource.clone(),
182            pay_to: self.pay_to.clone(),
183        })
184    }
185
186    /// A short human-readable summary of the quote, for alerts + audit reasons.
187    pub fn summary(&self) -> String {
188        match &self.network {
189            Some(net) => format!(
190                "{} {} ({} atomic, {} decimals) on {net} [{}]",
191                self.scheme, self.asset, self.amount_atomic, self.decimals, self.scheme
192            ),
193            None => format!(
194                "{} {} ({} atomic, {} decimals)",
195                self.scheme, self.asset, self.amount_atomic, self.decimals
196            ),
197        }
198    }
199}
200
201/// A settled (or authorized) paid-API cost, normalized to cents — a first-class
202/// cost event that rides the **same** `cost_cents` ledger as token cost.
203///
204/// Fold it into a run's [`BudgetUsage`] with [`X402CostEvent::apply_to`] once a
205/// payment is authorized, so the run's cost slope reflects autonomous payments
206/// alongside inference. Mirror the fields onto an OTel span with
207/// `fd_otel::genai::span_helpers::record_x402_cost` so a paid call is queryable
208/// next to its token cost.
209#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
210pub struct X402CostEvent {
211    /// Payment scheme the price was quoted under (e.g. `"exact"`).
212    pub scheme: String,
213    /// Settlement asset symbol (e.g. `"USDC"`).
214    pub asset: String,
215    /// Settlement network, if the challenge named one.
216    pub network: Option<String>,
217    /// The quoted amount in the asset's atomic unit (unchanged from the quote).
218    pub amount_atomic: u128,
219    /// Asset decimals used for the conversion.
220    pub decimals: u32,
221    /// The quoted price normalized to cents (rounded up). This is what the
222    /// budget gate checks and what `apply_to` charges.
223    pub cost_cents: u64,
224    /// The paywalled resource, if known.
225    pub resource: Option<String>,
226    /// The destination address, if known.
227    pub pay_to: Option<String>,
228}
229
230impl X402CostEvent {
231    /// Fold this paid-API cost into a run's [`BudgetUsage`], **alongside token
232    /// cost**, by adding `cost_cents` to the same ledger the LLM spend uses.
233    ///
234    /// It intentionally does **not** touch `tool_calls`: an x402 payment rides
235    /// an outbound tool call the caller already counts, so incrementing here
236    /// would double-count. Saturating add — a run can't wrap its cost ledger.
237    pub fn apply_to(&self, usage: &mut BudgetUsage) {
238        usage.cost_cents = usage.cost_cents.saturating_add(self.cost_cents);
239    }
240}
241
242/// The pre-call spend-gate decision for an x402 payment.
243///
244/// Produced by [`evaluate_x402_payment`] *before* any payment is authorized.
245/// `Authorize` is the only variant that lets the paid call proceed; both deny
246/// variants hard-stop it.
247#[derive(Debug, Clone, PartialEq, Eq)]
248pub enum X402GateOutcome {
249    /// The quoted payment fits under the remaining budget — the caller may
250    /// authorize it. `budget_remaining_cents` is the headroom that *would*
251    /// remain after this payment settles (`None` when no cost cap is set).
252    Authorize {
253        event: X402CostEvent,
254        budget_remaining_cents: Option<u64>,
255    },
256    /// Paying would breach the per-agent cost ceiling — **hard stop**.
257    /// `over_by_cents` is how far over the cap the payment would push spend.
258    Deny {
259        event: X402CostEvent,
260        reason: String,
261        over_by_cents: u64,
262    },
263    /// The quote could not be priced in cents (no known USD peg), so it cannot
264    /// be checked against the budget — denied by default.
265    DenyUnpriceable { asset: String, reason: String },
266}
267
268impl X402GateOutcome {
269    /// Whether the paid call may proceed.
270    pub fn is_authorized(&self) -> bool {
271        matches!(self, X402GateOutcome::Authorize { .. })
272    }
273
274    /// The single operator alert to emit on a hard stop, or `None` when the
275    /// payment was authorized. Exactly one alert per over-budget (or
276    /// unpriceable) quote — the gate is a single pre-call check, so this fires
277    /// once, not per retry.
278    pub fn alert_line(&self) -> Option<String> {
279        match self {
280            X402GateOutcome::Authorize { .. } => None,
281            X402GateOutcome::Deny {
282                event,
283                over_by_cents,
284                ..
285            } => Some(format!(
286                "x402 spend gate BLOCKED payment: {}¢ in {} would breach the budget by {over_by_cents}¢ — payment not authorized",
287                event.cost_cents, event.asset
288            )),
289            X402GateOutcome::DenyUnpriceable { asset, reason } => Some(format!(
290                "x402 spend gate BLOCKED payment: asset {asset} is unpriceable ({reason}) — payment not authorized"
291            )),
292        }
293    }
294
295    /// Map onto the crate's [`PolicyDecision`] so an x402 verdict flows through
296    /// the same allow/deny pipeline as every other gate. `Authorize` → allow;
297    /// either deny → deny.
298    pub fn to_policy_decision(&self) -> PolicyDecision {
299        match self {
300            X402GateOutcome::Authorize { event, .. } => PolicyDecision::allow(format!(
301                "x402 payment authorized: {}¢ in {} within budget",
302                event.cost_cents, event.asset
303            )),
304            X402GateOutcome::Deny { reason, .. } => PolicyDecision::deny(reason.clone()),
305            X402GateOutcome::DenyUnpriceable { reason, .. } => PolicyDecision::deny(reason.clone()),
306        }
307    }
308}
309
310/// The x402 pre-call spend gate: check a quoted payment against the per-agent
311/// remaining budget **before** authorizing it.
312///
313/// This is the token gate's [`Budget::has_cost_headroom`] applied to a paid-API
314/// quote instead of a token estimate — same primitive, new spend category. On a
315/// priceable quote it authorizes iff `usage.cost_cents + price <= cap`,
316/// otherwise it denies with the exact overage. An unpriceable quote is denied by
317/// default (see the module docs). Pure and deterministic — no clock, no I/O, no
318/// settlement.
319pub fn evaluate_x402_payment(
320    challenge: &X402Challenge,
321    budget: &Budget,
322    usage: &BudgetUsage,
323) -> X402GateOutcome {
324    let Some(event) = challenge.to_cost_event() else {
325        return X402GateOutcome::DenyUnpriceable {
326            asset: challenge.asset.clone(),
327            reason: format!(
328                "x402 quote in '{}' has no known USD peg; cannot price against a cents budget (deny-by-default)",
329                challenge.asset
330            ),
331        };
332    };
333
334    if budget.has_cost_headroom(usage, event.cost_cents) {
335        // Headroom that would remain after this payment settles.
336        let budget_remaining_cents = budget
337            .max_cost_cents
338            .map(|cap| cap.saturating_sub(usage.cost_cents.saturating_add(event.cost_cents)));
339        X402GateOutcome::Authorize {
340            event,
341            budget_remaining_cents,
342        }
343    } else {
344        // Safe because we only land here when a cap exists and is exceeded.
345        let cap = budget.max_cost_cents.unwrap_or(0);
346        let projected = usage.cost_cents.saturating_add(event.cost_cents);
347        let over_by_cents = projected.saturating_sub(cap);
348        let reason = format!(
349            "x402 spend gate: paying {}¢ ({} {}) would push run cost to {projected}¢ over the {cap}¢ budget (by {over_by_cents}¢)",
350            event.cost_cents, event.amount_atomic, event.asset
351        );
352        X402GateOutcome::Deny {
353            event,
354            reason,
355            over_by_cents,
356        }
357    }
358}
359
360// =============================================================================
361// Normalization helpers
362// =============================================================================
363
364/// Known USD-pegged stablecoins (1 whole token == $1.00). Symbol-cased loosely.
365fn is_usd_pegged(symbol: &str) -> bool {
366    matches!(
367        symbol.trim().to_ascii_uppercase().as_str(),
368        "USDC" | "USDC.E" | "USDT" | "DAI" | "PYUSD" | "USDP" | "GUSD" | "USDG" | "OUSD"
369    )
370}
371
372/// Default decimals for known stablecoins, when the challenge omits them.
373fn default_decimals(symbol: &str) -> Option<u32> {
374    match symbol.trim().to_ascii_uppercase().as_str() {
375        "USDC" | "USDC.E" | "USDT" | "PYUSD" | "USDP" | "GUSD" | "USDG" | "OUSD" => Some(6),
376        "DAI" => Some(18),
377        _ => None,
378    }
379}
380
381/// Convert an atomic stablecoin amount to cents, **rounding up**. For a
382/// USD-pegged token, `whole_tokens = atomic / 10^decimals` dollars, so
383/// `cents = ⌈atomic · 100 / 10^decimals⌉`. u128 math throughout; saturates to
384/// `u64::MAX` on the (absurd) overflow case rather than wrapping.
385fn atomic_to_cents_ceil(amount_atomic: u128, decimals: u32) -> u64 {
386    // 10^decimals; saturate to keep the function total for pathological inputs.
387    let divisor = 10u128.checked_pow(decimals).unwrap_or(u128::MAX);
388    let numerator = amount_atomic.saturating_mul(100);
389    // Ceil division.
390    let cents = numerator.div_ceil(divisor);
391    cents.min(u64::MAX as u128) as u64
392}
393
394/// Read an atomic amount that x402 encodes as either a decimal string (the spec
395/// default for large integers) or a JSON number.
396fn parse_atomic_amount(v: &serde_json::Value) -> Option<u128> {
397    if let Some(s) = v.as_str() {
398        s.trim().parse::<u128>().ok()
399    } else {
400        v.as_u64().map(u128::from)
401    }
402}
403
404/// Heuristic: does this string look like a 0x contract address (so it's the
405/// asset *address*, not a human symbol we can peg)?
406fn is_address(s: &str) -> bool {
407    let s = s.trim();
408    s.starts_with("0x") && s.len() >= 6
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414
415    fn budget_with_cap(cap: u64) -> Budget {
416        Budget {
417            max_cost_cents: Some(cap),
418            ..Budget::default()
419        }
420    }
421
422    fn usdc_body(atomic: &str) -> serde_json::Value {
423        // A faithful-shape x402 402 body (USDC on base-sepolia, "exact" scheme).
424        serde_json::json!({
425            "x402Version": 1,
426            "error": "payment required",
427            "accepts": [{
428                "scheme": "exact",
429                "network": "base-sepolia",
430                "maxAmountRequired": atomic,
431                "resource": "https://api.example.com/paywalled",
432                "payTo": "0xabc0000000000000000000000000000000000001",
433                "asset": "0xUSDCcontractaddress0000000000000000000000",
434                "extra": { "name": "USDC", "decimals": 6 }
435            }]
436        })
437    }
438
439    #[test]
440    fn parses_x402_body_first_accepts_entry() {
441        // 10_000 atomic USDC (6 dp) = $0.01 = 1 cent.
442        let ch = X402Challenge::from_body(&usdc_body("10000")).expect("parse");
443        assert_eq!(ch.scheme, "exact");
444        assert_eq!(ch.asset, "USDC");
445        assert_eq!(ch.network.as_deref(), Some("base-sepolia"));
446        assert_eq!(ch.amount_atomic, 10_000);
447        assert_eq!(ch.decimals, 6);
448        assert_eq!(
449            ch.resource.as_deref(),
450            Some("https://api.example.com/paywalled")
451        );
452    }
453
454    #[test]
455    fn normalizes_usdc_atomic_to_cents_rounding_up() {
456        // $0.01 exactly → 1 cent.
457        let ev = X402Challenge::from_body(&usdc_body("10000"))
458            .unwrap()
459            .to_cost_event()
460            .unwrap();
461        assert_eq!(ev.cost_cents, 1);
462
463        // $1.00 → 100 cents.
464        let ev = X402Challenge::from_body(&usdc_body("1000000"))
465            .unwrap()
466            .to_cost_event()
467            .unwrap();
468        assert_eq!(ev.cost_cents, 100);
469
470        // A sub-cent dust quote (1 atomic = $0.000001) must round UP to 1 cent,
471        // never floor to 0 — else a stream of dust payments slips the budget.
472        let ev = X402Challenge::from_body(&usdc_body("1"))
473            .unwrap()
474            .to_cost_event()
475            .unwrap();
476        assert_eq!(ev.cost_cents, 1);
477
478        // $0.015 → 2 cents (ceil), not 1.
479        let ev = X402Challenge::from_body(&usdc_body("15000"))
480            .unwrap()
481            .to_cost_event()
482            .unwrap();
483        assert_eq!(ev.cost_cents, 2);
484    }
485
486    #[test]
487    fn gate_authorizes_a_payment_that_fits() {
488        // Cap 100¢, 40¢ already spent (token cost). A 50¢ paid call fits: 90<=100.
489        let budget = budget_with_cap(100);
490        let usage = BudgetUsage {
491            cost_cents: 40,
492            ..BudgetUsage::default()
493        };
494        let ch = X402Challenge::from_body(&usdc_body("500000")).unwrap(); // $0.50
495        let outcome = evaluate_x402_payment(&ch, &budget, &usage);
496        match outcome {
497            X402GateOutcome::Authorize {
498                event,
499                budget_remaining_cents,
500            } => {
501                assert_eq!(event.cost_cents, 50);
502                assert_eq!(budget_remaining_cents, Some(10)); // 100 - (40+50)
503            }
504            other => panic!("expected authorize, got {other:?}"),
505        }
506        assert!(evaluate_x402_payment(&ch, &budget, &usage).is_authorized());
507        assert!(evaluate_x402_payment(&ch, &budget, &usage)
508            .alert_line()
509            .is_none());
510    }
511
512    #[test]
513    fn gate_hard_stops_a_payment_that_breaches_the_ceiling() {
514        // Cap 100¢, 80¢ spent. A 50¢ paid call would push to 130¢ → deny by 30¢.
515        let budget = budget_with_cap(100);
516        let usage = BudgetUsage {
517            cost_cents: 80,
518            ..BudgetUsage::default()
519        };
520        let ch = X402Challenge::from_body(&usdc_body("500000")).unwrap(); // $0.50
521        let outcome = evaluate_x402_payment(&ch, &budget, &usage);
522        match &outcome {
523            X402GateOutcome::Deny {
524                event,
525                over_by_cents,
526                reason,
527            } => {
528                assert_eq!(event.cost_cents, 50);
529                assert_eq!(*over_by_cents, 30);
530                assert!(reason.contains("130")); // projected spend
531            }
532            other => panic!("expected deny, got {other:?}"),
533        }
534        // Hard stop maps to a policy Deny + exactly one alert.
535        assert!(outcome.to_policy_decision().is_denied());
536        assert!(outcome.alert_line().unwrap().contains("30¢"));
537    }
538
539    #[test]
540    fn boundary_exactly_at_cap_is_authorized() {
541        // 50¢ spent + 50¢ payment == 100¢ cap → fits (<= is headroom).
542        let budget = budget_with_cap(100);
543        let usage = BudgetUsage {
544            cost_cents: 50,
545            ..BudgetUsage::default()
546        };
547        let ch = X402Challenge::from_body(&usdc_body("500000")).unwrap();
548        let outcome = evaluate_x402_payment(&ch, &budget, &usage);
549        assert!(outcome.is_authorized());
550        if let X402GateOutcome::Authorize {
551            budget_remaining_cents,
552            ..
553        } = outcome
554        {
555            assert_eq!(budget_remaining_cents, Some(0));
556        }
557    }
558
559    #[test]
560    fn unpriceable_asset_is_denied_by_default() {
561        // A non-stablecoin asset has no offline USD peg → deny, don't guess.
562        let body = serde_json::json!({
563            "accepts": [{
564                "scheme": "exact",
565                "maxAmountRequired": "1000000000000000000",
566                "extra": { "name": "WETH", "decimals": 18 }
567            }]
568        });
569        let ch = X402Challenge::from_body(&body).unwrap();
570        assert_eq!(ch.asset, "WETH");
571        assert!(ch.to_cost_event().is_none());
572        let outcome =
573            evaluate_x402_payment(&ch, &budget_with_cap(100_000), &BudgetUsage::default());
574        match &outcome {
575            X402GateOutcome::DenyUnpriceable { asset, .. } => assert_eq!(asset, "WETH"),
576            other => panic!("expected unpriceable deny, got {other:?}"),
577        }
578        assert!(outcome.to_policy_decision().is_denied());
579        assert!(outcome.alert_line().is_some());
580    }
581
582    #[test]
583    fn no_cost_cap_authorizes_and_reports_unbounded() {
584        // has_cost_headroom is true when no cap is set → authorize, remaining None.
585        let budget = Budget {
586            max_cost_cents: None,
587            ..Budget::default()
588        };
589        let ch = X402Challenge::from_body(&usdc_body("999999999")).unwrap();
590        let outcome = evaluate_x402_payment(&ch, &budget, &BudgetUsage::default());
591        match outcome {
592            X402GateOutcome::Authorize {
593                budget_remaining_cents,
594                ..
595            } => assert_eq!(budget_remaining_cents, None),
596            other => panic!("expected authorize, got {other:?}"),
597        }
598    }
599
600    #[test]
601    fn apply_to_folds_paid_cost_into_the_same_ledger_as_tokens() {
602        // The run's cost slope must include paid-API calls, not just inference.
603        let mut usage = BudgetUsage {
604            cost_cents: 30, // token cost so far
605            ..BudgetUsage::default()
606        };
607        let ev = X402Challenge::from_body(&usdc_body("500000"))
608            .unwrap()
609            .to_cost_event()
610            .unwrap(); // 50¢
611        ev.apply_to(&mut usage);
612        assert_eq!(usage.cost_cents, 80); // 30 token + 50 paid, one ledger
613                                          // Tool-call count is the caller's concern; apply_to leaves it alone.
614        assert_eq!(usage.tool_calls, 0);
615    }
616
617    #[test]
618    fn parses_amount_as_json_number_and_string() {
619        let as_number = serde_json::json!({
620            "accepts": [{ "maxAmountRequired": 1000000u64, "extra": { "name": "USDC", "decimals": 6 } }]
621        });
622        let as_string = serde_json::json!({
623            "accepts": [{ "maxAmountRequired": "1000000", "extra": { "name": "USDC", "decimals": 6 } }]
624        });
625        assert_eq!(
626            X402Challenge::from_body(&as_number).unwrap().amount_atomic,
627            1_000_000
628        );
629        assert_eq!(
630            X402Challenge::from_body(&as_string).unwrap().amount_atomic,
631            1_000_000
632        );
633    }
634
635    #[test]
636    fn infers_decimals_from_known_symbol_when_omitted() {
637        // No `decimals` in the challenge → inferred from the USDC table (6).
638        let body = serde_json::json!({
639            "accepts": [{ "maxAmountRequired": "2000000", "symbol": "USDC" }]
640        });
641        let ch = X402Challenge::from_body(&body).unwrap();
642        assert_eq!(ch.decimals, 6);
643        assert_eq!(ch.to_cost_event().unwrap().cost_cents, 200); // $2.00
644    }
645
646    #[test]
647    fn body_without_accepts_is_none() {
648        assert!(X402Challenge::from_body(&serde_json::json!({"error": "nope"})).is_none());
649        assert!(X402Challenge::from_body(&serde_json::json!({"accepts": []})).is_none());
650    }
651
652    #[test]
653    fn challenge_round_trips_through_serde() {
654        let ch = X402Challenge::from_body(&usdc_body("250000")).unwrap();
655        let json = serde_json::to_string(&ch).unwrap();
656        let back: X402Challenge = serde_json::from_str(&json).unwrap();
657        assert_eq!(back, ch);
658    }
659}