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}